From 338bb2e20f27ef5af0b7446d47c042b61eaa9ca7 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sun, 26 Apr 2026 04:29:23 +0800 Subject: [PATCH] =?UTF-8?q?4-26=20=E6=A0=91rust-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- rust/crates/bridge-runtime/src/lib.rs | 745 ++++++++- rust/crates/mnote-web/src/routes/documents.rs | 1 + rust/crates/mnote-web/src/routes/sse.rs | 123 +- .../mnote-web/src/routes/stream_support.rs | 433 ++++- rust/crates/mnote-web/src/routes/tree.rs | 519 +++++- rust/target/.rustc_info.json | 2 +- ...sk112-tree-rust-family-regression-smoke.js | 1167 +++++++++++++ ...ask113-picker-keyboard-regression-smoke.js | 276 +++ scripts/tree-shell-smoke-helpers.js | 227 ++- .../convex/_utils/documentRecord.ts | 26 + wolai-frontend/convex/_utils/documentTree.ts | 24 +- wolai-frontend/convex/documents.ts | 52 +- .../src/app/api/documents/copy-tree/route.ts | 186 +-- .../src/app/api/documents/delete/route.ts | 112 +- .../src/app/api/documents/embed/route.ts | 71 +- .../src/app/api/documents/purge/route.ts | 72 +- .../src/app/api/documents/restore/route.ts | 112 +- .../app/api/documents/route-adapters.test.ts | 193 ++- .../app/api/mnote-web/stream/route.test.ts | 313 ++-- .../src/app/api/mnote-web/stream/route.ts | 201 ++- .../src/app/api/tree/commands/route.test.ts | 1150 ++++++++++++- .../src/app/api/tree/commands/route.ts | 685 +++++++- .../move-embed-picker-dialog.test.tsx | 401 ++++- .../documents/move-embed-picker-dialog.tsx | 200 ++- .../src/components/sidebar/file-tree.tsx | 47 +- .../sidebar/sidebar-navigation.test.ts | 18 + .../components/sidebar/sidebar-navigation.ts | 19 + .../src/components/sidebar/sidebar.tsx | 534 +++--- .../components/sidebar/tree-shell-host.tsx | 68 +- .../sidebar/tree-shell-iframe-host.test.tsx | 476 +++++- .../sidebar/tree-shell-iframe-host.tsx | 1479 +++++++++++++++-- .../sidebar/tree-shell-surface.test.tsx | 159 +- .../components/sidebar/tree-shell-surface.tsx | 90 +- wolai-frontend/src/components/ui/input.tsx | 35 +- .../src/lib/documents/bridge-log.ts | 3 +- wolai-frontend/src/lib/documents/bridge.ts | 3 + .../src/lib/documents/document-record.test.ts | 35 + .../src/lib/documents/document-tree.test.ts | 29 + .../page-lifecycle-command-adapter.test.ts | 254 +++ .../page-lifecycle-command-adapter.ts | 115 +- .../page-write-command-adapter.test.ts | 25 +- .../documents/page-write-command-adapter.ts | 51 + .../src/lib/documents/rust-runtime.ts | 5 +- .../lib/documents/tree-command-client.test.ts | 34 +- .../src/lib/documents/tree-command-client.ts | 98 +- wolai-frontend/src/lib/file-tree/rows.test.ts | 172 +- wolai-frontend/src/lib/file-tree/rows.ts | 34 + .../src/lib/file-tree/shell.test.ts | 294 ++++ wolai-frontend/src/lib/file-tree/shell.ts | 207 +++ wolai-frontend/src/lib/runtime-config.test.ts | 4 +- wolai-frontend/src/lib/runtime-config.ts | 4 +- .../src/lib/tree-stream/server.test.ts | 499 ++++++ wolai-frontend/src/lib/tree-stream/server.ts | 392 +++++ .../src/lib/tree-stream/tree-delta.test.ts | 233 ++- .../src/lib/tree-stream/tree-delta.ts | 125 +- .../use-sidebar-tree-stream.test.tsx | 134 +- .../tree-stream/use-sidebar-tree-stream.ts | 4 +- 58 files changed, 11718 insertions(+), 1256 deletions(-) create mode 100644 scripts/task112-tree-rust-family-regression-smoke.js create mode 100644 scripts/task113-picker-keyboard-regression-smoke.js create mode 100644 wolai-frontend/src/components/sidebar/sidebar-navigation.test.ts create mode 100644 wolai-frontend/src/components/sidebar/sidebar-navigation.ts create mode 100644 wolai-frontend/src/lib/documents/document-tree.test.ts create mode 100644 wolai-frontend/src/lib/file-tree/shell.test.ts create mode 100644 wolai-frontend/src/lib/file-tree/shell.ts create mode 100644 wolai-frontend/src/lib/tree-stream/server.test.ts create mode 100644 wolai-frontend/src/lib/tree-stream/server.ts diff --git a/.gitignore b/.gitignore index c8780ba6..276c84a3 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ design # pnpm 本地缓存 /.pnpm-store/ -.playwright-mcp \ No newline at end of file +.playwright-mcp +rust/spikes/leptos-tiptap-spike/trunk-8123.err +rust/spikes/leptos-tiptap-spike/trunk-8123.out diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 88c41967..802c2e46 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -34,7 +34,7 @@ use index_fts::{ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; use storage_convex_bridge::{ @@ -122,6 +122,8 @@ pub struct RuntimeCommandEnvelopeWire { pub source: RuntimeSourceWire, pub target: Option, pub payload: Value, + #[serde(default)] + pub preflight_data: Option, pub reason: Option, pub refs: Vec, pub dry_run: bool, @@ -606,6 +608,190 @@ struct DocumentMoveCommandPayload { sort_order: i64, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMovePreflightDocument { + id: String, + workspace_id: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct DocumentMoveSnapshotDocument { + id: String, + #[serde(default, alias = "workspace_id")] + workspace_id: Option, + #[serde(default, alias = "parent_id")] + parent_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMovePreflightPayload { + source_document: DocumentMovePreflightDocument, + target_parent_document: Option, + #[serde(default)] + target_ancestor_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMoveSnapshotSidebarPayload { + #[serde(default)] + documents: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMoveSnapshotPayload { + #[serde(default)] + documents: Vec, + #[serde(default, alias = "sidebarSnapshot")] + sidebar_snapshot: Option, +} + +fn derive_document_move_preflight_from_snapshot( + payload: &DocumentMoveCommandPayload, + snapshot: &DocumentMoveSnapshotPayload, +) -> Result { + let documents = if !snapshot.documents.is_empty() { + snapshot.documents.clone() + } else if let Some(sidebar_snapshot) = snapshot.sidebar_snapshot.as_ref() { + sidebar_snapshot.documents.clone() + } else { + return Err(BridgeError::validation( + "move preflightData 缺少 documents 快照", + )); + }; + + let document_by_id: HashMap = documents + .into_iter() + .map(|document| (document.id.clone(), document)) + .collect(); + + let source_document = + document_by_id + .get(payload.document_id.as_str()) + .ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?; + + let target_parent_document = payload + .parent_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|parent_id| { + document_by_id + .get(parent_id) + .cloned() + .ok_or_else(|| BridgeError::validation("目标父页面不存在或无权限")) + }) + .transpose()?; + + let mut target_ancestor_ids = Vec::new(); + let mut cursor = target_parent_document + .as_ref() + .and_then(|document| document.parent_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let mut depth = 0; + while let Some(parent_id) = cursor { + if depth >= 256 { + break; + } + target_ancestor_ids.push(parent_id.clone()); + cursor = document_by_id + .get(parent_id.as_str()) + .and_then(|document| document.parent_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + depth += 1; + } + + Ok(DocumentMovePreflightPayload { + source_document: DocumentMovePreflightDocument { + id: source_document.id.clone(), + workspace_id: source_document.workspace_id.clone(), + }, + target_parent_document: target_parent_document.map(|document| DocumentMovePreflightDocument { + id: document.id, + workspace_id: document.workspace_id, + }), + target_ancestor_ids, + }) +} + +fn resolve_document_move_preflight( + payload: &DocumentMoveCommandPayload, + preflight_data: Option<&Value>, +) -> Result, BridgeError> { + let Some(raw_preflight) = preflight_data else { + return Ok(None); + }; + + if let Ok(preflight) = + serde_json::from_value::(raw_preflight.clone()) + { + return Ok(Some(preflight)); + } + + let snapshot = serde_json::from_value::(raw_preflight.clone()) + .map_err(|error| BridgeError::validation(format!("move preflightData 非法: {error}")))?; + derive_document_move_preflight_from_snapshot(payload, &snapshot).map(Some) +} + +fn validate_document_move_legality( + payload: &DocumentMoveCommandPayload, + preflight_data: Option<&Value>, +) -> Result<(), BridgeError> { + let Some(parent_id) = payload.parent_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + + if parent_id == payload.document_id { + return Err(BridgeError::validation("不能把页面移动到自身下面")); + } + + let Some(preflight) = resolve_document_move_preflight(payload, preflight_data)? else { + return Ok(()); + }; + + if preflight.source_document.id.trim() == parent_id { + return Err(BridgeError::validation("不能把页面移动到自身下面")); + } + + if preflight + .target_ancestor_ids + .iter() + .any(|ancestor_id| ancestor_id.trim() == payload.document_id) + { + return Err(BridgeError::validation("不能把页面移动到自己的后代下面")); + } + + if let Some(target_parent_document) = preflight.target_parent_document.as_ref() { + let source_workspace_id = preflight + .source_document + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let target_workspace_id = target_parent_document + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if source_workspace_id.is_some() + && target_workspace_id.is_some() + && source_workspace_id != target_workspace_id + { + return Err(BridgeError::validation("暂不支持跨工作空间移动页面")); + } + } + + Ok(()) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentDeleteCommandPayload { @@ -5557,10 +5743,15 @@ fn execute_command( }), })) } - "documents.embed" => { + "documents.embed" | "tree.node.embed" => { let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.embed" { + "tree.node.embed" + } else { + "documents.embed" + }; let command = CommandEnvelope { - name: "documents.embed".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -5571,9 +5762,7 @@ fn execute_command( workspace_id: payload.workspace_id.clone(), revision: payload.revision, content_json: serde_json::to_string(&payload.content).map_err(|error| { - BridgeError::validation(format!( - "documents.embed content 序列化失败: {error}" - )) + BridgeError::validation(format!("{command_name} content 序列化失败: {error}")) })?, conflict_detection_key: payload.conflict_detection_key.clone(), }, @@ -5818,6 +6007,7 @@ fn execute_command( } "documents.move" | "tree.subtree.move" => { let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?; + validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?; let command_name = if command_wire.name == "tree.subtree.move" { "tree.subtree.move" } else { @@ -5854,11 +6044,16 @@ fn execute_command( }), })) } - "documents.delete" => { + "documents.delete" | "tree.node.archive" => { let payload: DocumentDeleteCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.archive" { + "tree.node.archive" + } else { + "documents.delete" + }; let command = CommandEnvelope { - name: "documents.delete".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -5886,11 +6081,16 @@ fn execute_command( }), })) } - "documents.restore" => { + "documents.restore" | "tree.node.restore" => { let payload: DocumentRestoreCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.restore" { + "tree.node.restore" + } else { + "documents.restore" + }; let command = CommandEnvelope { - name: "documents.restore".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -6017,10 +6217,15 @@ fn execute_command( }), })) } - "documents.purge" => { + "documents.purge" | "tree.node.purge" => { let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.purge" { + "tree.node.purge" + } else { + "documents.purge" + }; let command = CommandEnvelope { - name: "documents.purge".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -6048,11 +6253,16 @@ fn execute_command( }), })) } - "documents.copy_tree" => { + "documents.copy_tree" | "tree.subtree.copy" => { let payload: DocumentCopyTreeCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.subtree.copy" { + "tree.subtree.copy" + } else { + "documents.copy_tree" + }; let command = CommandEnvelope { - name: "documents.copy_tree".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -6426,6 +6636,7 @@ mod tests { "type": "paragraph", }, }), + preflight_data: None, reason: Some("替换块快照".into()), refs: vec![], dry_run: false, @@ -6732,6 +6943,7 @@ mod tests { }, "createOnly": true, }), + preflight_data: None, reason: Some("保存导图".into()), refs: vec!["task-032".into()], dry_run: false, @@ -7229,6 +7441,7 @@ mod tests { }, "conflictDetectionKey": "doc_1:4" }), + preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-055".into()], dry_run: false, @@ -7326,6 +7539,7 @@ mod tests { ], "conflictDetectionKey": "doc_1:6" }), + preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-fallback".into()], dry_run: false, @@ -7427,6 +7641,7 @@ mod tests { ], "conflictDetectionKey": "doc_1:7" }), + preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-prefer-editor".into()], dry_run: false, @@ -7489,6 +7704,7 @@ mod tests { ], "conflictDetectionKey": "doc_1:8" }), + preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-content-only".into()], dry_run: false, @@ -7540,6 +7756,7 @@ mod tests { "blockId": "block_1", "targetDocumentId": "doc_2", }), + preflight_data: None, reason: Some("移动块".into()), refs: vec![], dry_run: false, @@ -7594,6 +7811,7 @@ mod tests { "targetDocumentId": "doc_2", "targetBlockId": "anchor_1", }), + preflight_data: None, reason: Some("嵌入块".into()), refs: vec![], dry_run: false, @@ -7653,6 +7871,7 @@ mod tests { "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", }), + preflight_data: None, reason: Some("嵌入页面".into()), refs: vec![], dry_run: false, @@ -7683,6 +7902,504 @@ mod tests { } } + #[test] + fn tree_lifecycle_command_aliases_keep_tree_command_names() { + let cases = [ + ( + "tree.node.archive", + "documents:softDelete", + json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + json!({ + "id": "doc_1", + }), + ), + ( + "tree.node.restore", + "documents:restore", + json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + json!({ + "id": "doc_1", + }), + ), + ( + "tree.node.purge", + "documents:purge", + json!({ + "documentId": "doc_1", + }), + json!({ + "id": "doc_1", + }), + ), + ]; + + for (command_name, function_name, payload, args_json) in cases { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{command_name}"), + idempotency_key: Some(format!("idem_{command_name}")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("command plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.function_name, function_name); + assert_eq!(plan.command_name, command_name); + assert_eq!(plan.args_json, args_json); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + } + + #[test] + fn tree_subtree_move_command_rejects_self_target_via_preflight() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_self".into(), + idempotency_key: Some("idem_tree_move_self".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "parentId": "doc_1", + "sortOrder": 0, + "movePreflight": { + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetAncestorIds": [] + } + }), + preflight_data: Some(json!({ + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetAncestorIds": [] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("self move should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert_eq!(error.message, "不能把页面移动到自身下面"); + } + + #[test] + fn tree_subtree_move_command_rejects_descendant_target_via_preflight() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_descendant".into(), + idempotency_key: Some("idem_tree_move_descendant".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "parentId": "child_1", + "sortOrder": 0, + "movePreflight": { + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "child_1", + "workspaceId": "ws_1", + "parentId": "doc_1" + }, + "targetAncestorIds": ["doc_1"] + } + }), + preflight_data: Some(json!({ + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "child_1", + "workspaceId": "ws_1", + "parentId": "doc_1" + }, + "targetAncestorIds": ["doc_1"] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("descendant move should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert_eq!(error.message, "不能把页面移动到自己的后代下面"); + } + + #[test] + fn tree_subtree_move_command_rejects_cross_workspace_target_via_preflight() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_cross_workspace".into(), + idempotency_key: Some("idem_tree_move_cross_workspace".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "parentId": "parent_remote", + "sortOrder": 0, + "movePreflight": { + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "parent_remote", + "workspaceId": "ws_2", + "parentId": null + }, + "targetAncestorIds": [] + } + }), + preflight_data: Some(json!({ + "sourceDocument": { + "id": "doc_1", + "workspaceId": "ws_1", + "parentId": null + }, + "targetParentDocument": { + "id": "parent_remote", + "workspaceId": "ws_2", + "parentId": null + }, + "targetAncestorIds": [] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("cross-workspace move should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert_eq!(error.message, "暂不支持跨工作空间移动页面"); + } + + #[test] + fn tree_subtree_move_command_rejects_descendant_target_via_snapshot_documents() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_desc_snapshot".into(), + idempotency_key: Some("idem_tree_move_desc_snapshot".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "parentId": "child_1", + "sortOrder": 0 + }), + preflight_data: Some(json!({ + "documents": [ + { + "id": "doc_1", + "workspace_id": "ws_1", + "parent_id": null + }, + { + "id": "child_1", + "workspace_id": "ws_1", + "parent_id": "doc_1" + } + ] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("descendant move should be rejected from snapshot documents"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert_eq!(error.message, "不能把页面移动到自己的后代下面"); + } + + #[test] + fn tree_subtree_move_command_rejects_missing_target_parent_via_snapshot_documents() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_missing_parent_snapshot".into(), + idempotency_key: Some("idem_tree_move_missing_parent_snapshot".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "parentId": "missing_parent", + "sortOrder": 0 + }), + preflight_data: Some(json!({ + "documents": [ + { + "id": "doc_1", + "workspace_id": "ws_1", + "parent_id": null + } + ] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("missing parent should be rejected from snapshot documents"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert_eq!(error.message, "目标父页面不存在或无权限"); + } + + #[test] + fn tree_embed_and_copy_aliases_keep_tree_command_names() { + let embed_plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.embed".into(), + command_id: "cmd_tree_embed_1".into(), + idempotency_key: Some("idem_tree_embed".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_2".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_2", + "workspaceId": "ws_1", + "revision": 5, + "content": [{ "id": "block_1", "type": "pageReference" }], + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + }), + preflight_data: None, + reason: Some("树命令嵌入页面".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("embed plan should build"); + + match embed_plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.command_name, "tree.node.embed"); + assert_eq!( + plan.args_json, + json!({ + "id": "doc_2", + "content": [{ "id": "block_1", "type": "pageReference" }], + "expectedRevision": 5, + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + + let copy_plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.copy".into(), + command_id: "cmd_tree_copy_1".into(), + idempotency_key: Some("idem_tree_copy".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("parent_1".into()), + block_id: None, + }), + payload: json!({ + "items": [ + { + "documentId": "doc_1", + "recursive": true, + } + ], + "targetParentId": "parent_1", + }), + preflight_data: None, + reason: Some("树命令复制子树".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("copy plan should build"); + + match copy_plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.function_name, "documents:copyTree"); + assert_eq!(plan.command_name, "tree.subtree.copy"); + assert_eq!( + plan.args_json, + json!({ + "items": [ + { + "documentId": "doc_1", + "recursive": true, + } + ], + "targetParentId": "parent_1", + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + #[test] fn mindmap_get_tool_plan_uses_mindmaps_get_query() { let plan = execute_runtime_input(RuntimeInput::Tool { diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 67d8d943..d4fcf44c 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -482,6 +482,7 @@ pub async fn save( "snapshotCapturedAt": body.snapshot_captured_at, "blockCount": body.block_count, }), + preflight_data: None, reason: Some("mnote-web human editor save".into()), refs: vec!["mnote-web-editor-runtime".into()], dry_run: false, diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 5e5aea2e..5086e62a 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -1,34 +1,138 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery}; +use crate::routes::stream_support::{ + build_stream_delta_payload, load_stream_overview, load_stream_snapshot, + read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind, + StreamSnapshotQuery, +}; use axum::extract::{Extension, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::stream; use serde_json::Value; use std::convert::Infallible; use std::time::Duration; +use tokio::time::sleep; pub async fn events( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result>>, WebError> { - let payload = load_stream_snapshot(state.config(), &context, &query).await?; - let event = snapshot_event(&payload); + let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?; + let initial_cursor = read_stream_cursor_from_payload(&initial_payload); + let max_polls = query.max_polls; + let poll_ms = query.poll_ms.unwrap_or(2_000).max(250); + let state_for_stream = state.clone(); + let context_for_stream = context.clone(); + let query_for_stream = query.clone(); + let stream = stream::unfold( + Some(StreamPollState { + app_state: state_for_stream, + context: context_for_stream, + query: query_for_stream, + current_cursor: initial_cursor, + polls: 0, + initial_payload, + initial_emitted: false, + }), + move |state| async move { + let mut state = state?; - Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive( + if !state.initial_emitted { + state.initial_emitted = true; + return Some(( + Ok(stream_event("snapshot", &state.initial_payload)), + Some(state), + )); + } + + loop { + if let Some(max_polls) = max_polls { + if state.polls >= max_polls { + return None; + } + } + state.polls += 1; + sleep(Duration::from_millis(poll_ms)).await; + + let Ok((workspace_id, overview)) = + load_stream_overview(state.app_state.config(), &state.context, &state.query) + .await + else { + return None; + }; + let Some(change) = + resolve_stream_change(&overview, state.current_cursor.as_deref()) + else { + continue; + }; + + state.current_cursor = change.cursor.clone(); + + match change.kind { + StreamChangeKind::Delta => { + let payload = build_stream_delta_payload( + &state.context, + &state.query, + &workspace_id, + &overview, + change.cursor, + change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })), + ); + return Some((Ok(stream_event("delta", &payload)), Some(state))); + } + StreamChangeKind::Resync => { + let mut next_query = state.query.clone(); + next_query.cursor = change.cursor; + let Ok(snapshot_payload) = load_stream_snapshot( + state.app_state.config(), + &state.context, + &next_query, + ) + .await + else { + return None; + }; + state.query = next_query; + state.current_cursor = + read_stream_cursor_from_payload(&snapshot_payload); + return Some(( + Ok(stream_event( + "resync", + &with_stream_kind(&snapshot_payload, "resync"), + )), + Some(state), + )); + } + } + } + }, + ); + + Ok(Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keepalive"), )) } -fn snapshot_event(payload: &Value) -> Event { +#[derive(Clone)] +struct StreamPollState { + app_state: AppState, + context: RequestContext, + query: StreamSnapshotQuery, + current_cursor: Option, + polls: u32, + initial_payload: Value, + initial_emitted: bool, +} + +fn stream_event(event_name: &str, payload: &Value) -> Event { Event::default() - .event("snapshot") + .event(event_name) .json_data(payload) - .expect("SSE snapshot 事件必须可序列化") + .expect("SSE 事件必须可序列化") } #[cfg(test)] @@ -62,7 +166,7 @@ mod tests { let response = app() .oneshot( Request::builder() - .uri("/api/stream/events?workspaceId=ws_demo") + .uri("/api/stream/events?workspaceId=ws_demo&maxPolls=0") .body(Body::empty()) .expect("request"), ) @@ -75,7 +179,8 @@ mod tests { .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); - assert!(text.contains("\"scope\":\"workspace\"")); + assert!(text.contains("\"stream\":\"workspace\"")); + assert!(text.contains("\"projection\":\"sidebar_tree\"")); assert!(text.contains("\"workspaceId\":\"ws_demo\"")); } } diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index b1ea37e7..a6a842cf 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -13,6 +13,15 @@ use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; +const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ + "page.body.save", + "page.layout.updateOptions", + "documents.stats.update", + "blocks.patch", + "blocks.move", + "blocks.embed", +]; + #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct StreamSnapshotQuery { @@ -21,6 +30,8 @@ pub struct StreamSnapshotQuery { pub depth: Option, pub cursor: Option, pub limit: Option, + pub poll_ms: Option, + pub max_polls: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -29,6 +40,19 @@ pub enum StreamSnapshotScope { Subtree, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamChangeKind { + Delta, + Resync, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StreamChange { + pub kind: StreamChangeKind, + pub cursor: Option, + pub delta: Option, +} + impl StreamSnapshotScope { pub fn as_str(self) -> &'static str { match self { @@ -36,6 +60,19 @@ impl StreamSnapshotScope { Self::Subtree => "subtree", } } + + pub fn projection(self) -> &'static str { + match self { + Self::Workspace => "sidebar_tree", + Self::Subtree => "page_tree", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DecodedStreamCursor { + created_at: String, + id: String, } pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope { @@ -52,6 +89,15 @@ pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope } } +fn normalize_root_node_id(query: &StreamSnapshotQuery) -> Option { + query + .root_node_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn workspace_overview_query( workspace_id: &str, query: &StreamSnapshotQuery, @@ -72,6 +118,246 @@ fn workspace_overview_query( } } +fn is_record(value: &Value) -> bool { + value.is_object() +} + +fn read_string_field(value: &Value, keys: &[&str]) -> Option { + let map = value.as_object()?; + for key in keys { + let candidate = map.get(*key).and_then(Value::as_str).map(str::trim).unwrap_or(""); + if !candidate.is_empty() { + return Some(candidate.to_string()); + } + } + None +} + +fn read_array_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { + let map = value.as_object()?; + for key in keys { + if let Some(items) = map.get(*key).and_then(Value::as_array) { + return Some(items); + } + } + None +} + +fn encode_stream_cursor(id: &str, created_at: &str) -> Option { + let id = id.trim(); + let created_at = created_at.trim(); + if id.is_empty() || created_at.is_empty() { + return None; + } + Some(json!({ + "createdAt": created_at, + "id": id, + }) + .to_string()) +} + +fn encode_command_cursor(row: &Value) -> Option { + let id = read_string_field(row, &["id", "command_id", "commandId"])?; + let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?; + encode_stream_cursor(&id, &created_at) +} + +fn encode_domain_event_cursor(row: &Value) -> Option { + let id = read_string_field(row, &["event_id", "eventId", "id"])?; + let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?; + encode_stream_cursor(&format!("domain_event:{id}"), &created_at) +} + +fn decode_stream_cursor(raw: &str) -> Option { + let parsed = serde_json::from_str::(raw).ok()?; + Some(DecodedStreamCursor { + created_at: read_string_field(&parsed, &["createdAt"])?, + id: read_string_field(&parsed, &["id"])?, + }) +} + +pub fn resolve_stream_cursor( + overview: Option<&Value>, + fallback: Option<&str>, +) -> Option { + let fallback = fallback + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let Some(overview) = overview else { + return fallback; + }; + + let command_cursor = read_array_field(overview, &["command_logs", "commandLogs"]) + .and_then(|rows| rows.first()) + .and_then(encode_command_cursor); + let domain_event_cursor = read_array_field(overview, &["domain_events", "domainEvents"]) + .and_then(|rows| rows.first()) + .and_then(encode_domain_event_cursor); + + match (command_cursor, domain_event_cursor) { + (None, None) => fallback, + (Some(cursor), None) => Some(cursor), + (None, Some(cursor)) => Some(cursor), + (Some(command_cursor), Some(domain_event_cursor)) => { + let decoded_command = decode_stream_cursor(&command_cursor); + let decoded_domain_event = decode_stream_cursor(&domain_event_cursor); + match (decoded_command, decoded_domain_event) { + (Some(command), Some(event)) => { + if event.created_at > command.created_at { + Some(domain_event_cursor) + } else { + Some(command_cursor) + } + } + (Some(_), None) => Some(command_cursor), + (None, Some(_)) => Some(domain_event_cursor), + (None, None) => fallback, + } + } + } +} + +fn collect_new_command_logs( + rows: &[Value], + previous_cursor: Option<&str>, +) -> (Vec, bool) { + let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else { + return (rows.to_vec(), false); + }; + + let previous_index = rows.iter().position(|row| { + let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default(); + let created_at = + read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"]) + .unwrap_or_default(); + id == previous_cursor.id && created_at == previous_cursor.created_at + }); + + if let Some(index) = previous_index { + (rows.iter().take(index).cloned().collect(), false) + } else { + (rows.to_vec(), !rows.is_empty()) + } +} + +fn read_command_payload_delta(row: &Value) -> Option { + let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default(); + if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) { + return Some(json!({ "op": "noop" })); + } + + let payload = row.as_object()?.get("payload")?; + if !is_record(payload) { + return None; + } + + let candidate = payload + .as_object() + .and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?; + if candidate + .as_object() + .and_then(|map| map.get("op")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return Some(candidate.clone()); + } + None +} + +pub fn resolve_stream_change( + overview: &Value, + previous_cursor: Option<&str>, +) -> Option { + let next_cursor = resolve_stream_cursor(Some(overview), previous_cursor); + let previous_cursor = previous_cursor + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + if next_cursor == previous_cursor { + return None; + } + + let rows = read_array_field(overview, &["command_logs", "commandLogs"]).cloned().unwrap_or_default(); + let (new_rows, drifted) = collect_new_command_logs(&rows, previous_cursor.as_deref()); + if !drifted && new_rows.len() == 1 { + if let Some(delta) = read_command_payload_delta(&new_rows[0]) { + return Some(StreamChange { + kind: StreamChangeKind::Delta, + cursor: next_cursor, + delta: Some(delta), + }); + } + } + + Some(StreamChange { + kind: StreamChangeKind::Resync, + cursor: next_cursor, + delta: None, + }) +} + +pub fn read_stream_cursor_from_payload(payload: &Value) -> Option { + read_string_field(payload, &["cursor"]) +} + +pub fn with_stream_kind(payload: &Value, kind: &str) -> Value { + if let Some(mut map) = payload.as_object().cloned() { + map.insert("kind".into(), Value::String(kind.into())); + return Value::Object(map); + } + json!({ + "kind": kind, + "data": payload, + }) +} + +pub fn build_stream_delta_payload( + context: &RequestContext, + query: &StreamSnapshotQuery, + workspace_id: &str, + overview: &Value, + cursor: Option, + delta: Value, +) -> Value { + let scope = resolve_stream_scope(query); + json!({ + "kind": "delta", + "stream": scope.as_str(), + "projection": scope.projection(), + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "workspaceId": workspace_id, + "rootNodeId": normalize_root_node_id(query), + "depth": query.depth, + "cursor": cursor, + "data": delta, + "snapshot": Value::Null, + "overview": overview, + }) +} + +pub async fn load_stream_overview( + config: &AppConfig, + context: &RequestContext, + query: &StreamSnapshotQuery, +) -> Result<(String, Value), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let overview = execute_runtime_query_via_convex( + config, + context, + Some(&effective_workspace_id), + workspace_overview_query(&effective_workspace_id, query), + ) + .await?; + Ok((effective_workspace_id, overview)) +} + pub async fn load_stream_snapshot( config: &AppConfig, context: &RequestContext, @@ -102,17 +388,13 @@ pub async fn load_stream_snapshot( }) } StreamSnapshotScope::Subtree => { - let root_node_id = query - .root_node_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + let root_node_id = normalize_root_node_id(query) .expect("subtree scope 已确保 rootNodeId 存在"); let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?; let tree = execute_kernel_query( context, &effective_workspace_id, - subtree_query(&effective_workspace_id, root_node_id, query.depth), + subtree_query(&effective_workspace_id, &root_node_id, query.depth), dataset.clone(), )?; @@ -131,15 +413,20 @@ pub async fn load_stream_snapshot( ) .await .ok(); + let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref()); Ok(json!({ "kind": "snapshot", "scope": scope.as_str(), + "stream": scope.as_str(), + "projection": scope.projection(), + "cursor": cursor, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "workspaceId": effective_workspace_id, - "rootNodeId": query.root_node_id, + "rootNodeId": normalize_root_node_id(query), "depth": query.depth, + "data": snapshot, "snapshot": snapshot, "overview": overview, })) @@ -147,7 +434,11 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { - use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope}; + use super::{ + resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, + StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, + }; + use serde_json::json; #[test] fn stream_scope_defaults_to_workspace() { @@ -167,4 +458,130 @@ mod tests { StreamSnapshotScope::Subtree ); } + + #[test] + fn stream_cursor_prefers_newer_domain_event() { + let overview = json!({ + "command_logs": [ + { + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:00Z" + } + ], + "domain_events": [ + { + "event_id": "evt_2", + "created_at": "2026-04-25T10:00:01Z" + } + ] + }); + + assert_eq!( + resolve_stream_cursor(Some(&overview), None), + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_2"}"#.into()) + ); + } + + #[test] + fn stream_change_detects_delta_from_single_new_command() { + let overview = json!({ + "command_logs": [ + { + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.node.archive", + "payload": { + "streamDelta": { + "op": "remove_document", + "documentId": "page_2" + } + } + }, + { + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.cursor, + Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into()) + ); + assert_eq!( + change.delta, + Some(json!({ + "op": "remove_document", + "documentId": "page_2" + })) + ); + } + + #[test] + fn stream_change_detects_noop_delta_for_non_tree_mutating_command() { + let overview = json!({ + "command_logs": [ + { + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "page.body.save", + "payload": { + "documentId": "page_1" + } + }, + { + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!(change.delta, Some(json!({ "op": "noop" }))); + } + + #[test] + fn stream_change_falls_back_to_resync_when_delta_is_unstable() { + let overview = json!({ + "command_logs": [ + { + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.subtree.move", + "payload": { + "documentId": "page_2" + } + }, + { + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Resync); + assert_eq!(change.delta, None); + } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 81a5c6e2..89e8450a 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -27,6 +27,8 @@ pub struct TreeShellQuery { pub root_node_id: Option, pub depth: Option, pub active_document_id: Option, + pub focused_document_id: Option, + pub active_picker_item_key: Option, pub actor_id: Option, pub channel: Option, pub host: Option, @@ -162,6 +164,8 @@ fn build_tree_shell_html( workspace_id: &str, root_node_id: Option<&str>, active_document_id: Option<&str>, + focused_document_id: Option<&str>, + active_picker_item_key: Option<&str>, channel: &str, host: Option<&str>, context: &RequestContext, @@ -175,6 +179,8 @@ fn build_tree_shell_html( "workspaceId": workspace_id, "rootNodeId": root_node_id, "activeDocumentId": active_document_id, + "focusedDocumentId": focused_document_id, + "activePickerItemKey": active_picker_item_key, "actorId": context.auth.actor_id, "channel": channel, "host": host, @@ -663,6 +669,21 @@ fn build_tree_shell_html( .tree-kind-badge[data-kind="table"] { color: #b45309; } + .tree-kind-badge[data-kind="pdf"] { + color: #dc2626; + } + .tree-kind-badge[data-kind="book"] { + color: #0f766e; + } + .tree-kind-badge[data-kind="image"] { + color: #0891b2; + } + .tree-kind-badge[data-kind="video"] { + color: #ea580c; + } + .tree-kind-badge[data-kind="audio"] { + color: #16a34a; + } .tree-kind-badge[data-kind="file"] { color: #64748b; } @@ -776,6 +797,14 @@ fn build_tree_shell_html( typeof state.activeDocumentId === "string" && state.activeDocumentId.trim() ? state.activeDocumentId.trim() : ""; + const focusedDocumentId = + typeof state.focusedDocumentId === "string" && state.focusedDocumentId.trim() + ? state.focusedDocumentId.trim() + : ""; + const activePickerItemKey = + typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim() + ? state.activePickerItemKey.trim() + : ""; const mode = (() => { const rawMode = typeof state.mode === "string" ? state.mode.trim() : ""; @@ -938,19 +967,43 @@ fn build_tree_shell_html( .filter((item) => item.childCount > 0 && item.expandedByDefault) .map((item) => item.nodeId), ); - let focusedNodeId = - activeDocumentId && itemById.has(activeDocumentId) - ? activeDocumentId - : roots[0]?.nodeId || ""; - let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []); - let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null; - let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null; + let currentActiveDocumentId = activeDocumentId; + let currentFocusedDocumentId = focusedDocumentId; + let currentActivePickerItemKey = activePickerItemKey; + const resolvePickerRootFocused = () => + mode === "picker" && currentActivePickerItemKey === "__root__"; + const resolveFocusedNodeIdFromHostState = () => { + const pickerRootFocused = resolvePickerRootFocused(); + return mode === "picker" + ? currentActivePickerItemKey && + currentActivePickerItemKey !== "__root__" && + itemById.has(currentActivePickerItemKey) + ? currentActivePickerItemKey + : currentActiveDocumentId && itemById.has(currentActiveDocumentId) + ? currentActiveDocumentId + : pickerRootFocused + ? "" + : roots[0]?.nodeId || "" + : currentFocusedDocumentId && itemById.has(currentFocusedDocumentId) + ? currentFocusedDocumentId + : currentActiveDocumentId && itemById.has(currentActiveDocumentId) + ? currentActiveDocumentId + : roots[0]?.nodeId || ""; + }; + let focusedNodeId = resolveFocusedNodeIdFromHostState(); + let selectedFileTreeRowIds = new Set( + currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : [] + ); + let fileTreeAnchorRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null; + let fileTreeFocusedRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null; let visibleFileTreeRowIds = []; + let draggingPageNodeId = ""; + let activePageDropNodeId = null; let draggingFileTreeRowIds = []; let activeFileTreeDropRowId = null; let activeFileTreeRootDrop = false; - let activeCursor = itemById.get(activeDocumentId) || null; + let activeCursor = itemById.get(currentActiveDocumentId) || null; while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) { expanded.add(activeCursor.parentNodeId); activeCursor = itemById.get(activeCursor.parentNodeId) || null; @@ -1400,6 +1453,36 @@ fn build_tree_shell_html( `, + pdf: ` + + `, + book: ` + + `, + image: ` + + `, + video: ` + + `, + audio: ` + + `, file: ` ', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + + await act(async () => { + root.render( + undefined)} + />, + ); + }); + + const input = container.querySelector("input"); + expect(input).not.toBeNull(); + + await act(async () => { + Object.defineProperty(input as HTMLInputElement, "value", { + configurable: true, + value: "第", + }); + input?.dispatchEvent(new Event("input", { bubbles: true })); + input?.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); + expect(iframe).not.toBeNull(); + Object.defineProperty(iframe, "contentWindow", { + configurable: true, + value: window, + }); + const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined); + postMessageMock.mockClear(); + + await act(async () => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "tree-picker-surface", + type: "tree.picker.command", + command: "next", + }), + "*", + ); + + postMessageMock.mockClear(); + + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + channel: "tree-picker-surface", + type: "tree.picker.focus.changed", + documentId: "doc_second", + itemKey: "doc_second", + }, + source: window, + }), + ); + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "tree-picker-surface", + type: "tree.shell.state.patch", + activeDocumentId: "doc_second", + activePickerItemKey: "doc_second", + }), + "*", + ); + + postMessageMock.mockClear(); + + await act(async () => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "tree-picker-surface", + type: "tree.picker.command", + command: "pick", + }), + "*", + ); + }); + + it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => { + window.__MNOTE_RUNTIME_CONFIG__ = { + treeRendererFamily: "rust_family", + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + + const onPick = vi.fn(async () => undefined); + const onOpenChange = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const surface = container.querySelector('[data-testid="tree-picker-surface"]'); + const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); + + expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); + expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy"); + expect(iframe).not.toBeNull(); + expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); + }); + }); diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx index 0c2c71dc..c68c543d 100644 --- a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface"; +import type { TreeShellPickerCommand } from "@/components/sidebar/tree-shell-host"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -14,6 +15,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search"; import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; export type MoveEmbedMode = "move" | "embed"; +const PICKER_ROOT_ITEM_KEY = "__root__"; const DEFAULT_FILTERS: DocumentSearchFilters = { titleOnly: true, @@ -106,7 +108,11 @@ function MoveEmbedPickerDialogBody({ const [mode, setMode] = useState(defaultMode); const [query, setQuery] = useState(""); const [highlighted, setHighlighted] = useState(0); - const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react"; + const [pickerCommand, setPickerCommand] = useState(null); + const searchInputRef = useRef(null); + const keyboardHighlightPendingRef = useRef(false); + const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family"; + const canDelegatePickerKeyboardToShell = treeRendererFamily === "rust_family" && Boolean(workspaceId); const handleModeChange = useCallback((value: string) => { setMode(value as MoveEmbedMode); @@ -119,6 +125,13 @@ function MoveEmbedPickerDialogBody({ setHighlighted(0); }, []); + const queuePickerCommand = useCallback((kind: TreeShellPickerCommand["kind"]) => { + setPickerCommand((prev) => ({ + kind, + seq: (prev?.seq ?? 0) + 1, + })); + }, []); + const payload = useMemo(() => { if (!workspaceId) return null; return { @@ -152,11 +165,11 @@ function MoveEmbedPickerDialogBody({ const result: PickerItem[] = []; - if (allowRoot && mode === "move") { - result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" }); - } - if (isEmptyQuery) { + if (allowRoot && mode === "move") { + result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" }); + } + const tree = sidebarQuery.data?.kernelSidebarTree ?? []; const flattened = buildPickerTreeItems( buildPageTreeProjectionItems(tree), @@ -191,6 +204,14 @@ function MoveEmbedPickerDialogBody({ return result; }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]); + const pickerItemIndexByKey = useMemo(() => { + const result = new Map(); + items.forEach((item, index) => { + result.set(item.kind === "root" ? PICKER_ROOT_ITEM_KEY : item.id, index); + }); + return result; + }, [items]); + const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; const handlePick = useCallback( async (targetId: string | null) => { @@ -199,26 +220,161 @@ function MoveEmbedPickerDialogBody({ }, [mode, onOpenChange, onPick], ); + useEffect(() => { + if (items.length === 0) { + if (highlighted !== 0) { + setHighlighted(0); + } + return; + } + if (highlighted >= items.length) { + setHighlighted(items.length - 1); + } + }, [highlighted, items.length]); + const handleShellPickerFocusChange = useCallback( + (payload: { itemKey: string | null; documentId: string | null }) => { + const nextKey = payload.itemKey ?? payload.documentId; + if (!nextKey) { + return; + } + const nextIndex = pickerItemIndexByKey.get(nextKey); + if (typeof nextIndex === "number") { + setHighlighted(nextIndex); + } + }, + [pickerItemIndexByKey], + ); + const handlePickerKeyDown = useCallback( + (event: KeyboardEvent) => { + if (items.length === 0) { + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + keyboardHighlightPendingRef.current = true; + if (canDelegatePickerKeyboardToShell) { + queuePickerCommand("next"); + return; + } + setHighlighted((prev) => Math.min(items.length - 1, prev + 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + keyboardHighlightPendingRef.current = true; + if (canDelegatePickerKeyboardToShell) { + queuePickerCommand("previous"); + return; + } + setHighlighted((prev) => Math.max(0, prev - 1)); + } else if (event.key === "Home") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + keyboardHighlightPendingRef.current = true; + if (canDelegatePickerKeyboardToShell) { + queuePickerCommand("home"); + return; + } + setHighlighted(0); + } else if (event.key === "End") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + keyboardHighlightPendingRef.current = true; + if (canDelegatePickerKeyboardToShell) { + queuePickerCommand("end"); + return; + } + setHighlighted(items.length - 1); + } else if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (canDelegatePickerKeyboardToShell) { + queuePickerCommand("pick"); + return; + } + const picked = items[highlighted]; + if (!picked) return; + void handlePick(picked.id); + } + }, + [canDelegatePickerKeyboardToShell, handlePick, highlighted, items, queuePickerCommand], + ); + useEffect(() => { + const input = searchInputRef.current; + if (!input) { + return undefined; + } + const onKeyDown = (event: KeyboardEvent) => { + handlePickerKeyDown(event); + }; + input.addEventListener("keydown", onKeyDown, true); + return () => { + input.removeEventListener("keydown", onKeyDown, true); + }; + }, [handlePickerKeyDown]); + useEffect(() => { + if (!keyboardHighlightPendingRef.current) { + return; + } + keyboardHighlightPendingRef.current = false; + if (isEmptyQuery || treeRendererFamily !== "rust_family") { + return; + } + const input = searchInputRef.current; + if (!input) { + return; + } + window.requestAnimationFrame(() => { + const current = searchInputRef.current; + if (!current) { + return; + } + // 说明:same-origin picker shell 在搜索态会随高亮更新重新同步 iframe。 + // 这里把焦点稳回搜索框,避免第二次 ArrowDown 丢到输入框外。 + current.focus({ preventScroll: true }); + const end = current.value.length; + try { + current.setSelectionRange(end, end); + } catch { + // 说明:部分输入实现不支持 selection range,这里静默忽略即可。 + } + }); + }, [highlighted, isEmptyQuery, treeRendererFamily]); + const highlightedItem = items[highlighted] ?? null; + const highlightedDocumentId = highlightedItem?.kind === "doc" ? highlightedItem.id : null; + const activePickerItemKey = + highlightedItem?.kind === "root" + ? PICKER_ROOT_ITEM_KEY + : highlightedItem?.kind === "doc" + ? highlightedItem.id + : null; const pickerFallback = ( sidebarQuery.isLoading ? (
加载中...
) : sidebarQuery.error ? (
{String(sidebarQuery.error)}
- ) : items.length === 0 ? ( -
没有匹配结果
) : ( { void handlePick(targetId); }} + onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined} /> ) ); @@ -243,6 +399,7 @@ function MoveEmbedPickerDialogBody({
handleQueryChange(e.target.value)} placeholder={placeholder} @@ -253,26 +410,7 @@ function MoveEmbedPickerDialogBody({
-
{ - if (isEmptyQuery) { - return; - } - if (event.key === "ArrowDown") { - event.preventDefault(); - setHighlighted((prev) => Math.min(items.length - 1, prev + 1)); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - setHighlighted((prev) => Math.max(0, prev - 1)); - } else if (event.key === "Enter") { - event.preventDefault(); - const picked = items[highlighted]; - if (!picked) return; - void handlePick(picked.id); - } - }} - > +
{!workspaceId ? (
缺少 workspaceId,无法加载页面列表。
) : isEmptyQuery ? ( @@ -281,15 +419,16 @@ function MoveEmbedPickerDialogBody({
加载中...
) : error ? (
{String(error)}
- ) : items.length === 0 ? ( -
没有匹配结果
) : ( { void handlePick(targetId); }} + onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined} /> )}
diff --git a/wolai-frontend/src/components/sidebar/file-tree.tsx b/wolai-frontend/src/components/sidebar/file-tree.tsx index 27a4b885..8734d059 100644 --- a/wolai-frontend/src/components/sidebar/file-tree.tsx +++ b/wolai-frontend/src/components/sidebar/file-tree.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react"; +import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { FileTreeRow } from "@/lib/file-tree/types"; @@ -26,6 +26,47 @@ interface FileTreeProps { const INDENT = 16; +function resolveAssetIconKind(row: Extract) { + const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase(); + if (assetType === "mindmap") return "mindmap"; + if (assetType === "luckysheet") return "table"; + + const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase(); + const fileName = String(row.asset.file_name ?? "").trim().toLowerCase(); + const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : ""; + + if (ext === "pdf" || mimeType.includes("pdf")) return "pdf"; + if (ext === "epub" || mimeType.includes("epub")) return "book"; + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + return "file"; +} + +function renderAssetIcon(row: Extract) { + const iconKind = resolveAssetIconKind(row); + const baseClass = "w-4 h-4 shrink-0"; + + switch (iconKind) { + case "mindmap": + return ; + case "table": + return ; + case "pdf": + return ; + case "book": + return ; + case "image": + return ; + case "video": + return ; + case "audio": + return ; + default: + return ; + } +} + export function FileTree({ rows, activeId, @@ -300,13 +341,13 @@ export function FileTree({ ) : ( )} - + {renderAssetIcon(row)} {label} ) : ( <> - + {renderAssetIcon(row)} {label} )} diff --git a/wolai-frontend/src/components/sidebar/sidebar-navigation.test.ts b/wolai-frontend/src/components/sidebar/sidebar-navigation.test.ts new file mode 100644 index 00000000..64554e8b --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-navigation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation"; + +describe("sidebar-navigation", () => { + it("页面树普通打开应留在当前窗口", () => { + expect(buildSidebarDocumentOpenTarget("doc_1", "main", "http://127.0.0.1:3000")).toEqual({ + kind: "same-window", + path: "/documents/doc_1", + }); + }); + + it("显式侧栏预览打开才应使用新窗口 URL", () => { + expect(buildSidebarDocumentOpenTarget("doc_1", "sidebar", "http://127.0.0.1:3000")).toEqual({ + kind: "new-window", + url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar", + }); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-navigation.ts b/wolai-frontend/src/components/sidebar/sidebar-navigation.ts new file mode 100644 index 00000000..525e9b37 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-navigation.ts @@ -0,0 +1,19 @@ +export type SidebarDocumentOpenMode = "main" | "sidebar"; + +export type SidebarDocumentOpenTarget = + | { kind: "same-window"; path: string } + | { kind: "new-window"; url: string }; + +export function buildSidebarDocumentOpenTarget( + documentId: string, + mode: SidebarDocumentOpenMode, + origin?: string | null, +): SidebarDocumentOpenTarget { + const path = `/documents/${documentId}`; + if (mode === "main") { + return { kind: "same-window", path }; + } + + const base = origin ? `${origin}${path}` : path; + return { kind: "new-window", url: `${base}?preview=sidebar` }; +} diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index ff0e07eb..15237366 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -54,9 +54,18 @@ import { import { useSearchPaletteStore } from "@/store/search-palette"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useCurrentDocumentStore } from "@/store/current-document"; -import { buildVisibleRows } from "@/lib/file-tree/rows"; -import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd"; +import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows"; +import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd"; import { isRealFileAsset } from "@/lib/file-tree/asset"; +import { + computeFileTreeShellDeleteTargets, + buildFileTreeShellRowById, + buildFileTreeShellVisibleRowIds, + type FileTreeShellRow, + inferFileTreeShellTargetDocumentId, + getOrderedFileTreeShellRows, + resolveFileTreeShellMindmapTargetId, +} from "@/lib/file-tree/shell"; import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog"; import { computeTreePaneDeleteTargets, @@ -69,6 +78,10 @@ import { type TreePaneSelectionState, writeTreePaneClipboardPayload, } from "@/components/sidebar/tree-pane-bindings"; +import { + buildSidebarDocumentOpenTarget, + type SidebarDocumentOpenMode, +} from "@/components/sidebar/sidebar-navigation"; import type { MediaAsset } from "@/types/media"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; @@ -105,8 +118,6 @@ const SECTION_ICONS: Record, React.ReactNod templates: , }; -const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/"); - const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => { const name = (fileName ?? "").trim().toLowerCase(); const mt = (mimeType ?? "").trim().toLowerCase(); @@ -122,27 +133,6 @@ const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | nul return null; }; -const extractMindmapIdFromStoragePath = ( - storagePath: string | null | undefined, -): string | null => { - if (!storagePath) return null; - const normalized = normalizeStoragePath(storagePath); - - const prefix = "mindmaps/"; - if (normalized.startsWith(prefix)) { - const rest = normalized.slice(prefix.length); - const id = rest.split("/")[0]; - return id ? id : null; - } - - const marker = "/mindmaps/"; - const idx = normalized.indexOf(marker); - if (idx === -1) return null; - const rest = normalized.slice(idx + marker.length); - const id = rest.split("/")[0]; - return id ? id : null; -}; - interface SidebarProps { initialData: SidebarInitialData; sidebarData?: SidebarInitialData; @@ -212,7 +202,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const { signOut } = useAuthActions(); const activeId = segments?.[1] ?? ""; const editorBridge = useEditorBridgeStore((state) => state.bridge); - const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react"; + const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family"; + const isRustFamilyTreeRenderer = treeRendererFamily === "rust_family"; const [tree, setTree] = useState(() => sidebarData.kernelSidebarTree); const [filter, setFilter] = useState(""); @@ -282,6 +273,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const mediaAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? [])); const mindmapAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? [])); const tableAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? [])); + const pageTreeFocusedDocumentIdRef = useRef(activeId || null); const resourcePaneContainerRef = useRef(null); const creatingDocumentUnderParentRef = useRef>(new Set()); @@ -301,6 +293,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }); }, [sidebarData.kernelSidebarTree]); + useEffect(() => { + pageTreeFocusedDocumentIdRef.current = activeId || null; + }, [activeId]); + useEffect(() => { const nextAssets = sidebarData.mediaAssets ?? []; const nextSyncKey = buildMediaAssetListSyncKey(nextAssets); @@ -588,80 +584,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword)); }, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]); - const mindmapChildrenSnapshot = useMemo(() => { - const mapping = sidebarData.mindmapAssetChildren ?? {}; - const mindmapDocById = new Map( - (mindmapAssets ?? []) - .filter((asset) => asset.asset_type === "mindmap") - .map((asset) => [asset.id, asset.document_id]), - ); - const mindmapIds = new Set(mindmapDocById.keys()); - - const mediaById = new Map( - (mediaAssets ?? []).map((asset) => [asset.id, asset]), - ); - - const childAssetsByMindmapId: Record = {}; - const childIds = new Set(); - const assigned = new Set(); - - // 物理目录:storage_path 归属到 mindmaps// 的附件,作为导图文件夹内容 - (mediaAssets ?? []).forEach((asset) => { - const sp = asset.storage_path; - if (!sp || typeof sp !== "string") return; - const mindmapId = extractMindmapIdFromStoragePath(sp); - if (!mindmapId) return; - if (!mindmapIds.has(mindmapId)) return; - const docId = mindmapDocById.get(mindmapId); - if (docId && asset.document_id !== docId) return; - if (assigned.has(asset.id)) return; - assigned.add(asset.id); - childIds.add(asset.id); - if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = []; - childAssetsByMindmapId[mindmapId].push(asset); - }); - - // 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重) - (mindmapAssets ?? []).forEach((mindmapAsset) => { - const ids = mapping[mindmapAsset.id] ?? []; - if (!Array.isArray(ids) || ids.length === 0) return; - ids.forEach((id) => { - const asset = mediaById.get(id); - if (!asset) return; - if (asset.document_id !== mindmapAsset.document_id) return; - if (assigned.has(asset.id)) return; - assigned.add(asset.id); - childIds.add(asset.id); - if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = []; - childAssetsByMindmapId[mindmapAsset.id].push(asset); - }); - }); - - return { childAssetsByMindmapId, childIds }; - }, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]); - - const assetsByDoc = useMemo(() => { - const map: Record = {}; - const assets = [ - ...((mediaAssets ?? []).filter( - (asset) => !mindmapChildrenSnapshot.childIds.has(asset.id), - )), - ...(mindmapAssets ?? []), - ...(tableAssets ?? []), - ]; - - assets.forEach((asset) => { - if (!map[asset.document_id]) { - map[asset.document_id] = []; - } - const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type); - if (!exists) { - map[asset.document_id].push(asset); - } - }); - return map; - }, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]); - const assetById = useMemo(() => { const map = new Map(); [...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => { @@ -672,40 +594,81 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const [expandedAssetFolders, setExpandedAssetFolders] = useState>(() => new Set()); - const resourceRows = useMemo( + const resourceTreeShellItems = useMemo( () => - buildVisibleRows({ - fileTreeItems: - filter.trim().length === 0 - ? sidebarData.kernelFileTreeProjection.items - : undefined, - pageRows: visibleFilteredPrivatePageRows, - expanded, - assetsByDoc, - assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId, - expandedAssetFolderIds: expandedAssetFolders, - nodeById, - assetById, - }), + filter.trim().length === 0 + ? undefined + : filterKernelFileTreeProjectionItems({ + fileTreeItems: sidebarData.kernelFileTreeProjection.items, + visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)), + expandedDocumentIds: expanded, + expandedAssetFolderIds: expandedAssetFolders, + }), [ - assetById, - assetsByDoc, expanded, expandedAssetFolders, - mindmapChildrenSnapshot.childAssetsByMindmapId, - nodeById, sidebarData.kernelFileTreeProjection.items, visibleFilteredPrivatePageRows, filter, ], ); + const effectivePageTreeShellRows = useMemo( + () => (filter.trim().length > 0 ? filteredPrivatePageRows : privatePageRows), + [filter, filteredPrivatePageRows, privatePageRows], + ); + + const effectiveResourceTreeShellItems = useMemo( + () => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items, + [resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items], + ); + + const resourceShellVisibleRowIds = useMemo( + () => buildFileTreeShellVisibleRowIds(effectiveResourceTreeShellItems), + [effectiveResourceTreeShellItems], + ); + + const resourceShellRowById = useMemo( + () => + buildFileTreeShellRowById({ + fileTreeItems: effectiveResourceTreeShellItems, + nodeById, + assetById, + }), + [assetById, effectiveResourceTreeShellItems, nodeById], + ); + + const resourceRows = useMemo(() => { + if (isRustFamilyTreeRenderer) { + return []; + } + return buildVisibleRows({ + fileTreeItems: effectiveResourceTreeShellItems, + expanded, + expandedAssetFolderIds: expandedAssetFolders, + nodeById, + assetById, + }); + }, [ + assetById, + effectiveResourceTreeShellItems, + expanded, + expandedAssetFolders, + isRustFamilyTreeRenderer, + nodeById, + ]); + const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]); const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]); + const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer + ? resourceShellVisibleRowIds + : resourceVisibleRowIds; useEffect(() => { - setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds)); - }, [resourceVisibleRowIds]); + setResourceSelection((prev) => + normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds), + ); + }, [resourceSelectionVisibleRowIds]); const docParentById = useMemo( () => @@ -730,18 +693,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const activeWorkspace = sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ?? sidebarData.workspaces[0]; + const pageTreeFocusedDocumentId = pageTreeFocusedDocumentIdRef.current ?? (activeId || null); const handleOpenDocument = useCallback( - (documentId: string, mode: "main" | "sidebar") => { - const targetPath = `/documents/${documentId}`; - if (mode === "main") { - router.push(targetPath); + (documentId: string, mode: SidebarDocumentOpenMode) => { + const target = buildSidebarDocumentOpenTarget( + documentId, + mode, + typeof window !== "undefined" ? window.location.origin : null, + ); + if (target.kind === "same-window") { + router.push(target.path); setOpen(false); return; } if (typeof window !== "undefined") { - const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`; - window.open(sidebarUrl, "_blank", "noopener,noreferrer"); + window.open(target.url, "_blank", "noopener,noreferrer"); } }, [router, setOpen], @@ -981,7 +948,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const handlePageTreeShellNavigate = useCallback( (documentId: string) => { - handleOpenDocument(documentId, "sidebar"); + pageTreeFocusedDocumentIdRef.current = documentId; + handleOpenDocument(documentId, "main"); }, [handleOpenDocument], ); @@ -1008,6 +976,31 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar [nodeById], ); + const handlePageTreeShellExpandChange = useCallback( + (payload: { documentId: string | null; expanded: boolean }) => { + if (!payload.documentId) { + return; + } + setExpanded((prev) => { + const next = new Set(prev); + if (payload.expanded) { + next.add(payload.documentId!); + } else { + next.delete(payload.documentId!); + } + return next; + }); + }, + [], + ); + + const handlePageTreeShellFocusChange = useCallback( + (payload: { documentId: string | null }) => { + pageTreeFocusedDocumentIdRef.current = payload.documentId; + }, + [], + ); + const handleFileTreeShellContextMenu = useCallback( (payload: { documentId: string | null; @@ -1029,9 +1022,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } } - const row = payload.rowId ? resourceRowById.get(payload.rowId) : null; + const row = payload.rowId ? resourceShellRowById.get(payload.rowId) : null; const node = - row && (row.kind === "doc" || row.kind === "index") + row && (row.rowKind === "doc" || row.rowKind === "index") ? row.node : payload.documentId ? nodeById.get(payload.documentId) ?? null @@ -1045,7 +1038,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar y: payload.y, }); }, - [assetById, nodeById, resourceRowById], + [assetById, nodeById, resourceShellRowById], ); const handleFileTreeShellSelectionChange = useCallback( @@ -1054,26 +1047,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar anchorRowId: string | null; focusedRowId: string | null; }) => { - const visibleRowIds = resourceRows.map((row) => row.rowId); const normalized = normalizeTreePaneSelectionForVisibleRows( { selectedRowIds: new Set( - payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)), + payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)), ), anchorRowId: - payload.anchorRowId && resourceRowById.has(payload.anchorRowId) + payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId) ? payload.anchorRowId : null, focusedRowId: - payload.focusedRowId && resourceRowById.has(payload.focusedRowId) + payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId) ? payload.focusedRowId : null, }, - visibleRowIds, + resourceShellVisibleRowIds, ); setResourceSelection(normalized); }, - [resourceRowById, resourceRows], + [resourceShellRowById, resourceShellVisibleRowIds], ); const handleFileTreeShellAssetOpen = useCallback( @@ -1121,9 +1113,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } event.preventDefault(); - const orderedRowIds = resourceRows - .filter((row) => resourceSelection.selectedRowIds.has(row.rowId)) - .map((row) => row.rowId); + const orderedRowIds = resourceSelectionVisibleRowIds.filter((rowId) => + resourceSelection.selectedRowIds.has(rowId), + ); await writeTreePaneClipboardPayload({ type: "mnote-file-tree", version: 1, @@ -1140,30 +1132,68 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } - const targetDocId = inferPasteTargetDocId({ - focusedRowId: resourceSelection.focusedRowId, - rowById: resourceRowById, - activeDocId: activeId || null, - }); + const targetDocId = isRustFamilyTreeRenderer + ? inferFileTreeShellTargetDocumentId({ + focusedRowId: resourceSelection.focusedRowId, + rowById: resourceShellRowById, + activeDocId: activeId || null, + }) + : inferPasteTargetDocId({ + focusedRowId: resourceSelection.focusedRowId, + rowById: resourceRowById, + activeDocId: activeId || null, + }); if (!targetDocId) { setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); return; } - const rows = payload.rowIds - .map((rowId) => resourceRowById.get(rowId as any)) - .filter(Boolean) as TreePaneRow[]; - const docItemsMap = new Map(); - rows.forEach((row) => { - if (row.kind === "doc") { - docItemsMap.set(row.docId, true); - } else if (row.kind === "index") { - if (!docItemsMap.has(row.docId)) { - docItemsMap.set(row.docId, false); + const copyableAssetIds: string[] = []; + + if (isRustFamilyTreeRenderer) { + const rows = getOrderedFileTreeShellRows({ + rowIds: payload.rowIds, + visibleRowIds: resourceShellVisibleRowIds, + rowById: resourceShellRowById, + }); + + rows.forEach((row) => { + if (row.rowKind === "doc") { + docItemsMap.set(row.documentId, true); + return; } - } - }); + if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) { + docItemsMap.set(row.documentId, false); + return; + } + if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) { + copyableAssetIds.push(row.asset.id); + } + }); + } else { + const rows = payload.rowIds + .map((rowId) => resourceRowById.get(rowId as any)) + .filter(Boolean) as TreePaneRow[]; + + rows.forEach((row) => { + if (row.kind === "doc") { + docItemsMap.set(row.docId, true); + } else if (row.kind === "index") { + if (!docItemsMap.has(row.docId)) { + docItemsMap.set(row.docId, false); + } + } + }); + + rows + .filter((row): row is Extract => row.kind === "asset") + .map((row) => row.asset) + .filter((asset) => isRealFileAsset(asset)) + .forEach((asset) => { + copyableAssetIds.push(asset.id); + }); + } if (docItemsMap.size > 0) { try { @@ -1183,9 +1213,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar emitDocumentsChanged(targetDocId); } - const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; - const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); - if (copyableAssetIds.length > 0) { const resp = await fetch("/api/media/batch", { method: "POST", @@ -1213,10 +1240,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return () => window.removeEventListener("keydown", handler); }, [ activeId, + isRustFamilyTreeRenderer, + resourceSelectionVisibleRowIds, + resourceShellRowById, resourceRowById, - resourceRows, resourceSelection.focusedRowId, resourceSelection.selectedRowIds, + resourceShellVisibleRowIds, sidebarQuery, ]); @@ -1474,11 +1504,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const handleDeleteResourceSelection = useCallback(async () => { - const { docIds, assetIds } = computeTreePaneDeleteTargets({ - visibleRows: resourceRows, - selectedRowIds: resourceSelection.selectedRowIds, - parentById: docParentById, - }); + const shellDeleteTargets = isRustFamilyTreeRenderer + ? computeFileTreeShellDeleteTargets({ + visibleRowIds: resourceShellVisibleRowIds, + rowById: resourceShellRowById, + selectedRowIds: resourceSelection.selectedRowIds, + parentById: docParentById, + }) + : null; + const legacyDeleteTargets = !isRustFamilyTreeRenderer + ? computeTreePaneDeleteTargets({ + visibleRows: resourceRows, + selectedRowIds: resourceSelection.selectedRowIds, + parentById: docParentById, + }) + : null; + const docIds = shellDeleteTargets?.docIds ?? legacyDeleteTargets?.docIds ?? []; + const assetIds = shellDeleteTargets?.assetIds ?? legacyDeleteTargets?.assetIds ?? []; if (docIds.length === 0 && assetIds.length === 0) { return; @@ -1493,14 +1535,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ): row is Extract => row.kind === "asset" || row.kind === "asset-folder"; - const selectedAssetHints = Array.from( - new Map( - resourceRows - .filter((row) => resourceSelection.selectedRowIds.has(row.rowId)) - .filter(isAssetRow) - .map((row) => [row.asset.id, row.asset] as const), - ).values(), - ); + const selectedAssetHints = + shellDeleteTargets?.assetHints ?? + Array.from( + new Map( + resourceRows + .filter((row) => resourceSelection.selectedRowIds.has(row.rowId)) + .filter(isAssetRow) + .map((row) => [row.asset.id, row.asset] as const), + ).values(), + ); const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length; const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length; @@ -1558,12 +1602,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }, [ activeId, docParentById, + isRustFamilyTreeRenderer, resourceRows, + resourceShellRowById, + resourceShellVisibleRowIds, resourceSelection.selectedRowIds, handleDeleteAssets, - mediaAssets, - mindmapAssets, - tableAssets, refreshTree, router, ]); @@ -1710,31 +1754,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const handleResourcePaneDropFiles = useCallback( - (docId: string, files: FileList, targetRow?: TreePaneRow) => { + (payload: { + targetDocumentId: string | null; + targetRowId: string | null; + targetRowKind: string | null; + targetAssetId: string | null; + files: FileList | File[]; + }) => { void (async () => { - const droppedFiles = Array.from(files ?? []); + const droppedFiles = Array.from(payload.files ?? []); if (droppedFiles.length === 0) return; + const targetRow = + payload.targetRowId + ? (resourceShellRowById.get(payload.targetRowId) ?? null) + : null; - const targetMindmapId = (() => { - if (!targetRow) return null; - if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") { - return targetRow.asset.id; - } - if (targetRow.kind === "asset") { - return extractMindmapIdFromStoragePath(targetRow.asset.storage_path); - } - return null; - })(); + const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow); if (targetMindmapId) { setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId)); } const inferredTargetDocId = - docId || - inferPasteTargetDocId({ + payload.targetDocumentId || + inferFileTreeShellTargetDocumentId({ focusedRowId: resourceSelection.focusedRowId, - rowById: resourceRowById, + rowById: resourceShellRowById, activeDocId: activeId || null, }) || ""; @@ -1799,7 +1844,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar [ activeId, editorBridge, - resourceRowById, + resourceShellRowById, resourceSelection.focusedRowId, sidebarData.activeWorkspaceId, sidebarData.documents, @@ -1808,23 +1853,33 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const handleResourcePaneInternalDrop = useCallback( - (args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => { + (payload: { + targetDocumentId: string | null; + targetRowId: string | null; + targetRowKind: string | null; + targetAssetId: string | null; + rowIds: string[]; + copy: boolean; + }) => { void (async () => { - const targetDocId = inferDropTargetDocId(args.targetRow); + const targetRow = + payload.targetRowId + ? (resourceShellRowById.get(payload.targetRowId) ?? null) + : null; + const targetDocId = + payload.targetDocumentId ?? + targetRow?.documentId ?? + inferFileTreeShellTargetDocumentId({ + focusedRowId: resourceSelection.focusedRowId, + rowById: resourceShellRowById, + activeDocId: activeId || null, + }); if (!targetDocId) { setTimeout(() => window.alert("无法识别拖拽目标页面"), 0); return; } - const targetMindmapId = (() => { - if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") { - return args.targetRow.asset.id; - } - if (args.targetRow.kind === "asset") { - return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path); - } - return null; - })(); + const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow); const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined; @@ -1834,26 +1889,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const uniqueRowIds: string[] = []; const seen = new Set(); - args.rowIds.forEach((id) => { + payload.rowIds.forEach((id) => { if (!id || seen.has(id)) return; seen.add(id); uniqueRowIds.push(id); }); const rows = uniqueRowIds - .map((rowId) => resourceRowById.get(rowId as any)) - .filter(Boolean) as TreePaneRow[]; + .map((rowId) => resourceShellRowById.get(rowId) ?? null) + .filter((row): row is FileTreeShellRow => Boolean(row)); - const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId); - const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; - const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); + const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId); + const assetRows = rows.filter( + (row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } => + row.rowKind === "asset" && Boolean(row.asset), + ); + const copyableAssetIds = assetRows + .map((row) => row.asset) + .filter((asset) => isRealFileAsset(asset)) + .map((asset) => asset.id); if (docIds.length === 0 && copyableAssetIds.length === 0) { setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0); return; } - if (args.copy) { + if (payload.copy) { if (docIds.length > 0) { try { await copyTreeCommand({ @@ -1894,17 +1955,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById); if (topLevelDocIds.length > 0) { - if ( - isInvalidDocDrop({ - sourceDocIds: topLevelDocIds, - targetParentId: targetDocId, - parentById: docParentById, - }) - ) { - setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0); - return; - } - const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0; setTree((prev) => { let next = prev; @@ -1915,12 +1965,19 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }); setExpanded((prev) => new Set(prev).add(targetDocId)); - for (let i = 0; i < topLevelDocIds.length; i += 1) { - await moveDocumentCommand({ - documentId: topLevelDocIds[i], - parentId: targetDocId, - position: baseIndex + i, - }); + try { + for (let i = 0; i < topLevelDocIds.length; i += 1) { + await moveDocumentCommand({ + documentId: topLevelDocIds[i], + parentId: targetDocId, + position: baseIndex + i, + }); + } + } catch (error) { + await refreshTree(); + const message = error instanceof Error ? error.message : "移动页面失败"; + setTimeout(() => window.alert(message), 0); + return; } await refreshTree(); emitDocumentsChanged(targetDocId); @@ -1943,7 +2000,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } await sidebarQuery.refetch(); - const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id)); + const sourceDocIds = new Set( + assetRows + .map((row) => row.asset?.document_id ?? null) + .filter((documentId): documentId is string => Boolean(documentId)), + ); sourceDocIds.forEach((id) => emitAssetsChanged(id)); emitAssetsChanged(targetDocId); } @@ -1952,7 +2013,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar [ childrenCountByParentId, docParentById, - resourceRowById, + resourceSelection.focusedRowId, + activeId, + resourceShellRowById, moveLocalNode, refreshTree, sidebarQuery, @@ -2719,17 +2782,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar mode="page" rendererFamily={treeRendererFamily} workspaceId={sidebarData.activeWorkspaceId ?? null} - treeShellEnabled={filter.trim().length === 0} + treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0} className="h-full" - rows={visibleFilteredPrivatePageRows} + rows={isRustFamilyTreeRenderer ? undefined : visibleFilteredPrivatePageRows} + treeShellRows={effectivePageTreeShellRows} expanded={expanded} activeId={activeId} + focusedDocumentId={pageTreeFocusedDocumentId} onToggleExpand={toggleExpand} onMove={handleMove} onCreateChild={handleCreate} onContextMenu={openContextMenu} onNavigate={handlePageTreeShellNavigate} onPageContextMenu={handlePageTreeShellContextMenu} + onPageExpandChange={handlePageTreeShellExpandChange} + onPageFocusChange={handlePageTreeShellFocusChange} onTreeMutation={handleTreeShellMutation} />
@@ -2750,9 +2817,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar mode="filetree" rendererFamily={treeRendererFamily} workspaceId={sidebarData.activeWorkspaceId ?? null} - treeShellEnabled={filter.trim().length === 0} + treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0} className="h-full" - rows={resourceRows} + rows={isRustFamilyTreeRenderer ? undefined : resourceRows} + treeShellItems={effectiveResourceTreeShellItems} activeId={activeId} selectedRowIds={resourceSelection.selectedRowIds} onRowClick={handleResourceRowClick} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx index 10042320..24596e27 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx @@ -5,31 +5,62 @@ import { TreeShellIframeHost, type TreeShellPickerItem, } from "@/components/sidebar/tree-shell-iframe-host"; -import type { FileTreeRow } from "@/lib/file-tree/types"; +import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; import { cn } from "@/lib/utils"; export type TreeRendererFamily = "react" | "rust_family"; export type TreeShellHostMode = "page" | "filetree" | "picker"; +export type TreeShellPickerCommand = { + kind: "next" | "previous" | "home" | "end" | "pick"; + seq: number; +}; + +export type FileTreeShellInternalDropPayload = { + targetDocumentId: string | null; + targetRowId: string | null; + targetRowKind: string | null; + targetAssetId: string | null; + rowIds: string[]; + copy: boolean; +}; + +export type FileTreeShellExternalDropPayload = { + targetDocumentId: string | null; + targetRowId: string | null; + targetRowKind: string | null; + targetAssetId: string | null; + files: FileList | File[]; +}; + type TreeShellHostProps = { mode: TreeShellHostMode; surfaceTestId: string; rendererFamily?: TreeRendererFamily; className?: string; treeShellEnabled?: boolean; + fallbackImplementation?: string; workspaceId?: string | null; rootNodeId?: string | null; activeDocumentId?: string | null; + focusedDocumentId?: string | null; + activePickerItemKey?: string | null; allowRootPick?: boolean; excludeIds?: string[]; + pickerCommand?: TreeShellPickerCommand | null; pickerItems?: TreeShellPickerItem[]; - fileTreeRows?: FileTreeRow[]; + pageTreeItems?: PageTreeProjectionItem[]; + inlineFileTreeItems?: KernelFileTreeProjectionItem[]; channel?: string; host?: string; onNavigate?: (documentId: string) => void; onPick?: (targetId: string | null) => void; + onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void; onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void; + onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void; + onPageFocusChange?: (payload: { documentId: string | null }) => void; onFileTreeContextMenu?: (payload: { documentId: string | null; assetId: string | null; @@ -43,8 +74,8 @@ type TreeShellHostProps = { anchorRowId: string | null; focusedRowId: string | null; }) => void; - onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void; - onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void; + onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; + onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; children: ReactNode; @@ -56,18 +87,26 @@ export function TreeShellHost({ rendererFamily = "react", className, treeShellEnabled = true, + fallbackImplementation, workspaceId = null, rootNodeId = null, activeDocumentId = null, + focusedDocumentId = null, + activePickerItemKey = null, allowRootPick = false, excludeIds = [], - pickerItems = [], - fileTreeRows = [], + pickerCommand = null, + pickerItems, + pageTreeItems, + inlineFileTreeItems, channel, host, onNavigate, onPick, + onPickerFocusChange, onPageContextMenu, + onPageExpandChange, + onPageFocusChange, onFileTreeContextMenu, onFileTreeSelectionChange, onInternalDrop, @@ -77,13 +116,12 @@ export function TreeShellHost({ children, }: TreeShellHostProps) { const useRustHost = rendererFamily === "rust_family"; - const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim()); + const useIframeHost = useRustHost && Boolean(workspaceId?.trim()); const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react"; const implementation = useIframeHost ? "mnote_web_iframe_proxy" - : rendererFamily === "rust_family" - ? "react_fallback" - : "react_primary"; + : fallbackImplementation ?? + (rendererFamily === "rust_family" ? "react_fallback" : "react_primary"); return (
{useRustHost ? ( @@ -109,15 +148,22 @@ export function TreeShellHost({ workspaceId={workspaceId} rootNodeId={rootNodeId} activeDocumentId={activeDocumentId} + focusedDocumentId={focusedDocumentId} + activePickerItemKey={activePickerItemKey} allowRootPick={allowRootPick} excludeIds={excludeIds} + pickerCommand={pickerCommand} pickerItems={pickerItems} - fileTreeRows={fileTreeRows} + pageTreeItems={pageTreeItems} + inlineFileTreeItems={inlineFileTreeItems} channel={channel} host={host} onNavigate={onNavigate} onPick={onPick} + onPickerFocusChange={onPickerFocusChange} onPageContextMenu={onPageContextMenu} + onPageExpandChange={onPageExpandChange} + onPageFocusChange={onPageFocusChange} onFileTreeContextMenu={onFileTreeContextMenu} onFileTreeSelectionChange={onFileTreeSelectionChange} onInternalDrop={onInternalDrop} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx index 95ae17fa..d38e845b 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx @@ -1,8 +1,15 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; +import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; +import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; import { TreeShellIframeHost, + type TreeShellPickerItem, + buildTreeShellInlineKernelFileTreeItems, + buildTreeShellInlinePageItems, buildTreeShellIframeSrc, buildTreeShellInlinePickerItems, injectTreeShellInlineOverrides, @@ -24,6 +31,7 @@ describe("tree-shell-iframe-host", () => { act(() => { root.unmount(); }); + vi.restoreAllMocks(); container.remove(); }); @@ -32,6 +40,8 @@ describe("tree-shell-iframe-host", () => { mode: "picker", workspaceId: "ws_picker", activeDocumentId: "doc_active", + focusedDocumentId: "doc_focus", + activePickerItemKey: "__root__", allowRootPick: true, excludeIds: ["doc_hidden", "doc_other"], channel: "tree-picker-surface", @@ -43,6 +53,8 @@ describe("tree-shell-iframe-host", () => { expect(url.searchParams.get("workspaceId")).toBe("ws_picker"); expect(url.searchParams.get("mode")).toBe("picker"); expect(url.searchParams.get("activeDocumentId")).toBe("doc_active"); + expect(url.searchParams.get("focusedDocumentId")).toBe("doc_focus"); + expect(url.searchParams.get("activePickerItemKey")).toBe("__root__"); expect(url.searchParams.get("allowRootPick")).toBe("1"); expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other"); expect(url.searchParams.get("channel")).toBe("tree-picker-surface"); @@ -85,9 +97,409 @@ describe("tree-shell-iframe-host", () => { ); }); + it("page tree 提供 inline items 时应直接生成 srcDoc,不再 fetch /api/tree/shell", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + const pageItems: PageTreeProjectionItem[] = [ + { + rowId: "page:doc_parent", + nodeId: "doc_parent", + parentNodeId: null, + nodeType: "page", + projectionKind: "page_tree", + depth: 0, + position: 0, + title: "父页面", + childCount: 1, + expandable: true, + expandedByDefault: false, + capabilities: ["expand", "open"], + resourceMeta: { + resourceKind: "document", + documentId: "doc_parent", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", + node: { + id: "doc_parent", + title: "父页面", + workspace_id: "ws_1", + parent_id: null, + sort_order: 0, + is_archived: false, + is_deleted: false, + is_published: false, + is_starred: false, + access_scope: "private", + created_at: "2026-04-24T00:00:00.000Z", + updated_at: "2026-04-24T00:00:00.000Z", + children: [], + kernel: { + nodeType: "page", + depth: 0, + position: 0, + childCount: 1, + expandedByDefault: false, + }, + } as unknown as SidebarTreeNode, + }, + ]; + + expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([ + expect.objectContaining({ + nodeId: "doc_parent", + parentNodeId: null, + title: "父页面", + childCount: 1, + expandedByDefault: true, + }), + ]); + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); + expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__"); + expect(iframe?.getAttribute("srcdoc")).toContain("父页面"); + expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu"); + expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create"); + expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename"); + expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands"); + expect(iframe?.getAttribute("srcdoc")).not.toContain("\n"); + }); + + it("SSR 时 inline tree shell 应先输出稳定 loading srcDoc,避免大模板属性水合不一致", () => { + const pageItems: PageTreeProjectionItem[] = [ + { + rowId: "page:doc_parent", + nodeId: "doc_parent", + parentNodeId: null, + nodeType: "page", + projectionKind: "page_tree", + depth: 0, + position: 0, + title: "父页面", + childCount: 0, + expandable: false, + expandedByDefault: false, + capabilities: ["open"], + resourceMeta: { + resourceKind: "document", + documentId: "doc_parent", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", + node: {} as SidebarTreeNode, + }, + ]; + + const html = renderToString( + , + ); + + expect(html).toContain("Tree Shell Loading"); + expect(html).not.toContain("__MNOTE_TREE_SHELL_OVERRIDE__"); + expect(html).not.toContain("父页面"); + }); + + it("page tree 提供空 inline items 时也应直接生成 srcDoc", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); + expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__"); + }); + + it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + const fileTreeItems: KernelFileTreeProjectionItem[] = [ + { + rowId: "doc:doc_a", + rowKind: "document", + nodeId: "doc_a", + parentNodeId: null, + nodeType: "page", + projectionKind: "file_tree", + title: "文档 A", + depth: 0, + position: 0, + childCount: 2, + expandable: true, + expandedByDefault: true, + capabilities: ["expand", "open", "select"], + resourceMeta: { + resourceKind: "document", + documentId: "doc_a", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", + }, + { + rowId: "asset:asset_pdf", + rowKind: "asset", + nodeId: "asset:asset_pdf", + parentNodeId: "doc_a", + nodeType: "pdf", + projectionKind: "file_tree", + title: "guide.pdf", + depth: 1, + position: 1, + childCount: 0, + expandable: false, + expandedByDefault: false, + capabilities: ["open-asset", "select"], + resourceMeta: { + resourceKind: "pdf", + documentId: "doc_a", + assetId: "asset_pdf", + workspaceId: "ws_1", + assetKind: "pdf", + iconHint: "pdf", + }, + iconHint: "pdf", + }, + ]; + + expect(buildTreeShellInlineKernelFileTreeItems(fileTreeItems)).toEqual([ + expect.objectContaining({ + nodeId: "doc_a", + rowKind: "document", + iconHint: "page", + }), + expect.objectContaining({ + nodeId: "asset:asset_pdf", + rowKind: "asset", + iconHint: "pdf", + }), + ]); + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); + expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__"); + expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"'); + expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf"); + expect(iframe?.getAttribute("srcdoc")).not.toContain("\n"); + }); + + it("picker inline override 在高亮变化时应复用 bootstrap 文档,并通过 postMessage 同步状态", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + const pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]; + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); + const postMessage = vi.fn(); + Object.defineProperty(iframe, "contentWindow", { + configurable: true, + value: { postMessage }, + }); + + await act(async () => { + root.render( + , + ); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "tree-picker-surface", + type: "tree.shell.state.patch", + activeDocumentId: "doc_2", + activePickerItemKey: "doc_2", + }), + "*", + ); + }); + + it("picker 宿主应向 iframe 下发键盘命令,并接回焦点变化事件", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ), + ); + const onPickerFocusChange = vi.fn(); + const pickerItems: TreeShellPickerItem[] = [ + { kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }, + { kind: "doc", id: "doc_2", title: "页面 2", depth: 0 }, + ]; + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); + const postMessage = vi.fn(); + Object.defineProperty(iframe, "contentWindow", { + configurable: true, + value: window, + }); + vi.spyOn(window, "postMessage").mockImplementation(postMessage); + + await act(async () => { + root.render( + , + ); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "tree-picker-surface", + type: "tree.picker.command", + command: "next", + }), + "*", + ); + + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + channel: "tree-picker-surface", + type: "tree.picker.focus.changed", + documentId: "doc_2", + itemKey: "doc_2", + }, + source: window, + }), + ); + }); + + expect(onPickerFocusChange).toHaveBeenCalledWith({ + documentId: "doc_2", + itemKey: "doc_2", + }); + }); + it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => { const onNavigate = vi.fn(); const onPageContextMenu = vi.fn(); + const onPageExpandChange = vi.fn(); + const onPageFocusChange = vi.fn(); const onPick = vi.fn(); const onFileTreeContextMenu = vi.fn(); const onFileTreeSelectionChange = vi.fn(); @@ -95,20 +507,6 @@ describe("tree-shell-iframe-host", () => { const onTreeMutation = vi.fn(); const onInternalDrop = vi.fn(); const onDropFiles = vi.fn(); - const targetRow = { - kind: "doc", - rowId: "doc:doc_target", - depth: 0, - docId: "doc_target", - parentDocId: null, - node: { - _id: "doc_target", - id: "doc_target", - title: "目标页面", - }, - hasChildren: false, - isExpanded: false, - }; const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" }); await act(async () => { @@ -122,6 +520,8 @@ describe("tree-shell-iframe-host", () => { host="sidebar-file-tree-shell" onNavigate={onNavigate} onPageContextMenu={onPageContextMenu} + onPageExpandChange={onPageExpandChange} + onPageFocusChange={onPageFocusChange} onPick={onPick} onFileTreeContextMenu={onFileTreeContextMenu} onFileTreeSelectionChange={onFileTreeSelectionChange} @@ -178,6 +578,27 @@ describe("tree-shell-iframe-host", () => { source: window, }), ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + channel: "sidebar-file-tree-shell", + type: "tree.page.expand.changed", + documentId: "doc_2", + expanded: true, + }, + source: window, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + channel: "sidebar-file-tree-shell", + type: "tree.page.focus.changed", + documentId: "doc_3", + }, + source: window, + }), + ); window.dispatchEvent( new MessageEvent("message", { data: { @@ -244,6 +665,13 @@ describe("tree-shell-iframe-host", () => { x: 12, y: 34, }); + expect(onPageExpandChange).toHaveBeenCalledWith({ + documentId: "doc_2", + expanded: true, + }); + expect(onPageFocusChange).toHaveBeenCalledWith({ + documentId: "doc_3", + }); expect(onPick).toHaveBeenCalledWith(null); expect(onFileTreeContextMenu).toHaveBeenCalledWith({ documentId: "doc_2", @@ -275,7 +703,9 @@ describe("tree-shell-iframe-host", () => { type: "tree.filetree.internal-drop", rowIds: ["doc:doc_source", "asset:asset_source"], copy: true, - targetRow, + rowId: "doc:doc_target", + rowKind: "doc", + documentId: "doc_target", }, source: window, }), @@ -286,7 +716,8 @@ describe("tree-shell-iframe-host", () => { channel: "sidebar-file-tree-shell", type: "tree.filetree.drop-files", documentId: "doc_target", - targetRow, + rowId: "doc:doc_target", + rowKind: "doc", files: [droppedFile], }, source: window, @@ -295,10 +726,19 @@ describe("tree-shell-iframe-host", () => { }); expect(onInternalDrop).toHaveBeenCalledWith({ - targetRow, + targetDocumentId: "doc_target", + targetRowId: "doc:doc_target", + targetRowKind: "doc", + targetAssetId: null, rowIds: ["doc:doc_source", "asset:asset_source"], copy: true, }); - expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow); + expect(onDropFiles).toHaveBeenCalledWith({ + targetDocumentId: "doc_target", + targetRowId: "doc:doc_target", + targetRowKind: "doc", + targetAssetId: null, + files: [droppedFile], + }); }); }); diff --git a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx index 884df6df..9a9abd92 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx @@ -1,13 +1,14 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { TreeShellHostMode } from "@/components/sidebar/tree-shell-host"; -import type { FileTreeRow } from "@/lib/file-tree/types"; - -type TreeShellDocRow = Extract; -type TreeShellIndexRow = Extract; -type TreeShellAssetFolderRow = Extract; -type TreeShellAssetRow = Extract; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { + FileTreeShellExternalDropPayload, + FileTreeShellInternalDropPayload, + TreeShellHostMode, + TreeShellPickerCommand, +} from "@/components/sidebar/tree-shell-host"; +import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; type TreeShellBridgeContextMenuPayload = { documentId: string | null; @@ -18,6 +19,11 @@ type TreeShellBridgeContextMenuPayload = { y: number; }; +type TreeShellBridgePageExpandPayload = { + documentId: string | null; + expanded: boolean; +}; + type TreeShellBridgeMutationPayload = { type: string; documentId: string | null; @@ -29,6 +35,20 @@ type TreeShellBridgeSelectionPayload = { focusedRowId: string | null; }; +type TreeShellStatePatchMessage = { + channel: string; + type: "tree.shell.state.patch"; + activeDocumentId: string | null; + focusedDocumentId: string | null; + activePickerItemKey: string | null; +}; + +type TreeShellPickerCommandMessage = { + channel: string; + type: "tree.picker.command"; + command: TreeShellPickerCommand["kind"]; +}; + export type TreeShellPickerItem = | { kind: "root"; id: null; title: string; subtitle?: string; depth?: number } | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number }; @@ -41,6 +61,17 @@ type TreeShellInlineProjectionItem = { childCount: number; position: number; expandedByDefault: boolean; + rowKind?: string; + iconHint?: string; + capabilities?: string[]; + resourceMeta?: { + resourceKind?: string; + documentId?: string; + assetId?: string; + workspaceId?: string; + assetKind?: string; + iconHint?: string; + }; }; export type TreeShellIframeHostProps = { @@ -49,19 +80,26 @@ export type TreeShellIframeHostProps = { workspaceId: string; rootNodeId?: string | null; activeDocumentId?: string | null; + focusedDocumentId?: string | null; + activePickerItemKey?: string | null; allowRootPick?: boolean; excludeIds?: string[]; + pickerCommand?: TreeShellPickerCommand | null; pickerItems?: TreeShellPickerItem[]; - fileTreeRows?: FileTreeRow[]; + pageTreeItems?: PageTreeProjectionItem[]; + inlineFileTreeItems?: KernelFileTreeProjectionItem[]; channel?: string; host?: string; onNavigate?: (documentId: string) => void; onPick?: (targetId: string | null) => void; + onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void; onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void; + onPageExpandChange?: (payload: TreeShellBridgePageExpandPayload) => void; + onPageFocusChange?: (payload: { documentId: string | null }) => void; onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void; onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void; - onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void; - onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void; + onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; + onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onTreeMutation?: (payload: TreeShellBridgeMutationPayload) => void; }; @@ -75,13 +113,14 @@ type TreeShellBridgeMessage = { rowKind: string | null; x: number; y: number; + expanded: boolean; selectedRowIds: string[]; anchorRowId: string | null; focusedRowId: string | null; rowIds: string[]; copy: boolean; - targetRow: FileTreeRow | null; files: File[]; + itemKey: string | null; }; const INLINE_TREE_SHELL_LOADING_HTML = [ @@ -103,6 +142,997 @@ const INLINE_TREE_SHELL_LOADING_HTML = [ "", ].join(""); +const subscribeClientSnapshot = () => () => undefined; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +const LOCAL_TREE_SHELL_TEMPLATE = ` + + + + + mnote Tree Shell + + + +
+
+
+
Sidebar / Page Tree
+
+ +
+
+
+
+ + +
+ + + +`; + +function compactTreeShellSrcDoc(html: string) { + return html + .split(/\r?\n/) + .map((line) => line.trim()) + .join(""); +} + const normalizeString = (value: unknown, fallback = "") => { if (typeof value !== "string") return fallback; const normalized = value.trim(); @@ -120,10 +1150,6 @@ const readNumber = (value: unknown) => { const readBoolean = (value: unknown) => value === true; -const isRecord = (value: unknown): value is Record => { - return Boolean(value) && typeof value === "object"; -}; - const readStringArray = (value: unknown) => { if (!Array.isArray(value)) { return []; @@ -143,78 +1169,6 @@ const readFiles = (value: unknown) => { return value.filter((item): item is File => typeof File !== "undefined" && item instanceof File); }; -const readFileTreeRow = (value: unknown): FileTreeRow | null => { - if (!isRecord(value)) { - return null; - } - - const kind = normalizeString(value.kind); - const rowId = normalizeString(value.rowId); - const docId = normalizeString(value.docId); - const depth = Math.max(0, readNumber(value.depth)); - - if (!kind || !rowId || !docId) { - return null; - } - - switch (kind) { - case "doc": - if (!isRecord(value.node)) { - return null; - } - return { - kind: "doc", - rowId: rowId as FileTreeRow["rowId"], - depth, - docId, - parentDocId: readNullableString(value.parentDocId), - node: value.node as TreeShellDocRow["node"], - hasChildren: readBoolean(value.hasChildren), - isExpanded: readBoolean(value.isExpanded), - }; - case "index": - if (!isRecord(value.node)) { - return null; - } - return { - kind: "index", - rowId: rowId as FileTreeRow["rowId"], - depth, - docId, - parentDocId: readNullableString(value.parentDocId) ?? docId, - node: value.node as TreeShellIndexRow["node"], - }; - case "asset-folder": - if (!isRecord(value.asset)) { - return null; - } - return { - kind: "asset-folder", - rowId: rowId as FileTreeRow["rowId"], - depth, - docId, - parentDocId: readNullableString(value.parentDocId) ?? docId, - asset: value.asset as TreeShellAssetFolderRow["asset"], - hasChildren: readBoolean(value.hasChildren), - isExpanded: readBoolean(value.isExpanded), - }; - case "asset": - if (!isRecord(value.asset)) { - return null; - } - return { - kind: "asset", - rowId: rowId as FileTreeRow["rowId"], - depth, - docId, - parentDocId: readNullableString(value.parentDocId) ?? docId, - asset: value.asset as TreeShellAssetRow["asset"], - }; - default: - return null; - } -}; - function parseTreeShellBridgeMessage( value: unknown, expectedChannel: string, @@ -237,13 +1191,14 @@ function parseTreeShellBridgeMessage( rowKind: readNullableString(payload.rowKind ?? payload.targetRowKind), x: readNumber(payload.x), y: readNumber(payload.y), + expanded: readBoolean(payload.expanded), selectedRowIds: readStringArray(payload.selectedRowIds), anchorRowId: readNullableString(payload.anchorRowId), focusedRowId: readNullableString(payload.focusedRowId), rowIds: readStringArray(payload.rowIds), copy: readBoolean(payload.copy), - targetRow: readFileTreeRow(payload.targetRow), files: readFiles(payload.files), + itemKey: readNullableString(payload.itemKey ?? payload.pickerItemKey), }; } @@ -273,6 +1228,66 @@ export function buildTreeShellInlinePickerItems( })); } +export function buildTreeShellInlinePageItems( + items: PageTreeProjectionItem[], + expanded: ReadonlySet = new Set(), +): TreeShellInlineProjectionItem[] { + return items.map((item) => ({ + nodeId: normalizeString(item.nodeId), + parentNodeId: item.parentNodeId ?? null, + title: normalizeString(item.title, "无标题"), + depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0, + childCount: typeof item.childCount === "number" && Number.isFinite(item.childCount) ? Math.max(0, item.childCount) : 0, + position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0, + expandedByDefault: item.childCount > 0 ? expanded.has(item.nodeId) : false, + rowKind: "document", + iconHint: normalizeString(item.iconHint, "page"), + capabilities: Array.isArray(item.capabilities) + ? item.capabilities + .map((capability) => normalizeString(capability)) + .filter(Boolean) + : [], + resourceMeta: { + resourceKind: normalizeString(item.resourceMeta?.resourceKind, "document"), + documentId: normalizeString(item.resourceMeta?.documentId ?? item.nodeId), + workspaceId: normalizeString(item.resourceMeta?.workspaceId), + iconHint: normalizeString(item.resourceMeta?.iconHint, "page"), + }, + })).filter((item) => Boolean(item.nodeId)); +} + +export function buildTreeShellInlineKernelFileTreeItems( + items: KernelFileTreeProjectionItem[], +): TreeShellInlineProjectionItem[] { + return items + .map((item) => ({ + nodeId: normalizeString(item.nodeId), + parentNodeId: item.parentNodeId ?? null, + title: normalizeString(item.title, "无标题"), + depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0, + childCount: + typeof item.childCount === "number" && Number.isFinite(item.childCount) ? Math.max(0, item.childCount) : 0, + position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0, + expandedByDefault: item.expandedByDefault === true, + rowKind: normalizeString(item.rowKind, "document"), + iconHint: normalizeString(item.iconHint, "page"), + capabilities: Array.isArray(item.capabilities) + ? item.capabilities + .map((capability) => normalizeString(capability)) + .filter(Boolean) + : [], + resourceMeta: { + resourceKind: normalizeString(item.resourceMeta?.resourceKind), + documentId: normalizeString(item.resourceMeta?.documentId), + assetId: normalizeString(item.resourceMeta?.assetId), + workspaceId: normalizeString(item.resourceMeta?.workspaceId), + assetKind: normalizeString(item.resourceMeta?.assetKind), + iconHint: normalizeString(item.resourceMeta?.iconHint, normalizeString(item.iconHint, "page")), + }, + })) + .filter((item) => Boolean(item.nodeId)); +} + export function injectTreeShellInlineOverrides( html: string, overrides: { items?: TreeShellInlineProjectionItem[] }, @@ -286,11 +1301,52 @@ export function injectTreeShellInlineOverrides( return `${scriptTag}${html}`; } +function buildTreeShellBootstrapHtml(input: { + mode: TreeShellHostMode; + workspaceId: string; + rootNodeId?: string | null; + activeDocumentId?: string | null; + focusedDocumentId?: string | null; + activePickerItemKey?: string | null; + allowRootPick?: boolean; + excludeIds?: string[]; + channel: string; + host: string; +}) { + const appState = { + workspaceId: input.workspaceId, + rootNodeId: readNullableString(input.rootNodeId), + activeDocumentId: readNullableString(input.activeDocumentId), + focusedDocumentId: readNullableString(input.focusedDocumentId), + activePickerItemKey: readNullableString(input.activePickerItemKey), + actorId: "", + channel: input.channel, + host: input.host, + mode: input.mode, + allowRootPick: input.allowRootPick === true, + excludeIds: (input.excludeIds ?? []).map((item) => normalizeString(item)).filter(Boolean), + commandPath: "/api/tree/commands", + items: [], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }; + return compactTreeShellSrcDoc( + LOCAL_TREE_SHELL_TEMPLATE.replace( + "__APP_STATE__", + escapeInlineScriptJson(JSON.stringify(appState)), + ), + ); +} + export function buildTreeShellIframeSrc(input: { mode: TreeShellHostMode; workspaceId: string; rootNodeId?: string | null; activeDocumentId?: string | null; + focusedDocumentId?: string | null; + activePickerItemKey?: string | null; allowRootPick?: boolean; excludeIds?: string[]; channel: string; @@ -312,6 +1368,16 @@ export function buildTreeShellIframeSrc(input: { params.set("activeDocumentId", activeDocumentId); } + const focusedDocumentId = normalizeString(input.focusedDocumentId); + if (focusedDocumentId) { + params.set("focusedDocumentId", focusedDocumentId); + } + + const activePickerItemKey = normalizeString(input.activePickerItemKey); + if (activePickerItemKey) { + params.set("activePickerItemKey", activePickerItemKey); + } + if (input.allowRootPick) { params.set("allowRootPick", "1"); } @@ -332,15 +1398,22 @@ export function TreeShellIframeHost({ workspaceId, rootNodeId = null, activeDocumentId = null, + focusedDocumentId = null, + activePickerItemKey = null, allowRootPick = false, excludeIds = [], - pickerItems = [], - fileTreeRows = [], + pickerCommand = null, + pickerItems, + pageTreeItems, + inlineFileTreeItems, channel, host, onNavigate, onPick, + onPickerFocusChange, onPageContextMenu, + onPageExpandChange, + onPageFocusChange, onFileTreeContextMenu, onFileTreeSelectionChange, onInternalDrop, @@ -349,26 +1422,62 @@ export function TreeShellIframeHost({ onTreeMutation, }: TreeShellIframeHostProps) { const iframeRef = useRef(null); - const [inlineSrcDoc, setInlineSrcDoc] = useState(null); - const [inlineLoadFailed, setInlineLoadFailed] = useState(false); + const mounted = useSyncExternalStore( + subscribeClientSnapshot, + getClientSnapshot, + getServerSnapshot, + ); + const [iframeLoadVersion, setIframeLoadVersion] = useState(0); const resolvedChannel = channel?.trim() || surfaceTestId; const resolvedHost = host?.trim() || surfaceTestId; - const fileTreeRowById = useMemo( - () => new Map(fileTreeRows.map((row) => [row.rowId, row])), - [fileTreeRows], + const hasInlinePageOverride = mode === "page" && Array.isArray(pageTreeItems); + const expandedPageNodeIds = useMemo( + () => + new Set( + (pageTreeItems ?? []) + .filter((item) => item.childCount > 0 && item.expandedByDefault) + .map((item) => item.nodeId), + ), + [pageTreeItems], ); + const hasInlinePickerOverride = mode === "picker" && Array.isArray(pickerItems); + const hasInlineFileTreeItemOverride = mode === "filetree" && Array.isArray(inlineFileTreeItems); const inlinePickerItems = useMemo( - () => (mode === "picker" ? buildTreeShellInlinePickerItems(pickerItems) : []), - [mode, pickerItems], + () => (hasInlinePickerOverride ? buildTreeShellInlinePickerItems(pickerItems ?? []) : []), + [hasInlinePickerOverride, pickerItems], ); - const useInlinePickerOverride = inlinePickerItems.length > 0; - const src = useMemo( + const inlinePageItems = useMemo( + () => (hasInlinePageOverride ? buildTreeShellInlinePageItems(pageTreeItems ?? [], expandedPageNodeIds) : []), + [expandedPageNodeIds, hasInlinePageOverride, pageTreeItems], + ); + const inlineFileTreeProjectionItems = useMemo( + () => + hasInlineFileTreeItemOverride + ? buildTreeShellInlineKernelFileTreeItems(inlineFileTreeItems ?? []) + : [], + [hasInlineFileTreeItemOverride, inlineFileTreeItems], + ); + const inlineProjectionItems = + mode === "picker" + ? inlinePickerItems + : mode === "page" + ? inlinePageItems + : inlineFileTreeProjectionItems; + const useInlineProjectionOverride = + mode === "picker" + ? hasInlinePickerOverride + : mode === "page" + ? hasInlinePageOverride + : hasInlineFileTreeItemOverride; + const fallbackSrc = useMemo( () => buildTreeShellIframeSrc({ mode, workspaceId, rootNodeId, activeDocumentId, + focusedDocumentId, + activePickerItemKey, allowRootPick, excludeIds, channel: resolvedChannel, @@ -376,8 +1485,10 @@ export function TreeShellIframeHost({ }), [ activeDocumentId, + activePickerItemKey, allowRootPick, excludeIds, + focusedDocumentId, mode, resolvedChannel, resolvedHost, @@ -385,50 +1496,88 @@ export function TreeShellIframeHost({ workspaceId, ], ); + const inlineBootstrapActiveDocumentId = mode === "picker" ? null : activeDocumentId; + const inlineBootstrapHtml = useMemo( + () => + buildTreeShellBootstrapHtml({ + mode, + workspaceId, + rootNodeId, + activeDocumentId: inlineBootstrapActiveDocumentId, + focusedDocumentId: null, + activePickerItemKey: null, + allowRootPick, + excludeIds, + channel: resolvedChannel, + host: resolvedHost, + }), + [ + mode, + workspaceId, + rootNodeId, + inlineBootstrapActiveDocumentId, + allowRootPick, + excludeIds, + resolvedChannel, + resolvedHost, + ], + ); + const inlineSrcDoc = useMemo( + () => + useInlineProjectionOverride + ? injectTreeShellInlineOverrides(inlineBootstrapHtml, { + items: inlineProjectionItems, + }) + : null, + [inlineBootstrapHtml, inlineProjectionItems, useInlineProjectionOverride], + ); + const renderedInlineSrcDoc = + mounted && inlineSrcDoc ? inlineSrcDoc : INLINE_TREE_SHELL_LOADING_HTML; useEffect(() => { - if (!useInlinePickerOverride) { - setInlineSrcDoc(null); - setInlineLoadFailed(false); + const contentWindow = iframeRef.current?.contentWindow; + if (!contentWindow) { return; } - let disposed = false; - setInlineLoadFailed(false); - setInlineSrcDoc(null); - - void (async () => { - try { - const response = await fetch(src, { - method: "GET", - credentials: "include", - cache: "no-store", - }); - if (!response.ok) { - throw new Error(`tree shell inline override fetch failed: ${response.status}`); - } - const html = await response.text(); - if (disposed) { - return; - } - setInlineSrcDoc( - injectTreeShellInlineOverrides(html, { - items: inlinePickerItems, - }), - ); - } catch { - if (disposed) { - return; - } - setInlineLoadFailed(true); - setInlineSrcDoc(null); - } - })(); - - return () => { - disposed = true; + const message: TreeShellStatePatchMessage = { + channel: resolvedChannel, + type: "tree.shell.state.patch", + activeDocumentId: readNullableString(activeDocumentId), + focusedDocumentId: readNullableString(focusedDocumentId), + activePickerItemKey: readNullableString(activePickerItemKey), }; - }, [inlinePickerItems, src, useInlinePickerOverride]); + contentWindow.postMessage(message, "*"); + }, [ + activeDocumentId, + activePickerItemKey, + focusedDocumentId, + iframeLoadVersion, + resolvedChannel, + ]); + + useEffect(() => { + if (mode !== "picker" || !pickerCommand) { + return; + } + + const contentWindow = iframeRef.current?.contentWindow; + if (!contentWindow) { + return; + } + + const message: TreeShellPickerCommandMessage = { + channel: resolvedChannel, + type: "tree.picker.command", + command: pickerCommand.kind, + }; + contentWindow.postMessage(message, "*"); + }, [ + iframeLoadVersion, + mode, + pickerCommand, + resolvedChannel, + ]); useEffect(() => { const onMessage = (event: MessageEvent) => { @@ -452,6 +1601,12 @@ export function TreeShellIframeHost({ case "tree.pick.root": onPick?.(null); return; + case "tree.picker.focus.changed": + onPickerFocusChange?.({ + itemKey: message.itemKey, + documentId: message.documentId, + }); + return; case "tree.page.context-menu": if (message.documentId) { onPageContextMenu?.({ @@ -461,6 +1616,17 @@ export function TreeShellIframeHost({ }); } return; + case "tree.page.expand.changed": + onPageExpandChange?.({ + documentId: message.documentId, + expanded: message.expanded, + }); + return; + case "tree.page.focus.changed": + onPageFocusChange?.({ + documentId: message.documentId, + }); + return; case "tree.filetree.context-menu": onFileTreeContextMenu?.({ documentId: message.documentId, @@ -480,14 +1646,11 @@ export function TreeShellIframeHost({ return; case "tree.filetree.internal-drop": if (message.rowIds.length > 0) { - const targetRow = - message.targetRow ?? - (message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null); - if (!targetRow) { - return; - } onInternalDrop?.({ - targetRow, + targetDocumentId: message.documentId, + targetRowId: message.rowId, + targetRowKind: message.rowKind, + targetAssetId: message.assetId, rowIds: message.rowIds, copy: message.copy, }); @@ -496,14 +1659,13 @@ export function TreeShellIframeHost({ case "tree.filetree.external-drop": case "tree.filetree.drop-files": if (message.files.length > 0) { - const targetRow = - message.targetRow ?? - (message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null); - const targetDocId = message.documentId ?? targetRow?.docId ?? null; - if (!targetDocId) { - return; - } - onDropFiles?.(targetDocId, message.files, targetRow ?? undefined); + onDropFiles?.({ + targetDocumentId: message.documentId, + targetRowId: message.rowId, + targetRowKind: message.rowKind, + targetAssetId: message.assetId, + files: message.files, + }); } return; case "tree.asset.open": @@ -531,14 +1693,16 @@ export function TreeShellIframeHost({ }; }, [ onAssetOpen, - fileTreeRowById, onFileTreeContextMenu, onFileTreeSelectionChange, onInternalDrop, onDropFiles, onNavigate, onPageContextMenu, + onPageExpandChange, + onPageFocusChange, onPick, + onPickerFocusChange, onTreeMutation, resolvedChannel, ]); @@ -546,13 +1710,14 @@ export function TreeShellIframeHost({ return (