From 16ef8db3604bdacf45183f1eea38720a5bca6e28 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 3 Jul 2026 22:08:18 +0800 Subject: [PATCH] feat(editor): host doc-root app-state hoisting in op-editor-core Minimal slice of the in-flight app-state work that the script-gen series depends on (subagent post-processing calls op_editor_core::hoist_app_state). The orchestrator-local copy is retired when the protocol collapse lands; the rest of the app-state feature stays in the working tree, uncommitted. --- crates/op-editor-core/src/hoist_app_state.rs | 194 +++++++++++++++++++ crates/op-editor-core/src/lib.rs | 2 + 2 files changed, 196 insertions(+) create mode 100644 crates/op-editor-core/src/hoist_app_state.rs diff --git a/crates/op-editor-core/src/hoist_app_state.rs b/crates/op-editor-core/src/hoist_app_state.rs new file mode 100644 index 000000000..f2dbc1227 --- /dev/null +++ b/crates/op-editor-core/src/hoist_app_state.rs @@ -0,0 +1,194 @@ +//! Hoist node-level `state` to one document-root `MergeAppState`. +//! +//! Generated nodes may declare `state` (the `$app.*` store). Document +//! state is global, so we strip every node's `state` and emit a single +//! [`EditorCommand::MergeAppState`] per subtask, tagged with the +//! subtask's plan index. The additive, `plan_idx`-keyed apply +//! (`op-editor-core`) then resolves cross-subtask conflicts +//! deterministically regardless of concurrent replay order. + +use jian_ops_schema::node::PenNode; +use jian_ops_schema::state::StateSchema; +use crate::EditorCommand; + +/// `plan_idx` for generation paths that have no orchestrator plan +/// (agentic-loop finalize, MCP inserts). `usize::MAX` is the weakest +/// priority: any planned subtask's default wins a key conflict +/// (lower `plan_idx` wins), and doc-owned keys always win regardless. +pub const UNPLANNED_APP_STATE_IDX: usize = usize::MAX; + +/// Take (strip) a node's own `state`, returning the drained schema. +fn take_node_state(node: &mut PenNode) -> Option { + match node { + PenNode::Frame(n) => n.state.take(), + PenNode::Group(n) => n.state.take(), + PenNode::Rectangle(n) => n.state.take(), + PenNode::Ellipse(n) => n.state.take(), + PenNode::Line(n) => n.state.take(), + PenNode::Polygon(n) => n.state.take(), + PenNode::Path(n) => n.state.take(), + PenNode::Text(n) => n.state.take(), + PenNode::TextInput(n) => n.state.take(), + PenNode::TextArea(n) => n.state.take(), + PenNode::Select(n) => n.state.take(), + PenNode::Switch(n) => n.state.take(), + PenNode::Checkbox(n) => n.state.take(), + PenNode::Slider(n) => n.state.take(), + PenNode::RadioGroup(n) => n.state.take(), + PenNode::NumberInput(n) => n.state.take(), + PenNode::Progress(n) => n.state.take(), + PenNode::Tabs(n) => n.state.take(), + PenNode::Image(n) => n.state.take(), + PenNode::IconFont(n) => n.state.take(), + PenNode::Ref(n) => n.state.take(), + } +} + +/// Return mutable children of any container-style node. +/// +/// Only variants that carry an `Option>` children field are +/// matched; leaf nodes return `None` so the recursive walk skips them. +fn children_mut(node: &mut PenNode) -> Option<&mut Vec> { + match node { + PenNode::Frame(n) => n.children.as_mut(), + PenNode::Group(n) => n.children.as_mut(), + PenNode::Rectangle(n) => n.children.as_mut(), + PenNode::Tabs(n) => n.children.as_mut(), + PenNode::Ref(n) => n.children.as_mut(), + _ => None, + } +} + +/// Recursively walk `nodes`, drain each node's `state` into `acc`. +/// +/// When the same key appears in multiple nodes within one subtask, the first +/// occurrence wins (the deeper cross-subtask conflict is resolved later by +/// `plan_idx` ordering in `op-editor-core`). +fn hoist_in_slice(nodes: &mut [PenNode], acc: &mut StateSchema) { + for node in nodes.iter_mut() { + if let Some(schema) = take_node_state(node) { + for (k, v) in schema { + acc.entry(k).or_insert(v); + } + } + // Recurse into any container children that exist. + if let Some(kids) = children_mut(node) { + hoist_in_slice(kids, acc); + } + } +} + +/// Strip every node's `state` from `forest` and return one +/// [`EditorCommand::MergeAppState`] tagged with `plan_idx`. +/// +/// Callers should skip emitting the command when the returned +/// `MergeAppState.state` is empty (no nodes declared state). +pub fn hoist_app_state(forest: &mut [PenNode], plan_idx: usize) -> EditorCommand { + let mut state = StateSchema::new(); + hoist_in_slice(forest, &mut state); + EditorCommand::MergeAppState { plan_idx, state } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_nodes(json: &str) -> Vec { + serde_json::from_str(json).expect("parse nodes") + } + + #[test] + fn hoist_strips_node_state_and_emits_one_command() { + let mut forest = parse_nodes( + r#"[{"type":"frame","id":"root","state":{"count":{"type":"int","default":0}}}]"#, + ); + let cmd = hoist_app_state(&mut forest, 2); + // Node state must be stripped. + let PenNode::Frame(f) = &forest[0] else { + panic!("expected Frame") + }; + assert!(f.state.is_none(), "node state must be stripped"); + // Command carries the key and the correct plan index. + let EditorCommand::MergeAppState { plan_idx, state } = cmd else { + panic!("expected MergeAppState") + }; + assert_eq!(plan_idx, 2); + assert!(state.contains_key("count")); + } + + #[test] + fn hoist_recurses_into_nested_nodes() { + // Outer frame has no state; inner frame carries state. + let mut forest = parse_nodes( + r#"[{"type":"frame","id":"outer","children":[ + {"type":"frame","id":"inner","state":{"score":{"type":"int","default":0}}} + ]}]"#, + ); + let cmd = hoist_app_state(&mut forest, 0); + // Inner node state must be drained. + let PenNode::Frame(outer) = &forest[0] else { + panic!() + }; + let inner_node = outer.children.as_ref().unwrap().first().unwrap(); + let PenNode::Frame(inner_f) = inner_node else { + panic!() + }; + assert!( + inner_f.state.is_none(), + "nested node state must be stripped" + ); + let EditorCommand::MergeAppState { state, .. } = cmd else { + panic!("expected MergeAppState") + }; + assert!(state.contains_key("score"), "nested key must be hoisted"); + } + + #[test] + fn hoist_merges_keys_from_multiple_nodes_first_wins() { + // Two sibling frames declare the same key with different defaults; first wins. + let mut forest = parse_nodes( + r#"[ + {"type":"frame","id":"a","state":{"x":{"type":"int","default":1}}}, + {"type":"frame","id":"b","state":{"x":{"type":"int","default":99}}} + ]"#, + ); + let EditorCommand::MergeAppState { state, .. } = hoist_app_state(&mut forest, 0) else { + panic!() + }; + let entry = state.get("x").unwrap(); + let default_val = entry + .default + .as_ref() + .and_then(|v| v.as_i64()) + .expect("default must be an integer"); + assert_eq!(default_val, 1, "first declaration of key x must win"); + } + + #[test] + fn hoist_empty_forest_yields_empty_state() { + let mut forest: Vec = Vec::new(); + let EditorCommand::MergeAppState { state, plan_idx } = hoist_app_state(&mut forest, 5) + else { + panic!() + }; + assert_eq!(plan_idx, 5); + assert!(state.is_empty(), "empty forest must produce empty state"); + } + + #[test] + fn hoist_group_children_walked() { + // A group container with a child frame that carries state. + let mut forest = parse_nodes( + r#"[{"type":"group","id":"grp","children":[ + {"type":"frame","id":"leaf","state":{"toggle":{"type":"bool","default":false}}} + ]}]"#, + ); + let EditorCommand::MergeAppState { state, .. } = hoist_app_state(&mut forest, 1) else { + panic!() + }; + assert!( + state.contains_key("toggle"), + "group child state must be hoisted" + ); + } +} diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 3099939ad..1444c550e 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -51,6 +51,7 @@ pub mod geometry; pub mod git_button_state; pub mod grouping; pub mod history; +pub mod hoist_app_state; pub mod host_support; pub mod icon_picker_state; pub mod image_node_props; @@ -199,6 +200,7 @@ pub use fills::{ pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect}; pub use git_button_state::GitButton; pub use history::{EditorSnapshot, History, HISTORY_CAP}; +pub use hoist_app_state::{hoist_app_state, UNPLANNED_APP_STATE_IDX}; pub use icon_picker_state::{IconPickerRemoteIcon, IconPickerRemoteState, IconifyLoadMoreRequest}; pub use image_node_props::image_node_summary; pub use instance_override::{