diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 897ed6b2..f48475c1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -481,6 +481,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive-where" version = "1.6.1" @@ -1410,6 +1419,7 @@ dependencies = [ "serde", "serde_json", "storage-convex-bridge", + "time", "tokio", "tower", "tower-http", @@ -1432,6 +1442,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "oco_ref" version = "0.2.1" @@ -1519,6 +1535,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2341,6 +2363,37 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 802c2e46..71e04e6e 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -1,44 +1,45 @@ use adapter_onlyoffice::{ - prepare_callback, prepare_forcesave, prepare_proxy_request, resolve_session, sign_config, OnlyOfficeCallbackPreparationInput, OnlyOfficeForcesavePreparationInput, - OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, + OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, prepare_callback, + prepare_forcesave, prepare_proxy_request, resolve_session, sign_config, }; use core_domain::Timestamp; use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode}; use core_protocol::{ - default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps, - CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult, - DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, - DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, - DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand, - EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand, - GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge, - KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, - KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, - KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, - KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind, - KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, - KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, - KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, - KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, - MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, - PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, - ToolExecutionMode, ToolInvocation, + ActorPayload, BlockProps, CommandEnvelope, ContentNode, ContentNodePayload, + DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, + DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, + DocumentReadStats, DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, + EditorCommand, EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, + GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, + KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, + KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, + KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, + KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, + KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter, + KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, + KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, + KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, + KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, MindmapNodeData, + MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, PatchBlock, + PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, + ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, + default_tool_registry, invocation_kind_label, tool_effect_label, }; use event_log::DomainEventRecord; use index_fts::{ - can_rebuild_from_events, evaluate_search_documents, rebuild_from_events, IndexCursor, - MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation, - SearchDocumentsRequest, + IndexCursor, MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation, + SearchDocumentsRequest, can_rebuild_from_events, evaluate_search_documents, + rebuild_from_events, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; use storage_convex_bridge::{ - build_query_request, build_write_request, BridgeContext, BridgeError, BridgeErrorKind, + BridgeContext, BridgeError, BridgeErrorKind, build_query_request, build_write_request, }; static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -57,6 +58,13 @@ pub enum RuntimeInput { context: RuntimeBridgeContextWire, command: RuntimeCommandEnvelopeWire, }, + CommandArtifact { + context: RuntimeBridgeContextWire, + command: RuntimeCommandEnvelopeWire, + plan: RuntimeCommandExecutionPlan, + result: Value, + now: String, + }, Tool { context: RuntimeBridgeContextWire, tool: RuntimeToolInvocationWire, @@ -219,6 +227,13 @@ pub struct RuntimeSuccess { pub plan: RuntimeExecutionPlan, } +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeArtifactSuccess { + pub ok: bool, + pub artifacts: Option, +} + #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeFailure { @@ -260,7 +275,7 @@ pub struct RuntimeQueryExecutionPlan { pub args_json: Value, } -#[derive(Debug, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeCommandExecutionPlan { pub command_name: String, @@ -275,6 +290,57 @@ pub struct RuntimeCommandExecutionPlan { pub args_json: Value, } +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeCommandArtifactPlan { + pub command_log: RuntimeCommandLogArtifactPlan, + pub domain_event: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeCommandLogArtifactPlan { + pub workspace_id: String, + pub id: String, + pub request_id: String, + pub trace_id: String, + pub command_id: String, + pub command_name: String, + pub actor_id: String, + pub actor_type: String, + pub source_channel: String, + pub source_client: String, + pub status: String, + pub target_page_id: Option, + pub target_block_id: Option, + pub payload: Value, + pub payload_summary: String, + pub refs: Vec, + pub idempotency_key: Option, + pub error: Option, + pub created_at: String, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeDomainEventArtifactPlan { + pub workspace_id: String, + pub id: String, + pub request_id: String, + pub trace_id: String, + pub command_id: String, + pub command_log_id: String, + pub event_type: String, + pub aggregate_type: String, + pub aggregate_id: String, + pub event_version: i64, + pub status: String, + pub actor_type: String, + pub payload: Value, + pub created_at: String, +} + #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeToolExecutionPlan { @@ -414,6 +480,8 @@ struct KernelProjectViewQueryPayload { include_edges: Option, node_types: Option>, edge_types: Option>, + query: Option, + max_results: Option, } #[derive(Debug, Deserialize)] @@ -500,6 +568,60 @@ struct DocumentSaveCommandPayload { conflict_detection_key: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentOptionsCommandPayload { + document_id: String, + workspace_id: Option, + options: DocumentOptionsPatchPayload, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentStatsCommandPayload { + document_id: String, + workspace_id: Option, + stats: DocumentStatsPatchPayload, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentStatsPatchPayload { + word_count: i64, + character_count: i64, + block_count: i64, + todo_total: i64, + todo_done: i64, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct DocumentOptionsPatchPayload { + wide_layout: Option, + small_text: Option, + show_heading_numbers: Option, + show_toc: Option, + show_structure: Option, + protect_editing: Option, + show_word_count: Option, + collapse_backlinks: Option, + page_font: Option, + layout_density: Option, + hide_child_pages: Option, + show_block_ref_count: Option, + embed_default_block_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MediaAssetReplaceStorageCommandPayload { + asset_id: String, + document_id: String, + workspace_id: Option, + storage_id: String, + user_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentEmbedCommandPayload { @@ -623,6 +745,30 @@ struct DocumentMoveSnapshotDocument { workspace_id: Option, #[serde(default, alias = "parent_id")] parent_id: Option, + #[serde(default, alias = "sort_order")] + sort_order: Option, + #[serde(default, alias = "created_at")] + created_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMoveOrderPatch { + document_id: String, + parent_id: Option, + sort_order: i64, + moved: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentMoveOrderPlan { + document_id: String, + from_parent_id: Option, + to_parent_id: Option, + requested_sort_order: i64, + normalized_sort_order: i64, + patches: Vec, } #[derive(Debug, Deserialize)] @@ -669,10 +815,9 @@ fn derive_document_move_preflight_from_snapshot( .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 source_document = document_by_id + .get(payload.document_id.as_str()) + .ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?; let target_parent_document = payload .parent_id @@ -714,14 +859,179 @@ fn derive_document_move_preflight_from_snapshot( 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_parent_document: target_parent_document.map(|document| { + DocumentMovePreflightDocument { + id: document.id, + workspace_id: document.workspace_id, + } }), target_ancestor_ids, }) } +fn normalize_parent_id(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn document_move_sort_key(document: &DocumentMoveSnapshotDocument) -> (i64, String, String) { + ( + document.sort_order.unwrap_or(i64::MAX), + document.created_at.clone().unwrap_or_default(), + document.id.clone(), + ) +} + +fn clamp_document_move_index(sort_order: i64, max: usize) -> usize { + if sort_order < 0 { + return 0; + } + (sort_order as usize).min(max) +} + +fn append_document_move_order_patches( + patches: &mut Vec, + ordered: &[DocumentMoveSnapshotDocument], + parent_id: Option, + moved_document_id: &str, +) { + for (index, document) in ordered.iter().enumerate() { + let sort_order = index as i64; + let moved = document.id == moved_document_id; + if normalize_parent_id(&document.parent_id) == parent_id + && document.sort_order == Some(sort_order) + && !moved + { + continue; + } + patches.push(DocumentMoveOrderPatch { + document_id: document.id.clone(), + parent_id: parent_id.clone(), + sort_order, + moved, + }); + } +} + +fn build_document_move_order_plan_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 + .iter() + .cloned() + .map(|document| (document.id.clone(), document)) + .collect(); + let source_document = document_by_id + .get(payload.document_id.as_str()) + .cloned() + .ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?; + let from_parent_id = normalize_parent_id(&source_document.parent_id); + let to_parent_id = normalize_parent_id(&payload.parent_id); + + let mut sibling_by_parent = + BTreeMap::, Vec>::new(); + for document in documents { + sibling_by_parent + .entry(normalize_parent_id(&document.parent_id)) + .or_default() + .push(document); + } + for siblings in sibling_by_parent.values_mut() { + siblings.sort_by_key(document_move_sort_key); + } + + let mut patches = Vec::new(); + let normalized_sort_order; + if from_parent_id == to_parent_id { + let mut siblings = sibling_by_parent + .remove(&to_parent_id) + .unwrap_or_default() + .into_iter() + .filter(|document| document.id != payload.document_id) + .collect::>(); + let position = clamp_document_move_index(payload.sort_order, siblings.len()); + normalized_sort_order = position as i64; + siblings.insert(position, source_document); + append_document_move_order_patches( + &mut patches, + &siblings, + to_parent_id.clone(), + payload.document_id.as_str(), + ); + } else { + let old_siblings = sibling_by_parent + .remove(&from_parent_id) + .unwrap_or_default() + .into_iter() + .filter(|document| document.id != payload.document_id) + .collect::>(); + append_document_move_order_patches( + &mut patches, + &old_siblings, + from_parent_id.clone(), + payload.document_id.as_str(), + ); + + let mut new_siblings = sibling_by_parent + .remove(&to_parent_id) + .unwrap_or_default() + .into_iter() + .filter(|document| document.id != payload.document_id) + .collect::>(); + let position = clamp_document_move_index(payload.sort_order, new_siblings.len()); + normalized_sort_order = position as i64; + new_siblings.insert(position, source_document); + append_document_move_order_patches( + &mut patches, + &new_siblings, + to_parent_id.clone(), + payload.document_id.as_str(), + ); + } + + Ok(DocumentMoveOrderPlan { + document_id: payload.document_id.clone(), + from_parent_id, + to_parent_id, + requested_sort_order: payload.sort_order, + normalized_sort_order, + patches, + }) +} + +fn resolve_document_move_order_plan( + payload: &DocumentMoveCommandPayload, + preflight_data: Option<&Value>, +) -> Result, BridgeError> { + let Some(raw_preflight) = preflight_data else { + return Ok(None); + }; + let snapshot = + match serde_json::from_value::(raw_preflight.clone()) { + Ok(snapshot) + if !snapshot.documents.is_empty() || snapshot.sidebar_snapshot.is_some() => + { + snapshot + } + _ => return Ok(None), + }; + build_document_move_order_plan_from_snapshot(payload, &snapshot).map(Some) +} + fn resolve_document_move_preflight( payload: &DocumentMoveCommandPayload, preflight_data: Option<&Value>, @@ -745,7 +1055,12 @@ 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 { + let Some(parent_id) = payload + .parent_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { return Ok(()); }; @@ -755,7 +1070,7 @@ fn validate_document_move_legality( 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("不能把页面移动到自身下面")); @@ -845,6 +1160,1218 @@ struct DocumentCopyTreeCommandPayload { target_parent_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceTransferCommandPayload { + asset_ids: Vec, + target_document_id: String, + target_sub_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceUploadCommandPayload { + asset_id: String, + workspace_id: String, + target_document_id: String, + target_sub_path: Option, + file_name: Option, + file_size: Option, + mime_type: Option, + asset_type: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropPreflightCommandPayload { + copy: bool, + target_document_id: Option, + target_row_id: Option, + focused_row_id: Option, + active_document_id: Option, + #[serde(default)] + row_ids: Vec, + #[serde(default)] + rows: Vec, + #[serde(default)] + document_parents: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeDeletePreflightCommandPayload { + #[serde(default)] + row_ids: Vec, + #[serde(default)] + rows: Vec, + #[serde(default)] + document_parents: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreePastePreflightCommandPayload { + target_document_id: Option, + focused_row_id: Option, + active_document_id: Option, + #[serde(default)] + row_ids: Vec, + #[serde(default)] + rows: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeUploadTargetPreflightCommandPayload { + workspace_id: Option, + target_document_id: Option, + target_row_id: Option, + focused_row_id: Option, + active_document_id: Option, + #[serde(default)] + rows: Vec, + #[serde(default)] + document_workspaces: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropPreflightRow { + row_id: String, + row_kind: String, + document_id: Option, + asset_id: Option, + asset_document_id: Option, + asset_type: Option, + storage_path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropPreflightDocumentParent { + document_id: String, + parent_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeUploadTargetDocumentWorkspace { + document_id: String, + workspace_id: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropPlan { + copy: bool, + target_document_id: String, + target_mindmap_id: Option, + target_sub_path: Option, + row_ids: Vec, + doc_ids: Vec, + top_level_doc_ids: Vec, + copyable_asset_ids: Vec, + source_asset_document_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + document_transfer_plan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resource_transfer_plan: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreeDeletePlan { + row_ids: Vec, + doc_ids: Vec, + asset_ids: Vec, + asset_document_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreePastePlan { + target_document_id: String, + target_mindmap_id: Option, + target_sub_path: Option, + row_ids: Vec, + doc_items: Vec, + copyable_asset_ids: Vec, + resource_transfer_plan: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreeUploadTargetPlan { + workspace_id: String, + target_document_id: String, + target_mindmap_id: Option, + target_sub_path: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropDocumentTransferPlan { + action: String, + target_parent_id: String, + document_ids: Vec, + top_level_document_ids: Vec, + copy_items: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct DocumentCopyTreeItemPlan { + document_id: String, + recursive: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ResourceTransferPlan { + action: String, + asset_ids: Vec, + target_document_id: String, + target_sub_path: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ResourceUploadPlan { + action: String, + asset_id: String, + workspace_id: String, + target_document_id: String, + target_sub_path: Option, + file_name: Option, + file_size: Option, + mime_type: Option, + asset_type: String, +} + +fn normalize_resource_asset_ids(raw_asset_ids: &[String]) -> Vec { + let mut asset_ids = Vec::new(); + for raw in raw_asset_ids { + let asset_id = raw.trim(); + if asset_id.is_empty() || asset_ids.iter().any(|existing| existing == asset_id) { + continue; + } + asset_ids.push(asset_id.to_string()); + } + asset_ids +} + +fn sanitize_resource_target_sub_path(raw: Option<&str>) -> Option { + let segments = raw? + .replace('\\', "/") + .split('/') + .map(str::trim) + .filter(|segment| !segment.is_empty() && *segment != "." && *segment != "..") + .map(|segment| segment.replace(['\\', '/'], "_")) + .take(8) + .collect::>(); + if segments.is_empty() { + None + } else { + Some(segments.join("/")) + } +} + +fn build_resource_transfer_plan( + action: &str, + payload: &ResourceTransferCommandPayload, +) -> Result { + let asset_ids = normalize_resource_asset_ids(&payload.asset_ids); + if asset_ids.is_empty() { + return Err(BridgeError::validation("resource command 缺少 assetIds")); + } + + let target_document_id = payload.target_document_id.trim(); + if target_document_id.is_empty() { + return Err(BridgeError::validation( + "resource command 缺少 targetDocumentId", + )); + } + + Ok(ResourceTransferPlan { + action: action.into(), + asset_ids, + target_document_id: target_document_id.into(), + target_sub_path: sanitize_resource_target_sub_path(payload.target_sub_path.as_deref()), + }) +} + +fn normalize_optional_resource_string(raw: Option<&str>) -> Option { + let value = raw?.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn normalize_resource_asset_type(raw: Option<&str>) -> String { + match raw.unwrap_or("file").trim() { + "image" => "image".into(), + "video" => "video".into(), + "audio" => "audio".into(), + "mindmap" => "mindmap".into(), + "luckysheet" => "luckysheet".into(), + _ => "file".into(), + } +} + +fn build_resource_upload_plan( + payload: &ResourceUploadCommandPayload, +) -> Result { + let asset_id = payload.asset_id.trim(); + if asset_id.is_empty() { + return Err(BridgeError::validation("resource upload 缺少 assetId")); + } + let workspace_id = payload.workspace_id.trim(); + if workspace_id.is_empty() { + return Err(BridgeError::validation("resource upload 缺少 workspaceId")); + } + let target_document_id = payload.target_document_id.trim(); + if target_document_id.is_empty() { + return Err(BridgeError::validation( + "resource upload 缺少 targetDocumentId", + )); + } + + Ok(ResourceUploadPlan { + action: "upload".into(), + asset_id: asset_id.into(), + workspace_id: workspace_id.into(), + target_document_id: target_document_id.into(), + target_sub_path: sanitize_resource_target_sub_path(payload.target_sub_path.as_deref()), + file_name: normalize_optional_resource_string(payload.file_name.as_deref()) + .map(|value| value.replace(['\\', '/'], "_")), + file_size: payload.file_size.filter(|value| *value >= 0), + mime_type: normalize_optional_resource_string(payload.mime_type.as_deref()), + asset_type: normalize_resource_asset_type(payload.asset_type.as_deref()), + }) +} + +fn normalize_optional_filetree_string(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn filetree_drop_row_document_id(row: &FileTreeDropPreflightRow) -> Option { + normalize_optional_filetree_string(&row.document_id) +} + +fn extract_filetree_drop_mindmap_id_from_storage_path(raw: Option<&str>) -> Option { + let normalized = raw?.replace('\\', "/"); + let prefix = "mindmaps/"; + if let Some(rest) = normalized.strip_prefix(prefix) { + return rest + .split('/') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + } + + let marker = "/mindmaps/"; + let idx = normalized.find(marker)?; + normalized[idx + marker.len()..] + .split('/') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn resolve_filetree_drop_mindmap_target_id( + row: Option<&FileTreeDropPreflightRow>, +) -> Option { + let row = row?; + let row_kind = row.row_kind.trim(); + let asset_type = row.asset_type.as_deref().map(str::trim).unwrap_or_default(); + if row_kind == "asset-folder" && asset_type == "mindmap" { + return normalize_optional_filetree_string(&row.asset_id); + } + if row_kind == "asset" { + return extract_filetree_drop_mindmap_id_from_storage_path(row.storage_path.as_deref()); + } + None +} + +fn ordered_unique_filetree_row_ids(row_ids: &[String]) -> Vec { + let mut result = Vec::new(); + for raw in row_ids { + let row_id = raw.trim(); + if row_id.is_empty() || result.iter().any(|existing| existing == row_id) { + continue; + } + result.push(row_id.to_string()); + } + result +} + +fn collect_filetree_doc_ancestors( + document_id: &str, + parent_by_document_id: &HashMap>, +) -> Vec { + let mut ancestors = Vec::new(); + let mut cursor = parent_by_document_id + .get(document_id) + .and_then(|parent_id| parent_id.clone()); + let mut depth = 0; + while let Some(parent_id) = cursor { + if depth >= 256 || ancestors.iter().any(|existing| existing == &parent_id) { + break; + } + ancestors.push(parent_id.clone()); + cursor = parent_by_document_id + .get(parent_id.as_str()) + .and_then(|parent_id| parent_id.clone()); + depth += 1; + } + ancestors +} + +fn filter_top_level_filetree_doc_ids( + doc_ids: &[String], + parent_by_document_id: &HashMap>, +) -> Vec { + let doc_set = doc_ids + .iter() + .cloned() + .collect::>(); + doc_ids + .iter() + .filter(|doc_id| { + !collect_filetree_doc_ancestors(doc_id, parent_by_document_id) + .iter() + .any(|ancestor_id| doc_set.contains(ancestor_id)) + }) + .cloned() + .collect() +} + +fn is_filetree_drop_real_asset(row: &FileTreeDropPreflightRow) -> bool { + normalize_optional_filetree_string(&row.asset_id).is_some() + && normalize_optional_filetree_string(&row.storage_path).is_some() +} + +fn validate_filetree_drop_doc_legality( + plan: &FileTreeDropPlan, + parent_by_document_id: &HashMap>, +) -> Result<(), BridgeError> { + if plan.copy { + return Ok(()); + } + + for doc_id in &plan.top_level_doc_ids { + if doc_id == &plan.target_document_id { + return Err(BridgeError::validation("不能把页面移动到自身或后代下面")); + } + let target_ancestors = + collect_filetree_doc_ancestors(plan.target_document_id.as_str(), parent_by_document_id); + if target_ancestors + .iter() + .any(|ancestor_id| ancestor_id == doc_id) + { + return Err(BridgeError::validation("不能把页面移动到自身或后代下面")); + } + } + + Ok(()) +} + +fn build_filetree_delete_plan( + payload: &FileTreeDeletePreflightCommandPayload, +) -> Result { + let row_by_id: HashMap = payload + .rows + .iter() + .filter_map(|row| { + let row_id = row.row_id.trim(); + if row_id.is_empty() { + None + } else { + Some((row_id.to_string(), row.clone())) + } + }) + .collect(); + let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) + .into_iter() + .filter(|row_id| row_by_id.contains_key(row_id.as_str())) + .collect::>(); + let selected_rows = row_ids + .iter() + .filter_map(|row_id| row_by_id.get(row_id.as_str())) + .collect::>(); + + let mut doc_candidates = Vec::new(); + let mut asset_candidates = Vec::new(); + let mut asset_document_by_id = HashMap::::new(); + + for row in selected_rows { + let row_kind = row.row_kind.trim(); + if row_kind == "doc" || row_kind == "index" { + if let Some(document_id) = filetree_drop_row_document_id(row) { + doc_candidates.push(document_id); + } + continue; + } + + if (row_kind == "asset" || row_kind == "asset-folder") + && normalize_optional_filetree_string(&row.asset_id).is_some() + { + let asset_id = normalize_optional_filetree_string(&row.asset_id).unwrap_or_default(); + if asset_id.is_empty() { + continue; + } + asset_candidates.push(asset_id.clone()); + if let Some(document_id) = normalize_optional_filetree_string(&row.asset_document_id) + .or_else(|| filetree_drop_row_document_id(row)) + { + asset_document_by_id.insert(asset_id, document_id); + } + } + } + + let parent_by_document_id: HashMap> = payload + .document_parents + .iter() + .filter_map(|entry| { + let document_id = entry.document_id.trim(); + if document_id.is_empty() { + return None; + } + Some(( + document_id.to_string(), + normalize_optional_filetree_string(&entry.parent_id), + )) + }) + .collect(); + let doc_ids = filter_top_level_filetree_doc_ids(&doc_candidates, &parent_by_document_id); + let doc_id_set = doc_ids + .iter() + .cloned() + .collect::>(); + + let mut asset_ids = Vec::new(); + let mut asset_document_ids = Vec::new(); + for asset_id in asset_candidates { + if asset_ids.iter().any(|existing| existing == &asset_id) { + continue; + } + let owner_document_id = asset_document_by_id.get(asset_id.as_str()).cloned(); + if let Some(document_id) = owner_document_id.as_ref() { + if doc_id_set.contains(document_id) + || collect_filetree_doc_ancestors(document_id, &parent_by_document_id) + .iter() + .any(|ancestor_id| doc_id_set.contains(ancestor_id)) + { + continue; + } + } + asset_ids.push(asset_id.clone()); + if let Some(owner_document_id) = owner_document_id { + if !asset_document_ids + .iter() + .any(|existing| existing == &owner_document_id) + { + asset_document_ids.push(owner_document_id); + } + } + } + + if doc_ids.is_empty() && asset_ids.is_empty() { + return Err(BridgeError::validation("没有可删除的对象")); + } + + Ok(FileTreeDeletePlan { + row_ids, + doc_ids, + asset_ids, + asset_document_ids, + }) +} + +fn upsert_filetree_paste_doc_item( + doc_items: &mut Vec, + document_id: String, + recursive: bool, +) { + if let Some(existing) = doc_items + .iter_mut() + .find(|item| item.document_id == document_id) + { + existing.recursive = existing.recursive || recursive; + return; + } + doc_items.push(DocumentCopyTreeItemPlan { + document_id, + recursive, + }); +} + +fn build_filetree_paste_plan( + payload: &FileTreePastePreflightCommandPayload, +) -> Result { + let row_by_id: HashMap = payload + .rows + .iter() + .filter_map(|row| { + let row_id = row.row_id.trim(); + if row_id.is_empty() { + None + } else { + Some((row_id.to_string(), row.clone())) + } + }) + .collect(); + + let focused_row = normalize_optional_filetree_string(&payload.focused_row_id) + .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); + let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) + .or_else(|| focused_row.as_ref().and_then(filetree_drop_row_document_id)) + .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) + .ok_or_else(|| BridgeError::validation("请选择一个目标页面后再粘贴"))?; + + let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) + .into_iter() + .filter(|row_id| row_by_id.contains_key(row_id.as_str())) + .collect::>(); + let selected_rows = row_ids + .iter() + .filter_map(|row_id| row_by_id.get(row_id.as_str())) + .collect::>(); + + let mut doc_items = Vec::new(); + let mut copyable_asset_ids = Vec::new(); + for row in selected_rows { + let row_kind = row.row_kind.trim(); + if row_kind == "doc" { + if let Some(document_id) = filetree_drop_row_document_id(row) { + upsert_filetree_paste_doc_item(&mut doc_items, document_id, true); + } + continue; + } + if row_kind == "index" { + if let Some(document_id) = filetree_drop_row_document_id(row) { + upsert_filetree_paste_doc_item(&mut doc_items, document_id, false); + } + continue; + } + if row_kind == "asset" && is_filetree_drop_real_asset(row) { + if let Some(asset_id) = normalize_optional_filetree_string(&row.asset_id) { + if !copyable_asset_ids + .iter() + .any(|existing| existing == &asset_id) + { + copyable_asset_ids.push(asset_id); + } + } + } + } + + if doc_items.is_empty() && copyable_asset_ids.is_empty() { + return Err(BridgeError::validation( + "没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)", + )); + } + + let target_mindmap_id = resolve_filetree_drop_mindmap_target_id(focused_row.as_ref()); + let target_sub_path = target_mindmap_id + .as_deref() + .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); + let resource_transfer_plan = if copyable_asset_ids.is_empty() { + None + } else { + Some(ResourceTransferPlan { + action: "copy".into(), + asset_ids: copyable_asset_ids.clone(), + target_document_id: target_document_id.clone(), + target_sub_path: target_sub_path.clone(), + }) + }; + + Ok(FileTreePastePlan { + target_document_id, + target_mindmap_id, + target_sub_path, + row_ids, + doc_items, + copyable_asset_ids, + resource_transfer_plan, + }) +} + +fn build_filetree_upload_target_plan( + payload: &FileTreeUploadTargetPreflightCommandPayload, +) -> Result { + let row_by_id: HashMap = payload + .rows + .iter() + .filter_map(|row| { + let row_id = row.row_id.trim(); + if row_id.is_empty() { + None + } else { + Some((row_id.to_string(), row.clone())) + } + }) + .collect(); + + let target_row = normalize_optional_filetree_string(&payload.target_row_id) + .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); + let focused_row = normalize_optional_filetree_string(&payload.focused_row_id) + .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); + let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) + .or_else(|| target_row.as_ref().and_then(filetree_drop_row_document_id)) + .or_else(|| focused_row.as_ref().and_then(filetree_drop_row_document_id)) + .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) + .ok_or_else(|| BridgeError::validation("请选择一个目标页面后再拖入文件"))?; + + let target_mindmap_id = + resolve_filetree_drop_mindmap_target_id(target_row.as_ref().or(focused_row.as_ref())); + let target_sub_path = target_mindmap_id + .as_deref() + .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); + + let workspace_by_document_id: HashMap> = payload + .document_workspaces + .iter() + .filter_map(|entry| { + let document_id = entry.document_id.trim(); + if document_id.is_empty() { + return None; + } + Some(( + document_id.to_string(), + normalize_optional_filetree_string(&entry.workspace_id), + )) + }) + .collect(); + let workspace_id = workspace_by_document_id + .get(target_document_id.as_str()) + .and_then(|workspace_id| workspace_id.clone()) + .or_else(|| normalize_optional_filetree_string(&payload.workspace_id)) + .ok_or_else(|| BridgeError::validation("无法识别当前工作区,上传失败"))?; + + Ok(FileTreeUploadTargetPlan { + workspace_id, + target_document_id, + target_mindmap_id, + target_sub_path, + }) +} + +fn build_filetree_drop_plan( + payload: &FileTreeDropPreflightCommandPayload, +) -> Result { + let row_by_id: HashMap = payload + .rows + .iter() + .filter_map(|row| { + let row_id = row.row_id.trim(); + if row_id.is_empty() { + None + } else { + Some((row_id.to_string(), row.clone())) + } + }) + .collect(); + + let target_row = normalize_optional_filetree_string(&payload.target_row_id) + .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); + let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) + .or_else(|| target_row.as_ref().and_then(filetree_drop_row_document_id)) + .or_else(|| { + normalize_optional_filetree_string(&payload.focused_row_id).and_then(|row_id| { + row_by_id + .get(row_id.as_str()) + .and_then(filetree_drop_row_document_id) + }) + }) + .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) + .ok_or_else(|| BridgeError::validation("无法识别拖拽目标页面"))?; + + let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) + .into_iter() + .filter(|row_id| row_by_id.contains_key(row_id.as_str())) + .collect::>(); + let selected_rows = row_ids + .iter() + .filter_map(|row_id| row_by_id.get(row_id.as_str())) + .collect::>(); + + let mut doc_ids = Vec::new(); + let mut copyable_asset_ids = Vec::new(); + let mut source_asset_document_ids = Vec::new(); + for row in selected_rows { + let row_kind = row.row_kind.trim(); + if row_kind == "doc" { + if let Some(document_id) = filetree_drop_row_document_id(row) { + doc_ids.push(document_id); + } + continue; + } + + if row_kind == "asset" && is_filetree_drop_real_asset(row) { + if let Some(asset_id) = normalize_optional_filetree_string(&row.asset_id) { + if !copyable_asset_ids + .iter() + .any(|existing| existing == &asset_id) + { + copyable_asset_ids.push(asset_id); + } + } + let source_document_id = normalize_optional_filetree_string(&row.asset_document_id) + .or_else(|| filetree_drop_row_document_id(row)); + if let Some(source_document_id) = source_document_id { + if !source_asset_document_ids + .iter() + .any(|existing| existing == &source_document_id) + { + source_asset_document_ids.push(source_document_id); + } + } + } + } + + if doc_ids.is_empty() && copyable_asset_ids.is_empty() { + return Err(BridgeError::validation( + "没有可拖拽的对象(虚拟附件暂不支持)", + )); + } + + let parent_by_document_id: HashMap> = payload + .document_parents + .iter() + .filter_map(|entry| { + let document_id = entry.document_id.trim(); + if document_id.is_empty() { + return None; + } + Some(( + document_id.to_string(), + normalize_optional_filetree_string(&entry.parent_id), + )) + }) + .collect(); + let top_level_doc_ids = filter_top_level_filetree_doc_ids(&doc_ids, &parent_by_document_id); + let target_mindmap_id = resolve_filetree_drop_mindmap_target_id(target_row.as_ref()); + let target_sub_path = target_mindmap_id + .as_deref() + .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); + + let document_transfer_plan = if doc_ids.is_empty() { + None + } else { + Some(FileTreeDropDocumentTransferPlan { + action: if payload.copy { "copy" } else { "move" }.into(), + target_parent_id: target_document_id.clone(), + document_ids: doc_ids.clone(), + top_level_document_ids: top_level_doc_ids.clone(), + copy_items: doc_ids + .iter() + .map(|document_id| DocumentCopyTreeItemPlan { + document_id: document_id.clone(), + recursive: true, + }) + .collect(), + }) + }; + let resource_transfer_plan = if copyable_asset_ids.is_empty() { + None + } else { + Some(ResourceTransferPlan { + action: if payload.copy { "copy" } else { "move" }.into(), + asset_ids: copyable_asset_ids.clone(), + target_document_id: target_document_id.clone(), + target_sub_path: target_sub_path.clone(), + }) + }; + + let plan = FileTreeDropPlan { + copy: payload.copy, + target_document_id, + target_mindmap_id, + target_sub_path, + row_ids, + doc_ids, + top_level_doc_ids, + copyable_asset_ids, + source_asset_document_ids, + document_transfer_plan, + resource_transfer_plan, + }; + validate_filetree_drop_doc_legality(&plan, &parent_by_document_id)?; + Ok(plan) +} + +fn tree_stream_delta_hint(kind: &str, args: Value) -> Value { + json!({ + "family": "tree", + "kind": kind, + "args": args, + }) +} + +fn tree_document_result_hint(document_field: &str) -> Value { + tree_stream_delta_hint( + "document_result", + json!({ + "documentField": document_field, + }), + ) +} + +fn tree_result_document_hint() -> Value { + tree_stream_delta_hint("result_document", json!({})) +} + +fn tree_domain_event_hint(event_type: &str) -> Value { + json!({ + "family": "tree", + "eventType": event_type, + }) +} + +fn tree_domain_event_plan(event_type: &str, stream_delta_hint: Value) -> Value { + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "streamDeltaHint": stream_delta_hint, + }) +} + +fn read_trimmed_str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) +} + +fn read_record_field<'a>(value: &'a Value, field: &str) -> Option<&'a Value> { + value.get(field).filter(|item| item.is_object()) +} + +fn materialize_tree_stream_delta( + plan: &RuntimeCommandExecutionPlan, + result: &Value, +) -> Option { + let hint = plan.args_json.get("streamDeltaHint")?; + materialize_tree_stream_delta_from_hint(hint, result) +} + +fn materialize_tree_stream_delta_from_hint(hint: &Value, result: &Value) -> Option { + if hint.get("family").and_then(Value::as_str) != Some("tree") { + return None; + } + let kind = hint.get("kind").and_then(Value::as_str)?.trim(); + let args = hint.get("args").filter(|value| value.is_object())?; + + match kind { + "noop" => Some(json!({ "op": "noop" })), + "remove_document" => { + let document_id = read_trimmed_str_field(args, "documentId")?; + Some(json!({ + "op": "remove_document", + "documentId": document_id, + })) + } + "upsert_document_patch" => { + let document_id = read_trimmed_str_field(args, "documentId")?; + let patch = read_record_field(args, "patch")?; + let mut document = serde_json::Map::new(); + document.insert("id".into(), Value::String(document_id.into())); + if let Some(patch_map) = patch.as_object() { + for (key, value) in patch_map { + document.insert(key.clone(), value.clone()); + } + } + if !document.contains_key("updated_at") { + if let Some(updated_at) = read_trimmed_str_field(result, "updated_at") { + document.insert("updated_at".into(), Value::String(updated_at.into())); + } + } + Some(json!({ + "op": "upsert_document", + "document": Value::Object(document), + })) + } + "document_result" => { + let document_field = + read_trimmed_str_field(args, "documentField").unwrap_or("document"); + let document = read_record_field(result, document_field)?; + Some(json!({ + "op": "upsert_document", + "document": document, + })) + } + "result_document" => { + if !result.is_object() { + return None; + } + Some(json!({ + "op": "upsert_document", + "document": result, + })) + } + "copy_result" => { + let items_field = read_trimmed_str_field(args, "itemsField").unwrap_or("items"); + let document_field = + read_trimmed_str_field(args, "documentField").unwrap_or("document"); + let items = result.get(items_field)?.as_array()?; + let documents = items + .iter() + .filter_map(|item| read_record_field(item, document_field).cloned()) + .collect::>(); + if documents.is_empty() { + return None; + } + Some(json!({ + "op": "upsert_documents", + "upsertDocuments": documents, + })) + } + "asset_result" => { + let items_field = read_trimmed_str_field(args, "itemsField").unwrap_or("items"); + let items = result.get(items_field)?.as_array()?; + if items.is_empty() { + return None; + } + Some(json!({ + "op": "upsert_assets", + "upsertAssets": items, + })) + } + "move_document" => { + let document_id = read_trimmed_str_field(args, "documentId")?; + let sort_order = result + .get("sort_order") + .and_then(Value::as_i64) + .or_else(|| args.get("sortOrder").and_then(Value::as_i64))?; + let parent_id = if result.get("parent_id").is_some() { + result.get("parent_id").cloned().unwrap_or(Value::Null) + } else { + args.get("parentId").cloned().unwrap_or(Value::Null) + }; + let mut delta = serde_json::Map::new(); + delta.insert("op".into(), Value::String("move_document".into())); + delta.insert("documentId".into(), Value::String(document_id.into())); + delta.insert("parentId".into(), parent_id); + delta.insert( + "sortOrder".into(), + Value::Number(serde_json::Number::from(sort_order)), + ); + if let Some(updated_at) = read_trimmed_str_field(result, "updated_at") { + delta.insert("updatedAt".into(), Value::String(updated_at.into())); + } + Some(Value::Object(delta)) + } + _ => None, + } +} + +fn materialize_tree_domain_event_plan( + plan: &RuntimeCommandExecutionPlan, + result: &Value, +) -> Option<(String, Value)> { + let event_plan = plan.args_json.get("domainEventPlan")?; + if event_plan.get("family").and_then(Value::as_str) != Some("tree") + || event_plan.get("schema").and_then(Value::as_str) != Some("mnote.tree.domain_event") + || event_plan.get("schemaVersion").and_then(Value::as_i64) != Some(1) + { + return None; + } + let event_type = read_trimmed_str_field(event_plan, "eventType")?.to_string(); + let mut materialized = event_plan.clone(); + if materialized.get("streamDelta").is_none() { + let stream_delta = materialize_tree_stream_delta(plan, result).or_else(|| { + event_plan + .get("streamDeltaHint") + .and_then(|hint| materialize_tree_stream_delta_from_hint(hint, result)) + }); + if let Some(stream_delta) = stream_delta { + if let Some(map) = materialized.as_object_mut() { + map.insert("streamDelta".into(), stream_delta); + } + } + } + Some((event_type, materialized)) +} + +fn tree_artifact_payload( + context: &RuntimeBridgeContextWire, + command: &RuntimeCommandEnvelopeWire, + event_type: &str, + aggregate_type: &str, + aggregate_id: &str, + domain_event_plan: &Value, +) -> Value { + let mut payload = json!({ + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "aggregate": { + "type": aggregate_type, + "id": aggregate_id, + }, + "trace": { + "requestId": context.request_id, + "traceId": context.trace_id, + }, + "command": { + "id": command.command_id, + "name": command.name, + "idempotencyKey": command.idempotency_key, + }, + "error": Value::Null, + "request_id": context.request_id, + "trace_id": context.trace_id, + "command_id": command.command_id, + "command_name": command.name, + "idempotency_key": command.idempotency_key, + }); + + if let Some(stream_delta) = domain_event_plan.get("streamDelta") { + if let Some(map) = payload.as_object_mut() { + map.insert("streamDelta".into(), stream_delta.clone()); + } + } + + payload +} + +pub fn build_runtime_command_artifact_plan( + context: &RuntimeBridgeContextWire, + command: &RuntimeCommandEnvelopeWire, + plan: &RuntimeCommandExecutionPlan, + result: &Value, + now: &str, +) -> Option { + let workspace_id = plan + .workspace_id + .as_deref() + .or(context.workspace_id.as_deref()) + .or_else(|| { + command + .target + .as_ref() + .and_then(|target| target.workspace_id.as_deref()) + })? + .trim(); + if workspace_id.is_empty() { + return None; + } + + let command_log_id = format!("clog_{}", command.command_id); + let target_page_id = command + .target + .as_ref() + .and_then(|target| target.page_id.as_ref()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let target_block_id = command + .target + .as_ref() + .and_then(|target| target.block_id.as_ref()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let materialized_event_plan = materialize_tree_domain_event_plan(plan, result); + let command_payload = if let Some((_, domain_event_plan)) = &materialized_event_plan { + if let Some(stream_delta) = domain_event_plan.get("streamDelta") { + let mut payload = command.payload.clone(); + if let Some(map) = payload.as_object_mut() { + map.insert("streamDelta".into(), stream_delta.clone()); + payload + } else { + json!({ + "payload": command.payload, + "streamDelta": stream_delta, + }) + } + } else { + command.payload.clone() + } + } else { + command.payload.clone() + }; + + let command_log = RuntimeCommandLogArtifactPlan { + workspace_id: workspace_id.into(), + id: command_log_id.clone(), + request_id: context.request_id.clone(), + trace_id: context.trace_id.clone(), + command_id: command.command_id.clone(), + command_name: command.name.clone(), + actor_id: context.actor.actor_id.clone(), + actor_type: context.actor.actor_type.clone(), + source_channel: context.source.channel.clone(), + source_client: context.source.client.clone(), + status: "succeeded".into(), + target_page_id: target_page_id.clone(), + target_block_id: target_block_id.clone(), + payload: command_payload, + payload_summary: format!( + "command={};request_id={};trace_id={}", + command.name, context.request_id, context.trace_id + ), + refs: command.refs.clone(), + idempotency_key: command.idempotency_key.clone(), + error: None, + created_at: now.into(), + finished_at: Some(now.into()), + }; + + let domain_event = materialized_event_plan.map(|(event_type, domain_event_plan)| { + let aggregate_type = if target_block_id.is_some() { + "block" + } else if target_page_id.is_some() { + "page" + } else { + "workspace" + }; + let aggregate_id = target_block_id + .as_deref() + .or(target_page_id.as_deref()) + .unwrap_or(workspace_id); + RuntimeDomainEventArtifactPlan { + workspace_id: workspace_id.into(), + id: format!("evt_{}", command.command_id), + request_id: context.request_id.clone(), + trace_id: context.trace_id.clone(), + command_id: command.command_id.clone(), + command_log_id, + event_type: event_type.clone(), + aggregate_type: aggregate_type.into(), + aggregate_id: aggregate_id.into(), + event_version: 1, + status: "committed".into(), + actor_type: context.actor.actor_type.clone(), + payload: tree_artifact_payload( + context, + command, + &event_type, + aggregate_type, + aggregate_id, + &domain_event_plan, + ), + created_at: now.into(), + } + }); + + Some(RuntimeCommandArtifactPlan { + command_log, + domain_event, + }) +} + #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct RuntimeBlockSummary { @@ -893,6 +2420,9 @@ pub fn execute_runtime_input(input: RuntimeInput) -> Result execute_query(context, query), RuntimeInput::Command { context, command } => execute_command(context, command), + RuntimeInput::CommandArtifact { .. } => Err(BridgeError::validation( + "execute_runtime_input 不支持 command artifact 输入", + )), RuntimeInput::Tool { context, tool, .. } => execute_tool_plan(context, tool), } } @@ -909,7 +2439,7 @@ pub fn execute_runtime_query(input: RuntimeInput) -> Result tool, data, } => execute_tool_result(context, tool, data.unwrap_or(Value::Null)), - RuntimeInput::Command { .. } => Err(BridgeError::validation( + RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err(BridgeError::validation( "execute_runtime_query 仅支持 query 输入", )), } @@ -919,6 +2449,35 @@ pub fn build_success_response(plan: RuntimeExecutionPlan) -> RuntimeSuccess { RuntimeSuccess { ok: true, plan } } +pub fn build_artifact_success_response( + artifacts: Option, +) -> RuntimeArtifactSuccess { + RuntimeArtifactSuccess { ok: true, artifacts } +} + +pub fn execute_runtime_command_artifact( + input: RuntimeInput, +) -> Result, BridgeError> { + match input { + RuntimeInput::CommandArtifact { + context, + command, + plan, + result, + now, + } => Ok(build_runtime_command_artifact_plan( + &context, + &command, + &plan, + &result, + &now, + )), + _ => Err(BridgeError::validation( + "execute_runtime_command_artifact 仅支持 command artifact 输入", + )), + } +} + pub fn build_failure_response(error: BridgeError) -> RuntimeFailure { RuntimeFailure { ok: false, @@ -936,6 +2495,7 @@ pub fn runtime_input_requests_result(input: &RuntimeInput) -> bool { parse_tool_mode(tool.mode.as_deref()).unwrap_or(ToolExecutionMode::Plan) == ToolExecutionMode::Result } + RuntimeInput::CommandArtifact { .. } => false, RuntimeInput::Command { .. } | RuntimeInput::Query { data: None, .. } => false, } } @@ -985,6 +2545,8 @@ fn execute_query( filters: KernelProjectionFilter { node_types: payload.node_types.clone().unwrap_or_default(), edge_types: payload.edge_types.clone().unwrap_or_default(), + query: None, + max_results: None, include_deleted: false, }, }, @@ -1109,6 +2671,8 @@ fn execute_query( filters: KernelProjectionFilter { node_types: payload.node_types.clone().unwrap_or_default(), edge_types: payload.edge_types.clone().unwrap_or_default(), + query: payload.query.clone(), + max_results: payload.max_results, include_deleted: false, }, include_content: payload.include_content.unwrap_or(false), @@ -4651,6 +6215,7 @@ fn build_file_tree_projection_result( _workspace_id: Option<&str>, root_node_id: Option<&str>, data: &Value, + filters: &KernelProjectionFilter, ) -> KernelProjectionResult { let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data); let page_ids = subtree @@ -4959,6 +6524,8 @@ fn build_file_tree_projection_result( ); } + apply_file_tree_projection_search_filter(&mut items, &mut edges, filters); + KernelProjectionResult { projection_id: format!( "kernel_projection:file_tree:{}", @@ -4971,6 +6538,77 @@ fn build_file_tree_projection_result( } } +fn normalize_projection_query(raw: Option<&str>) -> Option { + let query = raw?.trim().to_lowercase(); + if query.is_empty() { None } else { Some(query) } +} + +fn file_tree_item_matches_query(item: &KernelProjectionItem, query: &str) -> bool { + let title = item.title.as_deref().unwrap_or("").to_lowercase(); + if title.contains(query) { + return true; + } + item.resource_meta.as_ref().is_some_and(|meta| { + meta.document_id + .as_deref() + .map(|value| value.to_lowercase().contains(query)) + .unwrap_or(false) + || meta + .asset_id + .as_deref() + .map(|value| value.to_lowercase().contains(query)) + .unwrap_or(false) + }) +} + +fn apply_file_tree_projection_search_filter( + items: &mut Vec, + edges: &mut Vec, + filters: &KernelProjectionFilter, +) { + let Some(query) = normalize_projection_query(filters.query.as_deref()) else { + return; + }; + + let parent_by_node_id = items + .iter() + .filter_map(|item| { + item.parent_node_id + .as_ref() + .map(|parent_id| (item.node_id.clone(), parent_id.clone())) + }) + .collect::>(); + let mut include = BTreeMap::::new(); + let mut matched_count = 0usize; + for item in items.iter() { + if !file_tree_item_matches_query(item, &query) { + continue; + } + if let Some(max_results) = filters.max_results { + if matched_count >= max_results { + continue; + } + } + matched_count += 1; + include.insert(item.node_id.clone(), true); + let mut current = item.parent_node_id.clone(); + while let Some(parent_id) = current { + include.insert(parent_id.clone(), true); + current = parent_by_node_id.get(&parent_id).cloned(); + } + } + + items.retain(|item| include.contains_key(&item.node_id)); + for item in items.iter_mut() { + if item.expandable { + item.expanded_by_default = true; + } + } + edges.retain(|edge| { + include.contains_key(&edge.from_node_id) && include.contains_key(&edge.to_node_id) + }); +} + fn build_kernel_subtree_result( data: &Value, root_node_id: &str, @@ -5078,6 +6716,7 @@ fn build_kernel_projection_result( root_node_id: Option<&str>, workspace_id: Option<&str>, depth: Option, + filters: KernelProjectionFilter, ) -> Result { let subtree = if let Some(root_node_id) = root_node_id { build_kernel_subtree_result(data, root_node_id, workspace_id, depth)? @@ -5103,6 +6742,7 @@ fn build_kernel_projection_result( workspace_id, root_node_id, data, + &filters, )); } let items = subtree @@ -5271,6 +6911,13 @@ fn execute_query_result( payload.root_node_id.as_deref(), payload.workspace_id.as_deref(), payload.depth, + KernelProjectionFilter { + node_types: payload.node_types.unwrap_or_default(), + edge_types: payload.edge_types.unwrap_or_default(), + query: payload.query, + max_results: payload.max_results, + include_deleted: false, + }, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("kernel.project_view result 序列化失败: {error}")) @@ -5691,6 +7338,175 @@ fn execute_command( args_json: json!({ "id": payload.document_id, "title": payload.title, + "streamDeltaHint": tree_stream_delta_hint("upsert_document_patch", json!({ + "documentId": payload.document_id, + "patch": { + "title": payload.title, + }, + })), + "domainEventHint": tree_domain_event_hint("tree.node.renamed"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.renamed", + tree_stream_delta_hint("upsert_document_patch", json!({ + "documentId": payload.document_id, + "patch": { + "title": payload.title, + }, + })), + ), + }), + })) + } + "documents.options.update" | "page.layout.updateOptions" => { + let payload: DocumentOptionsCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command_name = match command_wire.name.as_str() { + "page.layout.updateOptions" => "page.layout.updateOptions", + _ => "documents.options.update", + }; + let options = payload.options; + let command = CommandEnvelope { + 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), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: UpdatePageOptions { + page_id: payload.document_id.clone(), + wide_layout: options.wide_layout, + small_text: options.small_text, + show_heading_numbers: options.show_heading_numbers, + show_toc: options.show_toc, + show_structure: options.show_structure, + protect_editing: options.protect_editing, + show_word_count: options.show_word_count, + collapse_backlinks: options.collapse_backlinks, + page_font: options.page_font.clone(), + layout_density: options.layout_density.clone(), + hide_child_pages: options.hide_child_pages, + show_block_ref_count: options.show_block_ref_count, + embed_default_block_id: options.embed_default_block_id.clone(), + }, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "id": payload.document_id, + "workspaceId": payload.workspace_id, + "options": { + "wideLayout": options.wide_layout, + "smallText": options.small_text, + "showHeadingNumbers": options.show_heading_numbers, + "showToc": options.show_toc, + "showStructure": options.show_structure, + "protectEditing": options.protect_editing, + "showWordCount": options.show_word_count, + "collapseBacklinks": options.collapse_backlinks, + "pageFont": options.page_font, + "layoutDensity": options.layout_density, + "hideChildPages": options.hide_child_pages, + "showBlockRefCount": options.show_block_ref_count, + "embedDefaultBlockId": options.embed_default_block_id, + }, + }), + })) + } + "documents.stats.update" => { + let payload: DocumentStatsCommandPayload = + parse_payload(command_wire.payload.clone())?; + let stats = payload.stats; + let command = CommandEnvelope { + name: "documents.stats.update".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: UpdatePageStats { + page_id: payload.document_id.clone(), + word_count: stats.word_count, + character_count: stats.character_count, + block_count: stats.block_count, + todo_total: stats.todo_total, + todo_done: stats.todo_done, + }, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "id": payload.document_id, + "workspaceId": payload.workspace_id, + "wordCount": stats.word_count, + "characterCount": stats.character_count, + "blockCount": stats.block_count, + "todoTotal": stats.todo_total, + "todoDone": stats.todo_done, + }), + })) + } + "media.assets.replace_storage" => { + let payload: MediaAssetReplaceStorageCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "media.assets.replace_storage".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: core_protocol::ReplaceMediaAssetStorage { + asset_id: payload.asset_id.clone(), + storage_id: payload.storage_id.clone(), + }, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "userId": payload.user_id, + "id": payload.asset_id, + "documentId": payload.document_id, + "workspaceId": payload.workspace_id, + "storageId": payload.storage_id, }), })) } @@ -5762,7 +7578,9 @@ 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!("{command_name} content 序列化失败: {error}")) + BridgeError::validation(format!( + "{command_name} content 序列化失败: {error}" + )) })?, conflict_detection_key: payload.conflict_detection_key.clone(), }, @@ -5790,6 +7608,12 @@ fn execute_command( "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, "anchorBlockId": payload.anchor_block_id, + "streamDeltaHint": tree_stream_delta_hint("noop", json!({})), + "domainEventHint": tree_domain_event_hint("tree.node.embedded"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.embedded", + tree_stream_delta_hint("noop", json!({})), + ), }), })) } @@ -6002,12 +7826,20 @@ fn execute_command( "title": payload.title, "accessScope": payload.access_scope, "content": payload.content, + "streamDeltaHint": tree_document_result_hint("document"), + "domainEventHint": tree_domain_event_hint("tree.node.created"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.created", + tree_document_result_hint("document"), + ), }), })) } "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 normalized_move = + resolve_document_move_order_plan(&payload, command_wire.preflight_data.as_ref())?; let command_name = if command_wire.name == "tree.subtree.move" { "tree.subtree.move" } else { @@ -6041,6 +7873,21 @@ fn execute_command( "id": payload.document_id, "parentId": payload.parent_id, "sortOrder": payload.sort_order, + "normalizedMove": normalized_move, + "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ + "documentId": payload.document_id, + "parentId": payload.parent_id, + "sortOrder": payload.sort_order, + })), + "domainEventHint": tree_domain_event_hint("tree.subtree.moved"), + "domainEventPlan": tree_domain_event_plan( + "tree.subtree.moved", + tree_stream_delta_hint("move_document", json!({ + "documentId": payload.document_id, + "parentId": payload.parent_id, + "sortOrder": payload.sort_order, + })), + ), }), })) } @@ -6078,6 +7925,16 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ + "documentId": payload.document_id, + })), + "domainEventHint": tree_domain_event_hint("tree.node.archived"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.archived", + tree_stream_delta_hint("remove_document", json!({ + "documentId": payload.document_id, + })), + ), }), })) } @@ -6115,6 +7972,12 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "streamDeltaHint": tree_document_result_hint("document"), + "domainEventHint": tree_domain_event_hint("tree.node.restored"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.restored", + tree_document_result_hint("document"), + ), }), })) } @@ -6149,6 +8012,12 @@ fn execute_command( "sourceId": payload.source_document_id, "newId": payload.new_document_id, "title": payload.title, + "streamDeltaHint": tree_result_document_hint(), + "domainEventHint": tree_domain_event_hint("tree.node.duplicated"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.duplicated", + tree_result_document_hint(), + ), }), })) } @@ -6250,6 +8119,16 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ + "documentId": payload.document_id, + })), + "domainEventHint": tree_domain_event_hint("tree.node.purged"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.purged", + tree_stream_delta_hint("remove_document", json!({ + "documentId": payload.document_id, + })), + ), }), })) } @@ -6291,6 +8170,260 @@ fn execute_command( "recursive": item.recursive, })).collect::>(), "targetParentId": payload.target_parent_id, + "streamDeltaHint": tree_stream_delta_hint("copy_result", json!({ + "itemsField": "items", + "documentField": "document", + })), + "domainEventHint": tree_domain_event_hint("tree.subtree.copied"), + "domainEventPlan": tree_domain_event_plan( + "tree.subtree.copied", + tree_stream_delta_hint("copy_result", json!({ + "itemsField": "items", + "documentField": "document", + })), + ), + }), + })) + } + "tree.filetree.drop.preflight" => { + let payload: FileTreeDropPreflightCommandPayload = + parse_payload(command_wire.payload.clone())?; + let drop_plan = build_filetree_drop_plan(&payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: drop_plan.row_ids.len(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "fileTreeDropPlan": drop_plan, + }), + })) + } + "tree.filetree.delete.preflight" => { + let payload: FileTreeDeletePreflightCommandPayload = + parse_payload(command_wire.payload.clone())?; + let delete_plan = build_filetree_delete_plan(&payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: delete_plan.row_ids.len(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "fileTreeDeletePlan": delete_plan, + }), + })) + } + "tree.filetree.paste.preflight" => { + let payload: FileTreePastePreflightCommandPayload = + parse_payload(command_wire.payload.clone())?; + let paste_plan = build_filetree_paste_plan(&payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: paste_plan.row_ids.len(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "fileTreePastePlan": paste_plan, + }), + })) + } + "tree.filetree.upload-target.preflight" => { + let payload: FileTreeUploadTargetPreflightCommandPayload = + parse_payload(command_wire.payload.clone())?; + let upload_target_plan = build_filetree_upload_target_plan(&payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: 1, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "fileTreeUploadTargetPlan": upload_target_plan, + }), + })) + } + "tree.resource.copy" | "tree.resource.move" => { + let payload: ResourceTransferCommandPayload = + parse_payload(command_wire.payload.clone())?; + let action = if command_wire.name == "tree.resource.copy" { + "copy" + } else { + "move" + }; + let transfer_plan = build_resource_transfer_plan(action, &payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: transfer_plan.asset_ids.len(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "assetIds": transfer_plan.asset_ids, + "targetDocumentId": transfer_plan.target_document_id, + "targetSubPath": transfer_plan.target_sub_path, + "resourceTransferPlan": transfer_plan, + "streamDeltaHint": tree_stream_delta_hint("asset_result", json!({ + "itemsField": "items", + })), + "domainEventHint": tree_domain_event_hint(if action == "copy" { + "tree.resource.copied" + } else { + "tree.resource.moved" + }), + "domainEventPlan": tree_domain_event_plan( + if action == "copy" { + "tree.resource.copied" + } else { + "tree.resource.moved" + }, + tree_stream_delta_hint("asset_result", json!({ + "itemsField": "items", + })), + ), + }), + })) + } + "tree.resource.upload" => { + let payload: ResourceUploadCommandPayload = + parse_payload(command_wire.payload.clone())?; + let upload_plan = build_resource_upload_plan(&payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: upload_plan.asset_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "assetId": upload_plan.asset_id, + "workspaceId": upload_plan.workspace_id, + "targetDocumentId": upload_plan.target_document_id, + "targetSubPath": upload_plan.target_sub_path, + "fileName": upload_plan.file_name, + "fileSize": upload_plan.file_size, + "mimeType": upload_plan.mime_type, + "assetType": upload_plan.asset_type, + "resourceUploadPlan": upload_plan, + "streamDeltaHint": tree_stream_delta_hint("asset_result", json!({ + "itemsField": "items", + })), + "domainEventHint": tree_domain_event_hint("tree.resource.uploaded"), + "domainEventPlan": tree_domain_event_plan( + "tree.resource.uploaded", + tree_stream_delta_hint("asset_result", json!({ + "itemsField": "items", + })), + ), }), })) } @@ -7660,7 +9793,8 @@ mod tests { Some(&json!("来自 editorDocument")) ); assert_eq!( - plan.args_json.pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"), + plan.args_json + .pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"), Some(&json!("来自 editorDocument")) ); assert_eq!( @@ -7718,7 +9852,10 @@ mod tests { }; assert_eq!(plan.function_name, "documents:updateContent"); - assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("legacy_content_1"))); + assert_eq!( + plan.args_json.pointer("/content/0/id"), + Some(&json!("legacy_content_1")) + ); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自旧式 content-only 保存")) @@ -7894,6 +10031,26 @@ mod tests { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.node.embedded" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.embedded", + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + } + } }) ); } @@ -7914,6 +10071,30 @@ mod tests { }), json!({ "id": "doc_1", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_document", + "args": { + "documentId": "doc_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.node.archived" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.archived", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_document", + "args": { + "documentId": "doc_1" + } + } + } }), ), ( @@ -7925,6 +10106,30 @@ mod tests { }), json!({ "id": "doc_1", + "streamDeltaHint": { + "family": "tree", + "kind": "document_result", + "args": { + "documentField": "document" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.node.restored" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.restored", + "streamDeltaHint": { + "family": "tree", + "kind": "document_result", + "args": { + "documentField": "document" + } + } + } }), ), ( @@ -7935,6 +10140,30 @@ mod tests { }), json!({ "id": "doc_1", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_document", + "args": { + "documentId": "doc_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.node.purged" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.purged", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_document", + "args": { + "documentId": "doc_1" + } + } + } }), ), ]; @@ -7961,7 +10190,7 @@ mod tests { block_id: None, }), payload, - preflight_data: None, + preflight_data: None, reason: Some("树命令切流".into()), refs: vec![], dry_run: false, @@ -8278,6 +10507,534 @@ mod tests { assert_eq!(error.message, "目标父页面不存在或无权限"); } + #[test] + fn document_move_order_plan_normalizes_same_parent_order() { + let payload = DocumentMoveCommandPayload { + document_id: "doc_b".into(), + parent_id: Some("root".into()), + sort_order: 2, + }; + let snapshot = serde_json::from_value::(json!({ + "documents": [ + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "root", "sort_order": 0, "created_at": "2026-04-25T00:00:02Z" }, + { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "root", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, + { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "root", "sort_order": null, "created_at": "2026-04-25T00:00:03Z" } + ] + })) + .expect("snapshot"); + + let plan = build_document_move_order_plan_from_snapshot(&payload, &snapshot) + .expect("move order plan"); + + assert_eq!(plan.normalized_sort_order, 2); + assert_eq!( + plan.patches, + vec![ + DocumentMoveOrderPatch { + document_id: "doc_c".into(), + parent_id: Some("root".into()), + sort_order: 1, + moved: false, + }, + DocumentMoveOrderPatch { + document_id: "doc_b".into(), + parent_id: Some("root".into()), + sort_order: 2, + moved: true, + }, + ] + ); + } + + #[test] + fn document_move_order_plan_normalizes_cross_parent_and_clamps_index() { + let payload = DocumentMoveCommandPayload { + document_id: "doc_b".into(), + parent_id: Some("target".into()), + sort_order: 99, + }; + let snapshot = serde_json::from_value::(json!({ + "documents": [ + { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, + { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 2, "created_at": "2026-04-25T00:00:03Z" }, + { "id": "doc_d", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 7, "created_at": "2026-04-25T00:00:04Z" } + ] + })) + .expect("snapshot"); + + let plan = build_document_move_order_plan_from_snapshot(&payload, &snapshot) + .expect("move order plan"); + + assert_eq!(plan.from_parent_id, Some("source".into())); + assert_eq!(plan.to_parent_id, Some("target".into())); + assert_eq!(plan.normalized_sort_order, 1); + assert_eq!( + plan.patches, + vec![ + DocumentMoveOrderPatch { + document_id: "doc_c".into(), + parent_id: Some("source".into()), + sort_order: 1, + moved: false, + }, + DocumentMoveOrderPatch { + document_id: "doc_d".into(), + parent_id: Some("target".into()), + sort_order: 0, + moved: false, + }, + DocumentMoveOrderPatch { + document_id: "doc_b".into(), + parent_id: Some("target".into()), + sort_order: 1, + moved: true, + }, + ] + ); + } + + #[test] + fn tree_subtree_move_command_includes_normalized_move_plan_from_snapshot() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_plan".into(), + idempotency_key: Some("idem_tree_move_plan".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_b".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_b", + "parentId": "target", + "sortOrder": -2 + }), + preflight_data: Some(json!({ + "documents": [ + { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, + { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, + { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 0, "created_at": "2026-04-25T00:00:03Z" } + ] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("command plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.subtree.move"); + assert_eq!(plan.function_name, "documents:move"); + assert_eq!(plan.args_json["sortOrder"], json!(-2)); + assert_eq!( + plan.args_json["normalizedMove"], + json!({ + "documentId": "doc_b", + "fromParentId": "source", + "toParentId": "target", + "requestedSortOrder": -2, + "normalizedSortOrder": 0, + "patches": [ + { + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 0, + "moved": true + }, + { + "documentId": "doc_c", + "parentId": "target", + "sortOrder": 1, + "moved": false + } + ] + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": "tree.subtree.moved" + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.subtree.moved", + "streamDeltaHint": { + "family": "tree", + "kind": "move_document", + "args": { + "documentId": "doc_b", + "parentId": "target", + "sortOrder": -2 + } + } + }) + ); + } + + #[test] + fn tree_command_artifact_plan_materializes_domain_event_payload_from_rust_plan() { + let context = demo_context(); + let command = RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_artifact".into(), + idempotency_key: Some("idem_tree_move_artifact".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_b".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 3 + }), + preflight_data: Some(json!({ + "documents": [ + { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } + ] + })), + reason: Some("树命令移动".into()), + refs: vec!["next-tree-route".into()], + dry_run: false, + validate_only: false, + }; + let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(RuntimeInput::Command { + context: context.clone(), + command: command.clone(), + }) + .expect("command plan should build") else { + panic!("expected command plan"); + }; + + let artifacts = build_runtime_command_artifact_plan( + &context, + &command, + &plan, + &json!({ + "ok": true, + "parent_id": "target", + "sort_order": 1, + "workspace_id": "ws_1", + "updated_at": "2026-04-26T10:00:00Z" + }), + "2026-04-26T10:00:01Z", + ) + .expect("artifact plan should build"); + + assert_eq!(artifacts.command_log.id, "clog_cmd_tree_move_artifact"); + assert_eq!(artifacts.command_log.workspace_id, "ws_1"); + assert_eq!(artifacts.command_log.command_name, "tree.subtree.move"); + assert_eq!( + artifacts.command_log.payload["streamDelta"], + json!({ + "op": "move_document", + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 1, + "updatedAt": "2026-04-26T10:00:00Z" + }) + ); + + let event = artifacts + .domain_event + .expect("tree command should have domain event"); + assert_eq!(event.id, "evt_cmd_tree_move_artifact"); + assert_eq!(event.event_type, "tree.subtree.moved"); + assert_eq!(event.aggregate_type, "page"); + assert_eq!(event.aggregate_id, "doc_b"); + assert_eq!(event.status, "committed"); + assert_eq!( + event.payload["streamDelta"], + json!({ + "op": "move_document", + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 1, + "updatedAt": "2026-04-26T10:00:00Z" + }) + ); + assert_eq!(event.payload["schema"], json!("mnote.tree.domain_event")); + assert_eq!(event.payload["schemaVersion"], json!(1)); + } + + #[test] + fn runtime_command_artifact_input_returns_rust_artifact_plan() { + let context = demo_context(); + let command = RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_artifact_input".into(), + idempotency_key: Some("idem_tree_move_artifact_input".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_b".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 3 + }), + preflight_data: Some(json!({ + "documents": [ + { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } + ] + })), + reason: Some("树命令移动".into()), + refs: vec!["next-tree-route".into()], + dry_run: false, + validate_only: false, + }; + let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(RuntimeInput::Command { + context: context.clone(), + command: command.clone(), + }) + .expect("command plan should build") else { + panic!("expected command plan"); + }; + + let artifacts = execute_runtime_command_artifact(RuntimeInput::CommandArtifact { + context, + command, + plan, + result: json!({ + "ok": true, + "parent_id": "target", + "sort_order": 1, + "workspace_id": "ws_1", + "updated_at": "2026-04-26T10:00:00Z" + }), + now: "2026-04-26T10:00:01Z".into(), + }) + .expect("artifact input should execute") + .expect("artifact plan should build"); + + assert_eq!( + artifacts.command_log.payload["streamDelta"], + json!({ + "op": "move_document", + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 1, + "updatedAt": "2026-04-26T10:00:00Z" + }) + ); + assert_eq!( + artifacts + .domain_event + .as_ref() + .expect("domain event") + .event_type, + "tree.subtree.moved" + ); + } + + #[test] + fn tree_create_and_rename_command_plans_include_stream_delta_hints() { + let create_plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.create".into(), + command_id: "cmd_tree_create_1".into(), + idempotency_key: Some("idem_tree_create_1".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_new".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_new", + "workspaceId": "ws_1", + "parentId": null, + "title": "新页面", + "accessScope": "private", + "content": [] + }), + preflight_data: None, + reason: Some("树命令创建页面".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("create plan should build"); + + match create_plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "tree.node.create"); + assert_eq!(plan.function_name, "documents:createWithParentReference"); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "document_result", + "args": { + "documentField": "document" + } + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": "tree.node.created" + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.created", + "streamDeltaHint": { + "family": "tree", + "kind": "document_result", + "args": { + "documentField": "document" + } + } + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + + let rename_plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.rename".into(), + command_id: "cmd_tree_rename_1".into(), + idempotency_key: Some("idem_tree_rename_1".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", + "title": "改名" + }), + preflight_data: None, + reason: Some("树命令重命名".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("rename plan should build"); + + match rename_plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "tree.node.rename"); + assert_eq!(plan.function_name, "documents:updateTitle"); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "upsert_document_patch", + "args": { + "documentId": "doc_1", + "patch": { + "title": "改名" + } + } + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": "tree.node.renamed" + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.renamed", + "streamDeltaHint": { + "family": "tree", + "kind": "upsert_document_patch", + "args": { + "documentId": "doc_1", + "patch": { + "title": "改名" + } + } + } + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + #[test] fn tree_embed_and_copy_aliases_keep_tree_command_names() { let embed_plan = execute_runtime_input(RuntimeInput::Command { @@ -8333,6 +11090,26 @@ mod tests { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.node.embedded" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.embedded", + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + } + } }) ); } @@ -8392,6 +11169,32 @@ mod tests { } ], "targetParentId": "parent_1", + "streamDeltaHint": { + "family": "tree", + "kind": "copy_result", + "args": { + "itemsField": "items", + "documentField": "document" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.subtree.copied" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.subtree.copied", + "streamDeltaHint": { + "family": "tree", + "kind": "copy_result", + "args": { + "itemsField": "items", + "documentField": "document" + } + } + } }) ); } @@ -8400,6 +11203,986 @@ mod tests { } } + #[test] + fn documents_duplicate_plan_includes_result_document_artifact_contract() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "documents.duplicate".into(), + command_id: "cmd_duplicate_1".into(), + idempotency_key: Some("idem_duplicate_1".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_copy".into()), + block_id: None, + }), + payload: json!({ + "sourceDocumentId": "doc_1", + "newDocumentId": "doc_copy", + "title": "页面副本" + }), + preflight_data: None, + reason: Some("复制页面".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("duplicate plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.function_name, "documents:duplicateWithMindmaps"); + assert_eq!(plan.command_name, "documents.duplicate"); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "result_document", + "args": {} + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.duplicated", + "streamDeltaHint": { + "family": "tree", + "kind": "result_document", + "args": {} + } + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + + #[test] + fn document_options_command_plan_maps_to_documents_update_options() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "documents.options.update".into(), + command_id: "cmd_options_1".into(), + idempotency_key: Some("idem_options_1".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", + "workspaceId": "ws_1", + "options": { + "showToc": true, + "layoutDensity": "compact", + "embedDefaultBlockId": null + } + }), + preflight_data: None, + reason: Some("更新页面选项".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("options plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "documents.options.update"); + assert_eq!(plan.function_name, "documents:updateOptions"); + assert_eq!( + plan.args_json, + json!({ + "id": "doc_1", + "workspaceId": "ws_1", + "options": { + "wideLayout": Value::Null, + "smallText": Value::Null, + "showHeadingNumbers": Value::Null, + "showToc": true, + "showStructure": Value::Null, + "protectEditing": Value::Null, + "showWordCount": Value::Null, + "collapseBacklinks": Value::Null, + "pageFont": Value::Null, + "layoutDensity": "compact", + "hideChildPages": Value::Null, + "showBlockRefCount": Value::Null, + "embedDefaultBlockId": Value::Null, + }, + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + + #[test] + fn document_stats_command_plan_maps_to_documents_update_stats() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "documents.stats.update".into(), + command_id: "cmd_stats_1".into(), + idempotency_key: Some("idem_stats_1".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", + "workspaceId": "ws_1", + "stats": { + "wordCount": 12, + "characterCount": 34, + "blockCount": 5, + "todoTotal": 6, + "todoDone": 2 + } + }), + preflight_data: None, + reason: Some("更新页面统计".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("stats plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "documents.stats.update"); + assert_eq!(plan.function_name, "documents:updateStats"); + assert_eq!( + plan.args_json, + json!({ + "id": "doc_1", + "workspaceId": "ws_1", + "wordCount": 12, + "characterCount": 34, + "blockCount": 5, + "todoTotal": 6, + "todoDone": 2, + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + + #[test] + fn media_asset_replace_storage_command_plan_maps_to_transport_write() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "media.assets.replace_storage".into(), + command_id: "cmd_asset_writeback_1".into(), + idempotency_key: Some("idem_asset_writeback_1".into()), + actor: RuntimeActorWire { + actor_type: "service".into(), + actor_id: "onlyoffice-callback".into(), + session_id: Some("onlyoffice".into()), + }, + source: RuntimeSourceWire { + channel: "onlyoffice-callback".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!({ + "assetId": "asset_1", + "documentId": "doc_1", + "workspaceId": "ws_1", + "storageId": "storage_1", + "userId": "user_1" + }), + preflight_data: None, + reason: Some("OnlyOffice callback 写回".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("media asset writeback plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "media.assets.replace_storage"); + assert_eq!(plan.function_name, "mediaAssets:replaceStorageFromUpload"); + assert_eq!( + plan.args_json, + json!({ + "userId": "user_1", + "id": "asset_1", + "documentId": "doc_1", + "workspaceId": "ws_1", + "storageId": "storage_1", + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + + #[test] + fn tree_resource_copy_and_move_plans_include_transfer_contract() { + let cases = [ + ( + "tree.resource.copy", + "mediaAssets:batchCopy", + "copy", + "tree.resource.copied", + ), + ( + "tree.resource.move", + "mediaAssets:batchMove", + "move", + "tree.resource.moved", + ), + ]; + + for (command_name, function_name, action, event_type) in cases { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{action}_asset"), + idempotency_key: Some(format!("idem_{action}_asset")), + 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("target_doc".into()), + block_id: None, + }), + payload: json!({ + "assetIds": [" asset_1 ", "asset_1", "asset_2"], + "targetDocumentId": "target_doc", + "targetSubPath": "mindmaps/mind_1/../assets", + }), + preflight_data: None, + reason: Some("文件树资源投放".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect("resource transfer plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, command_name); + assert_eq!(plan.function_name, function_name); + assert_eq!( + plan.args_json, + json!({ + "assetIds": ["asset_1", "asset_2"], + "targetDocumentId": "target_doc", + "targetSubPath": "mindmaps/mind_1/assets", + "resourceTransferPlan": { + "action": action, + "assetIds": ["asset_1", "asset_2"], + "targetDocumentId": "target_doc", + "targetSubPath": "mindmaps/mind_1/assets", + }, + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "itemsField": "items" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": event_type + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "itemsField": "items" + } + } + } + }) + ); + } + } + + #[test] + fn tree_resource_transfer_rejects_empty_assets_or_target() { + let empty_assets = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.resource.move".into(), + command_id: "cmd_move_empty_assets".into(), + idempotency_key: Some("idem_move_empty_assets".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("target_doc".into()), + block_id: None, + }), + payload: json!({ + "assetIds": [], + "targetDocumentId": "target_doc", + }), + preflight_data: None, + reason: Some("文件树资源投放".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("empty assets should be rejected"); + + assert_eq!(empty_assets.kind, BridgeErrorKind::Validation); + assert_eq!(empty_assets.message, "resource command 缺少 assetIds"); + + let missing_target = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.resource.copy".into(), + command_id: "cmd_copy_missing_target".into(), + idempotency_key: Some("idem_copy_missing_target".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: None, + block_id: None, + }), + payload: json!({ + "assetIds": ["asset_1"], + "targetDocumentId": " ", + }), + preflight_data: None, + reason: Some("文件树资源投放".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("missing target should be rejected"); + + assert_eq!(missing_target.kind, BridgeErrorKind::Validation); + assert_eq!( + missing_target.message, + "resource command 缺少 targetDocumentId" + ); + } + + #[test] + fn tree_filetree_drop_preflight_plans_mixed_doc_and_asset_drop() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.drop.preflight".into(), + command_id: "cmd_filetree_drop_preflight_1".into(), + idempotency_key: Some("idem_filetree_drop_preflight_1".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_target".into()), + block_id: None, + }), + payload: json!({ + "copy": false, + "targetDocumentId": null, + "targetRowId": "asset-folder:mind_1", + "focusedRowId": null, + "activeDocumentId": null, + "rowIds": ["asset:asset_child_1", "doc:doc_child", "asset:pdf_1", "asset:pdf_1"], + "rows": [ + { + "rowId": "doc:doc_target", + "rowKind": "doc", + "documentId": "doc_target" + }, + { + "rowId": "doc:doc_child", + "rowKind": "doc", + "documentId": "doc_child" + }, + { + "rowId": "asset-folder:mind_1", + "rowKind": "asset-folder", + "documentId": "doc_target", + "assetId": "mind_1", + "assetType": "mindmap", + "storagePath": "mindmaps/mind_1/mindmap.json" + }, + { + "rowId": "asset:asset_child_1", + "rowKind": "asset", + "documentId": "doc_source", + "assetId": "asset_child_1", + "assetDocumentId": "doc_source", + "assetType": "file", + "storagePath": "mindmaps/mind_1/assets/node.png" + }, + { + "rowId": "asset:pdf_1", + "rowKind": "asset", + "documentId": "doc_source", + "assetId": "pdf_1", + "assetDocumentId": "doc_source", + "assetType": "file", + "storagePath": "uploads/guide.pdf" + } + ], + "documentParents": [ + { "documentId": "doc_target", "parentId": null }, + { "documentId": "doc_child", "parentId": null } + ] + }), + preflight_data: None, + reason: Some("文件树内部拖放预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect("filetree drop preflight plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.filetree.drop.preflight"); + assert_eq!(plan.function_name, "tree:fileTreeDropPreflight"); + assert_eq!( + plan.args_json["fileTreeDropPlan"], + json!({ + "copy": false, + "targetDocumentId": "doc_target", + "targetMindmapId": "mind_1", + "targetSubPath": "mindmaps/mind_1", + "rowIds": ["asset:asset_child_1", "doc:doc_child", "asset:pdf_1"], + "docIds": ["doc_child"], + "topLevelDocIds": ["doc_child"], + "copyableAssetIds": ["asset_child_1", "pdf_1"], + "sourceAssetDocumentIds": ["doc_source"], + "documentTransferPlan": { + "action": "move", + "targetParentId": "doc_target", + "documentIds": ["doc_child"], + "topLevelDocumentIds": ["doc_child"], + "copyItems": [ + { + "documentId": "doc_child", + "recursive": true + } + ] + }, + "resourceTransferPlan": { + "action": "move", + "assetIds": ["asset_child_1", "pdf_1"], + "targetDocumentId": "doc_target", + "targetSubPath": "mindmaps/mind_1" + } + }) + ); + } + + #[test] + fn tree_filetree_drop_preflight_rejects_illegal_doc_move() { + let err = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.drop.preflight".into(), + command_id: "cmd_filetree_drop_preflight_illegal".into(), + idempotency_key: Some("idem_filetree_drop_preflight_illegal".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_child".into()), + block_id: None, + }), + payload: json!({ + "copy": false, + "targetDocumentId": "doc_child", + "targetRowId": null, + "focusedRowId": null, + "activeDocumentId": null, + "rowIds": ["doc:doc_parent"], + "rows": [ + { + "rowId": "doc:doc_parent", + "rowKind": "doc", + "documentId": "doc_parent" + }, + { + "rowId": "doc:doc_child", + "rowKind": "doc", + "documentId": "doc_child" + } + ], + "documentParents": [ + { "documentId": "doc_parent", "parentId": null }, + { "documentId": "doc_child", "parentId": "doc_parent" } + ] + }), + preflight_data: None, + reason: Some("文件树内部拖放预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect_err("illegal descendant move should be rejected"); + + assert_eq!(err.kind, BridgeErrorKind::Validation); + assert_eq!(err.message, "不能把页面移动到自身或后代下面"); + } + + #[test] + fn tree_filetree_delete_preflight_plans_doc_and_asset_targets() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.delete.preflight".into(), + command_id: "cmd_filetree_delete_preflight_1".into(), + idempotency_key: Some("idem_filetree_delete_preflight_1".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: None, + block_id: None, + }), + payload: json!({ + "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "missing"], + "rows": [ + { + "rowId": "doc:doc_parent", + "rowKind": "doc", + "documentId": "doc_parent" + }, + { + "rowId": "index:doc_child", + "rowKind": "index", + "documentId": "doc_child" + }, + { + "rowId": "asset:pdf_1", + "rowKind": "asset", + "documentId": "doc_parent", + "assetId": "pdf_1", + "assetDocumentId": "doc_parent", + "assetType": "file", + "storagePath": "uploads/guide.pdf" + }, + { + "rowId": "asset:asset_child_1", + "rowKind": "asset", + "documentId": "doc_child", + "assetId": "asset_child_1", + "assetDocumentId": "doc_child", + "assetType": "file", + "storagePath": "uploads/child.png" + } + ], + "documentParents": [ + { "documentId": "doc_parent", "parentId": null }, + { "documentId": "doc_child", "parentId": "doc_parent" } + ] + }), + preflight_data: None, + reason: Some("文件树删除预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect("filetree delete preflight plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.filetree.delete.preflight"); + assert_eq!(plan.function_name, "tree:fileTreeDeletePreflight"); + assert_eq!( + plan.args_json["fileTreeDeletePlan"], + json!({ + "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1"], + "docIds": ["doc_parent"], + "assetIds": [], + "assetDocumentIds": [] + }) + ); + } + + #[test] + fn tree_filetree_paste_preflight_plans_doc_index_and_asset_copy() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.paste.preflight".into(), + command_id: "cmd_filetree_paste_preflight_1".into(), + idempotency_key: Some("idem_filetree_paste_preflight_1".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_target".into()), + block_id: None, + }), + payload: json!({ + "targetDocumentId": null, + "focusedRowId": "asset-folder:mind_1", + "activeDocumentId": null, + "rowIds": ["index:doc_index", "doc:doc_full", "asset:pdf_1", "asset-folder:mind_1", "asset:virtual_1", "asset:pdf_1"], + "rows": [ + { + "rowId": "asset-folder:mind_1", + "rowKind": "asset-folder", + "documentId": "doc_target", + "assetId": "mind_1", + "assetType": "mindmap", + "storagePath": "mindmaps/mind_1/mindmap.json" + }, + { + "rowId": "doc:doc_full", + "rowKind": "doc", + "documentId": "doc_full" + }, + { + "rowId": "index:doc_index", + "rowKind": "index", + "documentId": "doc_index" + }, + { + "rowId": "asset:pdf_1", + "rowKind": "asset", + "documentId": "doc_source", + "assetId": "pdf_1", + "assetDocumentId": "doc_source", + "assetType": "file", + "storagePath": "uploads/guide.pdf" + }, + { + "rowId": "asset:virtual_1", + "rowKind": "asset", + "documentId": "doc_source", + "assetId": "virtual_1", + "assetDocumentId": "doc_source", + "assetType": "file", + "storagePath": null + } + ] + }), + preflight_data: None, + reason: Some("文件树粘贴预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect("filetree paste preflight plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.filetree.paste.preflight"); + assert_eq!(plan.function_name, "tree:fileTreePastePreflight"); + assert_eq!( + plan.args_json["fileTreePastePlan"], + json!({ + "targetDocumentId": "doc_target", + "targetMindmapId": "mind_1", + "targetSubPath": "mindmaps/mind_1", + "rowIds": ["index:doc_index", "doc:doc_full", "asset:pdf_1", "asset-folder:mind_1", "asset:virtual_1"], + "docItems": [ + { + "documentId": "doc_index", + "recursive": false + }, + { + "documentId": "doc_full", + "recursive": true + } + ], + "copyableAssetIds": ["pdf_1"], + "resourceTransferPlan": { + "action": "copy", + "assetIds": ["pdf_1"], + "targetDocumentId": "doc_target", + "targetSubPath": "mindmaps/mind_1" + } + }) + ); + } + + #[test] + fn tree_filetree_upload_target_preflight_resolves_workspace_doc_and_mindmap_target() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.upload-target.preflight".into(), + command_id: "cmd_filetree_upload_target_preflight_1".into(), + idempotency_key: Some("idem_filetree_upload_target_preflight_1".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_target".into()), + block_id: None, + }), + payload: json!({ + "workspaceId": "ws_fallback", + "targetDocumentId": null, + "targetRowId": "asset:asset_child_1", + "focusedRowId": null, + "activeDocumentId": "doc_active", + "rows": [ + { + "rowId": "asset:asset_child_1", + "rowKind": "asset", + "documentId": "doc_target", + "assetId": "asset_child_1", + "assetDocumentId": "doc_target", + "assetType": "file", + "storagePath": "mindmaps/mind_1/assets/node.png" + } + ], + "documentWorkspaces": [ + { "documentId": "doc_target", "workspaceId": "ws_1" }, + { "documentId": "doc_active", "workspaceId": "ws_active" } + ] + }), + preflight_data: None, + reason: Some("文件树上传目标预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect("filetree upload target preflight plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.filetree.upload-target.preflight"); + assert_eq!(plan.function_name, "tree:fileTreeUploadTargetPreflight"); + assert_eq!( + plan.args_json["fileTreeUploadTargetPlan"], + json!({ + "workspaceId": "ws_1", + "targetDocumentId": "doc_target", + "targetMindmapId": "mind_1", + "targetSubPath": "mindmaps/mind_1" + }) + ); + } + + #[test] + fn tree_resource_upload_plan_includes_upload_contract_and_delta_hint() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.resource.upload".into(), + command_id: "cmd_upload_asset".into(), + idempotency_key: Some("idem_upload_asset".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("target_doc".into()), + block_id: None, + }), + payload: json!({ + "assetId": " asset_upload_1 ", + "workspaceId": " ws_1 ", + "targetDocumentId": " target_doc ", + "targetSubPath": "mindmaps/mind_1/../assets", + "fileName": "demo/file.pdf", + "fileSize": 1024, + "mimeType": "application/pdf", + "assetType": "file", + }), + preflight_data: None, + reason: Some("文件树资源上传".into()), + refs: vec!["file-tree-upload".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect("resource upload plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, "tree.resource.upload"); + assert_eq!(plan.function_name, "mediaAssets:createWithStorage"); + assert_eq!( + plan.args_json, + json!({ + "assetId": "asset_upload_1", + "workspaceId": "ws_1", + "targetDocumentId": "target_doc", + "targetSubPath": "mindmaps/mind_1/assets", + "fileName": "demo_file.pdf", + "fileSize": 1024, + "mimeType": "application/pdf", + "assetType": "file", + "resourceUploadPlan": { + "action": "upload", + "assetId": "asset_upload_1", + "workspaceId": "ws_1", + "targetDocumentId": "target_doc", + "targetSubPath": "mindmaps/mind_1/assets", + "fileName": "demo_file.pdf", + "fileSize": 1024, + "mimeType": "application/pdf", + "assetType": "file", + }, + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "itemsField": "items" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.resource.uploaded" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.resource.uploaded", + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "itemsField": "items" + } + } + } + }) + ); + } + #[test] fn mindmap_get_tool_plan_uses_mindmaps_get_query() { let plan = execute_runtime_input(RuntimeInput::Tool { @@ -8614,10 +12397,12 @@ mod tests { result["items"][0]["resourceMeta"]["resourceKind"], json!("document") ); - assert!(result["items"][0]["capabilities"] - .as_array() - .map(|caps| caps.contains(&json!("create-child"))) - .unwrap_or(false)); + assert!( + result["items"][0]["capabilities"] + .as_array() + .map(|caps| caps.contains(&json!("create-child"))) + .unwrap_or(false) + ); assert_eq!(result["items"][1]["parentNodeId"], json!("page_root")); assert_eq!(result["items"][1]["position"], json!(1)); } @@ -8799,6 +12584,269 @@ mod tests { ); } + #[test] + fn kernel_file_tree_projection_query_keeps_matches_and_ancestors() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 3, + "includeEdges": true, + "nodeTypes": ["page"], + "query": "roadmap" + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "page_child", + "workspace_id": "ws_1", + "title": "子页面", + "parent_id": "page_root", + "sort_order": 1, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "mindmap_assets": [ + { + "id": "mind_roadmap", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "mindmap", + "file_name": "roadmap.json", + "mime_type": "application/json" + } + ], + "media_assets": [ + { + "id": "asset_other", + "workspace_id": "ws_1", + "document_id": "page_root", + "asset_type": "file", + "file_name": "cover.png", + "mime_type": "image/png" + } + ], + "table_assets": [], + "mindmap_asset_children": {} + })), + }) + .expect("file_tree search projection should build"); + + let row_ids = result["items"] + .as_array() + .expect("items should be array") + .iter() + .filter_map(|item| item.get("rowId").and_then(Value::as_str)) + .collect::>(); + + assert_eq!( + row_ids, + vec!["doc:page_root", "doc:page_child", "asset:mind_roadmap"] + ); + assert_eq!(result["items"][0]["expandedByDefault"], json!(true)); + assert_eq!(result["items"][1]["expandedByDefault"], json!(true)); + assert_eq!( + result["edges"] + .as_array() + .expect("edges should be array") + .iter() + .filter_map(|edge| edge.get("toNodeId").and_then(Value::as_str)) + .collect::>(), + vec!["page_child", "asset:mind_roadmap"] + ); + } + + #[test] + fn kernel_file_tree_projection_query_supports_extended_resources_and_max_results() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 3, + "includeEdges": true, + "nodeTypes": ["page"], + "query": "rust", + "maxResults": 4 + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "page_child", + "workspace_id": "ws_1", + "title": "子页面", + "parent_id": "page_root", + "sort_order": 1, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "media_assets": [ + { + "id": "asset_rust_pdf", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "rust-guide.pdf", + "mime_type": "application/pdf" + }, + { + "id": "asset_rust_book", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "rust-book.epub", + "mime_type": "application/epub+zip" + }, + { + "id": "asset_rust_child", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "rust-node.png", + "mime_type": "image/png" + }, + { + "id": "asset_extra", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "rust-extra.png", + "mime_type": "image/png" + } + ], + "mindmap_assets": [ + { + "id": "mind_rust", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "mindmap", + "file_name": "rust-map.json", + "mime_type": "application/json" + } + ], + "table_assets": [], + "mindmap_asset_children": { + "mind_rust": ["asset_rust_child"] + } + })), + }) + .expect("file_tree search projection should build"); + + let items = result["items"].as_array().expect("items should be array"); + let row_ids = items + .iter() + .filter_map(|item| item.get("rowId").and_then(Value::as_str)) + .collect::>(); + + assert_eq!( + row_ids, + vec![ + "doc:page_root", + "doc:page_child", + "asset:asset_rust_pdf", + "asset:asset_rust_book", + "asset:asset_extra", + "asset-folder:mind_rust" + ] + ); + assert_eq!(items[0]["expandedByDefault"], json!(true)); + assert_eq!(items[1]["expandedByDefault"], json!(true)); + assert_eq!(items[2]["resourceMeta"]["resourceKind"], json!("pdf")); + assert_eq!(items[3]["resourceMeta"]["resourceKind"], json!("book")); + assert_eq!(items[4]["resourceMeta"]["resourceKind"], json!("asset")); + assert_eq!(items[5]["resourceMeta"]["resourceKind"], json!("mindmap")); + assert!(!row_ids.contains(&"asset:asset_rust_child")); + } + + #[test] + fn kernel_file_tree_projection_query_matches_index_resource() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 2, + "includeEdges": true, + "nodeTypes": ["page"], + "query": "index.md" + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "media_assets": [], + "mindmap_assets": [], + "table_assets": [], + "mindmap_asset_children": {} + })), + }) + .expect("file_tree index search projection should build"); + + let row_ids = result["items"] + .as_array() + .expect("items should be array") + .iter() + .filter_map(|item| item.get("rowId").and_then(Value::as_str)) + .collect::>(); + + assert_eq!(row_ids, vec!["doc:page_root", "index:page_root"]); + assert_eq!( + result["items"][1]["resourceMeta"]["resourceKind"], + json!("index") + ); + } + #[test] fn kernel_subtree_query_executes_into_unified_subtree() { let result = execute_runtime_query(RuntimeInput::Query { diff --git a/rust/crates/bridge-runtime/src/main.rs b/rust/crates/bridge-runtime/src/main.rs index 0baf157c..26730dd3 100644 --- a/rust/crates/bridge-runtime/src/main.rs +++ b/rust/crates/bridge-runtime/src/main.rs @@ -1,7 +1,8 @@ use std::io::{self, Read}; use bridge_runtime::{ - build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, + build_artifact_success_response, build_failure_response, build_success_response, + execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query, runtime_input_requests_result, RuntimeFailure, RuntimeInput, }; use storage_convex_bridge::{BridgeError, BridgeErrorKind}; @@ -26,7 +27,16 @@ fn main() { } }; - if runtime_input_requests_result(&runtime_input) { + if matches!(runtime_input, RuntimeInput::CommandArtifact { .. }) { + match execute_runtime_command_artifact(runtime_input) { + Ok(artifacts) => { + let payload = serde_json::to_string(&build_artifact_success_response(artifacts)) + .expect("bridge runtime artifact 成功响应必须可序列化"); + println!("{payload}"); + } + Err(error) => emit_failure(error), + } + } else if runtime_input_requests_result(&runtime_input) { match execute_runtime_query(runtime_input) { Ok(result) => { let payload = serde_json::to_string(&serde_json::json!({ diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 42057a31..b8911afd 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -212,6 +212,8 @@ pub struct KernelProjectionFilter { pub node_types: Vec, #[serde(default)] pub edge_types: Vec, + pub query: Option, + pub max_results: Option, #[serde(default)] pub include_deleted: bool, } @@ -526,6 +528,8 @@ mod tests { filters: KernelProjectionFilter { node_types: vec![KernelNodeType::Page, KernelNodeType::Folder], edge_types: vec![KernelEdgeType::ParentOf], + query: Some("预算".into()), + max_results: Some(20), include_deleted: false, }, include_content: false, @@ -535,6 +539,8 @@ mod tests { let value = serde_json::to_value(&request).expect("request 应可序列化"); assert_eq!(value["projection"], json!("sidebar_tree")); assert_eq!(value["subtree"]["rootNodeId"], json!("page_root")); + assert_eq!(value["filters"]["query"], json!("预算")); + assert_eq!(value["filters"]["maxResults"], json!(20)); let decoded: KernelProjectionRequest = serde_json::from_value(value).expect("request 应可反序列化"); diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 1720350b..dd610165 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -22,3 +22,4 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } tower = "0.5" base64 = "0.22" +time = { version = "0.3", features = ["formatting"] } diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index fbaf1700..f1751a79 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -1,8 +1,8 @@ use crate::context::RequestContext; +use axum::Json; use axum::http::StatusCode; use axum::http::{HeaderName, HeaderValue}; use axum::response::{IntoResponse, Response}; -use axum::Json; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -68,6 +68,10 @@ impl WebError { self.headers.push((name, value.into())); self } + + pub fn message(&self) -> &str { + &self.message + } } impl IntoResponse for WebError { diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index 33d68e6b..e8b566bb 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -1,11 +1,13 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; -use crate::transport::convex::execute_convex_command_plan; +use crate::transport::convex::{ + ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts, +}; use bridge_runtime::{ - execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, + RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, - RuntimeTargetWire, + RuntimeTargetWire, execute_runtime_input, }; use serde_json::Value; @@ -66,6 +68,27 @@ pub async fn execute_runtime_command_via_convex( execute_convex_command_plan(config, context, &plan).await } +pub async fn execute_runtime_command_via_convex_with_artifacts( + config: &AppConfig, + context: &RequestContext, + effective_workspace_id: Option<&str>, + command: RuntimeCommandEnvelopeWire, +) -> Result { + let runtime_context = runtime_context(context, effective_workspace_id); + let runtime_input = RuntimeInput::Command { + context: runtime_context.clone(), + command: command.clone(), + }; + let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input) + .map_err(|error| WebError::bad_request(error.message).with_context(context))? + else { + return Err(WebError::internal("runtime command 未返回 command plan").with_context(context)); + }; + + execute_convex_command_plan_with_artifacts(config, context, &runtime_context, &command, &plan) + .await +} + pub fn build_tree_target( workspace_id: &str, page_id: Option<&str>, diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 07807846..355f3880 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -64,6 +64,8 @@ pub async fn next_sidebar( workspace_id: &effective_workspace_id, root_node_id: None, depth: None, + query: None, + max_results: None, projection: KernelProjectionKind::SidebarTree, }, ) diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index f6ff916b..06fc0bcc 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -20,6 +20,8 @@ pub struct KernelProjectionQuery { pub workspace_id: Option, pub root_node_id: Option, pub depth: Option, + pub query: Option, + pub max_results: Option, } #[derive(Debug, Deserialize)] @@ -73,6 +75,8 @@ async fn project_projection( workspace_id: &effective_workspace_id, root_node_id: query.root_node_id.as_deref(), depth: query.depth, + query: query.query.as_deref(), + max_results: query.max_results, projection, }, ) @@ -388,4 +392,64 @@ mod tests { .iter() .any(|value| value == "expand")); } + + #[tokio::test] + async fn file_tree_projection_query_returns_matches_and_ancestors() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=%E9%A2%84%E7%AE%97") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let items = payload["result"]["items"].as_array().expect("items"); + let row_ids = items + .iter() + .filter_map(|item| item["rowId"].as_str()) + .collect::>(); + + assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]); + assert_eq!(items[0]["expandedByDefault"], true); + assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table"); + let edges = payload["result"]["edges"].as_array().expect("edges"); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0]["fromNodeId"], "page_root"); + assert_eq!(edges[0]["toNodeId"], "asset:table_1"); + } + + #[tokio::test] + async fn file_tree_projection_query_honors_max_results_and_keeps_ancestors() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=png&maxResults=1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let items = payload["result"]["items"].as_array().expect("items"); + let row_ids = items + .iter() + .filter_map(|item| item["rowId"].as_str()) + .collect::>(); + + assert_eq!(row_ids, vec!["doc:page_root", "asset:asset_file_1"]); + assert_eq!(items[0]["expandedByDefault"], true); + assert_eq!(items[1]["resourceMeta"]["assetKind"], "image"); + } } diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs index 5063e119..a8b62368 100644 --- a/rust/crates/mnote-web/src/routes/snapshot_support.rs +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -13,6 +13,8 @@ pub struct ProjectionSnapshotSpec<'a> { pub workspace_id: &'a str, pub root_node_id: Option<&'a str>, pub depth: Option, + pub query: Option<&'a str>, + pub max_results: Option, pub projection: KernelProjectionKind, } @@ -53,6 +55,8 @@ pub fn projection_query(spec: &ProjectionSnapshotSpec<'_>) -> RuntimeQueryEnvelo "workspaceId": spec.workspace_id, "rootNodeId": spec.root_node_id, "depth": spec.depth, + "query": spec.query, + "maxResults": spec.max_results, "includeEdges": true, "includeContent": false, "nodeTypes": [KernelNodeType::Page], diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 5086e62a..3df2d519 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -78,7 +78,9 @@ pub async fn events( &workspace_id, &overview, change.cursor, - change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })), + change + .delta + .unwrap_or_else(|| serde_json::json!({ "op": "noop" })), ); return Some((Ok(stream_event("delta", &payload)), Some(state))); } @@ -95,8 +97,7 @@ pub async fn events( return None; }; state.query = next_query; - state.current_cursor = - read_stream_cursor_from_payload(&snapshot_payload); + state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload); return Some(( Ok(stream_event( "resync", diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index a6a842cf..451bf1d8 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -125,7 +125,11 @@ fn is_record(value: &Value) -> bool { 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(""); + let candidate = map + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); if !candidate.is_empty() { return Some(candidate.to_string()); } @@ -149,22 +153,30 @@ fn encode_stream_cursor(id: &str, created_at: &str) -> Option { if id.is_empty() || created_at.is_empty() { return None; } - Some(json!({ - "createdAt": created_at, - "id": id, - }) - .to_string()) + 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"])?; + 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"])?; + let created_at = read_string_field( + row, + &["created_at", "createdAt", "finished_at", "finishedAt"], + )?; encode_stream_cursor(&format!("domain_event:{id}"), &created_at) } @@ -176,10 +188,7 @@ fn decode_stream_cursor(raw: &str) -> Option { }) } -pub fn resolve_stream_cursor( - overview: Option<&Value>, - fallback: Option<&str>, -) -> Option { +pub fn resolve_stream_cursor(overview: Option<&Value>, fallback: Option<&str>) -> Option { let fallback = fallback .map(str::trim) .filter(|value| !value.is_empty()) @@ -218,29 +227,98 @@ pub fn resolve_stream_cursor( } } -fn collect_new_command_logs( - rows: &[Value], - previous_cursor: Option<&str>, -) -> (Vec, bool) { +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(); + 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 { + let newer_rows = rows + .iter() + .filter(|row| { + read_string_field( + row, + &["created_at", "createdAt", "finished_at", "finishedAt"], + ) + .map(|created_at| created_at > previous_cursor.created_at) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + if newer_rows.len() < rows.len() { + return (newer_rows, false); + } (rows.to_vec(), !rows.is_empty()) } } +fn collect_new_domain_events(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_id = previous_cursor + .id + .strip_prefix("domain_event:") + .unwrap_or(previous_cursor.id.as_str()); + + let previous_index = rows.iter().position(|row| { + let id = read_string_field(row, &["event_id", "eventId", "id"]).unwrap_or_default(); + let created_at = read_string_field( + row, + &["created_at", "createdAt", "finished_at", "finishedAt"], + ) + .unwrap_or_default(); + id == previous_id && created_at == previous_cursor.created_at + }); + + if let Some(index) = previous_index { + (rows.iter().take(index).cloned().collect(), false) + } else { + let newer_rows = rows + .iter() + .filter(|row| { + read_string_field( + row, + &["created_at", "createdAt", "finished_at", "finishedAt"], + ) + .map(|created_at| created_at > previous_cursor.created_at) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + if newer_rows.len() < rows.len() { + return (newer_rows, false); + } + (rows.to_vec(), !rows.is_empty()) + } +} + +fn read_stream_delta_candidate(candidate: &Value) -> Option { + 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 +} + 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()) { @@ -255,17 +333,52 @@ fn read_command_payload_delta(row: &Value) -> Option { let candidate = payload .as_object() .and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?; - if candidate + read_stream_delta_candidate(candidate) +} + +fn read_domain_event_payload_delta(row: &Value) -> Option { + let payload = row.as_object()?.get("payload")?; + if !is_record(payload) { + return None; + } + + let candidate = payload .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()); + .and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?; + read_stream_delta_candidate(candidate) +} + +fn read_command_row_command_id(row: &Value) -> Option { + read_string_field(row, &["command_id", "commandId", "id"]) +} + +fn read_domain_event_command_id(row: &Value) -> Option { + read_string_field(row, &["command_id", "commandId"]) +} + +fn resolve_matching_command_domain_event_delta( + command_rows: &[Value], + command_drifted: bool, + domain_event_rows: &[Value], + _domain_event_drifted: bool, +) -> Option { + if command_drifted || command_rows.len() != 1 || domain_event_rows.len() != 1 { + return None; + } + + let command_id = read_command_row_command_id(&command_rows[0])?; + let event_command_id = read_domain_event_command_id(&domain_event_rows[0])?; + if command_id != event_command_id { + return None; + } + + let command_delta = read_command_payload_delta(&command_rows[0])?; + let event_delta = read_domain_event_payload_delta(&domain_event_rows[0])?; + if command_delta == event_delta { + Some(event_delta) + } else { + None } - None } pub fn resolve_stream_change( @@ -281,8 +394,36 @@ pub fn resolve_stream_change( return None; } - let rows = read_array_field(overview, &["command_logs", "commandLogs"]).cloned().unwrap_or_default(); + 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()); + let event_rows = read_array_field(overview, &["domain_events", "domainEvents"]) + .cloned() + .unwrap_or_default(); + let (new_event_rows, event_drifted) = + collect_new_domain_events(&event_rows, previous_cursor.as_deref()); + if !new_rows.is_empty() && !new_event_rows.is_empty() { + if let Some(delta) = resolve_matching_command_domain_event_delta( + &new_rows, + drifted, + &new_event_rows, + event_drifted, + ) { + return Some(StreamChange { + kind: StreamChangeKind::Delta, + cursor: next_cursor, + delta: Some(delta), + }); + } + + return Some(StreamChange { + kind: StreamChangeKind::Resync, + cursor: next_cursor, + delta: None, + }); + } + if !drifted && new_rows.len() == 1 { if let Some(delta) = read_command_payload_delta(&new_rows[0]) { return Some(StreamChange { @@ -293,6 +434,16 @@ pub fn resolve_stream_change( } } + if !event_drifted && new_rows.is_empty() && new_event_rows.len() == 1 { + if let Some(delta) = read_domain_event_payload_delta(&new_event_rows[0]) { + return Some(StreamChange { + kind: StreamChangeKind::Delta, + cursor: next_cursor, + delta: Some(delta), + }); + } + } + Some(StreamChange { kind: StreamChangeKind::Resync, cursor: next_cursor, @@ -377,6 +528,8 @@ pub async fn load_stream_snapshot( workspace_id: &effective_workspace_id, root_node_id: None, depth: query.depth, + query: None, + max_results: None, projection: KernelProjectionKind::SidebarTree, }, ) @@ -388,8 +541,8 @@ pub async fn load_stream_snapshot( }) } StreamSnapshotScope::Subtree => { - let root_node_id = normalize_root_node_id(query) - .expect("subtree scope 已确保 rootNodeId 存在"); + 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, @@ -435,8 +588,8 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { use super::{ - resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, - StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, + resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, + StreamSnapshotQuery, StreamSnapshotScope, }; use serde_json::json; @@ -487,6 +640,7 @@ mod tests { let overview = json!({ "command_logs": [ { + "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "tree.node.archive", @@ -498,6 +652,7 @@ mod tests { } }, { + "id": "clog_1", "command_id": "cmd_1", "created_at": "2026-04-25T10:00:01Z" } @@ -507,14 +662,14 @@ mod tests { let change = resolve_stream_change( &overview, - Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), ) .expect("应识别到变化"); assert_eq!(change.kind, StreamChangeKind::Delta); assert_eq!( change.cursor, - Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into()) + Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into()) ); assert_eq!( change.delta, @@ -525,11 +680,195 @@ mod tests { ); } + #[test] + fn stream_change_preserves_move_document_delta_fields() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.subtree.move", + "payload": { + "streamDelta": { + "op": "move_document", + "documentId": "page_2", + "parentId": "page_1", + "sortOrder": 3, + "updatedAt": "2026-04-25T10:00:02Z" + } + } + }, + { + "id": "clog_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":"clog_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "move_document", + "documentId": "page_2", + "parentId": "page_1", + "sortOrder": 3, + "updatedAt": "2026-04-25T10:00:02Z" + })) + ); + } + + #[test] + fn stream_change_preserves_upsert_documents_delta_fields() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.subtree.copy", + "payload": { + "streamDelta": { + "op": "upsert_documents", + "upsertDocuments": [ + { + "id": "copy_1", + "workspace_id": "ws_1", + "title": "Copy", + "parent_id": null, + "sort_order": 2, + "is_starred": false, + "access_scope": "private", + "is_template": false, + "created_at": "2026-04-25T10:00:02Z", + "updated_at": "2026-04-25T10:00:02Z" + } + ] + } + } + }, + { + "id": "clog_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":"clog_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "upsert_documents", + "upsertDocuments": [ + { + "id": "copy_1", + "workspace_id": "ws_1", + "title": "Copy", + "parent_id": null, + "sort_order": 2, + "is_starred": false, + "access_scope": "private", + "is_template": false, + "created_at": "2026-04-25T10:00:02Z", + "updated_at": "2026-04-25T10:00:02Z" + } + ] + })) + ); + } + + #[test] + fn stream_change_preserves_upsert_assets_delta_fields() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.resource.move", + "payload": { + "streamDelta": { + "op": "upsert_assets", + "upsertAssets": [ + { + "id": "asset_1", + "workspace_id": "ws_1", + "document_id": "doc_target", + "asset_type": "file", + "file_url": "/file.pdf", + "thumbnail_url": "/file.pdf", + "file_name": "file.pdf", + "file_size": 1024, + "mime_type": "application/pdf", + "created_at": "2026-04-25T10:00:02Z", + "updated_at": "2026-04-25T10:00:02Z" + } + ] + } + } + }, + { + "id": "clog_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":"clog_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "upsert_assets", + "upsertAssets": [ + { + "id": "asset_1", + "workspace_id": "ws_1", + "document_id": "doc_target", + "asset_type": "file", + "file_url": "/file.pdf", + "thumbnail_url": "/file.pdf", + "file_name": "file.pdf", + "file_size": 1024, + "mime_type": "application/pdf", + "created_at": "2026-04-25T10:00:02Z", + "updated_at": "2026-04-25T10:00:02Z" + } + ] + })) + ); + } + #[test] fn stream_change_detects_noop_delta_for_non_tree_mutating_command() { let overview = json!({ "command_logs": [ { + "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "page.body.save", @@ -538,6 +877,7 @@ mod tests { } }, { + "id": "clog_1", "command_id": "cmd_1", "created_at": "2026-04-25T10:00:01Z" } @@ -547,7 +887,7 @@ mod tests { let change = resolve_stream_change( &overview, - Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), ) .expect("应识别到变化"); @@ -555,11 +895,276 @@ mod tests { assert_eq!(change.delta, Some(json!({ "op": "noop" }))); } + #[test] + fn stream_change_detects_delta_from_single_new_domain_event() { + let overview = json!({ + "command_logs": [], + "domain_events": [ + { + "event_id": "evt_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "command_name": "tree.node.rename", + "streamDelta": { + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "新标题" + } + } + } + }, + { + "event_id": "evt_1", + "created_at": "2026-04-25T10:00:01Z" + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#), + ) + .expect("应识别到 domain event delta"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.cursor, + Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into()) + ); + assert_eq!( + change.delta, + Some(json!({ + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "新标题" + } + })) + ); + } + + #[test] + fn stream_change_falls_back_to_resync_for_unknown_domain_event_payload() { + let overview = json!({ + "command_logs": [], + "domain_events": [ + { + "event_id": "evt_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.node.unknown" + } + }, + { + "event_id": "evt_1", + "created_at": "2026-04-25T10:00:01Z" + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#), + ) + .expect("应识别到未知 domain event 推进"); + + assert_eq!(change.kind, StreamChangeKind::Resync); + assert_eq!( + change.cursor, + Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into()) + ); + assert_eq!(change.delta, None); + } + + #[test] + fn stream_change_falls_back_to_resync_when_command_and_domain_event_both_advance() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:03Z", + "command_name": "tree.node.rename", + "payload": { + "streamDelta": { + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "命令标题" + } + } + } + }, + { + "id": "clog_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [ + { + "event_id": "evt_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "streamDelta": { + "op": "remove_document", + "documentId": "page_3" + } + } + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), + ) + .expect("应识别到混合变化"); + + assert_eq!(change.kind, StreamChangeKind::Resync); + assert_eq!(change.delta, None); + } + + #[test] + fn stream_change_dedupes_matching_command_and_domain_event_delta() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.node.rename", + "payload": { + "streamDelta": { + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "同一标题" + } + } + } + }, + { + "id": "clog_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [ + { + "event_id": "evt_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "streamDelta": { + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "同一标题" + } + } + } + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), + ) + .expect("应识别到同命令去重 delta"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "upsert_document", + "document": { + "id": "page_2", + "title": "同一标题" + } + })) + ); + } + + #[test] + fn stream_change_dedupes_matching_delta_when_previous_cursor_is_command_log() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.subtree.move", + "payload": { + "streamDelta": { + "op": "move_document", + "documentId": "page_2", + "parentId": "page_1", + "sortOrder": 2 + } + } + }, + { + "id": "clog_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [ + { + "event_id": "evt_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "streamDelta": { + "op": "move_document", + "documentId": "page_2", + "parentId": "page_1", + "sortOrder": 2 + } + } + }, + { + "event_id": "evt_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z", + "payload": { + "streamDelta": { + "op": "noop" + } + } + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), + ) + .expect("应识别到同命令去重 delta"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "move_document", + "documentId": "page_2", + "parentId": "page_1", + "sortOrder": 2 + })) + ); + } + #[test] fn stream_change_falls_back_to_resync_when_delta_is_unstable() { let overview = json!({ "command_logs": [ { + "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "tree.subtree.move", @@ -568,6 +1173,7 @@ mod tests { } }, { + "id": "clog_1", "command_id": "cmd_1", "created_at": "2026-04-25T10:00:01Z" } @@ -577,7 +1183,7 @@ mod tests { let change = resolve_stream_change( &overview, - Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#), + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), ) .expect("应识别到变化"); diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 89e8450a..f99131b8 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2,19 +2,34 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ - build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex, - read_optional_non_empty, + build_tree_target, ensure_non_empty, ensure_sort_order, + execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; use crate::routes::query_support::resolve_effective_workspace_id; -use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; -use axum::extract::{Extension, Query, State}; -use axum::http::{header, HeaderValue, StatusCode}; -use axum::response::{Html, IntoResponse, Response}; +use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; +use crate::tree_shell::filetree_renderer::{ + FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html, +}; +use crate::tree_shell::filetree_selection::FileTreeSelectionState; +use crate::tree_shell::page_renderer::{ + PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html, +}; +use crate::tree_shell::picker_renderer::{ + PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html, +}; +use crate::tree_shell::renderer_input::{ + FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, + TreeShellRendererInput, +}; use axum::Json; +use axum::extract::{Extension, Query, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Response}; use bridge_runtime::RuntimeCommandEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -138,6 +153,351 @@ fn parse_exclude_ids(value: Option<&str>) -> Vec { .collect() } +fn collect_projection_item_ids(projection: &Value) -> Vec { + projection + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + item.get("rowId") + .and_then(Value::as_str) + .or_else(|| item.get("nodeId").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .collect() + }) + .unwrap_or_default() +} + +fn collect_expanded_ids(projection: &Value) -> BTreeSet { + projection + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter(|item| { + item.get("expandedByDefault") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|item| { + item.get("nodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .collect() + }) + .unwrap_or_default() +} + +fn collect_page_tree_render_rows(projection: &Value) -> Vec { + projection + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let node_id = item + .get("nodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let row_kind = item + .get("rowKind") + .and_then(Value::as_str) + .unwrap_or_default(); + if row_kind != "document" { + return None; + } + Some(PageTreeRenderRow { + node_id: node_id.to_string(), + parent_node_id: item + .get("parentNodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + title: item + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("无标题") + .to_string(), + depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, + expandable: item + .get("expandable") + .and_then(Value::as_bool) + .unwrap_or_else(|| { + item.get("childCount") + .and_then(Value::as_u64) + .map(|count| count > 0) + .unwrap_or(false) + }), + expanded: item + .get("expandedByDefault") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn collect_filetree_render_rows( + projection: &Value, + active_document_id: Option<&str>, +) -> Vec { + let selected_ids = active_document_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|document_id| { + [format!("doc:{document_id}"), format!("index:{document_id}")] + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + + projection + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let row_id = item + .get("rowId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let node_id = item + .get("nodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let resource_meta = item.get("resourceMeta").and_then(Value::as_object); + Some(FileTreeRenderRow { + row_id: row_id.to_string(), + row_kind: item + .get("rowKind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("document") + .to_string(), + node_id: node_id.to_string(), + parent_node_id: item + .get("parentNodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + title: item + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("无标题") + .to_string(), + depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, + expandable: item + .get("expandable") + .and_then(Value::as_bool) + .unwrap_or_else(|| { + item.get("childCount") + .and_then(Value::as_u64) + .map(|count| count > 0) + .unwrap_or(false) + }), + expanded: item + .get("expandedByDefault") + .and_then(Value::as_bool) + .unwrap_or(false), + icon_kind: item + .get("iconHint") + .and_then(Value::as_str) + .or_else(|| { + resource_meta + .and_then(|meta| meta.get("iconHint")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("file") + .to_string(), + document_id: resource_meta + .and_then(|meta| meta.get("documentId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + asset_id: resource_meta + .and_then(|meta| meta.get("assetId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + selected: selected_ids.contains(row_id), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn collect_picker_render_rows( + projection: &Value, + active_picker_item_key: Option<&str>, + active_document_id: Option<&str>, + exclude_ids: &[String], +) -> Vec { + let active_key = active_picker_item_key + .or(active_document_id) + .map(str::trim) + .filter(|value| !value.is_empty()); + let excluded_ids = exclude_ids.iter().cloned().collect::>(); + + projection + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let node_id = item + .get("nodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + if excluded_ids.contains(node_id) { + return None; + } + let row_kind = item + .get("rowKind") + .and_then(Value::as_str) + .unwrap_or_default(); + if row_kind != "document" { + return None; + } + Some(PickerRenderRow { + node_id: node_id.to_string(), + parent_node_id: item + .get("parentNodeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + title: item + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("无标题") + .to_string(), + depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, + expandable: item + .get("expandable") + .and_then(Value::as_bool) + .unwrap_or_else(|| { + item.get("childCount") + .and_then(Value::as_u64) + .map(|count| count > 0) + .unwrap_or(false) + }), + expanded: item + .get("expandedByDefault") + .and_then(Value::as_bool) + .unwrap_or(false), + active: active_key + .map(|active_key| active_key == node_id) + .unwrap_or(false), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn build_tree_shell_command_dispatcher(channel: &str) -> TreeShellCommandDispatcher { + TreeShellCommandDispatcher { + channel: channel.into(), + command_names: [ + "tree.node.create", + "tree.node.rename", + "tree.subtree.move", + "tree.resource.copy", + "tree.resource.move", + "tree.resource.upload", + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + } +} + +fn build_tree_shell_renderer_input( + projection: &Value, + mode: &str, + channel: &str, + active_document_id: Option<&str>, + focused_document_id: Option<&str>, + active_picker_item_key: Option<&str>, + exclude_ids: &[String], +) -> TreeShellRendererInput { + let projection_item_ids = collect_projection_item_ids(projection); + let expanded_ids = collect_expanded_ids(projection); + let command_dispatcher = build_tree_shell_command_dispatcher(channel); + match mode { + "filetree" => { + let selection = active_document_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|document_id| { + FileTreeSelectionState::from_selected(&[ + format!("doc:{document_id}"), + format!("index:{document_id}"), + ]) + }) + .unwrap_or_default(); + TreeShellRendererInput::filetree(FileTreeRendererInput { + projection_item_ids, + expanded_ids, + filetree_selection: selection, + command_dispatcher, + }) + } + "picker" => TreeShellRendererInput::picker(PickerRendererInput { + projection_item_ids, + expanded_ids, + active_picker_item: active_picker_item_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + excluded_picker_ids: exclude_ids.iter().cloned().collect(), + command_dispatcher, + }), + _ => TreeShellRendererInput::page(PageTreeRendererInput { + projection_item_ids, + expanded_ids, + focused_id: focused_document_id + .or(active_document_id) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + command_dispatcher, + }), + } +} + fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext { let mut next = context.clone(); let actor_id = actor_id @@ -175,6 +535,15 @@ fn build_tree_shell_html( exclude_ids: &[String], dataset: &Value, ) -> String { + let renderer_input = build_tree_shell_renderer_input( + projection, + mode, + channel, + active_document_id, + focused_document_id, + active_picker_item_key, + exclude_ids, + ); let app_state = json!({ "workspaceId": workspace_id, "rootNodeId": root_node_id, @@ -187,6 +556,7 @@ fn build_tree_shell_html( "mode": mode, "allowRootPick": allow_root_pick, "excludeIds": exclude_ids, + "rendererInput": renderer_input, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "commandPath": "/api/tree/commands", @@ -198,6 +568,37 @@ fn build_tree_shell_html( }); let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into()); let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into()); + let initial_tree_html = match mode { + "page" => render_initial_page_tree_html(&PageTreeInitialRenderInput { + rows: collect_page_tree_render_rows(projection), + active_node_id: active_document_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + focused_node_id: focused_document_id + .or(active_document_id) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + }), + "filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput { + rows: collect_filetree_render_rows(projection, active_document_id), + }), + "picker" => render_initial_picker_html(&PickerInitialRenderInput { + rows: collect_picker_render_rows( + projection, + active_picker_item_key, + active_document_id, + exclude_ids, + ), + allow_root_pick, + root_active: active_picker_item_key + .map(str::trim) + .map(|value| value == "__root__") + .unwrap_or(false), + }), + _ => String::new(), + }; let root_label = root_node_id.unwrap_or("workspace_root"); let active_label = active_document_id.unwrap_or("未指定"); let template = r##" @@ -749,7 +1150,7 @@ fn build_tree_shell_html( -
+
__INITIAL_TREE_HTML__
@@ -776,6 +1177,35 @@ fn build_tree_shell_html( }; const state = parseState(); + const rendererInput = + state.rendererInput && typeof state.rendererInput === "object" + ? state.rendererInput + : {}; + const rendererFiletreeSelection = + rendererInput.filetreeSelection && typeof rendererInput.filetreeSelection === "object" + ? rendererInput.filetreeSelection + : {}; + const filetreeSelectionReducer = + rendererInput.filetreeSelectionReducer && + typeof rendererInput.filetreeSelectionReducer === "object" + ? rendererInput.filetreeSelectionReducer + : {}; + const pickerStateReducer = + rendererInput.pickerStateReducer && + typeof rendererInput.pickerStateReducer === "object" + ? rendererInput.pickerStateReducer + : {}; + const pageFocusKeyboardReducer = + rendererInput.pageFocusKeyboardReducer && + typeof rendererInput.pageFocusKeyboardReducer === "object" + ? rendererInput.pageFocusKeyboardReducer + : {}; + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) + : []; const hostOverride = window.__MNOTE_TREE_SHELL_OVERRIDE__ && typeof window.__MNOTE_TREE_SHELL_OVERRIDE__ === "object" @@ -802,9 +1232,11 @@ fn build_tree_shell_html( ? state.focusedDocumentId.trim() : ""; const activePickerItemKey = - typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim() - ? state.activePickerItemKey.trim() - : ""; + typeof rendererInput.activePickerItem === "string" && rendererInput.activePickerItem.trim() + ? rendererInput.activePickerItem.trim() + : typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim() + ? state.activePickerItemKey.trim() + : ""; const mode = (() => { const rawMode = typeof state.mode === "string" ? state.mode.trim() : ""; @@ -814,11 +1246,25 @@ fn build_tree_shell_html( })(); const allowRootPick = state.allowRootPick === true; const excludedIds = new Set( - Array.isArray(state.excludeIds) - ? state.excludeIds - .map((item) => (typeof item === "string" ? item.trim() : "")) - .filter(Boolean) - : [], + normalizeStringArray(rendererInput.excludedPickerIds).length > 0 + ? normalizeStringArray(rendererInput.excludedPickerIds) + : normalizeStringArray(state.excludeIds), + ); + const pickerStateReducerContractName = + typeof pickerStateReducer.contractName === "string" && + pickerStateReducer.contractName.trim() + ? pickerStateReducer.contractName.trim() + : ""; + const pickerStateReducerActions = new Set( + normalizeStringArray(pickerStateReducer.actions), + ); + const pageFocusKeyboardReducerContractName = + typeof pageFocusKeyboardReducer.contractName === "string" && + pageFocusKeyboardReducer.contractName.trim() + ? pageFocusKeyboardReducer.contractName.trim() + : ""; + const pageFocusKeyboardReducerActions = new Set( + normalizeStringArray(pageFocusKeyboardReducer.actions), ); const commandPath = typeof state.commandPath === "string" && state.commandPath.trim() @@ -924,6 +1370,7 @@ fn build_tree_shell_html( : []; const itemById = new Map(normalizedItems.map((item) => [item.nodeId, item])); + const fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item])); const childrenByParentId = new Map(); const roots = []; const assetsByDocId = new Map(); @@ -962,10 +1409,13 @@ fn build_tree_shell_html( roots.sort(compareItems); childrenByParentId.forEach((bucket) => bucket.sort(compareItems)); + const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds); const expanded = new Set( - normalizedItems - .filter((item) => item.childCount > 0 && item.expandedByDefault) - .map((item) => item.nodeId), + rendererExpandedIds.length > 0 + ? rendererExpandedIds + : normalizedItems + .filter((item) => item.childCount > 0 && item.expandedByDefault) + .map((item) => item.nodeId), ); let currentActiveDocumentId = activeDocumentId; let currentFocusedDocumentId = focusedDocumentId; @@ -991,11 +1441,34 @@ fn build_tree_shell_html( : roots[0]?.nodeId || ""; }; let focusedNodeId = resolveFocusedNodeIdFromHostState(); - let selectedFileTreeRowIds = new Set( - currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : [] + const rendererSelectedFileTreeRowIds = normalizeStringArray( + rendererFiletreeSelection.selectedRowIds, ); - let fileTreeAnchorRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null; - let fileTreeFocusedRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null; + const rendererAnchorRowId = + typeof rendererFiletreeSelection.anchorRowId === "string" && + rendererFiletreeSelection.anchorRowId.trim() + ? rendererFiletreeSelection.anchorRowId.trim() + : null; + const rendererFocusedRowId = + typeof rendererFiletreeSelection.focusedRowId === "string" && + rendererFiletreeSelection.focusedRowId.trim() + ? rendererFiletreeSelection.focusedRowId.trim() + : null; + const filetreeSelectionReducerContractName = + typeof filetreeSelectionReducer.contractName === "string" && + filetreeSelectionReducer.contractName.trim() + ? filetreeSelectionReducer.contractName.trim() + : ""; + const filetreeSelectionReducerActions = new Set( + normalizeStringArray(filetreeSelectionReducer.actions), + ); + let selectedFileTreeRowIds = new Set( + rendererSelectedFileTreeRowIds.length > 0 + ? rendererSelectedFileTreeRowIds + : currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : [] + ); + let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null); + let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null); let visibleFileTreeRowIds = []; let draggingPageNodeId = ""; let activePageDropNodeId = null; @@ -1223,14 +1696,6 @@ fn build_tree_shell_html( return getFileTreeRowDocumentId(firstDocItem) || null; }; - const resolveFileTreeDraggedRowIds = (rowId) => { - if (!rowId) return []; - if (selectedFileTreeRowIds.has(rowId)) { - return Array.from(selectedFileTreeRowIds); - } - return [rowId]; - }; - const isExternalFileDrag = (event) => { const types = event.dataTransfer?.types; return Array.isArray(types) @@ -1274,68 +1739,208 @@ fn build_tree_shell_html( return visibleFileTreeRowIds.slice(lo, hi + 1); }; + const normalizeFileTreeSelectionState = (selection) => { + const selectedRowIds = + selection?.selectedRowIds instanceof Set + ? new Set( + Array.from(selection.selectedRowIds) + .map((rowId) => normalizeText(rowId)) + .filter(Boolean), + ) + : new Set(normalizeStringArray(selection?.selectedRowIds)); + const anchorRowId = normalizeText(selection?.anchorRowId) || null; + const focusedRowId = normalizeText(selection?.focusedRowId) || null; + return { + selectedRowIds, + anchorRowId, + focusedRowId, + }; + }; + + const readFileTreeSelectionState = () => + normalizeFileTreeSelectionState({ + selectedRowIds: Array.from(selectedFileTreeRowIds), + anchorRowId: fileTreeAnchorRowId, + focusedRowId: fileTreeFocusedRowId, + }); + const commitFileTreeSelection = (nextSelection) => { - selectedFileTreeRowIds = nextSelection.selectedRowIds; - fileTreeAnchorRowId = nextSelection.anchorRowId; - fileTreeFocusedRowId = nextSelection.focusedRowId; + const normalizedSelection = normalizeFileTreeSelectionState(nextSelection); + selectedFileTreeRowIds = normalizedSelection.selectedRowIds; + fileTreeAnchorRowId = normalizedSelection.anchorRowId; + fileTreeFocusedRowId = normalizedSelection.focusedRowId; emitFileTreeSelectionChange(); + return normalizedSelection; + }; + + const computeFileTreeSelectionActionResult = (action) => { + const currentSelection = readFileTreeSelectionState(); + if ( + mode !== "filetree" || + filetreeSelectionReducerContractName !== "rust_filetree_selection_reducer_v1" || + !filetreeSelectionReducerActions.has(action?.kind || "") + ) { + return { + nextSelection: currentSelection, + dragRowIds: action?.kind === "resolve_drag_rows" + ? [normalizeText(action?.rowId)].filter(Boolean) + : null, + }; + } + + if (action.kind === "select_row") { + const rowId = normalizeText(action.rowId); + if (!rowId) { + return { nextSelection: currentSelection, dragRowIds: null }; + } + const shiftKey = action.modifiers?.shiftKey === true; + const metaKey = action.modifiers?.metaKey === true; + const ctrlKey = action.modifiers?.ctrlKey === true; + const toggleSelection = metaKey || ctrlKey; + + if (shiftKey) { + const anchor = + currentSelection.anchorRowId || currentSelection.focusedRowId || rowId; + const nextSelection = toggleSelection + ? new Set(currentSelection.selectedRowIds) + : new Set(); + getFileTreeRangeRowIds(anchor, rowId).forEach((id) => { + nextSelection.add(id); + }); + return { + nextSelection: { + selectedRowIds: nextSelection, + anchorRowId: currentSelection.anchorRowId || anchor, + focusedRowId: rowId, + }, + dragRowIds: null, + }; + } + + if (toggleSelection) { + const nextSelection = new Set(currentSelection.selectedRowIds); + if (nextSelection.has(rowId)) { + nextSelection.delete(rowId); + } else { + nextSelection.add(rowId); + } + return { + nextSelection: { + selectedRowIds: nextSelection, + anchorRowId: rowId, + focusedRowId: rowId, + }, + dragRowIds: null, + }; + } + + return { + nextSelection: { + selectedRowIds: new Set([rowId]), + anchorRowId: rowId, + focusedRowId: rowId, + }, + dragRowIds: null, + }; + } + + if (action.kind === "select_context_row") { + const rowId = normalizeText(action.rowId); + if (!rowId) { + return { nextSelection: currentSelection, dragRowIds: null }; + } + if (currentSelection.selectedRowIds.has(rowId)) { + return { + nextSelection: { + selectedRowIds: new Set(currentSelection.selectedRowIds), + anchorRowId: currentSelection.anchorRowId, + focusedRowId: rowId, + }, + dragRowIds: null, + }; + } + return { + nextSelection: { + selectedRowIds: new Set([rowId]), + anchorRowId: rowId, + focusedRowId: rowId, + }, + dragRowIds: null, + }; + } + + if (action.kind === "normalize_visible_rows") { + const visibleSet = new Set(normalizeStringArray(action.visibleRowIds)); + const nextSelectedRowIds = Array.from(currentSelection.selectedRowIds).filter((rowId) => + visibleSet.has(rowId), + ); + return { + nextSelection: { + selectedRowIds: new Set(nextSelectedRowIds), + anchorRowId: + currentSelection.anchorRowId && visibleSet.has(currentSelection.anchorRowId) + ? currentSelection.anchorRowId + : null, + focusedRowId: + currentSelection.focusedRowId && visibleSet.has(currentSelection.focusedRowId) + ? currentSelection.focusedRowId + : null, + }, + dragRowIds: null, + }; + } + + if (action.kind === "clear") { + return { + nextSelection: { + selectedRowIds: new Set(), + anchorRowId: null, + focusedRowId: null, + }, + dragRowIds: null, + }; + } + + if (action.kind === "resolve_drag_rows") { + const rowId = normalizeText(action.rowId); + if (!rowId) { + return { nextSelection: currentSelection, dragRowIds: [] }; + } + return { + nextSelection: currentSelection, + dragRowIds: currentSelection.selectedRowIds.has(rowId) + ? Array.from(currentSelection.selectedRowIds) + : [rowId], + }; + } + + return { nextSelection: currentSelection, dragRowIds: null }; + }; + + const applyFileTreeSelectionAction = (action) => { + const result = computeFileTreeSelectionActionResult(action); + if (action?.kind !== "resolve_drag_rows") { + commitFileTreeSelection(result.nextSelection); + } + return result; }; const selectFileTreeRow = (rowId, modifiers = {}) => { - const shiftKey = modifiers.shiftKey === true; - const metaKey = modifiers.metaKey === true; - const ctrlKey = modifiers.ctrlKey === true; - const toggleSelection = metaKey || ctrlKey; - - if (shiftKey) { - const anchor = fileTreeAnchorRowId || fileTreeFocusedRowId || rowId; - const nextSelection = toggleSelection ? new Set(selectedFileTreeRowIds) : new Set(); - getFileTreeRangeRowIds(anchor, rowId).forEach((id) => { - nextSelection.add(id); - }); - commitFileTreeSelection({ - selectedRowIds: nextSelection, - anchorRowId: fileTreeAnchorRowId || anchor, - focusedRowId: rowId, - }); - return; - } - - if (toggleSelection) { - const nextSelection = new Set(selectedFileTreeRowIds); - if (nextSelection.has(rowId)) { - nextSelection.delete(rowId); - } else { - nextSelection.add(rowId); - } - commitFileTreeSelection({ - selectedRowIds: nextSelection, - anchorRowId: rowId, - focusedRowId: rowId, - }); - return; - } - - commitFileTreeSelection({ - selectedRowIds: new Set([rowId]), - anchorRowId: rowId, - focusedRowId: rowId, + applyFileTreeSelectionAction({ + kind: "select_row", + rowId, + modifiers: { + shiftKey: modifiers.shiftKey === true, + ctrlKey: modifiers.ctrlKey === true, + metaKey: modifiers.metaKey === true, + }, }); }; const selectFileTreeContextRow = (rowId) => { - if (selectedFileTreeRowIds.has(rowId)) { - commitFileTreeSelection({ - selectedRowIds: new Set(selectedFileTreeRowIds), - anchorRowId: fileTreeAnchorRowId, - focusedRowId: rowId, - }); - return; - } - commitFileTreeSelection({ - selectedRowIds: new Set([rowId]), - anchorRowId: rowId, - focusedRowId: rowId, + applyFileTreeSelectionAction({ + kind: "select_context_row", + rowId, }); }; @@ -1348,38 +1953,38 @@ fn build_tree_shell_html( ) { return; } - commitFileTreeSelection({ - selectedRowIds: new Set(), - anchorRowId: null, - focusedRowId: null, - }); + applyFileTreeSelectionAction({ kind: "clear" }); renderTree(); }; const normalizeFileTreeSelectionForVisibleRows = () => { if (mode !== "filetree") return; - const visibleSet = new Set(visibleFileTreeRowIds); - const nextSelectedRowIds = Array.from(selectedFileTreeRowIds).filter((rowId) => visibleSet.has(rowId)); - const nextAnchorRowId = - fileTreeAnchorRowId && visibleSet.has(fileTreeAnchorRowId) ? fileTreeAnchorRowId : null; - const nextFocusedRowId = - fileTreeFocusedRowId && visibleSet.has(fileTreeFocusedRowId) ? fileTreeFocusedRowId : null; + const currentSelection = readFileTreeSelectionState(); + const nextSelection = computeFileTreeSelectionActionResult({ + kind: "normalize_visible_rows", + visibleRowIds: visibleFileTreeRowIds, + }).nextSelection; if ( - nextSelectedRowIds.length === selectedFileTreeRowIds.size && - nextAnchorRowId === fileTreeAnchorRowId && - nextFocusedRowId === fileTreeFocusedRowId + nextSelection.selectedRowIds.size === currentSelection.selectedRowIds.size && + Array.from(nextSelection.selectedRowIds).every((rowId) => + currentSelection.selectedRowIds.has(rowId), + ) && + nextSelection.anchorRowId === currentSelection.anchorRowId && + nextSelection.focusedRowId === currentSelection.focusedRowId ) { return; } - commitFileTreeSelection({ - selectedRowIds: new Set(nextSelectedRowIds), - anchorRowId: nextAnchorRowId, - focusedRowId: nextFocusedRowId, - }); + commitFileTreeSelection(nextSelection); }; + const resolveFileTreeDraggedRowIds = (rowId) => + computeFileTreeSelectionActionResult({ + kind: "resolve_drag_rows", + rowId, + }).dragRowIds || []; + const scheduleRefresh = () => { window.setTimeout(() => { window.location.reload(); @@ -1683,6 +2288,120 @@ fn build_tree_shell_html( return visible; }; + const isPickerEntryPickable = (entry) => { + if (!entry) return false; + if (entry.pickerItemKey === "__root__") { + return allowRootPick; + } + const documentId = normalizeText(entry.item?.nodeId || entry.pickerItemKey); + return Boolean(documentId && !excludedIds.has(documentId)); + }; + + const getPickablePickerEntries = () => + getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry)); + + const normalizePickerItemKey = (pickerItemKey) => { + const normalizedItemKey = normalizeText(pickerItemKey); + if (normalizedItemKey === "__root__" && allowRootPick) { + return "__root__"; + } + if (normalizedItemKey && itemById.has(normalizedItemKey) && !excludedIds.has(normalizedItemKey)) { + return normalizedItemKey; + } + return ""; + }; + + const resolveCurrentPickerItemKey = () => { + const fromActive = normalizePickerItemKey(currentActivePickerItemKey); + if (fromActive) return fromActive; + const fromDocument = normalizePickerItemKey(currentActiveDocumentId); + if (fromDocument) return fromDocument; + return getPickablePickerEntries()[0]?.pickerItemKey || ""; + }; + + const computePickerStateActionResult = (action) => { + const currentPickerItemKey = resolveCurrentPickerItemKey(); + if ( + mode !== "picker" || + pickerStateReducerContractName !== "rust_picker_state_reducer_v1" || + !pickerStateReducerActions.has(action?.kind || "") + ) { + return { + nextItemKey: currentPickerItemKey, + pickedDocumentId: + action?.kind === "pick" && currentPickerItemKey !== "__root__" + ? currentPickerItemKey || null + : null, + pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__", + }; + } + + const pickable = getPickablePickerEntries(); + if (pickable.length === 0) { + return { + nextItemKey: "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + const currentIndex = pickable.findIndex( + (entry) => entry.pickerItemKey === currentPickerItemKey, + ); + const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; + const actionKind = action.kind; + + if (actionKind === "normalize") { + return { + nextItemKey: pickable[resolvedIndex]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + + if (actionKind === "focus") { + const nextItemKey = normalizePickerItemKey(action.itemKey); + return { + nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + + if (actionKind === "pick") { + const target = pickable[resolvedIndex]; + const targetKey = target?.pickerItemKey || ""; + return { + nextItemKey: targetKey, + pickedDocumentId: + targetKey && targetKey !== "__root__" ? targetKey : null, + pickedRoot: targetKey === "__root__", + }; + } + + let nextIndex = resolvedIndex; + if (actionKind === "next") { + nextIndex = Math.min(pickable.length - 1, resolvedIndex + 1); + } else if (actionKind === "previous") { + nextIndex = Math.max(0, resolvedIndex - 1); + } else if (actionKind === "home") { + nextIndex = 0; + } else if (actionKind === "end") { + nextIndex = pickable.length - 1; + } else { + return { + nextItemKey: currentPickerItemKey, + pickedDocumentId: null, + pickedRoot: false, + }; + } + + return { + nextItemKey: pickable[nextIndex]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; + }; + const focusNode = (nodeId) => { if (!nodeId || !itemById.has(nodeId)) return; if (focusedNodeId === nodeId) { @@ -1695,6 +2414,97 @@ fn build_tree_shell_html( focusRowElement(nodeId); }; + const applyPageKeyboardAction = (action, item, sourceElement) => { + if (mode !== "page") return; + const actionKind = normalizeText(action?.kind).toLowerCase(); + if (!pageFocusKeyboardReducerActions.has(actionKind)) { + return; + } + + if (actionKind === "focus") { + const nextFocusId = normalizeText(action?.nodeId); + if (nextFocusId && itemById.has(nextFocusId)) { + focusNode(nextFocusId); + } + return; + } + + const visible = getVisiblePageItems(); + const visibleIds = visible.map((entry) => entry.nodeId); + if (visibleIds.length === 0) { + return; + } + const currentFocusId = + visibleIds.includes(focusedNodeId) ? focusedNodeId : visibleIds[0]; + const currentIndex = visibleIds.indexOf(currentFocusId); + + if (actionKind === "move_next") { + const next = visible[currentIndex + 1]; + if (next) focusNode(next.nodeId); + return; + } + if (actionKind === "move_previous") { + const previous = visible[currentIndex - 1]; + if (previous) focusNode(previous.nodeId); + return; + } + if (actionKind === "move_home") { + focusNode(visibleIds[0]); + return; + } + if (actionKind === "move_end") { + focusNode(visibleIds[visibleIds.length - 1]); + return; + } + if (actionKind === "expand") { + if (item?.childCount > 0 && !expanded.has(item.nodeId)) { + expanded.add(item.nodeId); + postPageExpandChange(item.nodeId, true); + renderTree(); + focusRowElement(item.nodeId); + return; + } + const firstChild = item ? getSiblings(item.nodeId)[0] : null; + if (firstChild) { + focusNode(firstChild.nodeId); + } + return; + } + if (actionKind === "collapse") { + if (item?.childCount > 0 && expanded.has(item.nodeId)) { + expanded.delete(item.nodeId); + postPageExpandChange(item.nodeId, false); + renderTree(); + focusRowElement(item.nodeId); + return; + } + if (item?.parentNodeId && itemById.has(item.parentNodeId)) { + focusNode(item.parentNodeId); + } + return; + } + if (actionKind === "open") { + if (item?.nodeId) { + handleNavigate(item.nodeId); + } + return; + } + if (actionKind === "context_menu") { + if (!item?.nodeId) { + return; + } + const rect = sourceElement?.getBoundingClientRect?.(); + if (!rect) { + return; + } + openContextMenu( + item.nodeId, + rect.left + Math.min(rect.width - 12, 28), + rect.top + Math.min(rect.height - 12, 18), + ); + } + }; + const postPickerFocusChange = (pickerItemKey) => { if (mode !== "picker") return; const normalizedItemKey = normalizeText(pickerItemKey); @@ -1716,14 +2526,11 @@ fn build_tree_shell_html( const applyPickerFocusByItemKey = (pickerItemKey) => { if (mode !== "picker") return; - - const normalizedItemKey = normalizeText(pickerItemKey); - const nextPickerItemKey = - normalizedItemKey === "__root__" - ? "__root__" - : itemById.has(normalizedItemKey) - ? normalizedItemKey - : ""; + const result = computePickerStateActionResult({ + kind: "focus", + itemKey: pickerItemKey, + }); + const nextPickerItemKey = result.nextItemKey || ""; const nextDocumentId = nextPickerItemKey && nextPickerItemKey !== "__root__" ? nextPickerItemKey @@ -1732,40 +2539,45 @@ fn build_tree_shell_html( currentActivePickerItemKey = nextPickerItemKey; currentActiveDocumentId = nextDocumentId; focusedNodeId = nextDocumentId || ""; - renderTree(); + if (usedRustInitialRenderer) { + patchPickerActiveDom(); + focusPickerRowElement(nextPickerItemKey); + } else { + renderTree(); + } if (nextDocumentId) { focusRowElement(nextDocumentId); } postPickerFocusChange(nextPickerItemKey || null); }; + const applyPickerStateAction = (action) => { + if (mode !== "picker") { + return { + nextItemKey: "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + + const result = computePickerStateActionResult(action); + if (action?.kind !== "pick") { + applyPickerFocusByItemKey(result.nextItemKey); + } + return result; + }; + const handlePickerCommand = (command) => { if (mode !== "picker") return; const normalizedCommand = normalizeText(command); - const visible = getVisiblePickerEntries(); - if (visible.length === 0) { + if (!pickerStateReducerActions.has(normalizedCommand)) { return; } - const currentPickerItemKey = - currentActivePickerItemKey || - (currentActiveDocumentId && itemById.has(currentActiveDocumentId) - ? currentActiveDocumentId - : allowRootPick - ? "__root__" - : visible[0]?.pickerItemKey || ""); - const currentIndex = visible.findIndex( - (entry) => entry.pickerItemKey === currentPickerItemKey, - ); - const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; - if (normalizedCommand === "pick") { - const target = visible[resolvedIndex]; - if (!target) { - return; - } - if (target.pickerItemKey === "__root__") { + const result = applyPickerStateAction({ kind: "pick" }); + if (result.pickedRoot) { setLastAction("已选择根目录"); postToHost("tree.pick.root", { documentId: null, @@ -1774,28 +2586,13 @@ fn build_tree_shell_html( }); return; } - handleNavigate(target.pickerItemKey); + if (result.pickedDocumentId) { + handleNavigate(result.pickedDocumentId); + } return; } - let nextIndex = resolvedIndex; - if (normalizedCommand === "next") { - nextIndex = Math.min(visible.length - 1, resolvedIndex + 1); - } else if (normalizedCommand === "previous") { - nextIndex = Math.max(0, resolvedIndex - 1); - } else if (normalizedCommand === "home") { - nextIndex = 0; - } else if (normalizedCommand === "end") { - nextIndex = visible.length - 1; - } else { - return; - } - - const target = visible[nextIndex]; - if (!target) { - return; - } - applyPickerFocusByItemKey(target.pickerItemKey); + applyPickerStateAction({ kind: normalizedCommand }); }; const openFileTreeContextMenu = ({ @@ -1875,52 +2672,29 @@ fn build_tree_shell_html( const handleRowKeyDown = (event, item) => { if (mode !== "page") return; - const visible = getVisiblePageItems(); - const currentIndex = visible.findIndex((entry) => entry.nodeId === item.nodeId); if (event.key === "ArrowDown") { event.preventDefault(); - const next = visible[currentIndex + 1]; - if (next) focusNode(next.nodeId); + applyPageKeyboardAction({ kind: "move_next" }, item, event.currentTarget); return; } if (event.key === "ArrowUp") { event.preventDefault(); - const previous = visible[currentIndex - 1]; - if (previous) focusNode(previous.nodeId); + applyPageKeyboardAction({ kind: "move_previous" }, item, event.currentTarget); return; } if (event.key === "ArrowRight") { event.preventDefault(); - if (item.childCount > 0 && !expanded.has(item.nodeId)) { - expanded.add(item.nodeId); - postPageExpandChange(item.nodeId, true); - renderTree(); - focusRowElement(item.nodeId); - return; - } - const firstChild = getSiblings(item.nodeId)[0]; - if (firstChild) { - focusNode(firstChild.nodeId); - } + applyPageKeyboardAction({ kind: "expand" }, item, event.currentTarget); return; } if (event.key === "ArrowLeft") { event.preventDefault(); - if (item.childCount > 0 && expanded.has(item.nodeId)) { - expanded.delete(item.nodeId); - postPageExpandChange(item.nodeId, false); - renderTree(); - focusRowElement(item.nodeId); - return; - } - if (item.parentNodeId && itemById.has(item.parentNodeId)) { - focusNode(item.parentNodeId); - } + applyPageKeyboardAction({ kind: "collapse" }, item, event.currentTarget); return; } if (event.key === "Enter") { event.preventDefault(); - handleNavigate(item.nodeId); + applyPageKeyboardAction({ kind: "open" }, item, event.currentTarget); return; } if (event.key === "F2") { @@ -1933,12 +2707,7 @@ fn build_tree_shell_html( (event.shiftKey && event.key === "F10") ) { event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - openContextMenu( - item.nodeId, - rect.left + Math.min(rect.width - 12, 28), - rect.top + Math.min(rect.height - 12, 18), - ); + applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget); } }; @@ -2102,6 +2871,432 @@ fn build_tree_shell_html( } }; + const bindPageRowEvents = (row, item) => { + if (!(row instanceof HTMLElement) || !item) return; + row.dataset.active = String(item.nodeId === currentActiveDocumentId); + row.dataset.focused = String(item.nodeId === focusedNodeId); + row.dataset.nodeId = item.nodeId; + row.dataset.shellMode = "page"; + row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId); + row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1; + row.setAttribute("role", "treeitem"); + row.setAttribute("aria-level", String(item.depth + 1)); + row.setAttribute("aria-expanded", item.childCount > 0 ? String(expanded.has(item.nodeId)) : "false"); + row.draggable = true; + row.dataset.draggable = "true"; + row.addEventListener("focus", () => { + if (focusedNodeId !== item.nodeId) { + applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row); + } + }); + row.addEventListener("keydown", (event) => handleRowKeyDown(event, item)); + row.addEventListener("contextmenu", (event) => { + event.preventDefault(); + openContextMenu(item.nodeId, event.clientX, event.clientY); + }); + row.addEventListener("dragstart", (event) => { + draggingPageNodeId = item.nodeId; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId); + event.dataTransfer.setData("text/plain", item.nodeId); + } + setLastAction(`开始拖拽页面 ${item.title}`); + }); + row.addEventListener("dragover", (event) => { + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) { + clearPageDropFeedback(); + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + setPageDropFeedback(targetNodeId); + }); + row.addEventListener("dragleave", (event) => { + const relatedTarget = + event.relatedTarget instanceof Node ? event.relatedTarget : null; + if (relatedTarget && row.contains(relatedTarget)) { + return; + } + if (activePageDropNodeId === item.nodeId) { + clearPageDropFeedback(); + } + }); + row.addEventListener("drop", (event) => { + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + clearPageDropFeedback(); + draggingPageNodeId = ""; + if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) { + return; + } + event.preventDefault(); + void handlePageDropMove(sourceNodeId, targetNodeId); + }); + row.addEventListener("dragend", () => { + draggingPageNodeId = ""; + clearPageDropFeedback(); + }); + + row.querySelectorAll("[data-rust-action]").forEach((element) => { + if (!(element instanceof HTMLElement)) return; + element.addEventListener("click", (event) => { + event.stopPropagation(); + const action = normalizeText(element.dataset.rustAction); + if (action === "open") { + handleNavigate(item.nodeId); + } else if (action === "create") { + void handleCreate(item.nodeId); + } else if (action === "rename") { + void handleRename(item.nodeId); + } else if (action === "menu") { + const center = getElementCenter(element); + openContextMenu(item.nodeId, center.x, center.y); + } + }); + }); + }; + + const hydrateInitialPageTree = () => { + if (mode !== "page") return false; + const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]'); + if (!(root instanceof HTMLElement)) { + return false; + } + root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = normalizeText(row.dataset.nodeId); + const item = itemById.get(nodeId); + if (!item) return; + bindPageRowEvents(row, item); + }); + if (focusedNodeId) { + focusRowElement(focusedNodeId); + } + return true; + }; + + const syncFileTreeSelectionDom = () => { + if (mode !== "filetree") return; + appElement.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const rowId = normalizeText(row.dataset.rowId); + row.dataset.selected = String(Boolean(rowId && selectedFileTreeRowIds.has(rowId))); + }); + }; + + const openHydratedFileTreeItem = (item) => { + const documentId = getFileTreeRowDocumentId(item); + const assetId = getFileTreeRowAssetId(item); + if (item.rowKind === "document" || item.rowKind === "index") { + handleNavigate(documentId || item.nodeId); + return; + } + setLastAction(`准备打开资源 ${assetId || item.rowId}`); + postToHost("tree.asset.open", { + documentId: documentId || null, + assetId: assetId || null, + target: { documentId: documentId || null }, + payload: { + documentId: documentId || null, + assetId: assetId || null, + rowId: item.rowId, + rowKind: item.rowKind, + }, + }); + }; + + const postHydratedFileTreeDropToHost = (type, target, extra = {}) => { + postToHost(type, { + workspaceId, + rowId: target.rowId, + rowKind: target.rowKind, + targetRowId: target.rowId, + targetRowKind: target.rowKind, + documentId: target.documentId, + assetId: target.assetId, + ...extra, + payload: { + workspaceId, + rowId: target.rowId, + rowKind: target.rowKind, + targetRowId: target.rowId, + targetRowKind: target.rowKind, + documentId: target.documentId, + assetId: target.assetId, + ...extra, + }, + }); + }; + + const attachHydratedFileTreeDragSource = (row, item) => { + row.draggable = true; + row.addEventListener("dragstart", (event) => { + const rowIds = resolveFileTreeDraggedRowIds(item.rowId); + draggingFileTreeRowIds = rowIds; + if (event.dataTransfer) { + const payload = JSON.stringify({ + type: "mnote-file-tree-dnd", + version: 1, + rowIds, + }); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setData(FILETREE_DRAG_MIME, payload); + event.dataTransfer.setData("application/x-mnote-file-tree", payload); + event.dataTransfer.setData("text/plain", payload); + } + setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`); + }); + row.addEventListener("dragend", () => { + draggingFileTreeRowIds = []; + clearFileTreeDropFeedback(); + }); + }; + + const bindFileTreeRootEvents = (root) => { + root.dataset.dropTarget = String(activeFileTreeRootDrop); + root.addEventListener("mousedown", (event) => { + if (event.target !== event.currentTarget) return; + clearFileTreeSelection(); + }); + root.addEventListener("dragover", (event) => { + const internalRowIds = readFileTreeInternalDropPayload(event); + const files = Array.from(event.dataTransfer?.files || []); + if (!internalRowIds && files.length === 0) { + return; + } + event.preventDefault(); + const target = getFileTreeDropTargetFromElement(event.target); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = + files.length > 0 || event.altKey ? "copy" : "move"; + } + setFileTreeDropFeedback(target); + }); + root.addEventListener("dragleave", (event) => { + const relatedTarget = + event.relatedTarget instanceof Node ? event.relatedTarget : null; + if (relatedTarget && root.contains(relatedTarget)) { + return; + } + clearFileTreeDropFeedback(); + }); + root.addEventListener("drop", (event) => { + const internalRowIds = readFileTreeInternalDropPayload(event); + const files = Array.from(event.dataTransfer?.files || []); + if (!internalRowIds && files.length === 0) { + return; + } + event.preventDefault(); + const target = getFileTreeDropTargetFromElement(event.target); + if (files.length > 0) { + setLastAction(`已发送 ${files.length} 个外部文件到宿主`); + postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, { + files, + }); + } else { + setLastAction( + event.altKey + ? `已发送复制拖放到 ${target.rowId || "根目录"}` + : `已发送移动拖放到 ${target.rowId || "根目录"}`, + ); + postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, { + rowIds: internalRowIds, + copy: event.altKey === true, + }); + } + draggingFileTreeRowIds = []; + clearFileTreeDropFeedback(); + }); + }; + + const bindFileTreeRowEvents = (row, item) => { + if (!(row instanceof HTMLElement) || !item) return; + const documentId = getFileTreeRowDocumentId(item) || null; + const assetId = getFileTreeRowAssetId(item) || null; + row.dataset.active = String( + item.rowKind === "document" && documentId === currentActiveDocumentId + ); + row.dataset.nodeId = item.nodeId; + row.dataset.rowId = item.rowId; + row.dataset.rowKind = item.rowKind; + row.dataset.documentId = documentId || ""; + row.dataset.assetId = assetId || ""; + row.dataset.shellMode = "filetree"; + row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId)); + row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId); + row.tabIndex = 0; + row.setAttribute("role", "treeitem"); + row.setAttribute("aria-level", String(item.depth + 1)); + row.setAttribute( + "aria-expanded", + canExpandFileTreeRow(item) ? String(expanded.has(item.nodeId)) : "false", + ); + row.addEventListener("click", (event) => { + selectFileTreeRow(item.rowId, event); + syncFileTreeSelectionDom(); + }); + row.addEventListener("dblclick", () => { + openHydratedFileTreeItem(item); + }); + row.addEventListener("contextmenu", (event) => { + event.preventDefault(); + selectFileTreeContextRow(item.rowId); + syncFileTreeSelectionDom(); + openFileTreeContextMenu({ + documentId, + assetId, + rowId: item.rowId, + rowKind: item.rowKind, + clientX: event.clientX, + clientY: event.clientY, + }); + }); + attachHydratedFileTreeDragSource(row, item); + row.querySelectorAll("[data-rust-action]").forEach((element) => { + if (!(element instanceof HTMLElement)) return; + element.addEventListener("click", (event) => { + const action = normalizeText(element.dataset.rustAction); + if (action === "open") { + openHydratedFileTreeItem(item); + return; + } + if (action === "menu") { + event.preventDefault(); + event.stopPropagation(); + selectFileTreeContextRow(item.rowId); + syncFileTreeSelectionDom(); + const center = getElementCenter(element); + openFileTreeContextMenu({ + documentId, + assetId, + rowId: item.rowId, + rowKind: item.rowKind, + clientX: center.x, + clientY: center.y, + }); + } + }); + }); + }; + + const hydrateInitialFileTree = () => { + if (mode !== "filetree") return false; + const root = appElement.querySelector('[data-rust-filetree-renderer="initial_v1"]'); + if (!(root instanceof HTMLElement)) { + return false; + } + visibleFileTreeRowIds = []; + bindFileTreeRootEvents(root); + root.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const rowId = normalizeText(row.dataset.rowId); + const nodeId = normalizeText(row.dataset.nodeId); + const item = fileTreeRowById.get(rowId) || itemById.get(nodeId); + if (!item) return; + visibleFileTreeRowIds.push(item.rowId); + bindFileTreeRowEvents(row, item); + }); + normalizeFileTreeSelectionForVisibleRows(); + syncFileTreeSelectionDom(); + return true; + }; + + const bindPickerRootEvents = (row) => { + if (!(row instanceof HTMLElement)) return; + row.dataset.focused = String(resolvePickerRootFocused()); + row.addEventListener("click", () => { + setLastAction("已选择根目录"); + postToHost("tree.pick.root", { + documentId: null, + target: { documentId: null }, + payload: { documentId: null }, + }); + }); + }; + + const bindPickerRowEvents = (row, item) => { + if (!(row instanceof HTMLElement) || !item) return; + row.dataset.nodeId = item.nodeId; + row.dataset.shellMode = "picker"; + row.dataset.focused = String( + currentActivePickerItemKey === item.nodeId || + (!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId), + ); + row.addEventListener("click", () => { + handleNavigate(item.nodeId); + }); + }; + + const focusPickerRowElement = (pickerItemKey) => { + const normalizedItemKey = normalizeText(pickerItemKey); + window.requestAnimationFrame(() => { + const row = + normalizedItemKey === "__root__" + ? appElement.querySelector('[data-rust-rendered-row="picker-root"]') + : appElement.querySelector( + `.tree-row[data-node-id="${CSS.escape(normalizedItemKey)}"]`, + ); + if (!(row instanceof HTMLElement)) return; + row.focus({ preventScroll: true }); + row.scrollIntoView({ block: "nearest" }); + }); + }; + + const patchPickerActiveDom = () => { + if (mode !== "picker") return; + appElement + .querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]') + .forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = normalizeText(row.dataset.nodeId); + const isRoot = row.dataset.rustRenderedRow === "picker-root"; + const isFocused = isRoot + ? currentActivePickerItemKey === "__root__" + : currentActivePickerItemKey === nodeId || + (!currentActivePickerItemKey && currentActiveDocumentId === nodeId); + row.dataset.focused = String(isFocused); + row.tabIndex = isFocused ? 0 : -1; + }); + }; + + const hydrateInitialPickerTree = () => { + if (mode !== "picker") return false; + const root = appElement.querySelector('[data-rust-picker-renderer="initial_v1"]'); + if (!(root instanceof HTMLElement)) { + return false; + } + root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => { + bindPickerRootEvents(row); + }); + root.querySelectorAll('[data-rust-rendered-row="picker"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = normalizeText(row.dataset.nodeId); + const item = itemById.get(nodeId); + if (!item) return; + bindPickerRowEvents(row, item); + }); + return true; + }; + + const hydrateInitialRenderer = () => { + if (mode === "page") { + const usedRustInitialPageRenderer = hydrateInitialPageTree(); + return usedRustInitialPageRenderer; + } + if (mode === "filetree") { + return hydrateInitialFileTree(); + } + if (mode === "picker") { + return hydrateInitialPickerTree(); + } + return false; + }; + const createKindBadge = (kind) => { const badge = document.createElement("span"); badge.className = "tree-kind-badge"; @@ -2162,9 +3357,7 @@ fn build_tree_shell_html( row.dataset.draggable = String(mode === "page"); row.addEventListener("focus", () => { if (focusedNodeId !== item.nodeId) { - focusedNodeId = item.nodeId; - postPageFocusChange(item.nodeId); - renderTree(); + applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row); } }); row.addEventListener("keydown", (event) => handleRowKeyDown(event, item)); @@ -2708,6 +3901,11 @@ fn build_tree_shell_html( } focusedNodeId = resolveFocusedNodeIdFromHostState(); + if (mode === "picker" && usedRustInitialRenderer) { + patchPickerActiveDom(); + focusPickerRowElement(currentActivePickerItemKey); + return; + } renderTree(); if (mode === "page" && focusedNodeId) { focusRowElement(focusedNodeId); @@ -2726,7 +3924,10 @@ fn build_tree_shell_html( }); }; - renderTree(); + const usedRustInitialRenderer = hydrateInitialRenderer(); + if (!usedRustInitialRenderer) { + renderTree(); + } if (mode === "page" && focusedNodeId) { postPageFocusChange(focusedNodeId); } @@ -2755,6 +3956,7 @@ fn build_tree_shell_html( .replace("__ROOT_LABEL__", &escape_html(root_label)) .replace("__ACTIVE_LABEL__", &escape_html(active_label)) .replace("__PROJECTION_JSON__", &escape_html(&projection_json)) + .replace("__INITIAL_TREE_HTML__", &initial_tree_html) .replace("__APP_STATE__", &escape_inline_json(&app_state_json)) } @@ -2789,6 +3991,8 @@ pub async fn tree_shell( workspace_id: &effective_workspace_id, root_node_id: query.root_node_id.as_deref(), depth: query.depth, + query: None, + max_results: None, projection: if mode == "filetree" { KernelProjectionKind::FileTree } else { @@ -3036,7 +4240,7 @@ pub async fn tree_command( resolve_effective_workspace_id(&context, requested_workspace_id, true)? .expect("workspace_required 已确保存在"); let command_wire = create_command_wire(&context, &effective_workspace_id, request)?; - let execution = execute_runtime_command_via_convex( + let execution = execute_runtime_command_via_convex_with_artifacts( state.config(), &context, Some(&effective_workspace_id), @@ -3044,10 +4248,21 @@ pub async fn tree_command( ) .await?; let response_document_id = execution + .result .get("id") .and_then(Value::as_str) .map(ToOwned::to_owned) .unwrap_or(requested_document_id); + let artifacts = execution + .artifacts + .as_ref() + .and_then(|artifacts| serde_json::to_value(artifacts).ok()) + .unwrap_or(Value::Null); + let artifact_error = execution + .artifact_error + .as_ref() + .map(|message| Value::String(message.clone())) + .unwrap_or(Value::Null); Ok(json_response( &context, @@ -3058,16 +4273,18 @@ pub async fn tree_command( "parentId": requested_parent_id, "title": requested_title, "sortOrder": requested_sort_order, - "updatedAt": execution.get("updated_at").cloned().unwrap_or(Value::Null), - "execution": execution, + "updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null), + "execution": execution.result, + "artifacts": artifacts, + "artifactError": artifact_error, }), )) } #[cfg(test)] mod tests { - use super::{create_command_wire, TreeCommandRequest}; - use crate::app::{build_app, AppConfig, AppState}; + use super::{TreeCommandRequest, create_command_wire}; + use crate::app::{AppConfig, AppState, build_app}; use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; @@ -3087,7 +4304,7 @@ mod tests { convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()), - mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"}}"#.into()), + mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -3126,6 +4343,12 @@ mod tests { assert!(html.contains("tree.page.expand.changed")); assert!(html.contains("tree.page.focus.changed")); assert!(html.contains("tree.shell.state.patch")); + assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); + assert!(html.contains("applyPageKeyboardAction")); + assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"page\"")); + assert!(html.contains("hydrateInitialPageTree")); + assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(html.contains("application/x-mnote-page-tree-node")); assert!(html.contains("页面已拖放到")); assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); @@ -3152,9 +4375,16 @@ mod tests { assert!(html.contains("\"mode\":\"picker\"")); assert!(html.contains("\"allowRootPick\":true")); assert!(html.contains("\"excludeIds\":[\"page_child\"]")); + assert!(html.contains("data-rust-picker-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"picker-root\"")); assert!(html.contains("tree.pick.root")); assert!(html.contains("tree.picker.command")); assert!(html.contains("tree.picker.focus.changed")); + assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); + assert!(html.contains("applyPickerStateAction")); + assert!(html.contains("patchPickerActiveDom")); + assert!(html.contains("hydrateInitialPickerTree")); + assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); } @@ -3176,13 +4406,60 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("filetree-doc-row")); + assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"filetree\"")); assert!(html.contains("\"mediaAssets\"")); assert!(html.contains("tree.filetree.selection.changed")); + assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\"")); + assert!(html.contains("applyFileTreeSelectionAction")); assert!(html.contains("tree.filetree.internal-drop")); assert!(html.contains("tree.filetree.external-drop")); assert!(html.contains("\"rowKind\":\"asset_folder\"")); assert!(html.contains("\"resourceMeta\"")); assert!(html.contains("dragover")); + assert!(html.contains("hydrateInitialFileTree")); + assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); + } + + #[tokio::test] + async fn tree_shell_embeds_renderer_input_contract() { + let filetree_response = app() + .oneshot( + Request::builder() + .uri("/tree?workspaceId=ws_demo&mode=filetree&activeDocumentId=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(filetree_response.status(), StatusCode::OK); + let filetree_body = axum::body::to_bytes(filetree_response.into_body(), usize::MAX) + .await + .expect("body"); + let filetree_html = String::from_utf8(filetree_body.to_vec()).expect("utf8"); + assert!(filetree_html.contains("\"rendererInput\"")); + assert!(filetree_html.contains("\"mode\":\"fileTree\"")); + assert!(filetree_html.contains("\"filetreeSelection\"")); + assert!(filetree_html.contains("\"selectedRowIds\"")); + assert!(filetree_html.contains("\"commandDispatcher\"")); + + let picker_response = app() + .oneshot( + Request::builder() + .uri("/tree?workspaceId=ws_demo&mode=picker&activePickerItemKey=page_child&excludeIds=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(picker_response.status(), StatusCode::OK); + let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX) + .await + .expect("body"); + let picker_html = String::from_utf8(picker_body.to_vec()).expect("utf8"); + assert!(picker_html.contains("\"mode\":\"picker\"")); + assert!(picker_html.contains("\"activePickerItem\":\"page_child\"")); + assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]")); } #[tokio::test] @@ -3284,6 +4561,41 @@ mod tests { ); } + #[tokio::test] + async fn tree_command_response_includes_rust_artifact_plan_for_domain_event() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from( + r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#, + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!( + payload["result"]["artifacts"]["domainEvent"]["eventType"], + Value::String("tree.subtree.moved".into()) + ); + assert_eq!( + payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], + Value::String("move_document".into()) + ); + assert_eq!( + payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"], + Value::String("move_document".into()) + ); + } + #[tokio::test] async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() { let response = app() diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 5909f96b..9393d16a 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -1,10 +1,14 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; -use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan}; -use serde_json::{json, Value}; +use bridge_runtime::{ + RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire, + RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan, +}; +use serde_json::{Value, json}; use std::fs; use std::time::Duration; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; const HEADER_REQUEST_ID: &str = "x-request-id"; const HEADER_TRACE_ID: &str = "x-trace-id"; @@ -154,6 +158,14 @@ fn load_mutation_fixture( config: &AppConfig, context: &RequestContext, plan: &RuntimeCommandExecutionPlan, +) -> Result, WebError> { + load_mutation_fixture_by_name(config, context, plan.function_name.as_str()) +} + +fn load_mutation_fixture_by_name( + config: &AppConfig, + context: &RequestContext, + function_name: &str, ) -> Result, WebError> { if !config.allow_dev_fixtures { return Ok(None); @@ -175,7 +187,7 @@ fn load_mutation_fixture( Ok(fixtures .as_object() - .and_then(|map| map.get(plan.function_name.as_str())) + .and_then(|map| map.get(function_name)) .cloned()) } @@ -434,6 +446,253 @@ pub async fn execute_convex_command_plan( } } +#[derive(Debug)] +pub struct ConvexCommandExecution { + pub result: Value, + pub artifacts: Option, + pub artifact_error: Option, +} + +fn now_iso_like() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) +} + +async fn execute_convex_mutation_by_name( + config: &AppConfig, + context: &RequestContext, + function_name: &str, + args: Value, + workspace_id: Option<&str>, + idempotency_key: Option<&str>, + error_phase: &'static str, +) -> Result { + if let Some(fixture) = load_mutation_fixture_by_name(config, context, function_name)? { + return Ok(fixture); + } + + let payload = json!({ + "path": function_name, + "format": "convex_encoded_json", + "args": [args], + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|error| { + WebError::internal(format!("Convex HTTP 客户端创建失败: {error}")) + .with_context(context) + .with_header("x-error-phase", "client_build") + .with_header("x-upstream-service", "convex") + })?; + + let mut request = client + .post(format!("{}/api/mutation", convex_url(config, context)?)) + .header("Authorization", build_authorization(config, context)?) + .header("Content-Type", "application/json") + .header("Convex-Client", "mnote-web") + .header(HEADER_REQUEST_ID, &context.trace.request_id) + .header(HEADER_TRACE_ID, &context.trace.trace_id) + .header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str()) + .header(HEADER_SOURCE_CLIENT, context.source.client.as_str()) + .json(&payload); + + if let Some(workspace_id) = workspace_id + .or(context.workspace.workspace_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.header(HEADER_WORKSPACE_ID, workspace_id); + } + if let Some(idempotency_key) = idempotency_key + .or(context.source.idempotency_key.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.header(HEADER_IDEMPOTENCY_KEY, idempotency_key); + } + + let response = request.send().await.map_err(|error| { + let base = if error.is_timeout() { + WebError::gateway_timeout_code( + "convex_timeout", + format!("Convex mutation 超时: {error}"), + ) + } else { + WebError::service_unavailable_code( + "convex_unavailable", + format!("Convex mutation 请求失败: {error}"), + ) + }; + base.with_context(context) + .with_header("x-error-phase", error_phase) + .with_header("x-upstream-service", "convex") + })?; + + let status = response.status(); + let body: Value = response.json().await.map_err(|error| { + WebError::bad_gateway_code( + "convex_bad_response", + format!("Convex mutation 响应解析失败: {error}"), + ) + .with_context(context) + .with_header("x-error-phase", error_phase) + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string()) + })?; + if !status.is_success() { + let message = body + .get("errorMessage") + .and_then(Value::as_str) + .unwrap_or("Convex mutation 失败"); + return Err( + WebError::bad_gateway_code("convex_upstream_error", message.to_string()) + .with_context(context) + .with_header("x-error-phase", error_phase) + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string()), + ); + } + + match body.get("status").and_then(Value::as_str) { + Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)), + Some("error") => Err(WebError::bad_gateway_code( + "convex_upstream_error", + body.get("errorMessage") + .and_then(Value::as_str) + .unwrap_or("Convex 返回 error") + .to_string(), + ) + .with_context(context) + .with_header("x-error-phase", error_phase) + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string())), + _ => Err(WebError::bad_gateway_code( + "convex_bad_response", + format!("未知 Convex 响应: {body}"), + ) + .with_context(context) + .with_header("x-error-phase", error_phase) + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string())), + } +} + +fn command_log_artifact_args(artifact: &bridge_runtime::RuntimeCommandLogArtifactPlan) -> Value { + json!({ + "workspaceId": artifact.workspace_id, + "id": artifact.id, + "requestId": artifact.request_id, + "traceId": artifact.trace_id, + "commandId": artifact.command_id, + "commandName": artifact.command_name, + "actorId": artifact.actor_id, + "actorType": artifact.actor_type, + "sourceChannel": artifact.source_channel, + "sourceClient": artifact.source_client, + "status": artifact.status, + "targetPageId": artifact.target_page_id, + "targetBlockId": artifact.target_block_id, + "payload": artifact.payload, + "payloadSummary": artifact.payload_summary, + "refs": artifact.refs, + "idempotencyKey": artifact.idempotency_key, + "error": artifact.error, + "createdAt": artifact.created_at, + "finishedAt": artifact.finished_at, + }) +} + +fn domain_event_artifact_args(artifact: &bridge_runtime::RuntimeDomainEventArtifactPlan) -> Value { + json!({ + "workspaceId": artifact.workspace_id, + "id": artifact.id, + "requestId": artifact.request_id, + "traceId": artifact.trace_id, + "commandId": artifact.command_id, + "commandLogId": artifact.command_log_id, + "eventType": artifact.event_type, + "aggregateType": artifact.aggregate_type, + "aggregateId": artifact.aggregate_id, + "eventVersion": artifact.event_version, + "status": artifact.status, + "actorType": artifact.actor_type, + "payload": artifact.payload, + "createdAt": artifact.created_at, + }) +} + +pub async fn persist_runtime_command_artifacts( + config: &AppConfig, + context: &RequestContext, + artifacts: &RuntimeCommandArtifactPlan, +) -> Result<(), WebError> { + execute_convex_mutation_by_name( + config, + context, + "bridgeLogs:recordCommandLog", + command_log_artifact_args(&artifacts.command_log), + Some(artifacts.command_log.workspace_id.as_str()), + artifacts.command_log.idempotency_key.as_deref(), + "artifact_command_log", + ) + .await?; + + if let Some(domain_event) = artifacts.domain_event.as_ref() { + execute_convex_mutation_by_name( + config, + context, + "bridgeLogs:recordDomainEvent", + domain_event_artifact_args(domain_event), + Some(domain_event.workspace_id.as_str()), + artifacts.command_log.idempotency_key.as_deref(), + "artifact_domain_event", + ) + .await?; + } + + Ok(()) +} + +pub async fn execute_convex_command_plan_with_artifacts( + config: &AppConfig, + context: &RequestContext, + runtime_context: &RuntimeBridgeContextWire, + command: &RuntimeCommandEnvelopeWire, + plan: &RuntimeCommandExecutionPlan, +) -> Result { + let result = execute_convex_command_plan(config, context, plan).await?; + let artifacts = build_runtime_command_artifact_plan( + runtime_context, + command, + plan, + &result, + &now_iso_like(), + ); + if let Some(artifacts) = artifacts.as_ref() { + if let Err(error) = persist_runtime_command_artifacts(config, context, artifacts).await { + let message = error.message().to_string(); + tracing::warn!( + error = %message, + command_id = %command.command_id, + "Rust command artifact 持久化失败,主 mutation 结果继续返回" + ); + return Ok(ConvexCommandExecution { + result, + artifacts: Some(artifacts.clone()), + artifact_error: Some(message), + }); + } + } + Ok(ConvexCommandExecution { + result, + artifacts, + artifact_error: None, + }) +} + #[cfg(test)] mod tests { use super::build_authorization; diff --git a/rust/crates/mnote-web/src/tree_shell/action_registry.rs b/rust/crates/mnote-web/src/tree_shell/action_registry.rs new file mode 100644 index 00000000..2abc72ff --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/action_registry.rs @@ -0,0 +1,78 @@ +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TreeShellAction { + Open, + CreateChild, + Rename, + Move, + ContextMenu, + Pick, + AssetOpen, + ResourceCopy, + ResourceMove, + ResourceUpload, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeShellActionRegistry { + pub actions: BTreeSet, +} + +impl TreeShellActionRegistry { + pub fn page_tree() -> Self { + Self { + actions: BTreeSet::from([ + TreeShellAction::Open, + TreeShellAction::CreateChild, + TreeShellAction::Rename, + TreeShellAction::Move, + TreeShellAction::ContextMenu, + ]), + } + } + + pub fn file_tree() -> Self { + Self { + actions: BTreeSet::from([ + TreeShellAction::Open, + TreeShellAction::ContextMenu, + TreeShellAction::AssetOpen, + TreeShellAction::ResourceCopy, + TreeShellAction::ResourceMove, + TreeShellAction::ResourceUpload, + ]), + } + } + + pub fn picker() -> Self { + Self { + actions: BTreeSet::from([TreeShellAction::Pick]), + } + } + + pub fn allows(&self, action: TreeShellAction) -> bool { + self.actions.contains(&action) + } +} + +#[cfg(test)] +mod tests { + use super::{TreeShellAction, TreeShellActionRegistry}; + + #[test] + fn tree_shell_action_registry_separates_page_filetree_and_picker_actions() { + let page = TreeShellActionRegistry::page_tree(); + assert!(page.allows(TreeShellAction::CreateChild)); + assert!(!page.allows(TreeShellAction::ResourceUpload)); + + let filetree = TreeShellActionRegistry::file_tree(); + assert!(filetree.allows(TreeShellAction::ResourceMove)); + assert!(filetree.allows(TreeShellAction::AssetOpen)); + assert!(!filetree.allows(TreeShellAction::Rename)); + + let picker = TreeShellActionRegistry::picker(); + assert!(picker.allows(TreeShellAction::Pick)); + assert!(!picker.allows(TreeShellAction::Open)); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs b/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs new file mode 100644 index 00000000..19db06d2 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs @@ -0,0 +1,63 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TreeShellDragEffect { + Copy, + Move, + None, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeShellDragPayload { + pub row_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeShellDropTarget { + pub row_id: Option, + pub document_id: Option, + pub asset_id: Option, +} + +pub fn resolve_drag_effect(has_external_files: bool, alt_key: bool) -> TreeShellDragEffect { + if has_external_files || alt_key { + TreeShellDragEffect::Copy + } else { + TreeShellDragEffect::Move + } +} + +pub fn normalize_drag_payload(row_ids: &[String]) -> Option { + let mut normalized = Vec::new(); + for row_id in row_ids { + let row_id = row_id.trim(); + if row_id.is_empty() || normalized.iter().any(|existing| existing == row_id) { + continue; + } + normalized.push(row_id.to_string()); + } + if normalized.is_empty() { + None + } else { + Some(TreeShellDragPayload { + row_ids: normalized, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{normalize_drag_payload, resolve_drag_effect, TreeShellDragEffect}; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn tree_shell_drag_drop_state_normalizes_payload_and_effect() { + let payload = normalize_drag_payload(&ids(&[" doc:a ", "asset:b", "doc:a", ""])).unwrap(); + assert_eq!(payload.row_ids, ids(&["doc:a", "asset:b"])); + assert_eq!(resolve_drag_effect(true, false), TreeShellDragEffect::Copy); + assert_eq!(resolve_drag_effect(false, true), TreeShellDragEffect::Copy); + assert_eq!(resolve_drag_effect(false, false), TreeShellDragEffect::Move); + assert!(normalize_drag_payload(&ids(&["", " "])).is_none()); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/expansion_state.rs b/rust/crates/mnote-web/src/tree_shell/expansion_state.rs new file mode 100644 index 00000000..ccdf4db4 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/expansion_state.rs @@ -0,0 +1,66 @@ +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeShellExpansionState { + pub expanded_ids: BTreeSet, +} + +impl TreeShellExpansionState { + pub fn from_defaults(default_expanded_ids: &[String]) -> Self { + Self { + expanded_ids: default_expanded_ids.iter().cloned().collect(), + } + } + + pub fn toggle(&self, node_id: &str) -> Self { + let mut expanded_ids = self.expanded_ids.clone(); + if !expanded_ids.insert(node_id.to_string()) { + expanded_ids.remove(node_id); + } + Self { expanded_ids } + } + + pub fn expand_ancestors( + &self, + node_id: &str, + parent_by_id: &BTreeMap>, + ) -> Self { + let mut expanded_ids = self.expanded_ids.clone(); + let mut current = parent_by_id.get(node_id).and_then(Clone::clone); + while let Some(parent_id) = current { + expanded_ids.insert(parent_id.clone()); + current = parent_by_id.get(&parent_id).and_then(Clone::clone); + } + Self { expanded_ids } + } +} + +#[cfg(test)] +mod tests { + use super::TreeShellExpansionState; + use std::collections::{BTreeMap, BTreeSet}; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn tree_shell_expansion_state_toggles_and_expands_active_ancestors() { + let state = TreeShellExpansionState::from_defaults(&ids(&["doc:root"])); + assert!(state.expanded_ids.contains("doc:root")); + + let state = state.toggle("doc:root"); + assert!(!state.expanded_ids.contains("doc:root")); + + let parent_by_id = BTreeMap::from([ + ("doc:root".into(), None), + ("doc:child".into(), Some("doc:root".into())), + ("doc:leaf".into(), Some("doc:child".into())), + ]); + let state = state.expand_ancestors("doc:leaf", &parent_by_id); + assert_eq!( + state.expanded_ids, + BTreeSet::from(["doc:root".into(), "doc:child".into()]) + ); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index 5523c38e..1b5adba1 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -1,10 +1,25 @@ use super::protocol; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileTreeRenderRow { pub row_id: String, pub row_kind: String, + pub node_id: String, + pub parent_node_id: Option, pub title: String, + pub depth: u32, + pub expandable: bool, + pub expanded: bool, + pub icon_kind: String, + pub document_id: Option, + pub asset_id: Option, + pub selected: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTreeInitialRenderInput { + pub rows: Vec, } pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> { @@ -19,3 +34,139 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, }) .collect() } + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn row_test_id(row_kind: &str) -> &'static str { + match row_kind { + "document" => protocol::TEST_ID_FILETREE_DOC_ROW, + "index" => protocol::TEST_ID_FILETREE_INDEX_ROW, + _ => protocol::TEST_ID_FILETREE_ASSET_ROW, + } +} + +fn render_filetree_row( + html: &mut String, + row: &FileTreeRenderRow, + children_by_parent: &BTreeMap, Vec>, +) { + html.push_str(&format!( + r#"
  • "#, + node_id = escape_html(&row.node_id), + aria_level = row.depth + 1, + expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, + test_id = row_test_id(&row.row_kind), + row_id = escape_html(&row.row_id), + row_kind = escape_html(&row.row_kind), + document_id = escape_html(row.document_id.as_deref().unwrap_or_default()), + asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()), + selected = row.selected, + icon_kind = escape_html(&row.icon_kind), + title = escape_html(&row.title), + )); + if row.expandable && row.expanded { + if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { + html.push_str(r#"
      "#); + for child in children { + render_filetree_row(html, child, children_by_parent); + } + html.push_str("
    "); + } + } + html.push_str("
  • "); +} + +pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String { + let mut html = String::from( + r#"
      "#, + ); + if input.rows.is_empty() { + html.push_str( + r#"
    • 当前 file tree 没有可渲染的页面。
    • "#, + ); + html.push_str("
    "); + return html; + } + + let ids = input + .rows + .iter() + .map(|row| row.node_id.clone()) + .collect::>(); + let mut children_by_parent = BTreeMap::, Vec>::new(); + for row in &input.rows { + let parent_id = row + .parent_node_id + .as_ref() + .filter(|parent_id| ids.contains(*parent_id)) + .cloned(); + children_by_parent + .entry(parent_id) + .or_default() + .push(row.clone()); + } + + if let Some(roots) = children_by_parent.get(&None).cloned() { + for root in &roots { + render_filetree_row(&mut html, root, &children_by_parent); + } + } + html.push_str(""); + html +} + +#[cfg(test)] +mod tests { + use super::{render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow}; + + #[test] + fn tree_shell_filetree_renderer_outputs_initial_nested_html_contract() { + let html = render_initial_filetree_html(&FileTreeInitialRenderInput { + rows: vec![ + FileTreeRenderRow { + row_id: "doc:page_root".into(), + row_kind: "document".into(), + node_id: "page_root".into(), + parent_node_id: None, + title: "首页 <安全>".into(), + depth: 0, + expandable: true, + expanded: true, + icon_kind: "page".into(), + document_id: Some("page_root".into()), + asset_id: None, + selected: true, + }, + FileTreeRenderRow { + row_id: "index:page_root".into(), + row_kind: "index".into(), + node_id: "index:page_root".into(), + parent_node_id: Some("page_root".into()), + title: "index.md".into(), + depth: 1, + expandable: false, + expanded: false, + icon_kind: "index".into(), + document_id: Some("page_root".into()), + asset_id: None, + selected: false, + }, + ], + }); + + assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"filetree\"")); + assert!(html.contains("data-testid=\"filetree-doc-row\"")); + assert!(html.contains("data-testid=\"filetree-index-row\"")); + assert!(html.contains("tree-children")); + assert!(html.contains("首页 <安全>")); + assert!(html.contains("data-selected=\"true\"")); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs b/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs new file mode 100644 index 00000000..9d140ae9 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs @@ -0,0 +1,258 @@ +use serde::Serialize; +use std::collections::BTreeSet; + +pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeSelectionReducerContract { + pub contract_name: &'static str, + pub actions: BTreeSet<&'static str>, +} + +impl Default for FileTreeSelectionReducerContract { + fn default() -> Self { + Self { + contract_name: FILETREE_SELECTION_REDUCER_CONTRACT_NAME, + actions: BTreeSet::from([ + "select_row", + "select_context_row", + "normalize_visible_rows", + "clear", + "resolve_drag_rows", + ]), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeSelectionModifiers { + pub shift_key: bool, + pub ctrl_key: bool, + pub meta_key: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeSelectionState { + pub selected_row_ids: BTreeSet, + pub anchor_row_id: Option, + pub focused_row_id: Option, +} + +impl FileTreeSelectionState { + pub fn from_selected(row_ids: &[String]) -> Self { + Self { + selected_row_ids: row_ids.iter().cloned().collect(), + anchor_row_id: row_ids.first().cloned(), + focused_row_id: row_ids.first().cloned(), + } + } + + pub fn select_row( + &self, + row_id: &str, + visible_row_ids: &[String], + modifiers: FileTreeSelectionModifiers, + ) -> Self { + let toggle_selection = modifiers.ctrl_key || modifiers.meta_key; + + if modifiers.shift_key { + let anchor = self + .anchor_row_id + .clone() + .or_else(|| self.focused_row_id.clone()) + .unwrap_or_else(|| row_id.to_string()); + let mut selected_row_ids = if toggle_selection { + self.selected_row_ids.clone() + } else { + BTreeSet::new() + }; + for range_row_id in range_row_ids(visible_row_ids, &anchor, row_id) { + selected_row_ids.insert(range_row_id); + } + return Self { + selected_row_ids, + anchor_row_id: self.anchor_row_id.clone().or(Some(anchor)), + focused_row_id: Some(row_id.to_string()), + }; + } + + if toggle_selection { + let mut selected_row_ids = self.selected_row_ids.clone(); + if selected_row_ids.contains(row_id) { + selected_row_ids.remove(row_id); + } else { + selected_row_ids.insert(row_id.to_string()); + } + return Self { + selected_row_ids, + anchor_row_id: Some(row_id.to_string()), + focused_row_id: Some(row_id.to_string()), + }; + } + + Self { + selected_row_ids: BTreeSet::from([row_id.to_string()]), + anchor_row_id: Some(row_id.to_string()), + focused_row_id: Some(row_id.to_string()), + } + } + + pub fn select_context_row(&self, row_id: &str) -> Self { + if self.selected_row_ids.contains(row_id) { + return Self { + selected_row_ids: self.selected_row_ids.clone(), + anchor_row_id: self.anchor_row_id.clone(), + focused_row_id: Some(row_id.to_string()), + }; + } + + Self { + selected_row_ids: BTreeSet::from([row_id.to_string()]), + anchor_row_id: Some(row_id.to_string()), + focused_row_id: Some(row_id.to_string()), + } + } + + pub fn normalize_for_visible_rows(&self, visible_row_ids: &[String]) -> Self { + let visible = visible_row_ids.iter().collect::>(); + Self { + selected_row_ids: self + .selected_row_ids + .iter() + .filter(|row_id| visible.contains(row_id)) + .cloned() + .collect(), + anchor_row_id: self + .anchor_row_id + .as_ref() + .filter(|row_id| visible.contains(row_id)) + .cloned(), + focused_row_id: self + .focused_row_id + .as_ref() + .filter(|row_id| visible.contains(row_id)) + .cloned(), + } + } + + pub fn clear(&self) -> Self { + Self::default() + } + + pub fn resolve_drag_row_ids(&self, row_id: &str) -> Vec { + if self.selected_row_ids.contains(row_id) { + return self.selected_row_ids.iter().cloned().collect(); + } + vec![row_id.to_string()] + } +} + +fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec { + let from_index = visible_row_ids.iter().position(|row_id| row_id == from_id); + let to_index = visible_row_ids.iter().position(|row_id| row_id == to_id); + let (Some(from_index), Some(to_index)) = (from_index, to_index) else { + return vec![to_id.to_string()]; + }; + let low = from_index.min(to_index); + let high = from_index.max(to_index); + visible_row_ids[low..=high].to_vec() +} + +#[cfg(test)] +mod tests { + use super::{ + FileTreeSelectionModifiers, FileTreeSelectionReducerContract, FileTreeSelectionState, + FILETREE_SELECTION_REDUCER_CONTRACT_NAME, + }; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn tree_shell_filetree_selection_click_toggle_and_range_follow_contract() { + let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]); + let mut state = FileTreeSelectionState::default(); + + state = state.select_row( + "doc:b", + &visible, + FileTreeSelectionModifiers::default(), + ); + assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect()); + assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b")); + assert_eq!(state.focused_row_id.as_deref(), Some("doc:b")); + + state = state.select_row( + "asset:d", + &visible, + FileTreeSelectionModifiers { + shift_key: true, + ..FileTreeSelectionModifiers::default() + }, + ); + assert_eq!( + state.selected_row_ids, + ids(&["doc:b", "asset:c", "asset:d"]).into_iter().collect() + ); + assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b")); + assert_eq!(state.focused_row_id.as_deref(), Some("asset:d")); + + state = state.select_row( + "doc:a", + &visible, + FileTreeSelectionModifiers { + ctrl_key: true, + ..FileTreeSelectionModifiers::default() + }, + ); + assert_eq!( + state.selected_row_ids, + ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect() + ); + assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a")); + assert_eq!(state.focused_row_id.as_deref(), Some("doc:a")); + } + + #[test] + fn tree_shell_filetree_selection_context_clear_normalize_and_drag_rows_are_stable() { + let visible = ids(&["doc:a", "doc:b", "asset:c"]); + let mut state = FileTreeSelectionState::from_selected(&ids(&["doc:a", "asset:missing"])); + state.focused_row_id = Some("asset:missing".into()); + + state = state.normalize_for_visible_rows(&visible); + assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect()); + assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a")); + assert_eq!(state.focused_row_id, None); + + state = state.select_context_row("asset:c"); + assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect()); + assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c")); + assert_eq!(state.focused_row_id.as_deref(), Some("asset:c")); + + assert_eq!(state.resolve_drag_row_ids("asset:c"), ids(&["asset:c"])); + assert_eq!(state.resolve_drag_row_ids("doc:b"), ids(&["doc:b"])); + + state = state.clear(); + assert!(state.selected_row_ids.is_empty()); + assert_eq!(state.anchor_row_id, None); + assert_eq!(state.focused_row_id, None); + } + + #[test] + fn tree_shell_filetree_selection_reducer_contract_exposes_supported_actions() { + let contract = FileTreeSelectionReducerContract::default(); + assert_eq!( + contract.contract_name, + FILETREE_SELECTION_REDUCER_CONTRACT_NAME + ); + assert!(contract.actions.contains("select_row")); + assert!(contract.actions.contains("select_context_row")); + assert!(contract.actions.contains("normalize_visible_rows")); + assert!(contract.actions.contains("clear")); + assert!(contract.actions.contains("resolve_drag_rows")); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/focus_state.rs b/rust/crates/mnote-web/src/tree_shell/focus_state.rs new file mode 100644 index 00000000..017f1fcb --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/focus_state.rs @@ -0,0 +1,145 @@ +use serde::Serialize; +use std::collections::BTreeSet; + +pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = + "rust_page_focus_keyboard_reducer_v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageFocusKeyboardReducerContract { + pub contract_name: &'static str, + pub actions: BTreeSet<&'static str>, +} + +impl Default for PageFocusKeyboardReducerContract { + fn default() -> Self { + Self { + contract_name: PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME, + actions: BTreeSet::from([ + "normalize", + "focus", + "move_next", + "move_previous", + "move_home", + "move_end", + "expand", + "collapse", + "open", + "context_menu", + ]), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeShellFocusState { + pub focused_id: Option, +} + +impl TreeShellFocusState { + pub fn normalize(&self, visible_ids: &[String]) -> Self { + if let Some(focused_id) = self.focused_id.as_deref() { + if visible_ids.iter().any(|id| id == focused_id) { + return self.clone(); + } + } + Self { + focused_id: visible_ids.first().cloned(), + } + } + + pub fn move_next(&self, visible_ids: &[String]) -> Self { + self.move_by(visible_ids, 1) + } + + pub fn move_previous(&self, visible_ids: &[String]) -> Self { + self.move_by(visible_ids, -1) + } + + pub fn move_home(&self, visible_ids: &[String]) -> Self { + Self { + focused_id: visible_ids.first().cloned(), + } + } + + pub fn move_end(&self, visible_ids: &[String]) -> Self { + Self { + focused_id: visible_ids.last().cloned(), + } + } + + fn move_by(&self, visible_ids: &[String], offset: isize) -> Self { + if visible_ids.is_empty() { + return Self::default(); + } + let current_index = self + .focused_id + .as_deref() + .and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id)) + .unwrap_or(0); + let next_index = (current_index as isize + offset) + .clamp(0, (visible_ids.len() - 1) as isize) as usize; + Self { + focused_id: Some(visible_ids[next_index].clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + PageFocusKeyboardReducerContract, TreeShellFocusState, + PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME, + }; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn tree_shell_focus_state_normalizes_and_moves_within_visible_rows() { + let visible_ids = ids(&["doc:a", "doc:b", "doc:c"]); + let state = TreeShellFocusState { + focused_id: Some("missing".into()), + } + .normalize(&visible_ids); + assert_eq!(state.focused_id.as_deref(), Some("doc:a")); + + let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids); + assert_eq!(state.focused_id.as_deref(), Some("doc:c")); + assert_eq!( + state.move_previous(&visible_ids).focused_id.as_deref(), + Some("doc:b") + ); + assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a")); + assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c")); + } + + #[test] + fn tree_shell_focus_state_empty_rows_clear_focus() { + let state = TreeShellFocusState { + focused_id: Some("doc:a".into()), + } + .normalize(&[]); + assert_eq!(state.focused_id, None); + assert_eq!(state.move_next(&[]).focused_id, None); + } + + #[test] + fn tree_shell_page_focus_keyboard_reducer_contract_exposes_supported_actions() { + let contract = PageFocusKeyboardReducerContract::default(); + assert_eq!( + contract.contract_name, + PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME + ); + assert!(contract.actions.contains("focus")); + assert!(contract.actions.contains("move_next")); + assert!(contract.actions.contains("move_previous")); + assert!(contract.actions.contains("move_home")); + assert!(contract.actions.contains("move_end")); + assert!(contract.actions.contains("expand")); + assert!(contract.actions.contains("collapse")); + assert!(contract.actions.contains("open")); + assert!(contract.actions.contains("context_menu")); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/keyboard_state.rs b/rust/crates/mnote-web/src/tree_shell/keyboard_state.rs new file mode 100644 index 00000000..82ef8c8a --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/keyboard_state.rs @@ -0,0 +1,80 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TreeShellKeyboardIntent { + MoveNext, + MovePrevious, + MoveHome, + MoveEnd, + Expand, + Collapse, + Open, + ContextMenu, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TreeShellKeyboardModifiers { + pub shift_key: bool, + pub ctrl_key: bool, + pub meta_key: bool, +} + +pub fn resolve_tree_shell_keyboard_intent( + key: &str, + modifiers: TreeShellKeyboardModifiers, +) -> TreeShellKeyboardIntent { + match key { + "ArrowDown" => TreeShellKeyboardIntent::MoveNext, + "ArrowUp" => TreeShellKeyboardIntent::MovePrevious, + "Home" => TreeShellKeyboardIntent::MoveHome, + "End" => TreeShellKeyboardIntent::MoveEnd, + "ArrowRight" => TreeShellKeyboardIntent::Expand, + "ArrowLeft" => TreeShellKeyboardIntent::Collapse, + "Enter" => TreeShellKeyboardIntent::Open, + "ContextMenu" => TreeShellKeyboardIntent::ContextMenu, + "F10" if modifiers.shift_key && !modifiers.ctrl_key && !modifiers.meta_key => { + TreeShellKeyboardIntent::ContextMenu + } + _ => TreeShellKeyboardIntent::None, + } +} + +#[cfg(test)] +mod tests { + use super::{ + resolve_tree_shell_keyboard_intent, TreeShellKeyboardIntent, TreeShellKeyboardModifiers, + }; + + #[test] + fn tree_shell_keyboard_state_maps_navigation_open_and_context_menu_intents() { + assert_eq!( + resolve_tree_shell_keyboard_intent("ArrowDown", TreeShellKeyboardModifiers::default()), + TreeShellKeyboardIntent::MoveNext + ); + assert_eq!( + resolve_tree_shell_keyboard_intent("ArrowUp", TreeShellKeyboardModifiers::default()), + TreeShellKeyboardIntent::MovePrevious + ); + assert_eq!( + resolve_tree_shell_keyboard_intent("Home", TreeShellKeyboardModifiers::default()), + TreeShellKeyboardIntent::MoveHome + ); + assert_eq!( + resolve_tree_shell_keyboard_intent("End", TreeShellKeyboardModifiers::default()), + TreeShellKeyboardIntent::MoveEnd + ); + assert_eq!( + resolve_tree_shell_keyboard_intent("Enter", TreeShellKeyboardModifiers::default()), + TreeShellKeyboardIntent::Open + ); + assert_eq!( + resolve_tree_shell_keyboard_intent( + "F10", + TreeShellKeyboardModifiers { + shift_key: true, + ..TreeShellKeyboardModifiers::default() + }, + ), + TreeShellKeyboardIntent::ContextMenu + ); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/mod.rs b/rust/crates/mnote-web/src/tree_shell/mod.rs index 1facd325..6039deb1 100644 --- a/rust/crates/mnote-web/src/tree_shell/mod.rs +++ b/rust/crates/mnote-web/src/tree_shell/mod.rs @@ -1,9 +1,17 @@ +pub mod action_registry; +pub mod drag_drop_state; pub mod dispatcher; +pub mod expansion_state; pub mod filetree_renderer; +pub mod filetree_selection; +pub mod focus_state; +pub mod keyboard_state; pub mod loader; pub mod page_renderer; pub mod picker_renderer; +pub mod picker_state; pub mod protocol; +pub mod renderer_input; pub mod state; use leptos::prelude::*; diff --git a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs index fe1ec1c5..665c8f08 100644 --- a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs @@ -1,11 +1,14 @@ use super::protocol; +use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageTreeRenderRow { pub node_id: String, + pub parent_node_id: Option, pub title: String, pub depth: u32, pub expandable: bool, + pub expanded: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,6 +23,13 @@ pub struct PageTreeDomRow { pub expandable: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageTreeInitialRenderInput { + pub rows: Vec, + pub active_node_id: Option, + pub focused_node_id: Option, +} + pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec { rows.iter() .map(|row| PageTreeDomRow { @@ -35,24 +45,127 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_page_row( + html: &mut String, + row: &PageTreeRenderRow, + children_by_parent: &BTreeMap, Vec>, + input: &PageTreeInitialRenderInput, +) { + let dom_rows = build_page_tree_dom_rows(&[row.clone()]); + if let Some(row) = dom_rows.first() { + let active = input + .active_node_id + .as_deref() + .map(|active_node_id| active_node_id == row.node_id) + .unwrap_or(false); + let focused = input + .focused_node_id + .as_deref() + .map(|focused_node_id| focused_node_id == row.node_id) + .unwrap_or(false); + let expanded = input + .rows + .iter() + .find(|source| source.node_id == row.node_id) + .map(|source| source.expanded) + .unwrap_or(false); + html.push_str(&format!( + r#"
  • "#, + node_id = escape_html(&row.node_id), + aria_level = row.depth + 1, + expanded_attr = if row.expandable && expanded { "true" } else { "false" }, + test_id = row.test_id, + active = active, + focused = focused, + tab_index = if focused { "0" } else { "-1" }, + title = escape_html(&row.title), + )); + if row.expandable && expanded { + if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { + html.push_str(r#"
      "#); + for child in children { + render_page_row(html, child, children_by_parent, input); + } + html.push_str("
    "); + } + } + html.push_str("
  • "); + } +} + +pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String { + let mut html = String::from( + r#"
      "#, + ); + if input.rows.is_empty() { + html.push_str( + r#"
    • 当前 projection 没有可渲染的页面。
    • "#, + ); + html.push_str("
    "); + return html; + } + + let ids = input + .rows + .iter() + .map(|row| row.node_id.clone()) + .collect::>(); + let mut children_by_parent = BTreeMap::, Vec>::new(); + for row in &input.rows { + let parent_id = row + .parent_node_id + .as_ref() + .filter(|parent_id| ids.contains(*parent_id)) + .cloned(); + children_by_parent + .entry(parent_id) + .or_default() + .push(row.clone()); + } + + if let Some(roots) = children_by_parent.get(&None).cloned() { + for root in &roots { + render_page_row(&mut html, root, &children_by_parent, input); + } + } + html.push_str(""); + html +} + #[cfg(test)] mod tests { - use super::{build_page_tree_dom_rows, PageTreeRenderRow}; + use super::{ + build_page_tree_dom_rows, render_initial_page_tree_html, PageTreeInitialRenderInput, + PageTreeRenderRow, + }; #[test] fn tree_shell_page_renderer_builds_rows_with_stable_testids() { let rows = build_page_tree_dom_rows(&[ PageTreeRenderRow { node_id: "page_root".into(), + parent_node_id: None, title: "首页".into(), depth: 0, expandable: true, + expanded: false, }, PageTreeRenderRow { node_id: "page_child".into(), + parent_node_id: Some("page_root".into()), title: "子页".into(), depth: 1, expandable: false, + expanded: false, }, ]); @@ -63,4 +176,42 @@ mod tests { assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up"); assert_eq!(rows[1].depth, 1); } + + #[test] + fn tree_shell_page_renderer_outputs_initial_html_contract() { + let html = render_initial_page_tree_html(&PageTreeInitialRenderInput { + rows: vec![ + PageTreeRenderRow { + node_id: "page_root".into(), + parent_node_id: None, + title: "首页 <安全>".into(), + depth: 0, + expandable: true, + expanded: true, + }, + PageTreeRenderRow { + node_id: "page_child".into(), + parent_node_id: Some("page_root".into()), + title: "子页".into(), + depth: 1, + expandable: false, + expanded: false, + }, + ], + active_node_id: Some("page_root".into()), + focused_node_id: Some("page_root".into()), + }); + + assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"page\"")); + assert!(html.contains("data-shell-mode=\"page\"")); + assert!(html.contains("data-rust-action=\"open\"")); + assert!(html.contains("data-rust-action=\"create\"")); + assert!(html.contains("draggable=\"true\"")); + assert!(html.contains("tree-children")); + assert!(html.contains("子页")); + assert!(html.contains("首页 <安全>")); + assert!(html.contains("data-active=\"true\"")); + assert!(html.contains("data-focused=\"true\"")); + } } diff --git a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs index 33244dd3..4c2c4a45 100644 --- a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs @@ -1,9 +1,15 @@ use super::protocol; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PickerRenderRow { pub node_id: String, + pub parent_node_id: Option, pub title: String, + pub depth: u32, + pub expandable: bool, + pub expanded: bool, + pub active: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -13,6 +19,13 @@ pub struct PickerRenderResult { pub allow_root_pick: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerInitialRenderInput { + pub rows: Vec, + pub allow_root_pick: bool, + pub root_active: bool, +} + pub fn build_picker_render_result( rows: &[PickerRenderRow], allow_root_pick: bool, @@ -24,10 +37,84 @@ pub fn build_picker_render_result( } } +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_picker_row( + html: &mut String, + row: &PickerRenderRow, + children_by_parent: &BTreeMap, Vec>, +) { + html.push_str(&format!( + r#"
  • "#, + node_id = escape_html(&row.node_id), + aria_level = row.depth + 1, + expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, + active = row.active, + title = escape_html(&row.title), + )); + if row.expandable && row.expanded { + if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { + html.push_str(r#"
      "#); + for child in children { + render_picker_row(html, child, children_by_parent); + } + html.push_str("
    "); + } + } + html.push_str("
  • "); +} + +pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String { + let mut html = String::from( + r#"
      "#, + ); + if input.allow_root_pick { + html.push_str(&format!( + r#"
    • "#, + focused = input.root_active, + )); + } + + let ids = input + .rows + .iter() + .map(|row| row.node_id.clone()) + .collect::>(); + let mut children_by_parent = BTreeMap::, Vec>::new(); + for row in &input.rows { + let parent_id = row + .parent_node_id + .as_ref() + .filter(|parent_id| ids.contains(*parent_id)) + .cloned(); + children_by_parent + .entry(parent_id) + .or_default() + .push(row.clone()); + } + if let Some(roots) = children_by_parent.get(&None).cloned() { + for root in &roots { + render_picker_row(&mut html, root, &children_by_parent); + } + } + html.push_str("
    "); + html +} + #[cfg(test)] mod tests { use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow}; - use super::{build_picker_render_result, PickerRenderRow}; + use super::{ + build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput, + PickerRenderRow, + }; #[test] fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() { @@ -35,18 +122,41 @@ mod tests { FileTreeRenderRow { row_id: "doc:page_root".into(), row_kind: "document".into(), + node_id: "page_root".into(), + parent_node_id: None, title: "首页".into(), + depth: 0, + expandable: false, + expanded: false, + icon_kind: "page".into(), + document_id: Some("page_root".into()), + asset_id: None, + selected: false, }, FileTreeRenderRow { row_id: "asset:asset_1".into(), row_kind: "asset".into(), + node_id: "asset:asset_1".into(), + parent_node_id: Some("page_root".into()), title: "附件".into(), + depth: 1, + expandable: false, + expanded: false, + icon_kind: "file".into(), + document_id: Some("page_root".into()), + asset_id: Some("asset_1".into()), + selected: false, }, ]); let picker = build_picker_render_result( &[PickerRenderRow { node_id: "page_root".into(), + parent_node_id: None, title: "首页".into(), + depth: 0, + expandable: false, + expanded: false, + active: false, }], true, ); @@ -56,4 +166,29 @@ mod tests { assert_eq!(picker.root_test_id, "tree-picker-root"); assert!(picker.allow_root_pick); } + + #[test] + fn tree_shell_picker_renderer_outputs_initial_html_contract() { + let html = render_initial_picker_html(&PickerInitialRenderInput { + allow_root_pick: true, + root_active: false, + rows: vec![PickerRenderRow { + node_id: "page_root".into(), + parent_node_id: None, + title: "首页 <安全>".into(), + depth: 0, + expandable: false, + expanded: false, + active: true, + }], + }); + + assert!(html.contains("data-rust-picker-renderer=\"initial_v1\"")); + assert!(html.contains("data-rust-rendered-row=\"picker-root\"")); + assert!(html.contains("data-rust-rendered-row=\"picker\"")); + assert!(html.contains("data-testid=\"tree-picker-root\"")); + assert!(html.contains("data-testid=\"tree-picker-row\"")); + assert!(html.contains("首页 <安全>")); + assert!(html.contains("data-focused=\"true\"")); + } } diff --git a/rust/crates/mnote-web/src/tree_shell/picker_state.rs b/rust/crates/mnote-web/src/tree_shell/picker_state.rs new file mode 100644 index 00000000..058d129c --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/picker_state.rs @@ -0,0 +1,201 @@ +use serde::Serialize; +use std::collections::BTreeSet; + +pub const PICKER_STATE_REDUCER_CONTRACT_NAME: &str = "rust_picker_state_reducer_v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PickerStateReducerContract { + pub contract_name: &'static str, + pub actions: BTreeSet<&'static str>, +} + +impl Default for PickerStateReducerContract { + fn default() -> Self { + Self { + contract_name: PICKER_STATE_REDUCER_CONTRACT_NAME, + actions: BTreeSet::from([ + "normalize", + "focus", + "next", + "previous", + "home", + "end", + "pick", + ]), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerItem { + pub item_key: String, + pub document_id: Option, + pub pickable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PickerState { + pub active_item_key: Option, +} + +impl PickerState { + pub fn normalize(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Self { + if let Some(active_item_key) = self.active_item_key.as_deref() { + if is_pickable_item_key(items, excluded_ids, active_item_key) { + return self.clone(); + } + } + Self { + active_item_key: first_pickable_item_key(items, excluded_ids), + } + } + + pub fn move_next(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Self { + self.move_by(items, excluded_ids, 1) + } + + pub fn move_previous(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Self { + self.move_by(items, excluded_ids, -1) + } + + pub fn move_home(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Self { + Self { + active_item_key: first_pickable_item_key(items, excluded_ids), + } + } + + pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Self { + Self { + active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()), + } + } + + pub fn pick(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> Option { + let active = self.active_item_key.as_deref()?; + pickable_items(items, excluded_ids) + .find(|item| item.item_key == active) + .and_then(|item| item.document_id.clone()) + } + + fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet, offset: isize) -> Self { + let pickable = pickable_items(items, excluded_ids).collect::>(); + if pickable.is_empty() { + return Self::default(); + } + let current_index = self + .active_item_key + .as_deref() + .and_then(|active| pickable.iter().position(|item| item.item_key == active)) + .unwrap_or(0); + let next_index = (current_index as isize + offset) + .clamp(0, (pickable.len() - 1) as isize) as usize; + Self { + active_item_key: Some(pickable[next_index].item_key.clone()), + } + } +} + +fn is_item_excluded(item: &PickerItem, excluded_ids: &BTreeSet) -> bool { + item.document_id + .as_ref() + .is_some_and(|document_id| excluded_ids.contains(document_id)) +} + +fn pickable_items<'a>( + items: &'a [PickerItem], + excluded_ids: &'a BTreeSet, +) -> impl DoubleEndedIterator + 'a { + items + .iter() + .filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids)) +} + +fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet) -> Option { + pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone()) +} + +fn is_pickable_item_key( + items: &[PickerItem], + excluded_ids: &BTreeSet, + item_key: &str, +) -> bool { + pickable_items(items, excluded_ids).any(|item| item.item_key == item_key) +} + +#[cfg(test)] +mod tests { + use super::{ + PickerItem, PickerState, PickerStateReducerContract, PICKER_STATE_REDUCER_CONTRACT_NAME, + }; + use std::collections::BTreeSet; + + fn excluded(values: &[&str]) -> BTreeSet { + values.iter().map(|value| (*value).to_string()).collect() + } + + fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerItem { + PickerItem { + item_key: item_key.into(), + document_id: document_id.map(ToOwned::to_owned), + pickable, + } + } + + #[test] + fn tree_shell_picker_state_skips_excluded_items_and_picks_active_document() { + let items = vec![ + item("root", None, true), + item("doc:a", Some("doc:a"), true), + item("doc:b", Some("doc:b"), true), + item("doc:c", Some("doc:c"), true), + ]; + let excluded_ids = excluded(&["doc:b"]); + + let state = PickerState { + active_item_key: Some("doc:b".into()), + } + .normalize(&items, &excluded_ids); + assert_eq!(state.active_item_key.as_deref(), Some("root")); + + let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids); + assert_eq!(state.active_item_key.as_deref(), Some("doc:c")); + assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c")); + + let state = state.move_previous(&items, &excluded_ids); + assert_eq!(state.active_item_key.as_deref(), Some("doc:a")); + } + + #[test] + fn tree_shell_picker_state_home_end_and_empty_cases_are_stable() { + let items = vec![ + item("doc:a", Some("doc:a"), true), + item("doc:b", Some("doc:b"), false), + item("doc:c", Some("doc:c"), true), + ]; + let excluded_ids = excluded(&[]); + let state = PickerState::default().move_end(&items, &excluded_ids); + assert_eq!(state.active_item_key.as_deref(), Some("doc:c")); + assert_eq!( + state.move_home(&items, &excluded_ids).active_item_key.as_deref(), + Some("doc:a") + ); + + let empty = PickerState::default().normalize(&items, &excluded(&["doc:a", "doc:c"])); + assert_eq!(empty.active_item_key, None); + assert_eq!(empty.pick(&items, &excluded(&["doc:a", "doc:c"])), None); + } + + #[test] + fn tree_shell_picker_state_reducer_contract_exposes_supported_actions() { + let contract = PickerStateReducerContract::default(); + assert_eq!(contract.contract_name, PICKER_STATE_REDUCER_CONTRACT_NAME); + assert!(contract.actions.contains("normalize")); + assert!(contract.actions.contains("focus")); + assert!(contract.actions.contains("next")); + assert!(contract.actions.contains("previous")); + assert!(contract.actions.contains("home")); + assert!(contract.actions.contains("end")); + assert!(contract.actions.contains("pick")); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/renderer_input.rs b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs new file mode 100644 index 00000000..93ce1307 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs @@ -0,0 +1,191 @@ +use super::filetree_selection::{FileTreeSelectionReducerContract, FileTreeSelectionState}; +use super::focus_state::PageFocusKeyboardReducerContract; +use super::picker_state::PickerStateReducerContract; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TreeShellRendererMode { + Page, + FileTree, + Picker, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeShellRendererInput { + pub mode: TreeShellRendererMode, + pub projection_item_ids: Vec, + pub expanded_ids: BTreeSet, + pub focused_id: Option, + pub page_focus_keyboard_reducer: Option, + pub filetree_selection: FileTreeSelectionState, + pub filetree_selection_reducer: Option, + pub active_picker_item: Option, + pub excluded_picker_ids: BTreeSet, + pub picker_state_reducer: Option, + pub command_dispatcher: TreeShellCommandDispatcher, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeShellCommandDispatcher { + pub channel: String, + pub command_names: BTreeSet, +} + +impl TreeShellRendererInput { + pub fn page(input: PageTreeRendererInput) -> Self { + Self { + mode: TreeShellRendererMode::Page, + projection_item_ids: input.projection_item_ids, + expanded_ids: input.expanded_ids, + focused_id: input.focused_id, + page_focus_keyboard_reducer: Some(PageFocusKeyboardReducerContract::default()), + filetree_selection: FileTreeSelectionState::default(), + filetree_selection_reducer: None, + active_picker_item: None, + excluded_picker_ids: BTreeSet::new(), + picker_state_reducer: None, + command_dispatcher: input.command_dispatcher, + } + } + + pub fn filetree(input: FileTreeRendererInput) -> Self { + Self { + mode: TreeShellRendererMode::FileTree, + projection_item_ids: input.projection_item_ids, + expanded_ids: input.expanded_ids, + focused_id: input.filetree_selection.focused_row_id.clone(), + page_focus_keyboard_reducer: None, + filetree_selection: input.filetree_selection, + filetree_selection_reducer: Some(FileTreeSelectionReducerContract::default()), + active_picker_item: None, + excluded_picker_ids: BTreeSet::new(), + picker_state_reducer: None, + command_dispatcher: input.command_dispatcher, + } + } + + pub fn picker(input: PickerRendererInput) -> Self { + Self { + mode: TreeShellRendererMode::Picker, + projection_item_ids: input.projection_item_ids, + expanded_ids: input.expanded_ids, + focused_id: input.active_picker_item.clone(), + page_focus_keyboard_reducer: None, + filetree_selection: FileTreeSelectionState::default(), + filetree_selection_reducer: None, + active_picker_item: input.active_picker_item, + excluded_picker_ids: input.excluded_picker_ids, + picker_state_reducer: Some(PickerStateReducerContract::default()), + command_dispatcher: input.command_dispatcher, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageTreeRendererInput { + pub projection_item_ids: Vec, + pub expanded_ids: BTreeSet, + pub focused_id: Option, + pub command_dispatcher: TreeShellCommandDispatcher, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTreeRendererInput { + pub projection_item_ids: Vec, + pub expanded_ids: BTreeSet, + pub filetree_selection: FileTreeSelectionState, + pub command_dispatcher: TreeShellCommandDispatcher, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRendererInput { + pub projection_item_ids: Vec, + pub expanded_ids: BTreeSet, + pub active_picker_item: Option, + pub excluded_picker_ids: BTreeSet, + pub command_dispatcher: TreeShellCommandDispatcher, +} + +#[cfg(test)] +mod tests { + use super::{ + FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, + TreeShellRendererInput, TreeShellRendererMode, + }; + use crate::tree_shell::filetree_selection::FileTreeSelectionState; + use std::collections::BTreeSet; + + fn set(values: &[&str]) -> BTreeSet { + values.iter().map(|value| (*value).to_string()).collect() + } + + fn dispatcher() -> TreeShellCommandDispatcher { + TreeShellCommandDispatcher { + channel: "mnote.tree.shell".into(), + command_names: set(&["tree.node.create", "tree.resource.move"]), + } + } + + #[test] + fn tree_shell_renderer_input_contract_covers_page_filetree_and_picker_state() { + let page = TreeShellRendererInput::page(PageTreeRendererInput { + projection_item_ids: vec!["doc:root".into()], + expanded_ids: set(&["doc:root"]), + focused_id: Some("doc:root".into()), + command_dispatcher: dispatcher(), + }); + assert_eq!(page.mode, TreeShellRendererMode::Page); + assert_eq!(page.focused_id.as_deref(), Some("doc:root")); + assert_eq!(page.command_dispatcher.channel, "mnote.tree.shell"); + assert_eq!( + page.page_focus_keyboard_reducer + .as_ref() + .map(|contract| contract.contract_name), + Some("rust_page_focus_keyboard_reducer_v1") + ); + + let mut selection = FileTreeSelectionState::from_selected(&["asset:a".into()]); + selection.focused_row_id = Some("asset:a".into()); + let filetree = TreeShellRendererInput::filetree(FileTreeRendererInput { + projection_item_ids: vec!["doc:root".into(), "asset:a".into()], + expanded_ids: set(&["doc:root"]), + filetree_selection: selection, + command_dispatcher: dispatcher(), + }); + assert_eq!(filetree.mode, TreeShellRendererMode::FileTree); + assert_eq!(filetree.focused_id.as_deref(), Some("asset:a")); + assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a")); + assert!(filetree.page_focus_keyboard_reducer.is_none()); + assert_eq!( + filetree + .filetree_selection_reducer + .as_ref() + .map(|contract| contract.contract_name), + Some("rust_filetree_selection_reducer_v1") + ); + + let picker = TreeShellRendererInput::picker(PickerRendererInput { + projection_item_ids: vec!["doc:root".into(), "doc:child".into()], + expanded_ids: set(&["doc:root"]), + active_picker_item: Some("doc:child".into()), + excluded_picker_ids: set(&["doc:archived"]), + command_dispatcher: dispatcher(), + }); + assert_eq!(picker.mode, TreeShellRendererMode::Picker); + assert_eq!(picker.focused_id.as_deref(), Some("doc:child")); + assert!(picker.excluded_picker_ids.contains("doc:archived")); + assert!(picker.page_focus_keyboard_reducer.is_none()); + assert!(picker.filetree_selection_reducer.is_none()); + assert_eq!( + picker + .picker_state_reducer + .as_ref() + .map(|contract| contract.contract_name), + Some("rust_picker_state_reducer_v1") + ); + } +} diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index c77f5b86..3f2588c6 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -41,6 +41,13 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "tree.node.purge" => "documents:purge", "tree.subtree.move" => "documents:move", "tree.subtree.copy" => "documents:copyTree", + "tree.resource.copy" => "mediaAssets:batchCopy", + "tree.resource.move" => "mediaAssets:batchMove", + "tree.resource.upload" => "mediaAssets:createWithStorage", + "tree.filetree.drop.preflight" => "tree:fileTreeDropPreflight", + "tree.filetree.delete.preflight" => "tree:fileTreeDeletePreflight", + "tree.filetree.paste.preflight" => "tree:fileTreePastePreflight", + "tree.filetree.upload-target.preflight" => "tree:fileTreeUploadTargetPreflight", "tree.node.embed" => "documents:updateContent", "documents.create" => "documents:createWithParentReference", "documents.move" => "documents:move", diff --git a/wolai-frontend/convex/_utils/documentMoveOrder.ts b/wolai-frontend/convex/_utils/documentMoveOrder.ts new file mode 100644 index 00000000..be09c6e7 --- /dev/null +++ b/wolai-frontend/convex/_utils/documentMoveOrder.ts @@ -0,0 +1,164 @@ +export type DocumentMoveOrderDocument = { + id: string; + parent_id?: string | null; + sort_order?: number | null; + created_at?: string | null; +}; + +export type DocumentMoveOrderPatch = { + documentId: string; + parentId: string | null; + sortOrder: number; + moved: boolean; +}; + +export type DocumentMoveOrderPlan = { + documentId: string; + fromParentId: string | null; + toParentId: string | null; + requestedSortOrder: number; + normalizedSortOrder: number; + patches: DocumentMoveOrderPatch[]; +}; + +function normalizeParentId(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeSortOrder(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return Number.MAX_SAFE_INTEGER; + } + return Math.floor(value); +} + +function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number { + const orderA = normalizeSortOrder(a.sort_order); + const orderB = normalizeSortOrder(b.sort_order); + if (orderA !== orderB) return orderA - orderB; + const createdA = String(a.created_at ?? ""); + const createdB = String(b.created_at ?? ""); + if (createdA !== createdB) return createdA.localeCompare(createdB); + return a.id.localeCompare(b.id); +} + +function clampMoveIndex(raw: number, max: number): number { + const value = Number.isFinite(raw) ? Math.floor(raw) : 0; + if (value < 0) return 0; + if (value > max) return max; + return value; +} + +function appendOrderPatches( + patches: DocumentMoveOrderPatch[], + ordered: DocumentMoveOrderDocument[], + parentId: string | null, + movedDocumentId: string, +) { + ordered.forEach((document, index) => { + const moved = document.id === movedDocumentId; + if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) { + return; + } + + patches.push({ + documentId: document.id, + parentId, + sortOrder: index, + moved, + }); + }); +} + +export function buildDocumentMoveOrderPlanFromDocuments(input: { + documents: readonly DocumentMoveOrderDocument[]; + documentId: string; + parentId: string | null; + sortOrder: number; +}): DocumentMoveOrderPlan { + const source = input.documents.find((document) => document.id === input.documentId); + if (!source) { + throw new Error("源页面不存在或无权限"); + } + + const fromParentId = normalizeParentId(source.parent_id); + const toParentId = normalizeParentId(input.parentId); + const siblingsByParent = new Map(); + input.documents.forEach((document) => { + const parentId = normalizeParentId(document.parent_id); + const bucket = siblingsByParent.get(parentId); + if (bucket) bucket.push(document); + else siblingsByParent.set(parentId, [document]); + }); + siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder)); + + const patches: DocumentMoveOrderPatch[] = []; + let normalizedSortOrder = 0; + + if (fromParentId === toParentId) { + const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId); + normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length); + siblings.splice(normalizedSortOrder, 0, source); + appendOrderPatches(patches, siblings, toParentId, input.documentId); + } else { + const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId); + appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId); + + const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId); + normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length); + newSiblings.splice(normalizedSortOrder, 0, source); + appendOrderPatches(patches, newSiblings, toParentId, input.documentId); + } + + return { + documentId: input.documentId, + fromParentId, + toParentId, + requestedSortOrder: input.sortOrder, + normalizedSortOrder, + patches, + }; +} + +function normalizePlan(value: unknown): DocumentMoveOrderPlan | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Partial; + if ( + typeof record.documentId !== "string" || + typeof record.requestedSortOrder !== "number" || + typeof record.normalizedSortOrder !== "number" || + !Array.isArray(record.patches) + ) { + return null; + } + return { + documentId: record.documentId, + fromParentId: normalizeParentId(record.fromParentId), + toParentId: normalizeParentId(record.toParentId), + requestedSortOrder: record.requestedSortOrder, + normalizedSortOrder: record.normalizedSortOrder, + patches: record.patches.map((patch) => { + const item = patch as Partial; + return { + documentId: String(item.documentId ?? ""), + parentId: normalizeParentId(item.parentId), + sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1, + moved: Boolean(item.moved), + }; + }), + }; +} + +export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) { + const normalizedExpected = normalizePlan(expected); + if (!normalizedExpected) { + throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); + } + + if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) { + throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); + } +} diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 9a08d2d2..2c172a9a 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -4,6 +4,10 @@ import { v } from "convex/values"; import { requireUserId } from "./_utils/auth"; import { nowIso } from "./_utils/time"; import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree"; +import { + assertDocumentMoveOrderPlanMatches, + buildDocumentMoveOrderPlanFromDocuments, +} from "./_utils/documentMoveOrder"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; import { extractTextFromDocumentContent } from "./_utils/text"; import { @@ -216,6 +220,21 @@ async function createDocumentRecord( }; } +function toDocumentDeltaRecord(doc: any) { + return { + id: doc.id, + workspace_id: doc.workspace_id, + title: doc.title ?? null, + parent_id: doc.parent_id ?? null, + sort_order: doc.sort_order ?? null, + is_starred: doc.is_starred ?? false, + access_scope: (doc.access_scope ?? "private") as "private" | "shared" | "public", + is_template: Boolean(doc.is_template), + created_at: doc.created_at ?? nowIso(), + updated_at: doc.updated_at ?? null, + }; +} + async function updateDocumentContentRecord( ctx: any, args: { @@ -1198,7 +1217,7 @@ export const create = mutation({ deleted_by: null, }); - return { + const document = { id: args.id, title, parent_id: args.parentId, @@ -1210,6 +1229,10 @@ export const create = mutation({ access_scope: args.accessScope, is_template: false, }; + return { + ...document, + document: toDocumentDeltaRecord(document), + }; }, }); @@ -1376,6 +1399,7 @@ export const move = mutation({ id: v.string(), parentId: v.union(v.string(), v.null()), sortOrder: v.number(), + normalizedMove: v.optional(v.any()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); @@ -1386,6 +1410,14 @@ export const move = mutation({ if (doc.user_id !== userId) throw new Error("无权限"); const toParentId = args.parentId; + const workspaceDocs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) + .collect(); + const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs) + .filter((row) => row.user_id === userId) + .filter((row) => row.deleted_at == null); + if (toParentId === doc.id) { throw new Error("不能把页面移动到自身下面"); } @@ -1398,19 +1430,24 @@ export const move = mutation({ throw new Error("暂不支持跨工作空间移动页面"); } - const workspaceDocs = await ctx.db - .query("documents") - .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) - .collect(); - const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs) - .filter((row) => row.user_id === userId) - .filter((row) => row.deleted_at == null); const parentById = buildParentById(canonicalWorkspaceDocs); if (isAncestorOf(doc.id, toParentId, parentById)) { throw new Error("不能把页面移动到自己的后代下面"); } } + if (args.normalizedMove != null) { + assertDocumentMoveOrderPlanMatches( + args.normalizedMove, + buildDocumentMoveOrderPlanFromDocuments({ + documents: canonicalWorkspaceDocs, + documentId: doc.id, + parentId: toParentId, + sortOrder: args.sortOrder, + }), + ); + } + // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 // 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。 @@ -1565,7 +1602,18 @@ export const restore = mutation({ await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts }); } } - return { ok: true }; + return { + ok: true, + updated_at: ts, + document: toDocumentDeltaRecord({ + ...doc, + deleted_at: null, + deleted_by: null, + parent_id: null, + access_scope: "private", + updated_at: ts, + }), + }; }, }); @@ -1801,8 +1849,10 @@ export const duplicate = mutation({ title, parent_id: source.parent_id ?? null, sort_order: sortOrder, + is_starred: false, workspace_id: source.workspace_id, access_scope: source.access_scope, + is_template: false, created_at: ts, updated_at: ts, }; @@ -1921,7 +1971,12 @@ export const copyTree = mutation({ throw new Error("没有可复制的页面"); } - const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = []; + const insertedDocs: Array<{ + oldId: string; + newId: string; + title: string; + document: ReturnType; + }> = []; for (const item of copyQueue) { const newId = newIdByOldId.get(item.old.id)!; @@ -1935,7 +1990,7 @@ export const copyTree = mutation({ const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet); - await createDocumentRecord(ctx, { + const created = await createDocumentRecord(ctx, { id: newId, workspaceId, parentId, @@ -1945,7 +2000,12 @@ export const copyTree = mutation({ }); await copyMindmapsForDocument(ctx, item.old.id, newId); - insertedDocs.push({ oldId: item.old.id, newId, title: newTitle }); + insertedDocs.push({ + oldId: item.old.id, + newId, + title: newTitle, + document: toDocumentDeltaRecord(created), + }); } return { diff --git a/wolai-frontend/convex/mediaAssets.ts b/wolai-frontend/convex/mediaAssets.ts index 0dd0cd1e..c264b2a1 100644 --- a/wolai-frontend/convex/mediaAssets.ts +++ b/wolai-frontend/convex/mediaAssets.ts @@ -72,6 +72,201 @@ function shouldExtractAttachmentText(args: { return false; } +function splitExtension(fileName: string): { base: string; ext: string } { + const safe = fileName.trim(); + const lastDot = safe.lastIndexOf("."); + if (lastDot <= 0 || lastDot === safe.length - 1) { + return { base: safe, ext: "" }; + } + return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) }; +} + +function makeUniqueFileName(fileName: string, existing: Set): string { + const safe = (fileName.trim() || "附件").replace(/[\\/]/g, "_"); + if (!existing.has(safe)) { + existing.add(safe); + return safe; + } + const { base, ext } = splitExtension(safe); + const first = `${base} 副本${ext}`; + if (!existing.has(first)) { + existing.add(first); + return first; + } + for (let i = 2; i < 1000; i += 1) { + const candidate = `${base} 副本 ${i}${ext}`; + if (!existing.has(candidate)) { + existing.add(candidate); + return candidate; + } + } + const fallback = `${base} 副本 ${Date.now()}${ext}`; + existing.add(fallback); + return fallback; +} + +function uniqueStrings(values: string[]): string[] { + const out: string[] = []; + for (const value of values) { + const normalized = String(value ?? "").trim(); + if (!normalized || out.includes(normalized)) { + continue; + } + out.push(normalized); + } + return out; +} + +function validateResourceTransferPlan(args: { + action: "copy" | "move"; + assetIds: string[]; + targetDocumentId: string; + targetSubPath?: string | null; + resourceTransferPlan?: any; +}) { + const plan = args.resourceTransferPlan; + if (!plan) { + return; + } + + if (plan.action !== args.action) { + throw new Error("资源操作计划不一致"); + } + if (String(plan.targetDocumentId ?? "").trim() !== args.targetDocumentId) { + throw new Error("资源目标页面计划不一致"); + } + const plannedSubPath = String(plan.targetSubPath ?? "").trim(); + const actualSubPath = String(args.targetSubPath ?? "").trim(); + if (plannedSubPath !== actualSubPath) { + throw new Error("资源目标子路径计划不一致"); + } + const plannedAssetIds = Array.isArray(plan.assetIds) + ? uniqueStrings(plan.assetIds.map((value: unknown) => String(value ?? ""))) + : []; + if (plannedAssetIds.length !== args.assetIds.length) { + throw new Error("资源列表计划不一致"); + } + for (let i = 0; i < args.assetIds.length; i += 1) { + if (plannedAssetIds[i] !== args.assetIds[i]) { + throw new Error("资源列表计划不一致"); + } + } +} + +function validateResourceUploadPlan(args: { + asset: { + id: string; + workspace_id: string; + document_id: string; + asset_type: string; + file_name?: string | null; + file_size?: number | null; + mime_type?: string | null; + }; + targetSubPath?: string | null; + resourceUploadPlan?: any; +}) { + const plan = args.resourceUploadPlan; + if (!plan || typeof plan !== "object") { + return; + } + if (plan.action !== "upload") { + throw new Error("Rust resource upload plan action 不一致"); + } + if (String(plan.assetId ?? "").trim() !== args.asset.id) { + throw new Error("Rust resource upload plan assetId 不一致"); + } + if (String(plan.workspaceId ?? "").trim() !== args.asset.workspace_id) { + throw new Error("Rust resource upload plan workspaceId 不一致"); + } + if (String(plan.targetDocumentId ?? "").trim() !== args.asset.document_id) { + throw new Error("Rust resource upload plan targetDocumentId 不一致"); + } + const plannedSubPath = String(plan.targetSubPath ?? "").trim(); + const actualSubPath = String(args.targetSubPath ?? "").trim(); + if (plannedSubPath !== actualSubPath) { + throw new Error("Rust resource upload plan targetSubPath 不一致"); + } + if (String(plan.assetType ?? "").trim() !== args.asset.asset_type) { + throw new Error("Rust resource upload plan assetType 不一致"); + } + const plannedName = String(plan.fileName ?? "").trim(); + const actualName = String(args.asset.file_name ?? "").trim(); + if (plannedName !== actualName) { + throw new Error("Rust resource upload plan fileName 不一致"); + } + if (typeof plan.fileSize === "number" && plan.fileSize !== args.asset.file_size) { + throw new Error("Rust resource upload plan fileSize 不一致"); + } + const plannedMime = String(plan.mimeType ?? "").trim(); + const actualMime = String(args.asset.mime_type ?? "").trim(); + if (plannedMime !== actualMime) { + throw new Error("Rust resource upload plan mimeType 不一致"); + } +} + +async function loadTransferAssets(ctx: MutationCtx, userId: string, assetIds: string[]) { + const assets: any[] = []; + for (const assetId of assetIds) { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", assetId)) + .first(); + if (!row || row.deleted_at || row.purged_at) { + continue; + } + await assertWorkspaceMember(ctx, userId, row.workspace_id); + assets.push(row); + } + return assets; +} + +async function loadExistingNames(ctx: MutationCtx, documentId: string) { + const existingRows = await ctx.db + .query("media_assets") + .withIndex("by_document", (q) => q.eq("document_id", documentId)) + .collect(); + return new Set( + existingRows.map((row) => String(row.file_name ?? "")).filter((name) => name.length > 0), + ); +} + +async function resolveTransferTarget(ctx: MutationCtx, userId: string, targetDocumentId: string) { + const targetDoc = await getCanonicalDocumentByBusinessId(ctx, targetDocumentId); + if (!targetDoc) { + throw new Error("目标页面不存在"); + } + await assertWorkspaceMember(ctx, userId, targetDoc.workspace_id); + return targetDoc; +} + +function buildTransferredAssetResult(row: any) { + return { + id: row.id, + workspace_id: row.workspace_id, + document_id: row.document_id, + asset_type: row.asset_type, + file_url: row.file_url ?? null, + thumbnail_url: row.thumbnail_url ?? row.file_url ?? null, + storage_id: row.storage_id ?? null, + bucket: row.bucket ?? null, + storage_path: row.storage_path ?? null, + file_name: row.file_name ?? null, + file_size: row.file_size ?? null, + mime_type: row.mime_type ?? null, + ocr_text: row.ocr_text ?? null, + ocr_status: row.ocr_status ?? null, + ocr_payload: row.ocr_payload, + ocr_strategy: row.ocr_strategy ?? null, + deleted_at: row.deleted_at ?? null, + deleted_by: row.deleted_by ?? null, + purged_at: row.purged_at ?? null, + signed_url: row.signed_url ?? null, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + export const getById = query({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { @@ -290,6 +485,8 @@ export const createWithStorage = mutation({ args: { userId: v.string(), storageId: v.id("_storage"), + targetSubPath: v.optional(v.union(v.string(), v.null())), + resourceUploadPlan: v.optional(v.any()), asset: v.object({ id: v.string(), workspace_id: v.string(), @@ -302,6 +499,11 @@ export const createWithStorage = mutation({ }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id); + validateResourceUploadPlan({ + asset: args.asset, + targetSubPath: args.targetSubPath, + resourceUploadPlan: args.resourceUploadPlan, + }); const doc = await getCanonicalDocumentByBusinessId(ctx, args.asset.document_id); @@ -324,7 +526,7 @@ export const createWithStorage = mutation({ thumbnail_url: url, storage_id: args.storageId, bucket: null, - storage_path: null, + storage_path: args.targetSubPath ? `${args.targetSubPath}/${args.asset.file_name ?? args.asset.id}` : null, file_name: args.asset.file_name, file_size: args.asset.file_size, mime_type: args.asset.mime_type, @@ -360,6 +562,128 @@ export const createWithStorage = mutation({ }, }); +export const batchCopy = mutation({ + args: { + userId: v.string(), + assetIds: v.array(v.string()), + targetDocumentId: v.string(), + targetSubPath: v.optional(v.union(v.string(), v.null())), + resourceTransferPlan: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const assetIds = uniqueStrings(args.assetIds); + if (assetIds.length === 0) { + throw new Error("缺少附件"); + } + + const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId); + validateResourceTransferPlan({ + action: "copy", + assetIds, + targetDocumentId: args.targetDocumentId, + targetSubPath: args.targetSubPath, + resourceTransferPlan: args.resourceTransferPlan, + }); + + const assets = await loadTransferAssets(ctx, args.userId, assetIds); + const existingNames = await loadExistingNames(ctx, args.targetDocumentId); + const items: any[] = []; + const ts = nowIso(); + + for (const asset of assets) { + const storageId = (asset.storage_id as any) ?? null; + if (!storageId) { + continue; + } + const id = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames); + const row = { + id, + workspace_id: String(targetDoc.workspace_id), + document_id: String(args.targetDocumentId), + asset_type: String(asset.asset_type ?? "file"), + file_url: asset.file_url ?? null, + thumbnail_url: asset.thumbnail_url ?? asset.file_url ?? null, + storage_id: storageId, + bucket: asset.bucket ?? null, + storage_path: asset.storage_path ?? null, + file_name: fileName, + file_size: typeof asset.file_size === "number" ? asset.file_size : null, + mime_type: (asset.mime_type ?? null) as any, + ocr_text: null, + ocr_status: shouldExtractAttachmentText({ + assetType: asset.asset_type, + mimeType: asset.mime_type, + fileName, + }) + ? "queued" + : null, + ocr_payload: undefined, + ocr_strategy: null, + deleted_at: null, + deleted_by: null, + purged_at: null, + created_by: args.userId, + created_at: ts, + updated_at: ts, + }; + await ctx.db.insert("media_assets", row); + if (row.ocr_status === "queued") { + await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: id, debounceMs: 800 }); + } + items.push(buildTransferredAssetResult(row)); + } + + return { items }; + }, +}); + +export const batchMove = mutation({ + args: { + userId: v.string(), + assetIds: v.array(v.string()), + targetDocumentId: v.string(), + targetSubPath: v.optional(v.union(v.string(), v.null())), + resourceTransferPlan: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const assetIds = uniqueStrings(args.assetIds); + if (assetIds.length === 0) { + throw new Error("缺少附件"); + } + + const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId); + validateResourceTransferPlan({ + action: "move", + assetIds, + targetDocumentId: args.targetDocumentId, + targetSubPath: args.targetSubPath, + resourceTransferPlan: args.resourceTransferPlan, + }); + + const assets = await loadTransferAssets(ctx, args.userId, assetIds); + const existingNames = await loadExistingNames(ctx, args.targetDocumentId); + const items: any[] = []; + + for (const asset of assets) { + const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames); + const patch = { + workspace_id: String(targetDoc.workspace_id), + document_id: String(args.targetDocumentId), + file_name: fileName, + updated_at: nowIso(), + }; + await ctx.db.patch(asset._id, patch); + items.push(buildTransferredAssetResult({ ...asset, ...patch })); + } + + return { items }; + }, +}); + export const patchById = mutation({ args: { userId: v.string(), diff --git a/wolai-frontend/src/app/api/media/batch/route.test.ts b/wolai-frontend/src/app/api/media/batch/route.test.ts new file mode 100644 index 00000000..c55a9ef2 --- /dev/null +++ b/wolai-frontend/src/app/api/media/batch/route.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockRequireAuthContext = vi.fn(); +const mockGetConvexAuthedHttpClient = vi.fn(); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockExecuteRustBridgeMutationTransport = vi.fn(); +const mockRecordRustBridgeCommandArtifacts = vi.fn(); +const mockMaterializeRustTreeStreamDelta = vi.fn(); +const mockMaterializeRustTreeDomainEventPlan = vi.fn(); +const mockReadRustTreeDomainEventType = vi.fn(); +const mockRecordBridgeCommandArtifacts = vi.fn(); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/auth/authContext", () => ({ + HttpError: class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, + requireAuthContext: () => mockRequireAuthContext(), +})); + +vi.mock("@/lib/convex/api", () => ({ + api: { + mediaAssets: { + listByIds: "mediaAssets:listByIds", + patchById: "mediaAssets:patchById", + }, + documents: { + getMeta: "documents:getMeta", + }, + }, +})); + +vi.mock("@/lib/convex/server", () => ({ + getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args), + buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args), + executeRustBridgeMutationTransport: (...args: unknown[]) => + mockExecuteRustBridgeMutationTransport(...args), + recordRustBridgeCommandArtifacts: (...args: unknown[]) => + mockRecordRustBridgeCommandArtifacts(...args), + materializeRustTreeStreamDelta: (...args: unknown[]) => + mockMaterializeRustTreeStreamDelta(...args), + materializeRustTreeDomainEventPlan: (...args: unknown[]) => + mockMaterializeRustTreeDomainEventPlan(...args), + readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args), +})); + +vi.mock("@/lib/documents/bridge-log", () => ({ + recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args), +})); + +vi.mock("@/lib/url/proxyForBrowser", () => ({ + maybeProxyForBrowserUrl: (_request: Request, url: string) => url, +})); + +describe("/api/media/batch route", () => { + beforeEach(() => { + vi.resetModules(); + mockIsConvexEnabled.mockReset().mockReturnValue(true); + mockRequireAuthContext.mockReset().mockResolvedValue({ + userId: "user_1", + }); + mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({ + requestId: "req_1", + traceId: "trace_1", + workspaceId: "ws_1", + actor: { actorType: "user", actorId: "user_1", sessionId: null }, + source: { channel: "next-route", client: "vitest" }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: false, + dryRun: false, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({ + ...(input as Record), + commandId: "cmd_asset_1", + idempotencyKey: null, + })); + mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({ + kind: "command", + commandName: "tree.resource.move", + commandId: "cmd_asset_1", + functionName: "mediaAssets:batchMove", + argsJson: { + domainEventPlan: { + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "tree.resource.moved", + }, + domainEventHint: { + family: "tree", + eventType: "tree.resource.moved", + }, + }, + }); + mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({ + items: [ + { + id: "asset_1", + workspace_id: "ws_1", + document_id: "doc_target", + asset_type: "file", + file_url: "/file.pdf", + thumbnail_url: "/file.pdf", + file_name: "file.pdf", + file_size: 1024, + mime_type: "application/pdf", + ocr_text: null, + ocr_status: null, + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + ], + }); + mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null); + mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({ + op: "upsert_assets", + upsertAssets: [{ id: "asset_1" }], + }); + mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({ + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "tree.resource.moved", + streamDelta: { + op: "upsert_assets", + upsertAssets: [{ id: "asset_1" }], + }, + }); + mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved"); + mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined); + }); + + it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => { + const client = { + query: vi.fn(async (name: string, args: Record) => { + if (name === "mediaAssets:listByIds") { + expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] }); + return [{ id: "asset_1", workspace_id: "ws_1" }]; + } + if (name === "documents:getMeta") { + expect(args).toEqual({ id: "doc_target" }); + return { id: "doc_target", workspace_id: "ws_1" }; + } + return null; + }), + mutation: vi.fn(), + }; + mockGetConvexAuthedHttpClient.mockResolvedValue(client); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://localhost/api/media/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "move", + assetIds: ["asset_1"], + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + items: [ + { + id: "asset_1", + document_id: "doc_target", + }, + ], + }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.resource.move", + payload: { + assetIds: ["asset_1"], + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + }, + target: { + workspaceId: "ws_1", + pageId: "doc_target", + }, + }), + ); + expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith( + expect.objectContaining({ + client, + plan: expect.objectContaining({ + functionName: "mediaAssets:batchMove", + }), + }), + ); + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( + expect.objectContaining({ + envelope: expect.objectContaining({ + name: "tree.resource.move", + }), + plan: expect.objectContaining({ + commandName: "tree.resource.move", + functionName: "mediaAssets:batchMove", + }), + result: expect.objectContaining({ + items: [expect.objectContaining({ id: "asset_1" })], + }), + }), + ); + expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled(); + }); +}); diff --git a/wolai-frontend/src/app/api/media/batch/route.ts b/wolai-frontend/src/app/api/media/batch/route.ts index 1a097e59..74d5b28c 100644 --- a/wolai-frontend/src/app/api/media/batch/route.ts +++ b/wolai-frontend/src/app/api/media/batch/route.ts @@ -1,12 +1,18 @@ import { NextResponse } from "next/server"; -import { makeUniqueFileName } from "@/lib/file-tree/naming"; -import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; -import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { api } from "@/lib/convex/api"; import { getConvexAuthedHttpClient } from "@/lib/convex/server"; import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, +} from "@/lib/documents/bridge"; +import { + recordRustBridgeCommandArtifacts, + resolveRustBridgeCommandPlan, + executeRustBridgeMutationTransport, +} from "@/lib/documents/rust-runtime"; export const dynamic = "force-dynamic"; @@ -19,41 +25,6 @@ interface BatchPayload { targetSubPath?: string; newName?: string; } - -const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace"; -const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents"; - -const parseStoragePath = (fileUrl: string) => { - try { - const url = new URL(fileUrl); - const segments = url.pathname.split("/").filter(Boolean); - const objectIdx = segments.findIndex((seg) => seg === "object"); - if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null; - if (segments[objectIdx + 1] === "public") { - const bucket = segments[objectIdx + 2]; - const path = segments.slice(objectIdx + 3).join("/"); - return { bucket, path }; - } - if (segments[objectIdx + 1] === "sign") { - const bucket = segments[objectIdx + 2]; - const path = segments.slice(objectIdx + 3).join("/"); - return { bucket, path }; - } - return null; - } catch { - return null; - } -}; - -function resolveAssetLocation(asset: any): { bucket: string; path: string } | null { - if (asset?.storage_path) { - return { bucket: asset.bucket || BUCKET, path: asset.storage_path }; - } - if (asset?.file_url) { - return parseStoragePath(asset.file_url); - } - return null; -} function sanitizeSubPath(input: string | undefined): string { const raw = typeof input === "string" ? input : ""; @@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string { return cleaned.join("/"); } +function sanitizeTransferredAssetForBrowser(request: Request, asset: any) { + return { + ...asset, + file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")), + thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")), + }; +} + export async function POST(request: Request) { if (isConvexEnabled()) { let auth; @@ -149,65 +128,57 @@ export async function POST(request: Request) { return NextResponse.json({ error: "目标页面不存在" }, { status: 404 }); } - const existing = (await client.query(api.mediaAssets.listByDocument, { - userId: auth.userId, - documentId: payload.targetDocumentId, - limit: 500, - })) as any[]; - const existingNames = new Set( - (existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean), - ); - - const results: any[] = []; - - for (const asset of assets) { - const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_"); - const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null; - if (!storageId) continue; - - if (payload.action === "copy") { - const newId = - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now()}_${Math.random().toString(16).slice(2)}`; - - const created = await client.mutation(api.mediaAssets.createWithStorage, { - userId: auth.userId, - storageId: storageId as any, - asset: { - id: newId, - workspace_id: String(targetDoc.workspace_id), - document_id: String(payload.targetDocumentId), - asset_type: String(asset.asset_type ?? "file"), - file_name: fileName, - file_size: typeof asset.file_size === "number" ? asset.file_size : null, - mime_type: (asset.mime_type ?? null) as any, - }, - }); - - results.push(created); - } else { - await client.mutation(api.mediaAssets.patchById, { - userId: auth.userId, - id: String(asset.id), - patch: { - workspace_id: String(targetDoc.workspace_id), - document_id: String(payload.targetDocumentId), - file_name: fileName, - }, - }); - - results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName }); - } + const workspaceId = String(targetDoc.workspace_id ?? "").trim(); + if (!workspaceId) { + return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); } - const safeItems = (results ?? []).map((a: any) => ({ - ...a, - file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")), - thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")), - })); + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + }); + const commandName = + payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move"; + const envelope = buildDocumentCommandEnvelope({ + name: commandName, + payload: { + assetIds: payload.assetIds, + targetDocumentId: payload.targetDocumentId, + targetSubPath: sanitizeSubPath(payload.targetSubPath), + }, + context, + target: { + workspaceId, + pageId: payload.targetDocumentId, + }, + reason: `media-batch ${commandName}`, + refs: ["file-tree-resource-command"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({ + client: client as never, + plan, + }); + try { + await recordRustBridgeCommandArtifacts({ + context, + envelope, + client: client as never, + plan, + result, + }); + } catch (error) { + console.warn("[media.batch] Rust bridge artifacts skipped:", error); + } - return NextResponse.json({ items: safeItems }); + const safeItems = (result.items ?? []).map((item: any) => + sanitizeTransferredAssetForBrowser(request, item), + ); + + return NextResponse.json({ items: safeItems }); } default: return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); diff --git a/wolai-frontend/src/app/api/media/upload/route.test.ts b/wolai-frontend/src/app/api/media/upload/route.test.ts new file mode 100644 index 00000000..9e74caef --- /dev/null +++ b/wolai-frontend/src/app/api/media/upload/route.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockRequireAuthContext = vi.fn(); +const mockGetConvexAuthedHttpClient = vi.fn(); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockRecordRustBridgeCommandArtifacts = vi.fn(); +const mockMaterializeRustTreeStreamDelta = vi.fn(); +const mockMaterializeRustTreeDomainEventPlan = vi.fn(); +const mockReadRustTreeDomainEventType = vi.fn(); +const mockRecordBridgeCommandArtifacts = vi.fn(); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/auth/authContext", () => ({ + HttpError: class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, + requireAuthContext: () => mockRequireAuthContext(), +})); + +vi.mock("@/lib/convex/api", () => ({ + api: { + mediaAssets: { + generateUploadUrl: "mediaAssets:generateUploadUrl", + createWithStorage: "mediaAssets:createWithStorage", + }, + }, +})); + +vi.mock("@/lib/convex/server", () => ({ + getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args), + buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args), + recordRustBridgeCommandArtifacts: (...args: unknown[]) => + mockRecordRustBridgeCommandArtifacts(...args), + materializeRustTreeStreamDelta: (...args: unknown[]) => + mockMaterializeRustTreeStreamDelta(...args), + materializeRustTreeDomainEventPlan: (...args: unknown[]) => + mockMaterializeRustTreeDomainEventPlan(...args), + readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args), +})); + +vi.mock("@/lib/documents/bridge-log", () => ({ + recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args), +})); + +vi.mock("@/lib/url/proxyForBrowser", () => ({ + maybeProxyForBrowserUrl: (_request: Request, url: string) => url, +})); + +describe("/api/media/upload route", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + mockIsConvexEnabled.mockReset().mockReturnValue(true); + mockRequireAuthContext.mockReset().mockResolvedValue({ + userId: "user_1", + }); + mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({ + requestId: "req_1", + traceId: "trace_1", + workspaceId: "ws_1", + actor: { actorType: "user", actorId: "user_1", sessionId: null }, + source: { channel: "next-route", client: "vitest" }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: false, + dryRun: false, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({ + ...(input as Record), + commandId: "cmd_upload_asset", + idempotencyKey: null, + })); + mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({ + kind: "command", + commandName: "tree.resource.upload", + commandId: "cmd_upload_asset", + functionName: "mediaAssets:createWithStorage", + workspaceId: "ws_1", + requestId: "req_1", + traceId: "trace_1", + actorId: "user_1", + idempotencyKey: null, + payloadJson: "{}", + argsJson: { + domainEventPlan: { + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "tree.resource.uploaded", + }, + assetId: "asset_upload_1", + workspaceId: "ws_1", + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + fileName: "demo.pdf", + fileSize: 7, + mimeType: "application/pdf", + assetType: "file", + resourceUploadPlan: { + action: "upload", + assetId: "asset_upload_1", + workspaceId: "ws_1", + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + fileName: "demo.pdf", + fileSize: 7, + mimeType: "application/pdf", + assetType: "file", + }, + }, + }); + mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({ + op: "upsert_assets", + upsertAssets: [{ id: "asset_upload_1" }], + }); + mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({ + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "tree.resource.uploaded", + streamDelta: { + op: "upsert_assets", + upsertAssets: [{ id: "asset_upload_1" }], + }, + }); + mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded"); + mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null); + mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined); + }); + + it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => { + const createdAsset = { + id: "asset_upload_1", + workspace_id: "ws_1", + document_id: "doc_target", + asset_type: "file", + file_url: "/files/demo.pdf", + thumbnail_url: "/files/demo.pdf", + file_name: "demo.pdf", + file_size: 7, + mime_type: "application/pdf", + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }; + const client = { + mutation: vi.fn(async (name: string, args: Record) => { + if (name === "mediaAssets:generateUploadUrl") { + expect(args).toEqual({ userId: "user_1" }); + return "https://convex.test/upload"; + } + if (name === "mediaAssets:createWithStorage") { + expect(args).toMatchObject({ + userId: "user_1", + storageId: "storage_1", + targetSubPath: "mindmaps/mind_1", + resourceUploadPlan: { + action: "upload", + assetId: "asset_upload_1", + targetDocumentId: "doc_target", + }, + asset: { + id: "asset_upload_1", + workspace_id: "ws_1", + document_id: "doc_target", + file_name: "demo.pdf", + }, + }); + return createdAsset; + } + return null; + }), + }; + mockGetConvexAuthedHttpClient.mockResolvedValue(client); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ storageId: "storage_1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const { POST } = await import("./route"); + const formData = new FormData(); + formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf"); + formData.set("workspaceId", "ws_1"); + formData.set("documentId", "doc_target"); + formData.set("mindmapId", "mind_1"); + + const response = await POST( + new Request("http://localhost/api/media/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + asset: { + id: "asset_upload_1", + document_id: "doc_target", + }, + mindmapUrl: "asset:asset_upload_1", + }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.resource.upload", + payload: expect.objectContaining({ + workspaceId: "ws_1", + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + fileName: expect.any(String), + }), + }), + ); + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( + expect.objectContaining({ + envelope: expect.objectContaining({ + name: "tree.resource.upload", + }), + plan: expect.objectContaining({ + commandName: "tree.resource.upload", + functionName: "mediaAssets:createWithStorage", + }), + result: expect.objectContaining({ + items: [expect.objectContaining({ id: "asset_upload_1" })], + }), + }), + ); + expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled(); + }); +}); diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index 66091007..906ee616 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -1,18 +1,21 @@ import { NextResponse } from "next/server"; import type { MediaAsset } from "@/types/media"; -import { extname } from "path"; -import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; -import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { api } from "@/lib/convex/api"; import { getConvexAuthedHttpClient } from "@/lib/convex/server"; import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, +} from "@/lib/documents/bridge"; +import { + recordRustBridgeCommandArtifacts, + resolveRustBridgeCommandPlan, +} from "@/lib/documents/rust-runtime"; export const dynamic = "force-dynamic"; -const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents"; - const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => { if (mime.startsWith("image/")) return "image"; if (mime.startsWith("video/")) return "video"; @@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => return "file"; }; +function isUploadFile(value: FormDataEntryValue | null): value is File { + return Boolean( + value && + typeof value === "object" && + (typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) && + typeof (value as File).arrayBuffer === "function" && + typeof (value as File).name === "string", + ); +} + +function readOptionalStringArg(args: Record, key: string): string | null { + const value = args[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readNumberArg(args: Record, key: string): number | null { + const value = args[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + export async function POST(request: Request) { if (isConvexEnabled()) { let auth; @@ -38,7 +61,7 @@ export async function POST(request: Request) { const documentId = String(formData.get("documentId") ?? ""); const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim(); - if (!(file instanceof File) || !workspaceId || !documentId) { + if (!isUploadFile(file) || !workspaceId || !documentId) { return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); } @@ -51,6 +74,35 @@ export async function POST(request: Request) { : `${Date.now()}_${Math.random().toString(16).slice(2)}`; const assetType = resolveAssetType(file.type || ""); const client = await getConvexAuthedHttpClient(); + const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined; + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.resource.upload", + payload: { + assetId, + workspaceId, + targetDocumentId: documentId, + targetSubPath, + fileName: file.name || null, + fileSize: file.size, + mimeType: file.type || null, + assetType, + }, + context, + target: { + workspaceId, + pageId: documentId, + }, + reason: "media-upload tree.resource.upload", + refs: ["file-tree-resource-upload"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); // 1) 获取 Convex 的上传 URL(短时有效) const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId }); @@ -78,16 +130,32 @@ export async function POST(request: Request) { const created = await client.mutation(api.mediaAssets.createWithStorage, { userId: auth.userId, storageId: storageId as any, + targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"), + resourceUploadPlan: + plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object" + ? plan.argsJson.resourceUploadPlan + : undefined, asset: { - id: assetId, - workspace_id: workspaceId, - document_id: documentId, - asset_type: assetType, - file_name: file.name || null, - file_size: file.size, - mime_type: file.type || null, + id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId, + workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId, + document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId, + asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType, + file_name: readOptionalStringArg(plan.argsJson, "fileName"), + file_size: readNumberArg(plan.argsJson, "fileSize"), + mime_type: readOptionalStringArg(plan.argsJson, "mimeType"), }, }); + try { + await recordRustBridgeCommandArtifacts({ + context, + envelope, + client: client as never, + plan, + result: { items: [created] }, + }); + } catch (error) { + console.warn("[media.upload] Rust bridge artifacts skipped:", error); + } const asset = created as unknown as MediaAsset; const safeAsset = { diff --git a/wolai-frontend/src/app/api/tree/commands/route.test.ts b/wolai-frontend/src/app/api/tree/commands/route.test.ts index 541b7893..ebead474 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.test.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.test.ts @@ -6,6 +6,7 @@ const mockBuildDocumentBridgeContext = vi.fn(); const mockBuildDocumentCommandEnvelope = vi.fn(); const mockResolveRustBridgeCommandPlan = vi.fn(); const mockExecuteRustBridgeMutationTransport = vi.fn(); +const mockRecordRustBridgeCommandArtifacts = vi.fn(); const mockRecordBridgeCommandArtifacts = vi.fn(); const mockRecordBridgeCommandFailureArtifacts = vi.fn(); const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => @@ -62,12 +63,112 @@ vi.mock("@/lib/documents/bridge", () => ({ }, buildDocumentBridgeContext: mockBuildDocumentBridgeContext, buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope, - documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args), + documentBridgeErrorResponse: (...args: Parameters) => + mockDocumentBridgeErrorResponse(...args), })); vi.mock("@/lib/documents/rust-runtime", () => ({ - resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args), - executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args), + resolveRustBridgeCommandPlan: (...args: Parameters) => + mockResolveRustBridgeCommandPlan(...args), + executeRustBridgeMutationTransport: (...args: Parameters) => + mockExecuteRustBridgeMutationTransport(...args), + recordRustBridgeCommandArtifacts: (...args: Parameters) => + mockRecordRustBridgeCommandArtifacts(...args), + readRustTreeDomainEventType: (plan: { argsJson?: Record }) => { + const eventPlan = plan.argsJson?.domainEventPlan as + | { + family?: string; + eventType?: string; + } + | undefined; + if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") { + return eventPlan.eventType; + } + const hint = plan.argsJson?.domainEventHint as + | { + family?: string; + eventType?: string; + } + | undefined; + return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null; + }, + materializeRustTreeDomainEventPlan: (input: { + plan: { argsJson?: Record }; + streamDelta?: Record | null; + }) => { + const eventPlan = input.plan.argsJson?.domainEventPlan as + | { + family?: string; + schema?: string; + schemaVersion?: number; + eventType?: string; + } + | undefined; + if ( + eventPlan?.family !== "tree" || + eventPlan.schema !== "mnote.tree.domain_event" || + eventPlan.schemaVersion !== 1 || + typeof eventPlan.eventType !== "string" + ) { + return null; + } + return { + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: eventPlan.eventType, + ...(input.streamDelta ? { streamDelta: input.streamDelta } : {}), + }; + }, + materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record }; result: unknown }) => { + const hint = input.plan.argsJson?.streamDeltaHint as + | { + family?: string; + kind?: string; + args?: Record; + } + | undefined; + if (hint?.family !== "tree" || !hint.kind) return null; + const args = hint.args ?? {}; + const result = input.result as Record; + if (hint.kind === "document_result") { + return result.document ? { op: "upsert_document", document: result.document } : null; + } + if (hint.kind === "upsert_document_patch") { + return { + op: "upsert_document", + document: { + id: args.documentId, + ...(args.patch as Record), + updated_at: result.updated_at ?? null, + }, + }; + } + if (hint.kind === "move_document") { + return { + op: "move_document", + documentId: args.documentId, + parentId: result.parent_id ?? args.parentId ?? null, + sortOrder: result.sort_order ?? args.sortOrder, + updatedAt: result.updated_at, + }; + } + if (hint.kind === "remove_document") { + return { op: "remove_document", documentId: args.documentId }; + } + if (hint.kind === "noop") { + return { op: "noop" }; + } + if (hint.kind === "copy_result") { + return { + op: "upsert_documents", + upsertDocuments: Array.isArray(result.items) + ? result.items.map((item) => item?.document).filter(Boolean) + : [], + }; + } + return null; + }, })); vi.mock("@/lib/documents/bridge-log", () => ({ @@ -92,6 +193,7 @@ describe("/api/tree/commands route", () => { mockBuildDocumentCommandEnvelope.mockReset(); mockResolveRustBridgeCommandPlan.mockReset(); mockExecuteRustBridgeMutationTransport.mockReset(); + mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null); mockRecordBridgeCommandArtifacts.mockReset(); mockRecordBridgeCommandFailureArtifacts.mockReset(); mockEnsureDocumentScaffold.mockReset(); @@ -146,7 +248,17 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "document_result", + args: { documentField: "document" }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.created", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ id: "doc_new", @@ -158,6 +270,18 @@ describe("/api/tree/commands route", () => { is_template: false, created_at: "2026-04-23T00:00:00Z", updated_at: "2026-04-23T00:00:00Z", + document: { + id: "doc_new", + workspace_id: "ws_root", + title: "无标题", + parent_id: null, + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-23T00:00:00Z", + updated_at: "2026-04-23T00:00:00Z", + }, }); const { POST } = await import("./route"); @@ -196,25 +320,26 @@ describe("/api/tree/commands route", () => { }), ); expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题"); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ + context: expect.objectContaining({ + requestId: "req_tree_1", + traceId: "trace_tree_1", + }), envelope: expect.objectContaining({ name: "tree.node.create", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "upsert_document", - document: expect.objectContaining({ - id: "doc_new", - workspace_id: "ws_root", - title: "无标题", - parent_id: null, - sort_order: 0, - }), - }, + plan: expect.objectContaining({ + commandName: "tree.node.create", + functionName: "documents:createWithParentReference", + }), + result: expect.objectContaining({ + id: "doc_new", + workspace_id: "ws_root", }), }), ); + expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled(); }); it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => { @@ -283,7 +408,21 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "move_document", + args: { + documentId: "doc_1", + parentId: "parent_1", + sortOrder: 2, + }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.subtree.moved", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true, @@ -392,27 +531,18 @@ describe("/api/tree/commands route", () => { }), ); expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled(); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.subtree.move", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "replace_documents", - documents: [ - { - id: "doc_1", - workspace_id: "ws_1", - parent_id: null, - }, - { - id: "parent_1", - workspace_id: "ws_1", - parent_id: null, - }, - ], - }, + plan: expect.objectContaining({ + commandName: "tree.subtree.move", + functionName: "documents:move", + }), + result: expect.objectContaining({ + parent_id: "parent_1", + sort_order: 1, }), }), ); @@ -482,7 +612,21 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "move_document", + args: { + documentId: "doc_1", + parentId: "parent_1", + sortOrder: 2, + }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.subtree.moved", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true, @@ -902,7 +1046,20 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "upsert_document_patch", + args: { + documentId: "doc_1", + patch: { title: "新标题" }, + }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.renamed", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true, @@ -949,19 +1106,16 @@ describe("/api/tree/commands route", () => { }), ); expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled(); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.node.rename", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "upsert_document", - document: expect.objectContaining({ - id: "doc_1", - title: "新标题", - }), - }, + plan: expect.objectContaining({ + commandName: "tree.node.rename", + }), + result: expect.objectContaining({ + updated_at: "2026-04-23T00:00:00Z", }), }), ); @@ -1000,7 +1154,17 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "remove_document", + args: { documentId: "doc_1" }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.archived", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true, @@ -1042,17 +1206,15 @@ describe("/api/tree/commands route", () => { }, }), ); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.node.archive", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "remove_document", - documentId: "doc_1", - }, + plan: expect.objectContaining({ + commandName: "tree.node.archive", }), + result: expect.any(Object), }), ); }); @@ -1112,7 +1274,17 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "noop", + args: {}, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.embedded", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ revision: 8, @@ -1170,16 +1342,15 @@ describe("/api/tree/commands route", () => { }), }), ); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.node.embed", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "noop", - }, + plan: expect.objectContaining({ + commandName: "tree.node.embed", }), + result: expect.any(Object), }), ); }); @@ -1222,7 +1393,17 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "copy_result", + args: { itemsField: "items", documentField: "document" }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.subtree.copied", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ items: [ @@ -1230,6 +1411,18 @@ describe("/api/tree/commands route", () => { oldId: "doc_1", newId: "doc_2", title: "复制页面", + document: { + id: "doc_2", + workspace_id: "ws_1", + title: "复制页面", + parent_id: "parent_1", + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-24T00:00:00Z", + updated_at: "2026-04-24T00:00:00Z", + }, }, ], }); @@ -1302,22 +1495,22 @@ describe("/api/tree/commands route", () => { ); expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面"); expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2"); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.subtree.copy", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "replace_documents", - documents: [], - }, + plan: expect.objectContaining({ + commandName: "tree.subtree.copy", + }), + result: expect.objectContaining({ + items: [expect.objectContaining({ newId: "doc_2" })], }), }), ); }); - it("restore action 走 tree.node.restore,并附带 replace_documents delta", async () => { + it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => { const client = { mutation: vi.fn(), query: vi.fn(async () => ({ @@ -1350,10 +1543,32 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "document_result", + args: { documentField: "document" }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.restored", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true, + document: { + id: "doc_restore_1", + workspace_id: "ws_1", + title: "恢复页面", + parent_id: null, + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-23T00:00:00Z", + updated_at: "2026-04-24T00:00:00Z", + }, updated_at: "2026-04-24T00:00:00Z", }); mockLoadSidebarDataFromConvex.mockResolvedValue({ @@ -1412,16 +1627,16 @@ describe("/api/tree/commands route", () => { }, }), ); - expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith( + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ name: "tree.node.restore", }), - commandPayload: expect.objectContaining({ - streamDelta: { - op: "replace_documents", - documents: [], - }, + plan: expect.objectContaining({ + commandName: "tree.node.restore", + }), + result: expect.objectContaining({ + document: expect.objectContaining({ id: "doc_restore_1" }), }), }), ); @@ -1476,7 +1691,17 @@ describe("/api/tree/commands route", () => { actorId: "user_1", idempotencyKey: null, payloadJson: "{}", - argsJson: {}, + argsJson: { + streamDeltaHint: { + family: "tree", + kind: "remove_document", + args: { documentId: "doc_1" }, + }, + domainEventHint: { + family: "tree", + eventType: "tree.node.archived", + }, + }, }); mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed")); diff --git a/wolai-frontend/src/app/api/tree/commands/route.ts b/wolai-frontend/src/app/api/tree/commands/route.ts index 885bc284..239a228b 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.ts @@ -13,7 +13,6 @@ import { documentBridgeErrorResponse, } from "@/lib/documents/bridge"; import { - recordBridgeCommandArtifacts, recordBridgeCommandFailureArtifacts, } from "@/lib/documents/bridge-log"; import { @@ -24,6 +23,7 @@ import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { executeRustBridgeMutationTransport, + recordRustBridgeCommandArtifacts, resolveRustBridgeCommandPlan, } from "@/lib/documents/rust-runtime"; @@ -57,6 +57,26 @@ type TreeCommandPayload = { items?: TreeCopyItem[] | null; }; +type TreeDeltaDocument = { + id: string; + workspace_id: string; + title: string | null; + parent_id: string | null; + sort_order: number | null; + is_starred: boolean | null; + access_scope: "private" | "shared" | "public"; + is_template: boolean; + created_at: string; + updated_at: string | null; +}; + +type TreeMutationResult = { + context: Awaited>; + envelope: ReturnType; + plan: Awaited>; + result: TResult; +}; + function trimOrNull(value: unknown) { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -76,37 +96,23 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function attachStreamDelta(commandPayload: unknown, streamDelta?: Record | null) { - if (!streamDelta) { - return commandPayload; - } - if (isRecord(commandPayload)) { - return { - ...commandPayload, - streamDelta, - }; - } - return { - payload: commandPayload, - streamDelta, - }; -} - async function recordTreeCommandSuccess(args: { context: Awaited>; envelope: ReturnType; client: Awaited>["client"]; - commandPayload?: unknown; + plan: Awaited>; + result: unknown; }) { try { - await recordBridgeCommandArtifacts({ + await recordRustBridgeCommandArtifacts({ context: args.context, envelope: args.envelope, client: args.client, - commandPayload: args.commandPayload, + plan: args.plan, + result: args.result, }); } catch (error) { - console.warn("[tree.commands] bridge success artifacts skipped:", error); + console.warn("[tree.commands] Rust bridge success artifacts skipped:", error); } } @@ -136,24 +142,6 @@ async function loadTreeCommandSidebarSnapshot(args: { } } -function buildTreeCommandSnapshotDelta( - sidebarSnapshot: unknown, -): Record | null { - if (!isRecord(sidebarSnapshot)) { - return null; - } - if (Array.isArray(sidebarSnapshot.documents)) { - return { - op: "replace_documents", - documents: sidebarSnapshot.documents, - }; - } - return { - op: "replace_sidebar", - sidebar: sidebarSnapshot, - }; -} - function buildTreeMovePreflightDataFromSidebarSnapshot( sidebarSnapshot: unknown, ): Record | null { @@ -203,6 +191,7 @@ async function resolveTreeMutationResult(args: { return { context, envelope, + plan, result, }; } catch (error) { @@ -275,8 +264,9 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) { const documentId = trimOrNull(payload.documentId) ?? randomUUID(); const title = normalizeTitle(payload.title); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ id: string; + document?: TreeDeltaDocument | null; title: string | null; parent_id: string | null; sort_order: number | null; @@ -300,27 +290,15 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); + const { context, envelope, result } = mutation; await ensureDocumentScaffold(result.id, result.title ?? title); await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta(envelope.payload, { - op: "upsert_document", - document: { - id: result.id, - workspace_id: result.workspace_id, - title: result.title ?? title, - parent_id: result.parent_id ?? parentId, - sort_order: result.sort_order ?? 0, - access_scope: result.access_scope, - is_starred: false, - is_template: result.is_template, - created_at: result.created_at, - updated_at: result.updated_at, - }, - }), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -356,7 +334,7 @@ async function handleMove(request: Request, payload: TreeCommandPayload) { workspaceId, }); const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ ok?: boolean; parent_id?: string | null; sort_order?: number | null; @@ -375,19 +353,13 @@ async function handleMove(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); - const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({ - client, - auth, - workspaceId, - }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta( - envelope.payload, - buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot), - ), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -426,8 +398,9 @@ async function handleRename(request: Request, payload: TreeCommandPayload) { const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const title = assertTitle(payload.title ?? null); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ ok?: boolean; + document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, @@ -441,18 +414,13 @@ async function handleRename(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta(envelope.payload, { - op: "upsert_document", - document: { - id: documentId, - title, - updated_at: result?.updated_at ?? null, - }, - }), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -478,8 +446,9 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) { } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ ok?: boolean; + document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, @@ -492,14 +461,13 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta(envelope.payload, { - op: "remove_document", - documentId, - }), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -516,7 +484,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) { } async function handleRestore(request: Request, payload: TreeCommandPayload) { - const { auth, client } = await getAuthedConvexClient(); + const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { @@ -524,8 +492,9 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) { } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ ok?: boolean; + document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, @@ -538,19 +507,13 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); - const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({ - client, - auth, - workspaceId, - }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta( - envelope.payload, - buildTreeCommandSnapshotDelta(sidebarSnapshot), - ), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -575,7 +538,7 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) { } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ ok?: boolean; purged?: boolean; purged_at?: string | null; @@ -589,14 +552,13 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) { pageId: documentId, client, }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta(envelope.payload, { - op: "remove_document", - documentId, - }), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -659,7 +621,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) { trimOrNull(sourceDoc.workspace_id) ?? trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ revision?: number | null; conflict_detection_key?: string | null; }>({ @@ -688,13 +650,13 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) { pageId: targetId, client, }); + const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta(envelope.payload, { - op: "noop", - }), + plan: mutation.plan, + result, }); return NextResponse.json({ @@ -712,7 +674,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) { } async function handleCopy(request: Request, payload: TreeCommandPayload) { - const { auth, client } = await getAuthedConvexClient(); + const { client } = await getAuthedConvexClient(); const normalizedItems = (payload.items ?? []) .filter((item) => item?.documentId) .map((item) => ({ @@ -747,11 +709,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) { return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); } - const { context, envelope, result } = await resolveTreeMutationResult<{ + const mutation = await resolveTreeMutationResult<{ items: Array<{ oldId: string; newId: string; title?: string | null; + document?: TreeDeltaDocument | null; }>; }>({ request, @@ -765,6 +728,7 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) { pageId: targetParentId, client, }); + const { context, envelope, result } = mutation; await Promise.all( (result.items ?? []).map(async (item) => { @@ -772,19 +736,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) { await copyMindmapFilesIfExists(item.oldId, item.newId); }), ); - const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({ - client, - auth, - workspaceId, - }); await recordTreeCommandSuccess({ context, envelope, client, - commandPayload: attachStreamDelta( - envelope.payload, - buildTreeCommandSnapshotDelta(sidebarSnapshot), - ), + plan: mutation.plan, + result, }); return NextResponse.json({ diff --git a/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.test.ts b/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.test.ts new file mode 100644 index 00000000..d9e71db1 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => + Response.json( + { + error: error instanceof Error ? error.message : String(error), + }, + { + status: + typeof (error as { status?: unknown })?.status === "number" + ? ((error as { status: number }).status ?? 500) + : 500, + }, + ), +); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: mockBuildDocumentBridgeContext, + buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope, + documentBridgeErrorResponse: (...args: Parameters) => + mockDocumentBridgeErrorResponse(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: Parameters) => + mockResolveRustBridgeCommandPlan(...args), +})); + +describe("/api/tree/filetree/delete-preflight route", () => { + beforeEach(() => { + mockIsConvexEnabled.mockReturnValue(true); + mockBuildDocumentBridgeContext.mockReset(); + mockBuildDocumentCommandEnvelope.mockReset(); + mockResolveRustBridgeCommandPlan.mockReset(); + mockDocumentBridgeErrorResponse.mockClear(); + }); + + it("应通过 Rust tree.filetree.delete.preflight 返回规范化 delete plan", async () => { + mockBuildDocumentBridgeContext.mockResolvedValue({ + requestId: "req_filetree_delete_1", + traceId: "trace_filetree_delete_1", + workspaceId: "ws_1", + actor: { + actorType: "user", + actorId: "user_1", + sessionId: null, + }, + source: { + channel: "next-route", + client: "wolai-frontend", + }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: true, + dryRun: true, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input); + mockResolveRustBridgeCommandPlan.mockResolvedValue({ + kind: "command", + commandName: "tree.filetree.delete.preflight", + commandId: "cmd_filetree_delete_1", + functionName: "tree:fileTreeDeletePreflight", + workspaceId: "ws_1", + requestId: "req_filetree_delete_1", + traceId: "trace_filetree_delete_1", + actorId: "user_1", + idempotencyKey: null, + payloadJson: "{}", + argsJson: { + fileTreeDeletePlan: { + rowIds: ["doc:doc_1", "asset:asset_1"], + docIds: ["doc_1"], + assetIds: ["asset_1"], + assetDocumentIds: ["doc_other"], + }, + }, + }); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://127.0.0.1:3000/api/tree/filetree/delete-preflight", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "ws_1", + rowIds: ["doc:doc_1", "asset:asset_1"], + rows: [], + documentParents: [], + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + requestId: "req_filetree_delete_1", + traceId: "trace_filetree_delete_1", + plan: { + rowIds: ["doc:doc_1", "asset:asset_1"], + docIds: ["doc_1"], + assetIds: ["asset_1"], + assetDocumentIds: ["doc_other"], + }, + }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.filetree.delete.preflight", + payload: expect.objectContaining({ + workspaceId: "ws_1", + rowIds: ["doc:doc_1", "asset:asset_1"], + }), + reason: "filetree-delete-preflight tree.filetree.delete.preflight", + refs: ["file-tree-shell"], + }), + ); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.ts b/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.ts new file mode 100644 index 00000000..2e7b4a5d --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/delete-preflight/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; + +type FileTreeDeletePreflightPayload = { + workspaceId?: string | null; + rowIds?: string[]; + rows?: unknown[]; + documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>; +}; + +function trimOrNull(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean); +} + +function readFileTreeDeletePlan(plan: Awaited>) { + const value = plan.argsJson.fileTreeDeletePlan; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Rust runtime 未返回 fileTreeDeletePlan"); + } + return value; +} + +export async function POST(request: Request) { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + try { + const payload = (await request.json()) as FileTreeDeletePreflightPayload; + const workspaceId = trimOrNull(payload.workspaceId); + const normalizedPayload = { + workspaceId, + rowIds: normalizeStringArray(payload.rowIds), + rows: Array.isArray(payload.rows) ? payload.rows : [], + documentParents: Array.isArray(payload.documentParents) + ? payload.documentParents.map((item) => ({ + documentId: trimOrNull(item?.documentId), + parentId: trimOrNull(item?.parentId), + })) + : [], + }; + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + validateOnly: true, + dryRun: true, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.filetree.delete.preflight", + payload: normalizedPayload, + context, + target: { + workspaceId, + }, + reason: "filetree-delete-preflight tree.filetree.delete.preflight", + refs: ["file-tree-shell"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + + return NextResponse.json({ + requestId: context.requestId, + traceId: context.traceId, + plan: readFileTreeDeletePlan(plan), + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } +} diff --git a/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.test.ts b/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.test.ts new file mode 100644 index 00000000..2b795dc6 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => + Response.json( + { + error: error instanceof Error ? error.message : String(error), + }, + { + status: + typeof (error as { status?: unknown })?.status === "number" + ? ((error as { status: number }).status ?? 500) + : 500, + }, + ), +); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: mockBuildDocumentBridgeContext, + buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope, + documentBridgeErrorResponse: (...args: Parameters) => + mockDocumentBridgeErrorResponse(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: Parameters) => + mockResolveRustBridgeCommandPlan(...args), +})); + +describe("/api/tree/filetree/drop-preflight route", () => { + beforeEach(() => { + mockIsConvexEnabled.mockReturnValue(true); + mockBuildDocumentBridgeContext.mockReset(); + mockBuildDocumentCommandEnvelope.mockReset(); + mockResolveRustBridgeCommandPlan.mockReset(); + mockDocumentBridgeErrorResponse.mockClear(); + }); + + it("应通过 Rust tree.filetree.drop.preflight 返回规范化 drop plan", async () => { + mockBuildDocumentBridgeContext.mockResolvedValue({ + requestId: "req_filetree_drop_1", + traceId: "trace_filetree_drop_1", + workspaceId: "ws_1", + actor: { + actorType: "user", + actorId: "user_1", + sessionId: null, + }, + source: { + channel: "next-route", + client: "wolai-frontend", + }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: true, + dryRun: true, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input); + mockResolveRustBridgeCommandPlan.mockResolvedValue({ + kind: "command", + commandName: "tree.filetree.drop.preflight", + commandId: "cmd_filetree_drop_1", + functionName: "tree:fileTreeDropPreflight", + workspaceId: "ws_1", + requestId: "req_filetree_drop_1", + traceId: "trace_filetree_drop_1", + actorId: "user_1", + idempotencyKey: null, + payloadJson: "{}", + argsJson: { + fileTreeDropPlan: { + copy: false, + targetDocumentId: "doc_target", + targetMindmapId: null, + targetSubPath: null, + rowIds: ["doc:doc_1"], + docIds: ["doc_1"], + topLevelDocIds: ["doc_1"], + copyableAssetIds: [], + sourceAssetDocumentIds: [], + documentTransferPlan: { + action: "move", + targetParentId: "doc_target", + documentIds: ["doc_1"], + topLevelDocumentIds: ["doc_1"], + copyItems: [{ documentId: "doc_1", recursive: true }], + }, + resourceTransferPlan: null, + }, + }, + }); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://127.0.0.1:3000/api/tree/filetree/drop-preflight", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "ws_1", + copy: false, + targetDocumentId: "doc_target", + targetRowId: null, + focusedRowId: null, + activeDocumentId: null, + rowIds: ["doc:doc_1"], + rows: [], + documentParents: [], + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + requestId: "req_filetree_drop_1", + traceId: "trace_filetree_drop_1", + plan: { + copy: false, + targetDocumentId: "doc_target", + targetMindmapId: null, + targetSubPath: null, + rowIds: ["doc:doc_1"], + docIds: ["doc_1"], + topLevelDocIds: ["doc_1"], + copyableAssetIds: [], + sourceAssetDocumentIds: [], + documentTransferPlan: { + action: "move", + targetParentId: "doc_target", + documentIds: ["doc_1"], + topLevelDocumentIds: ["doc_1"], + copyItems: [{ documentId: "doc_1", recursive: true }], + }, + resourceTransferPlan: null, + }, + }); + expect(mockBuildDocumentBridgeContext).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws_1", + validateOnly: true, + dryRun: true, + }), + ); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.filetree.drop.preflight", + payload: expect.objectContaining({ + workspaceId: "ws_1", + targetDocumentId: "doc_target", + rowIds: ["doc:doc_1"], + }), + reason: "filetree-drop-preflight tree.filetree.drop.preflight", + refs: ["file-tree-shell"], + }), + ); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.ts b/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.ts new file mode 100644 index 00000000..12a40259 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/drop-preflight/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; + +type FileTreeDropPreflightPayload = { + workspaceId?: string | null; + copy?: boolean; + targetDocumentId?: string | null; + targetRowId?: string | null; + focusedRowId?: string | null; + activeDocumentId?: string | null; + rowIds?: string[]; + rows?: unknown[]; + documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>; +}; + +function trimOrNull(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean); +} + +function readFileTreeDropPlan(plan: Awaited>) { + const value = plan.argsJson.fileTreeDropPlan; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Rust runtime 未返回 fileTreeDropPlan"); + } + return value; +} + +export async function POST(request: Request) { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + try { + const payload = (await request.json()) as FileTreeDropPreflightPayload; + const workspaceId = trimOrNull(payload.workspaceId); + const normalizedPayload = { + workspaceId, + copy: Boolean(payload.copy), + targetDocumentId: trimOrNull(payload.targetDocumentId), + targetRowId: trimOrNull(payload.targetRowId), + focusedRowId: trimOrNull(payload.focusedRowId), + activeDocumentId: trimOrNull(payload.activeDocumentId), + rowIds: normalizeStringArray(payload.rowIds), + rows: Array.isArray(payload.rows) ? payload.rows : [], + documentParents: Array.isArray(payload.documentParents) + ? payload.documentParents.map((item) => ({ + documentId: trimOrNull(item?.documentId), + parentId: trimOrNull(item?.parentId), + })) + : [], + }; + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + validateOnly: true, + dryRun: true, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.filetree.drop.preflight", + payload: normalizedPayload, + context, + target: { + workspaceId, + pageId: normalizedPayload.targetDocumentId ?? undefined, + }, + reason: "filetree-drop-preflight tree.filetree.drop.preflight", + refs: ["file-tree-shell"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + + return NextResponse.json({ + requestId: context.requestId, + traceId: context.traceId, + plan: readFileTreeDropPlan(plan), + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } +} diff --git a/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.test.ts b/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.test.ts new file mode 100644 index 00000000..4a065964 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => + Response.json( + { + error: error instanceof Error ? error.message : String(error), + }, + { + status: + typeof (error as { status?: unknown })?.status === "number" + ? ((error as { status: number }).status ?? 500) + : 500, + }, + ), +); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: mockBuildDocumentBridgeContext, + buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope, + documentBridgeErrorResponse: (...args: Parameters) => + mockDocumentBridgeErrorResponse(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: Parameters) => + mockResolveRustBridgeCommandPlan(...args), +})); + +describe("/api/tree/filetree/paste-preflight route", () => { + beforeEach(() => { + mockIsConvexEnabled.mockReturnValue(true); + mockBuildDocumentBridgeContext.mockReset(); + mockBuildDocumentCommandEnvelope.mockReset(); + mockResolveRustBridgeCommandPlan.mockReset(); + mockDocumentBridgeErrorResponse.mockClear(); + }); + + it("应通过 Rust tree.filetree.paste.preflight 返回规范化 paste plan", async () => { + mockBuildDocumentBridgeContext.mockResolvedValue({ + requestId: "req_filetree_paste_1", + traceId: "trace_filetree_paste_1", + workspaceId: "ws_1", + actor: { + actorType: "user", + actorId: "user_1", + sessionId: null, + }, + source: { + channel: "next-route", + client: "wolai-frontend", + }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: true, + dryRun: true, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input); + mockResolveRustBridgeCommandPlan.mockResolvedValue({ + kind: "command", + commandName: "tree.filetree.paste.preflight", + commandId: "cmd_filetree_paste_1", + functionName: "tree:fileTreePastePreflight", + workspaceId: "ws_1", + requestId: "req_filetree_paste_1", + traceId: "trace_filetree_paste_1", + actorId: "user_1", + idempotencyKey: null, + payloadJson: "{}", + argsJson: { + fileTreePastePlan: { + targetDocumentId: "doc_target", + targetMindmapId: "mind_1", + targetSubPath: "mindmaps/mind_1", + rowIds: ["index:doc_1", "asset:asset_1"], + docItems: [{ documentId: "doc_1", recursive: false }], + copyableAssetIds: ["asset_1"], + resourceTransferPlan: { + action: "copy", + assetIds: ["asset_1"], + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + }, + }, + }, + }); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://127.0.0.1:3000/api/tree/filetree/paste-preflight", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "ws_1", + targetDocumentId: null, + focusedRowId: "asset-folder:mind_1", + activeDocumentId: "doc_active", + rowIds: ["index:doc_1", "asset:asset_1"], + rows: [], + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + requestId: "req_filetree_paste_1", + traceId: "trace_filetree_paste_1", + plan: { + targetDocumentId: "doc_target", + targetMindmapId: "mind_1", + targetSubPath: "mindmaps/mind_1", + rowIds: ["index:doc_1", "asset:asset_1"], + docItems: [{ documentId: "doc_1", recursive: false }], + copyableAssetIds: ["asset_1"], + resourceTransferPlan: { + action: "copy", + assetIds: ["asset_1"], + targetDocumentId: "doc_target", + targetSubPath: "mindmaps/mind_1", + }, + }, + }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.filetree.paste.preflight", + payload: expect.objectContaining({ + workspaceId: "ws_1", + focusedRowId: "asset-folder:mind_1", + rowIds: ["index:doc_1", "asset:asset_1"], + }), + reason: "filetree-paste-preflight tree.filetree.paste.preflight", + refs: ["file-tree-shell"], + }), + ); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.ts b/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.ts new file mode 100644 index 00000000..f9051738 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/paste-preflight/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; + +type FileTreePastePreflightPayload = { + workspaceId?: string | null; + targetDocumentId?: string | null; + focusedRowId?: string | null; + activeDocumentId?: string | null; + rowIds?: string[]; + rows?: unknown[]; +}; + +function trimOrNull(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean); +} + +function readFileTreePastePlan(plan: Awaited>) { + const value = plan.argsJson.fileTreePastePlan; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Rust runtime 未返回 fileTreePastePlan"); + } + return value; +} + +export async function POST(request: Request) { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + try { + const payload = (await request.json()) as FileTreePastePreflightPayload; + const workspaceId = trimOrNull(payload.workspaceId); + const normalizedPayload = { + workspaceId, + targetDocumentId: trimOrNull(payload.targetDocumentId), + focusedRowId: trimOrNull(payload.focusedRowId), + activeDocumentId: trimOrNull(payload.activeDocumentId), + rowIds: normalizeStringArray(payload.rowIds), + rows: Array.isArray(payload.rows) ? payload.rows : [], + }; + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + validateOnly: true, + dryRun: true, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.filetree.paste.preflight", + payload: normalizedPayload, + context, + target: { + workspaceId, + pageId: normalizedPayload.targetDocumentId ?? undefined, + }, + reason: "filetree-paste-preflight tree.filetree.paste.preflight", + refs: ["file-tree-shell"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + + return NextResponse.json({ + requestId: context.requestId, + traceId: context.traceId, + plan: readFileTreePastePlan(plan), + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } +} diff --git a/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.test.ts b/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.test.ts new file mode 100644 index 00000000..f8b43ade --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => + Response.json( + { + error: error instanceof Error ? error.message : String(error), + }, + { + status: + typeof (error as { status?: unknown })?.status === "number" + ? ((error as { status: number }).status ?? 500) + : 500, + }, + ), +); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: mockBuildDocumentBridgeContext, + buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope, + documentBridgeErrorResponse: (...args: Parameters) => + mockDocumentBridgeErrorResponse(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: Parameters) => + mockResolveRustBridgeCommandPlan(...args), +})); + +describe("/api/tree/filetree/upload-target-preflight route", () => { + beforeEach(() => { + mockIsConvexEnabled.mockReturnValue(true); + mockBuildDocumentBridgeContext.mockReset(); + mockBuildDocumentCommandEnvelope.mockReset(); + mockResolveRustBridgeCommandPlan.mockReset(); + mockDocumentBridgeErrorResponse.mockClear(); + }); + + it("应通过 Rust tree.filetree.upload-target.preflight 返回规范化 upload target plan", async () => { + mockBuildDocumentBridgeContext.mockResolvedValue({ + requestId: "req_filetree_upload_target_1", + traceId: "trace_filetree_upload_target_1", + workspaceId: "ws_1", + actor: { + actorType: "user", + actorId: "user_1", + sessionId: null, + }, + source: { + channel: "next-route", + client: "wolai-frontend", + }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: true, + dryRun: true, + }); + mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input); + mockResolveRustBridgeCommandPlan.mockResolvedValue({ + kind: "command", + commandName: "tree.filetree.upload-target.preflight", + commandId: "cmd_filetree_upload_target_1", + functionName: "tree:fileTreeUploadTargetPreflight", + workspaceId: "ws_1", + requestId: "req_filetree_upload_target_1", + traceId: "trace_filetree_upload_target_1", + actorId: "user_1", + idempotencyKey: null, + payloadJson: "{}", + argsJson: { + fileTreeUploadTargetPlan: { + workspaceId: "ws_1", + targetDocumentId: "doc_target", + targetMindmapId: "mind_1", + targetSubPath: "mindmaps/mind_1", + }, + }, + }); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://127.0.0.1:3000/api/tree/filetree/upload-target-preflight", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "ws_fallback", + targetDocumentId: null, + targetRowId: "asset:asset_child_1", + focusedRowId: null, + activeDocumentId: "doc_active", + rows: [], + documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }], + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + requestId: "req_filetree_upload_target_1", + traceId: "trace_filetree_upload_target_1", + plan: { + workspaceId: "ws_1", + targetDocumentId: "doc_target", + targetMindmapId: "mind_1", + targetSubPath: "mindmaps/mind_1", + }, + }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.filetree.upload-target.preflight", + payload: expect.objectContaining({ + workspaceId: "ws_fallback", + targetRowId: "asset:asset_child_1", + activeDocumentId: "doc_active", + }), + reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight", + refs: ["file-tree-shell"], + }), + ); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.ts b/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.ts new file mode 100644 index 00000000..4190ba62 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/filetree/upload-target-preflight/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; + +type FileTreeUploadTargetPreflightPayload = { + workspaceId?: string | null; + targetDocumentId?: string | null; + targetRowId?: string | null; + focusedRowId?: string | null; + activeDocumentId?: string | null; + rows?: unknown[]; + documentWorkspaces?: Array<{ documentId?: string | null; workspaceId?: string | null }>; +}; + +function trimOrNull(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readFileTreeUploadTargetPlan( + plan: Awaited>, +) { + const value = plan.argsJson.fileTreeUploadTargetPlan; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Rust runtime 未返回 fileTreeUploadTargetPlan"); + } + return value; +} + +export async function POST(request: Request) { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + try { + const payload = (await request.json()) as FileTreeUploadTargetPreflightPayload; + const workspaceId = trimOrNull(payload.workspaceId); + const normalizedPayload = { + workspaceId, + targetDocumentId: trimOrNull(payload.targetDocumentId), + targetRowId: trimOrNull(payload.targetRowId), + focusedRowId: trimOrNull(payload.focusedRowId), + activeDocumentId: trimOrNull(payload.activeDocumentId), + rows: Array.isArray(payload.rows) ? payload.rows : [], + documentWorkspaces: Array.isArray(payload.documentWorkspaces) + ? payload.documentWorkspaces.map((item) => ({ + documentId: trimOrNull(item?.documentId), + workspaceId: trimOrNull(item?.workspaceId), + })) + : [], + }; + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + validateOnly: true, + dryRun: true, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.filetree.upload-target.preflight", + payload: normalizedPayload, + context, + target: { + workspaceId, + pageId: normalizedPayload.targetDocumentId ?? undefined, + }, + reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight", + refs: ["file-tree-shell"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + + return NextResponse.json({ + requestId: context.requestId, + traceId: context.traceId, + plan: readFileTreeUploadTargetPlan(plan), + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } +} diff --git a/wolai-frontend/src/app/api/tree/projections/file/route.test.ts b/wolai-frontend/src/app/api/tree/projections/file/route.test.ts new file mode 100644 index 00000000..eea49272 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/projections/file/route.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(); +const mockGetAuthedConvexClient = vi.fn(); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentQueryEnvelope = vi.fn(); +const mockResolveRustBridgeQueryPlan = vi.fn(); +const mockExecuteRustBridgeQueryTransport = vi.fn(); +const mockResolveKernelFileTreeProjection = vi.fn(); +const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) => + Response.json( + { + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ), +); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/convex/route", () => ({ + getAuthedConvexClient: () => mockGetAuthedConvexClient(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args), + buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args), + documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + executeRustBridgeQueryTransport: (...args: unknown[]) => + mockExecuteRustBridgeQueryTransport(...args), + resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args), +})); + +vi.mock("@/lib/server/kernel-file-tree", async () => { + const actual = await vi.importActual( + "@/lib/server/kernel-file-tree", + ); + return { + ...actual, + resolveKernelFileTreeProjection: (...args: unknown[]) => + mockResolveKernelFileTreeProjection(...args), + }; +}); + +describe("/api/tree/projections/file route", () => { + beforeEach(() => { + vi.resetModules(); + mockIsConvexEnabled.mockReset().mockReturnValue(true); + mockGetAuthedConvexClient.mockReset().mockResolvedValue({ + auth: { + userId: "user_1", + }, + client: { + query: vi.fn(), + mutation: vi.fn(), + }, + }); + mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({ + requestId: "req_1", + traceId: "trace_1", + }); + mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input); + mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({ + functionName: "sidebar:datasetList", + argsJson: { + workspaceId: "ws_1", + }, + }); + mockExecuteRustBridgeQueryTransport.mockReset().mockResolvedValue({ + active_workspace_id: "ws_1", + documents: [], + media_assets: [], + mindmap_assets: [], + table_assets: [], + mindmap_asset_children: {}, + }); + mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({ + projectionId: "kernel_projection:file_tree:page_root", + projection: "file_tree", + rootNodeId: "page_root", + items: [ + { + rowId: "doc:page_root", + nodeId: "page_root", + projectionKind: "file_tree", + rowKind: "document", + }, + { + rowId: "asset:table_1", + nodeId: "asset:table_1", + projectionKind: "file_tree", + rowKind: "asset", + }, + ], + edges: [], + }); + mockDocumentBridgeErrorResponse.mockClear(); + }); + + it("通过 3000 同源 route 返回 Rust file_tree 搜索 projection", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const { GET } = await import("./route"); + const response = await GET( + new Request( + "http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12", + { method: "GET" }, + ), + ); + + expect(response.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws_1", + rootNodeId: "page_root", + depth: 3, + query: "预算", + maxResults: 12, + }), + ); + const body = await response.json(); + expect(body).toMatchObject({ + ok: true, + result: { + projection: "file_tree", + rootNodeId: "page_root", + }, + }); + expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([ + "doc:page_root", + "asset:table_1", + ]); + }); + + it("Convex 未启用时返回 501", async () => { + mockIsConvexEnabled.mockReturnValue(false); + + const { GET } = await import("./route"); + const response = await GET( + new Request("http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1"), + ); + + expect(response.status).toBe(501); + expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" }); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/projections/file/route.ts b/wolai-frontend/src/app/api/tree/projections/file/route.ts new file mode 100644 index 00000000..4da362b8 --- /dev/null +++ b/wolai-frontend/src/app/api/tree/projections/file/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { + buildDocumentBridgeContext, + buildDocumentQueryEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { + executeRustBridgeQueryTransport, + resolveRustBridgeQueryPlan, +} from "@/lib/documents/rust-runtime"; +import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data"; +import { resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function readNumberParam(url: URL, name: string): number | null { + const raw = url.searchParams.get(name); + if (!raw?.trim()) { + return null; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +export async function GET(request: Request) { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + const url = new URL(request.url); + const workspaceId = url.searchParams.get("workspaceId")?.trim(); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + try { + const { auth, client } = await getAuthedConvexClient(); + const context = await buildDocumentBridgeContext({ + request, + workspaceId, + }); + const envelope = buildDocumentQueryEnvelope({ + name: "sidebar.dataset.list", + payload: { + workspaceId, + }, + }); + const plan = await resolveRustBridgeQueryPlan({ + context, + envelope, + }); + const dataset = await executeRustBridgeQueryTransport({ + client, + plan, + }); + const projection = await resolveKernelFileTreeProjection({ + client, + request, + workspaceId, + actor: { + actorType: "user", + actorId: auth.userId, + sessionId: null, + }, + dataset, + rootNodeId: url.searchParams.get("rootNodeId")?.trim() || null, + depth: readNumberParam(url, "depth"), + query: url.searchParams.get("query")?.trim() || null, + maxResults: readNumberParam(url, "maxResults"), + }); + + return NextResponse.json({ + ok: true, + requestId: context.requestId, + traceId: context.traceId, + result: projection, + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } +} diff --git a/wolai-frontend/src/app/api/tree/shell/route.test.ts b/wolai-frontend/src/app/api/tree/shell/route.test.ts index 959ef25f..2306eab0 100644 --- a/wolai-frontend/src/app/api/tree/shell/route.test.ts +++ b/wolai-frontend/src/app/api/tree/shell/route.test.ts @@ -20,7 +20,23 @@ describe("/api/tree/shell route", () => { vi.stubGlobal("fetch", mockFetch); }); - it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => { + it("未显式 debug 时不应再代理 3104 tree shell", async () => { + const { GET } = await import("./route"); + const response = await GET( + new Request( + "http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9", + ), + ); + + expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host", + }); + }); + + it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => { mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104"); mockBuildForwardHeaders.mockResolvedValue( new Headers({ @@ -43,7 +59,7 @@ describe("/api/tree/shell route", () => { const { GET } = await import("./route"); const response = await GET( new Request( - "http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9", + "http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1", { method: "GET", headers: { @@ -54,7 +70,7 @@ describe("/api/tree/shell route", () => { ); expect(mockFetch).toHaveBeenCalledWith( - "http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9", + "http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1", expect.objectContaining({ method: "GET", headers: expect.any(Headers), diff --git a/wolai-frontend/src/app/api/tree/shell/route.ts b/wolai-frontend/src/app/api/tree/shell/route.ts index feaf429a..42b466c6 100644 --- a/wolai-frontend/src/app/api/tree/shell/route.ts +++ b/wolai-frontend/src/app/api/tree/shell/route.ts @@ -24,6 +24,19 @@ const stripHopByHopHeaders = (headers: Headers) => { export async function GET(request: Request) { try { const requestUrl = new URL(request.url); + const debugEnabled = + requestUrl.searchParams.get("debug") === "1" || + requestUrl.searchParams.get("internal") === "1" || + process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1"; + if (!debugEnabled) { + return NextResponse.json( + { + error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host", + }, + { status: 404 }, + ); + } + const internalBaseUrl = await resolveMnoteWebInternalUrl(); const targetUrl = new URL("/tree", `${internalBaseUrl}/`); targetUrl.search = requestUrl.search; diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx index 80db7aa8..8b76d7f1 100644 --- a/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx @@ -458,7 +458,7 @@ describe("MoveEmbedPickerDialog", () => { const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]'); expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); - expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy"); + expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect( container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), ).not.toBeNull(); @@ -494,7 +494,7 @@ describe("MoveEmbedPickerDialog", () => { const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]'); expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); - expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy"); + expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect( container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), ).not.toBeNull(); @@ -658,7 +658,7 @@ describe("MoveEmbedPickerDialog", () => { 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(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(iframe).not.toBeNull(); expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); }); diff --git a/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts new file mode 100644 index 00000000..d04c78a3 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); + +describe("sidebar file tree delete preflight source", () => { + it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + + expect(source).toContain("preflightFileTreeDelete("); + expect(source).toContain("buildFileTreeShellDeletePreflightPayload("); + expect(source).not.toContain("computeFileTreeShellDeleteTargets("); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts new file mode 100644 index 00000000..f1120473 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); + +describe("sidebar file tree paste preflight source", () => { + it("rust_family 粘贴链应走 Rust paste preflight,而不是本地 shell row 语义推导", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + const preflightIndex = source.indexOf("preflightFileTreePaste("); + const rustBranchStart = source.lastIndexOf("if (isRustFamilyTreeRenderer) {", preflightIndex); + const legacyBranchStart = source.indexOf("const targetDocId = inferPasteTargetDocId", preflightIndex); + + expect(preflightIndex).toBeGreaterThanOrEqual(0); + expect(rustBranchStart).toBeGreaterThanOrEqual(0); + expect(legacyBranchStart).toBeGreaterThan(rustBranchStart); + const rustPasteBranch = source.slice(rustBranchStart, legacyBranchStart); + + expect(rustPasteBranch).toContain("preflightFileTreePaste("); + expect(rustPasteBranch).toContain("buildFileTreeShellPastePreflightPayload("); + expect(rustPasteBranch).toContain("pastePlan.docItems"); + expect(rustPasteBranch).toContain("pastePlan.resourceTransferPlan"); + expect(rustPasteBranch).not.toContain("docItemsMap"); + expect(rustPasteBranch).not.toContain("copyableAssetIds"); + expect(source).not.toContain("getOrderedFileTreeShellRows"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-selection-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-selection-source.test.ts new file mode 100644 index 00000000..2c471da7 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-selection-source.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); + +describe("sidebar file tree selection source", () => { + it("rust_family renderer selection snapshot 只能由 filetree selection event 写入", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + const writes = source.match(/setResourceRendererSelection\(/g) ?? []; + + expect(writes).toHaveLength(1); + expect(source).toContain("const [resourceRendererSelection, setResourceRendererSelection]"); + expect(source).toContain("const handleFileTreeShellSelectionChange = useCallback"); + expect(source).toContain("materializeRendererSelectionSnapshot"); + expect(source).not.toContain("selectedRowIds={resourceSelection.selectedRowIds}"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-upload-target-preflight-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-upload-target-preflight-source.test.ts new file mode 100644 index 00000000..00bf355a --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-upload-target-preflight-source.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); + +describe("sidebar file tree upload target preflight source", () => { + it("外部上传链应走 Rust upload target preflight,而不是在 Sidebar 解释目标行与工作区", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + const handlerStart = source.indexOf("const handleResourcePaneDropFiles = useCallback"); + const handlerEnd = source.indexOf("const handleResourcePaneInternalDrop = useCallback"); + + expect(handlerStart).toBeGreaterThanOrEqual(0); + expect(handlerEnd).toBeGreaterThan(handlerStart); + const handlerSource = source.slice(handlerStart, handlerEnd); + + expect(handlerSource).toContain("preflightFileTreeUploadTarget("); + expect(handlerSource).toContain("buildFileTreeShellUploadTargetPreflightPayload("); + expect(handlerSource).toContain("uploadTargetPlan.workspaceId"); + expect(handlerSource).toContain("uploadTargetPlan.targetDocumentId"); + expect(handlerSource).toContain("uploadTargetPlan.targetMindmapId"); + expect(handlerSource).not.toContain("resolveFileTreeShellMindmapTargetId"); + expect(handlerSource).not.toContain("inferFileTreeShellTargetDocumentId"); + expect(handlerSource).not.toContain("sidebarData.documents.find"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 15237366..116178b5 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -54,18 +54,38 @@ import { import { useSearchPaletteStore } from "@/store/search-palette"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useCurrentDocumentStore } from "@/store/current-document"; -import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows"; -import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd"; +import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree"; +import { buildVisibleRows } from "@/lib/file-tree/rows"; +import { fetchKernelFileTreeProjection } from "@/lib/file-tree/projection-client"; +import { + copyFileTreeResourceAssets, + deleteFileTreeResourceAssets, + moveFileTreeResourceAssets, + preflightFileTreeDelete, + preflightFileTreeInternalDrop, + preflightFileTreePaste, + preflightFileTreeUploadTarget, + renameFileTreeResourceAsset, + restoreFileTreeResourceAssets, + uploadFileTreeResourceAsset, +} from "@/lib/file-tree/resource-command-client"; +import { buildParentById } from "@/lib/file-tree/dnd"; import { isRealFileAsset } from "@/lib/file-tree/asset"; import { - computeFileTreeShellDeleteTargets, + buildFileTreeShellDeletePreflightPayload, + buildFileTreeShellInternalDropPreflightPayload, + buildFileTreeShellPastePreflightPayload, + buildFileTreeShellUploadTargetPreflightPayload, buildFileTreeShellRowById, buildFileTreeShellVisibleRowIds, + collectFileTreeShellAssetHints, type FileTreeShellRow, - inferFileTreeShellTargetDocumentId, - getOrderedFileTreeShellRows, - resolveFileTreeShellMindmapTargetId, } from "@/lib/file-tree/shell"; +import { + createEmptyFileTreeSelectionState, + materializeRendererSelectionSnapshot, + resolveActiveFileTreeSelection, +} from "@/lib/file-tree/selection-source"; import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog"; import { computeTreePaneDeleteTargets, @@ -263,11 +283,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( null, ); - const [resourceSelection, setResourceSelection] = useState(() => ({ - selectedRowIds: new Set(), - anchorRowId: null, - focusedRowId: null, - })); + const [legacyResourceSelection, setLegacyResourceSelection] = useState( + () => createEmptyFileTreeSelectionState(), + ); + const [resourceRendererSelection, setResourceRendererSelection] = useState( + () => createEmptyFileTreeSelectionState(), + ); + const [searchFileTreeProjection, setSearchFileTreeProjection] = + useState(null); + const [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState(null); const [sidebarHydrated, setSidebarHydrated] = useState(false); const treeSyncKeyRef = useRef(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree)); const mediaAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? [])); @@ -297,6 +321,42 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar pageTreeFocusedDocumentIdRef.current = activeId || null; }, [activeId]); + useEffect(() => { + const query = filter.trim(); + const workspaceId = sidebarData.activeWorkspaceId?.trim(); + if (!query || !workspaceId) { + setSearchFileTreeProjection(null); + setSearchFileTreeProjectionKey(null); + return; + } + + const requestKey = `${workspaceId}:${query}`; + let cancelled = false; + setSearchFileTreeProjectionKey(requestKey); + setSearchFileTreeProjection(null); + void fetchKernelFileTreeProjection({ + workspaceId, + query, + maxResults: 80, + }) + .then((projection) => { + if (cancelled) { + return; + } + setSearchFileTreeProjection(projection); + }) + .catch(() => { + if (cancelled) { + return; + } + setSearchFileTreeProjection(null); + }); + + return () => { + cancelled = true; + }; + }, [filter, sidebarData.activeWorkspaceId]); + useEffect(() => { const nextAssets = sidebarData.mediaAssets ?? []; const nextSyncKey = buildMediaAssetListSyncKey(nextAssets); @@ -594,22 +654,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const [expandedAssetFolders, setExpandedAssetFolders] = useState>(() => new Set()); + const normalizedFileTreeSearchQuery = filter.trim(); + const expectedSearchFileTreeProjectionKey = + normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId + ? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}` + : null; const resourceTreeShellItems = useMemo( () => - filter.trim().length === 0 - ? undefined - : filterKernelFileTreeProjectionItems({ - fileTreeItems: sidebarData.kernelFileTreeProjection.items, - visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)), - expandedDocumentIds: expanded, - expandedAssetFolderIds: expandedAssetFolders, - }), + expectedSearchFileTreeProjectionKey && + searchFileTreeProjectionKey === expectedSearchFileTreeProjectionKey + ? (searchFileTreeProjection?.items ?? []) + : undefined, [ - expanded, - expandedAssetFolders, - sidebarData.kernelFileTreeProjection.items, - visibleFilteredPrivatePageRows, - filter, + expectedSearchFileTreeProjectionKey, + searchFileTreeProjection, + searchFileTreeProjectionKey, ], ); @@ -619,8 +678,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const effectiveResourceTreeShellItems = useMemo( - () => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items, - [resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items], + () => + normalizedFileTreeSearchQuery.length > 0 + ? (resourceTreeShellItems ?? []) + : sidebarData.kernelFileTreeProjection.items, + [ + normalizedFileTreeSearchQuery, + resourceTreeShellItems, + sidebarData.kernelFileTreeProjection.items, + ], ); const resourceShellVisibleRowIds = useMemo( @@ -663,12 +729,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer ? resourceShellVisibleRowIds : resourceVisibleRowIds; + const resourceSelection = useMemo( + () => + resolveActiveFileTreeSelection({ + preferRendererSnapshot: isRustFamilyTreeRenderer, + legacySelection: legacyResourceSelection, + rendererSelection: resourceRendererSelection, + }), + [isRustFamilyTreeRenderer, legacyResourceSelection, resourceRendererSelection], + ); useEffect(() => { - setResourceSelection((prev) => + if (isRustFamilyTreeRenderer) { + return; + } + setLegacyResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds), ); - }, [resourceSelectionVisibleRowIds]); + }, [isRustFamilyTreeRenderer, resourceSelectionVisibleRowIds]); const docParentById = useMemo( () => @@ -680,6 +758,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ), [sidebarData.documents], ); + const documentWorkspaceById = useMemo( + () => + new Map( + (sidebarData.documents ?? []).map((doc) => [ + doc.id, + typeof doc.workspace_id === "string" ? doc.workspace_id : null, + ]), + ), + [sidebarData.documents], + ); const childrenCountByParentId = useMemo(() => { const map = new Map(); @@ -868,12 +956,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }, [activeId, editorBridge, router, setOpen]); const handleResourcePaneBlankMouseDown = useCallback(() => { - setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); + setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); }, []); const handleResourceRowClick = useCallback( (row: TreePaneRow, event: React.MouseEvent) => { - setResourceSelection((prev) => + setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "click", rowId: row.rowId, @@ -902,7 +990,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const handleResourceRowDragStart = useCallback((row: TreePaneRow) => { - setResourceSelection((prev) => { + setLegacyResourceSelection((prev) => { if (prev.selectedRowIds.has(row.rowId)) return prev; return reduceTreePaneSelection(prev, { type: "click", @@ -928,7 +1016,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar (row: TreePaneRow, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - setResourceSelection((prev) => + setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }), ); @@ -1047,25 +1135,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar anchorRowId: string | null; focusedRowId: string | null; }) => { - const normalized = normalizeTreePaneSelectionForVisibleRows( - { - selectedRowIds: new Set( - payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)), - ), - anchorRowId: - payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId) - ? payload.anchorRowId - : null, - focusedRowId: - payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId) - ? payload.focusedRowId - : null, - }, - resourceShellVisibleRowIds, + setResourceRendererSelection( + materializeRendererSelectionSnapshot({ + payload, + hasRowId: (rowId) => resourceShellRowById.has(rowId), + }), ); - setResourceSelection(normalized); }, - [resourceShellRowById, resourceShellVisibleRowIds], + [resourceShellRowById], ); const handleFileTreeShellAssetOpen = useCallback( @@ -1132,17 +1209,63 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } - const targetDocId = isRustFamilyTreeRenderer - ? inferFileTreeShellTargetDocumentId({ - focusedRowId: resourceSelection.focusedRowId, - rowById: resourceShellRowById, - activeDocId: activeId || null, - }) - : inferPasteTargetDocId({ - focusedRowId: resourceSelection.focusedRowId, - rowById: resourceRowById, - activeDocId: activeId || null, - }); + if (isRustFamilyTreeRenderer) { + let pastePlan; + try { + pastePlan = await preflightFileTreePaste( + buildFileTreeShellPastePreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + targetDocumentId: null, + focusedRowId: resourceSelection.focusedRowId, + activeDocId: activeId || null, + rowIds: payload.rowIds, + rowById: resourceShellRowById, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "文件树粘贴预检失败"; + setTimeout(() => window.alert(message), 0); + return; + } + + if (pastePlan.docItems.length > 0) { + try { + await copyTreeCommand({ + items: pastePlan.docItems, + targetParentId: pastePlan.targetDocumentId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴页面失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitDocumentsChanged(pastePlan.targetDocumentId); + } + + if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) { + try { + await copyFileTreeResourceAssets({ + assetIds: pastePlan.resourceTransferPlan.assetIds, + targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId, + targetSubPath: pastePlan.resourceTransferPlan.targetSubPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴附件失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId); + } + return; + } + + const targetDocId = inferPasteTargetDocId({ + focusedRowId: resourceSelection.focusedRowId, + rowById: resourceRowById, + activeDocId: activeId || null, + }); if (!targetDocId) { setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); return; @@ -1151,50 +1274,28 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const docItemsMap = new Map(); const copyableAssetIds: string[] = []; - if (isRustFamilyTreeRenderer) { - const rows = getOrderedFileTreeShellRows({ - rowIds: payload.rowIds, - visibleRowIds: resourceShellVisibleRowIds, - rowById: resourceShellRowById, - }); + const rows = payload.rowIds + .map((rowId) => resourceRowById.get(rowId as any)) + .filter(Boolean) as TreePaneRow[]; - rows.forEach((row) => { - if (row.rowKind === "doc") { - docItemsMap.set(row.documentId, true); - return; + 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); } - 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); }); - 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 { await copyTreeCommand({ @@ -1214,18 +1315,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } if (copyableAssetIds.length > 0) { - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "copy", + try { + await copyFileTreeResourceAssets({ assetIds: copyableAssetIds, targetDocumentId: targetDocId, - }), - }); - if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0); + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴附件失败"; + setTimeout(() => window.alert(message), 0); return; } await sidebarQuery.refetch(); @@ -1370,14 +1467,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const input = window.prompt("输入新文件名", asset.file_name ?? ""); if (!input || !input.trim()) return; const newName = input.trim(); - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "重命名失败"); + try { + await renameFileTreeResourceAsset({ assetId: asset.id, newName }); + } catch (error) { + window.alert(error instanceof Error ? error.message : "重命名失败"); return; } await sidebarQuery.refetch(); @@ -1399,18 +1492,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } const target = window.prompt("输入目标页面 ID", asset.document_id); if (!target || !target.trim()) return; - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "move", + try { + await moveFileTreeResourceAssets({ assetIds: [asset.id], targetDocumentId: target.trim(), - }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "移动失败"); + }); + } catch (error) { + window.alert(error instanceof Error ? error.message : "移动失败"); return; } await sidebarQuery.refetch(); @@ -1475,14 +1563,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } if (fileAssetIds.length > 0) { - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "删除失败"); + try { + await deleteFileTreeResourceAssets(fileAssetIds); + } catch (error) { + window.alert(error instanceof Error ? error.message : "删除失败"); return; } } @@ -1504,14 +1588,35 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ); const handleDeleteResourceSelection = useCallback(async () => { - const shellDeleteTargets = isRustFamilyTreeRenderer - ? computeFileTreeShellDeleteTargets({ - visibleRowIds: resourceShellVisibleRowIds, - rowById: resourceShellRowById, - selectedRowIds: resourceSelection.selectedRowIds, - parentById: docParentById, - }) - : null; + const selectedRowIds = Array.from(resourceSelection.selectedRowIds); + let shellDeleteTargets: { + docIds: string[]; + assetIds: string[]; + assetHints: MediaAsset[]; + } | null = null; + if (isRustFamilyTreeRenderer) { + try { + const deletePlan = await preflightFileTreeDelete( + buildFileTreeShellDeletePreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + rowIds: selectedRowIds, + rowById: resourceShellRowById, + parentById: docParentById, + }), + ); + shellDeleteTargets = { + docIds: deletePlan.docIds, + assetIds: deletePlan.assetIds, + assetHints: collectFileTreeShellAssetHints({ + rowById: resourceShellRowById, + assetIds: deletePlan.assetIds, + }), + }; + } catch (error) { + window.alert(error instanceof Error ? error.message : "文件树删除预检失败"); + return; + } + } const legacyDeleteTargets = !isRustFamilyTreeRenderer ? computeTreePaneDeleteTargets({ visibleRows: resourceRows, @@ -1595,7 +1700,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar await refreshTree(); setContextMenu(null); - setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); + if (!isRustFamilyTreeRenderer) { + setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); + } } catch (error) { window.alert(error instanceof Error ? error.message : "删除失败"); } @@ -1605,11 +1712,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar isRustFamilyTreeRenderer, resourceRows, resourceShellRowById, - resourceShellVisibleRowIds, resourceSelection.selectedRowIds, handleDeleteAssets, refreshTree, router, + sidebarData.activeWorkspaceId, ]); const handleResizeStart = useCallback( @@ -1764,59 +1871,43 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar void (async () => { const droppedFiles = Array.from(payload.files ?? []); if (droppedFiles.length === 0) return; - const targetRow = - payload.targetRowId - ? (resourceShellRowById.get(payload.targetRowId) ?? null) - : null; - const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow); - - if (targetMindmapId) { - setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId)); - } - - const inferredTargetDocId = - payload.targetDocumentId || - inferFileTreeShellTargetDocumentId({ - focusedRowId: resourceSelection.focusedRowId, - rowById: resourceShellRowById, - activeDocId: activeId || null, - }) || - ""; - - if (!inferredTargetDocId) { - setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0); + let uploadTargetPlan; + try { + uploadTargetPlan = await preflightFileTreeUploadTarget( + buildFileTreeShellUploadTargetPreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + targetDocumentId: payload.targetDocumentId, + targetRowId: payload.targetRowId, + focusedRowId: resourceSelection.focusedRowId, + activeDocId: activeId || null, + rowById: resourceShellRowById, + documentWorkspaceById, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "文件树上传目标预检失败"; + setTimeout(() => window.alert(message), 0); return; } - const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null; - const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? ""; - if (!workspaceId) { - setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0); - return; + if (uploadTargetPlan.targetMindmapId) { + setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId)); } const errors: string[] = []; for (const file of droppedFiles) { try { - const form = new FormData(); - form.append("file", file); - form.append("workspaceId", workspaceId); - form.append("documentId", inferredTargetDocId); - if (targetMindmapId) { - form.append("mindmapId", targetMindmapId); - } - const resp = await fetch("/api/media/upload", { method: "POST", body: form }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`); - continue; - } - const payload = (await resp.json()) as { asset?: MediaAsset }; + const payload = await uploadFileTreeResourceAsset({ + file, + workspaceId: uploadTargetPlan.workspaceId, + documentId: uploadTargetPlan.targetDocumentId, + mindmapId: uploadTargetPlan.targetMindmapId, + }); if (payload.asset?.id) { - emitAssetsChanged(inferredTargetDocId, payload.asset); + emitAssetsChanged(uploadTargetPlan.targetDocumentId, payload.asset); // 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区 - if (inferredTargetDocId === activeId && !targetMindmapId) { + if (uploadTargetPlan.targetDocumentId === activeId && !uploadTargetPlan.targetMindmapId) { editorBridge?.insertMediaAsset?.(payload.asset); } } else { @@ -1843,11 +1934,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }, [ activeId, + documentWorkspaceById, editorBridge, resourceShellRowById, resourceSelection.focusedRowId, sidebarData.activeWorkspaceId, - sidebarData.documents, sidebarQuery, ], ); @@ -1862,64 +1953,44 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar copy: boolean; }) => { void (async () => { - 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); + const preflightPayload = buildFileTreeShellInternalDropPreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + copy: payload.copy, + targetDocumentId: payload.targetDocumentId, + targetRowId: payload.targetRowId, + rowIds: payload.rowIds, + rowById: resourceShellRowById, + focusedRowId: resourceSelection.focusedRowId, + activeDocId: activeId || null, + parentById: docParentById, + }); + let dropPlan; + try { + dropPlan = await preflightFileTreeInternalDrop(preflightPayload); + } catch (error) { + const message = error instanceof Error ? error.message : "文件树拖放预检失败"; + setTimeout(() => window.alert(message), 0); return; } - const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow); - - const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined; + const { + targetDocumentId: targetDocId, + targetMindmapId, + documentTransferPlan, + resourceTransferPlan, + sourceAssetDocumentIds, + } = dropPlan; if (targetMindmapId) { setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId)); } - const uniqueRowIds: string[] = []; - const seen = new Set(); - payload.rowIds.forEach((id) => { - if (!id || seen.has(id)) return; - seen.add(id); - uniqueRowIds.push(id); - }); - - const rows = uniqueRowIds - .map((rowId) => resourceShellRowById.get(rowId) ?? null) - .filter((row): row is FileTreeShellRow => Boolean(row)); - - 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 (payload.copy) { - if (docIds.length > 0) { + if (documentTransferPlan && documentTransferPlan.copyItems.length > 0) { try { await copyTreeCommand({ - items: docIds.map((documentId) => ({ documentId, recursive: true })), - targetParentId: targetDocId, + items: documentTransferPlan.copyItems, + targetParentId: documentTransferPlan.targetParentId, }); } catch (error) { const message = error instanceof Error ? error.message : "复制页面失败"; @@ -1930,20 +2001,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar emitDocumentsChanged(targetDocId); } - if (copyableAssetIds.length > 0) { - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "copy", - assetIds: copyableAssetIds, - targetDocumentId: targetDocId, - targetSubPath, - }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0); + if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) { + try { + await copyFileTreeResourceAssets({ + assetIds: resourceTransferPlan.assetIds, + targetDocumentId: resourceTransferPlan.targetDocumentId, + targetSubPath: resourceTransferPlan.targetSubPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "复制附件失败"; + setTimeout(() => window.alert(message), 0); return; } await sidebarQuery.refetch(); @@ -1953,23 +2020,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } - const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById); - if (topLevelDocIds.length > 0) { + if (documentTransferPlan && documentTransferPlan.topLevelDocumentIds.length > 0) { + const topLevelDocIds = documentTransferPlan.topLevelDocumentIds; const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0; setTree((prev) => { let next = prev; topLevelDocIds.forEach((id, offset) => { - next = moveLocalNode(next, id, targetDocId, baseIndex + offset); + next = moveLocalNode(next, id, documentTransferPlan.targetParentId, baseIndex + offset); }); return next; }); - setExpanded((prev) => new Set(prev).add(targetDocId)); + setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId)); try { for (let i = 0; i < topLevelDocIds.length; i += 1) { await moveDocumentCommand({ documentId: topLevelDocIds[i], - parentId: targetDocId, + parentId: documentTransferPlan.targetParentId, position: baseIndex + i, }); } @@ -1983,29 +2050,20 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar emitDocumentsChanged(targetDocId); } - if (copyableAssetIds.length > 0) { - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "move", - assetIds: copyableAssetIds, - targetDocumentId: targetDocId, - targetSubPath, - }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0); + if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) { + try { + await moveFileTreeResourceAssets({ + assetIds: resourceTransferPlan.assetIds, + targetDocumentId: resourceTransferPlan.targetDocumentId, + targetSubPath: resourceTransferPlan.targetSubPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "移动附件失败"; + setTimeout(() => window.alert(message), 0); return; } await sidebarQuery.refetch(); - const sourceDocIds = new Set( - assetRows - .map((row) => row.asset?.document_id ?? null) - .filter((documentId): documentId is string => Boolean(documentId)), - ); - sourceDocIds.forEach((id) => emitAssetsChanged(id)); + sourceAssetDocumentIds.forEach((id) => emitAssetsChanged(id)); emitAssetsChanged(targetDocId); } })(); @@ -2013,6 +2071,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar [ childrenCountByParentId, docParentById, + sidebarData.activeWorkspaceId, resourceSelection.focusedRowId, activeId, resourceShellRowById, @@ -2091,12 +2150,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar try { await handleDeleteAssets(uniqueAssetIds, assetHint); - setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); + if (!isRustFamilyTreeRenderer) { + setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); + } } catch (error) { window.alert(error instanceof Error ? error.message : "删除失败"); } }, - [handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets], + [handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets], ); const handleConvertToChild = useCallback( @@ -2182,14 +2243,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar (filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ?? (sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ?? null; - const response = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "restore", assetIds: [assetId] }), - }); - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - window.alert(payload?.error ?? "恢复附件失败,请稍后再试"); + try { + await restoreFileTreeResourceAssets([assetId]); + } catch (error) { + window.alert(error instanceof Error ? error.message : "恢复附件失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); @@ -2822,7 +2879,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar rows={isRustFamilyTreeRenderer ? undefined : resourceRows} treeShellItems={effectiveResourceTreeShellItems} activeId={activeId} - selectedRowIds={resourceSelection.selectedRowIds} onRowClick={handleResourceRowClick} onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)} onRowContextMenu={handleResourceRowContextMenu} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx index 24596e27..dec32ce0 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx @@ -13,6 +13,8 @@ export type TreeRendererFamily = "react" | "rust_family"; export type TreeShellHostMode = "page" | "filetree" | "picker"; +const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1"; + export type TreeShellPickerCommand = { kind: "next" | "previous" | "home" | "end" | "pick"; seq: number; @@ -118,8 +120,9 @@ export function TreeShellHost({ const useRustHost = rendererFamily === "rust_family"; const useIframeHost = useRustHost && Boolean(workspaceId?.trim()); const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react"; + const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined; const implementation = useIframeHost - ? "mnote_web_iframe_proxy" + ? "rust_inline_compat_host" : fallbackImplementation ?? (rendererFamily === "rust_family" ? "react_fallback" : "react_primary"); @@ -130,6 +133,7 @@ export function TreeShellHost({ data-renderer-family={rendererFamily} data-tree-host-kind={hostKind} data-tree-host-implementation={implementation} + data-tree-renderer-contract={rendererContract} data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined} className={cn(className)} > @@ -139,6 +143,7 @@ export function TreeShellHost({ data-tree-host-mode={mode} data-tree-host-kind="rust_family" data-tree-host-implementation={implementation} + data-tree-renderer-contract={rendererContract} className="contents" > {useIframeHost && workspaceId ? ( 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 d38e845b..674f64a5 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 @@ -17,6 +17,24 @@ import { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +function readTreeShellState(srcDoc: string | null | undefined) { + const match = (srcDoc ?? "").match( + /