PanelView 面板实体

面板的诞生与销毁

属性面板不是常驻组件。它随选中节点**创建**、随取消选中或切换扩展**销毁**。`FlowEditorView` 用一个 `panel_view: Option<PanelEntity>` 持有它:

用户点击节点 ──→ ensure_panel_view ──→ PanelView::new(...) ──→ panel_view = Some(...)
用户点空白   ──→ panel_view = None
注入新扩展   ──→ panel_view = None(下次 render 重建)

不选中时不占资源——这是属性面板的核心生命周期特征。

PanelEntity:按 kind 分发

并非所有节点都走通用面板。`PanelEntity` 是一个二选一枚举,按节点 `kind` 决定使用哪种面板实体:

#[derive(Clone)]
pub enum PanelEntity {
    Generic(Entity<PanelView>),            // schema 驱动通用面板
    Start(Entity<start::StartPanelView>),  // Start 节点专属
}

分发规则简单:`kind == "start"` 走 `Start`,其余全部走 `Generic`。`PanelEntity` 提供统一接口 `render_element` / `set_theme`,让 `FlowEditorView` 不必关心具体类型:

pub fn render_element(self, window: &mut Window, cx: &mut App) -> gpui::AnyElement {
    match self {
        PanelEntity::Generic(entity) => entity.update(cx, |v, cx| v.render(window, cx).into_any_element()),
        PanelEntity::Start(entity)   => entity.update(cx, |v, cx| v.render(window, cx).into_any_element()),
    }
}

这种「枚举 + 统一接口」是 rust-agent-flow 处理「少数特例 + 多数通用」的典型手法,避免了为特例引入 trait object 的开销。

PanelView 结构

`PanelView` 是一个有状态的 GPUI 实体,实现 `Render` trait:

pub struct PanelView {
    pub node: Node,                              // 当前编辑的节点快照
    pub flow_node: Option<Arc<dyn IFlowNode>>,   // 节点逻辑(提供 schema)
    pub theme: Theme,
    pub on_action: Option<ActionCallback>,       // 回写到编辑器的通道
    pub syntax_service: SharedSyntaxService,     // 代码字段高亮
    pub language: Language,

    label_input: Entity<InputState>,             // 节点名称(label 字段单独处理)
    field_states: Vec<FieldState>,               // 其余字段状态,与 schema.fields 对齐
    syncing: bool,                               // 同步标记,避免回环
    scroll_handle: ScrollHandle,                 // 内容区滚动
    _subscriptions: Vec<Subscription>,
}

几个关键字段的含义:

| 字段 | 作用 | |------|------| | `node` | 节点数据快照,`sync_from_node` 据此判断是否变化 | | `flow_node` | 提供 `schema()`,决定渲染哪些字段、什么类型 | | `on_action` | 闭包通道,字段变化时发 `NodeAction::SetData` 回编辑器 | | `label_input` | label 字段单独一个 InputState,不进 `field_states` | | `field_states` | 与 `schema.fields` **一一对应**(label 位置用占位) | | `syncing` | 同步期间置 true,拦截 InputEvent::Change 防止回环 |

build:schema 即蓝图

构造 `PanelView` 时,`build` 按 `flow_node.schema().fields` 逐个构建 `FieldState`:

fn build(...) -> Self {
    // 1. label 字段单独建 InputState
    let label_input = cx.new(|cx| InputState::new(...).default_value(label.as_str()));
    let sub_label = cx.subscribe_in(&label_input, window, Self::on_label_change);

    let mut field_states: Vec<FieldState> = Vec::new();
    let mut subscriptions = vec![sub_label];

    if let Some(ref fn_) = flow_node {
        for (idx, field) in fn_.schema().fields.iter().enumerate() {
            if field.key == "label" {
                field_states.push(FieldState::Switch(false)); // 占位,不渲染
                continue;
            }
            let default_value = node.data.get(&field.key)
                .cloned().unwrap_or_else(|| field.default.clone());
            let state = Self::build_field_state(idx, field, &default_value, ...);
            field_states.push(state);
        }
    }
    ...
}

注意 `label` 字段被**跳过**——它由 `label_input` 专门管理。为了保持 `field_states` 与 `schema.fields` 索引对齐(便于后续按 `field_idx` 反查 schema),label 位置塞入一个不会被渲染的占位 `FieldState::Switch(false)`。这种「索引对齐」是面板多处逻辑的前提。

render:schema 驱动分发

`render_schema_panel` 遍历 `schema.fields`,跳过 label,按 `FieldType` 分发到不同渲染函数:

fn render_field(&mut self, field_idx, field, label, theme, cx) -> AnyElement {
    match &field.field_type {
        FieldType::Text | FieldType::Number        => self.render_input_field(field_idx, label, None, theme),
        FieldType::TextArea                         => self.render_input_field(field_idx, label, Some(px(80.0)), theme),
        FieldType::CodeEditor                       => self.render_input_field(field_idx, label, None, theme),
        FieldType::CodeBlock                        => self.render_input_field(field_idx, label, Some(px(120.0)), theme),
        FieldType::Switch                           => self.render_switch_field(field_idx, label, theme, cx),
        FieldType::Dropdown(options)                => self.render_dropdown_field(field_idx, label, options, theme, cx),
        FieldType::List(list_spec)                  => self.render_list_field(field_idx, label, list_spec, theme, cx),
    }
}

`field_label` 负责把 `(kind, field_key)` 映射到本地化文案(如 `("condition","conditions")` → `PanelConditions`),找不到则回退到 schema 中的 `field.label`。

整个面板布局是定宽 300px 的纵向滚动容器:

┌──────── 300px ────────┐
│ 头部:图标 + 类型标签  │
├───────────────────────┤
│ 节点名称 [Input]       │  ← label_input
│ ───────────────       │
│ 字段1 标签             │
│ [控件]                 │
│ 字段2 标签             │
│ [控件]                 │
│ ...(可滚动)          │
└───────────────────────┘

为什么是 schema 驱动

对比两种方案:

| 方案 | 新增节点工作量 | 维护成本 | |------|----------------|----------| | per-kind 面板 | 写一整个面板文件 | 每种节点独立维护 | | schema 驱动 | 只声明 `NodeSchema.fields` | 面板代码集中维护 |

schema 驱动的代价是字段控件类型受限(Text/Number/TextArea/CodeEditor/CodeBlock/Switch/Dropdown/List)。但实践证明这 8 种已覆盖绝大多数配置场景,极少数需要复杂交互的节点(如 Start 的参数/变量树)则走 `PanelEntity` 的特例通道。

小结

`PanelEntity` 用枚举区分通用与特例;`PanelView` 持有节点快照、schema 来源、回写通道与字段状态。`build` 按 schema 构建状态、订阅变化,`render` 按 `FieldType` 分发控件——新增节点只需声明 schema,面板自动生成。

下一节:[FieldState 与同步机制](fieldstate-sync.md)