use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id_from_root_uri, LocalAccessMode, }; use crate::routes::query_support::{ fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use crate::transport::convex::execute_retired_mutation_by_name; use crate::tree_shell::filetree_renderer::{ render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow, }; use crate::tree_shell::filetree_selection::FileTreeSelectionState; use crate::tree_shell::page_renderer::{ render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow, }; use crate::tree_shell::picker_renderer::{ render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow, }; use crate::tree_shell::renderer_input::{ FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, TreeShellRendererInput, }; use crate::tree_shell::runtime_api::{ reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest, TreeShellRuntimeResult, }; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeCommandEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static TREE_DOCUMENT_COUNTER: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TreeShellQuery { pub workspace_id: Option, pub root_node_id: Option, pub depth: Option, pub active_document_id: Option, pub focused_document_id: Option, pub active_picker_item_key: Option, pub actor_id: Option, pub channel: Option, pub host: Option, pub mode: Option, pub source_kind: Option, pub root_uri: Option, pub allow_root_pick: Option, pub exclude_ids: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalFolderWatchQuery { pub root_uri: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TreeCommandEnvelope { pub action: String, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, #[serde(default)] pub source_capabilities: Vec, pub target_node_id: Option, pub target_resource_meta: Option, pub selection: Option, pub operation: Option, pub batch_id: Option, pub document_id: Option, pub parent_id: Option, pub target_parent_id: Option, pub title: Option, pub access_scope: Option, pub content: Option, pub sort_order: Option, #[serde(default)] pub items: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TreeCommandCopyItem { pub document_id: String, #[serde(default)] pub recursive: bool, } #[derive(Debug, Clone, Default)] pub struct TreeCommandEnvelopeContext { pub source_kind: Option, pub root_uri: Option, pub source_capabilities: Vec, pub target_node_id: Option, pub target_resource_meta: Option, pub selection: Option, pub operation: Option, pub batch_id: Option, } impl TreeCommandEnvelopeContext { fn from_envelope(envelope: &TreeCommandEnvelope) -> Self { Self { source_kind: read_optional_non_empty(envelope.source_kind.clone()), root_uri: read_optional_non_empty(envelope.root_uri.clone()), source_capabilities: envelope .source_capabilities .iter() .filter_map(|capability| read_optional_non_empty(Some(capability.clone()))) .collect(), target_node_id: read_optional_non_empty(envelope.target_node_id.clone()), target_resource_meta: envelope.target_resource_meta.clone(), selection: envelope.selection.clone(), operation: read_optional_non_empty(envelope.operation.clone()), batch_id: read_optional_non_empty(envelope.batch_id.clone()), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub struct TreeCompatAliasCatalogEntry { pub compat_command: &'static str, pub preferred_command: &'static str, pub source_kind: &'static str, pub retained_for: &'static str, pub retirement_condition: &'static str, } #[allow(dead_code)] pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[ TreeCompatAliasCatalogEntry { compat_command: "documents.create", preferred_command: "tree.node.create", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.node.create", }, TreeCompatAliasCatalogEntry { compat_command: "documents.title.update", preferred_command: "tree.node.rename", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.node.rename", }, TreeCompatAliasCatalogEntry { compat_command: "documents.move", preferred_command: "tree.subtree.move", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.subtree.move", }, TreeCompatAliasCatalogEntry { compat_command: "documents.delete", preferred_command: "tree.node.archive", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.node.archive", }, TreeCompatAliasCatalogEntry { compat_command: "documents.restore", preferred_command: "tree.node.restore", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.node.restore", }, TreeCompatAliasCatalogEntry { compat_command: "documents.purge", preferred_command: "tree.node.purge", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.node.purge", }, TreeCompatAliasCatalogEntry { compat_command: "documents.copy_tree", preferred_command: "tree.subtree.copy", source_kind: "convex_workspace", retained_for: "legacy cloud / remote callers still emitting documents.*", retirement_condition: "all cloud and remote callers emit tree.subtree.copy", }, ]; #[derive(Debug)] pub enum TreeCommandRequest { Create { workspace_id: Option, document_id: String, parent_id: Option, title: String, access_scope: Option, content: Option, }, CreateFolder { workspace_id: Option, document_id: String, parent_id: Option, title: String, }, Rename { workspace_id: Option, document_id: String, title: String, }, Move { workspace_id: Option, document_id: String, parent_id: Option, sort_order: i64, }, Archive { workspace_id: Option, document_id: String, }, Restore { workspace_id: Option, document_id: String, }, Copy { workspace_id: Option, document_id: String, target_parent_id: Option, items: Vec, title: Option, }, DropFiles { workspace_id: Option, parent_id: Option, files_json: String, }, Purge { workspace_id: Option, document_id: String, }, } fn escape_html(input: &str) -> String { input .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn escape_inline_json(input: &str) -> String { input .replace('&', "\\u0026") .replace('<', "\\u003c") .replace('>', "\\u003e") } fn normalize_title(value: Option) -> String { let trimmed = value.unwrap_or_default().trim().to_string(); if trimmed.is_empty() { "无标题".into() } else { trimmed } } fn normalize_channel(value: Option) -> String { let trimmed = value.unwrap_or_default().trim().to_string(); if trimmed.is_empty() { "mnote-tree-shell-v1".into() } else { trimmed } } fn normalize_tree_mode(value: Option<&str>) -> &'static str { match value.unwrap_or_default().trim() { "picker" => "picker", "filetree" => "filetree", _ => "page", } } fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool { match value .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "1" | "true" | "yes" | "on" => true, "0" | "false" | "no" | "off" => false, _ => default, } } fn build_workspace_source_wire( context: &RequestContext, workspace_id: &str, envelope_context: &TreeCommandEnvelopeContext, ) -> bridge_runtime::RuntimeSourceWire { let source_kind = envelope_context .source_kind .clone() .unwrap_or_else(|| "convex_workspace".into()); let root_uri = envelope_context.root_uri.clone().or_else(|| { if source_kind == "convex_workspace" { Some(format!("convex://workspace/{workspace_id}")) } else { None } }); let capabilities = if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" { vec![ "load-snapshot".into(), "preflight-command".into(), "execute-command".into(), "resolve-page-aggregate".into(), ] } else { envelope_context.source_capabilities.clone() }; bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: Some(source_kind), root_uri, workspace_id: Some(workspace_id.to_string()), capabilities, } } fn attach_tree_command_envelope_context( mut payload: Value, envelope_context: &TreeCommandEnvelopeContext, ) -> Value { if let Some(map) = payload.as_object_mut() { if let Some(target_node_id) = envelope_context.target_node_id.as_ref() { map.insert("targetNodeId".into(), json!(target_node_id)); } if let Some(target_resource_meta) = envelope_context.target_resource_meta.as_ref() { map.insert("targetResourceMeta".into(), target_resource_meta.clone()); } if let Some(selection) = envelope_context.selection.as_ref() { map.insert("selection".into(), selection.clone()); } if let Some(operation) = envelope_context.operation.as_ref() { map.insert("operation".into(), json!(operation)); } } payload } fn parse_exclude_ids(value: Option<&str>) -> Vec { value .unwrap_or_default() .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .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() } pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec { let mut rows: 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") .or_else(|| item.get("parentId")) .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), openable: item .get("resourceMeta") .and_then(Value::as_object) .and_then(|meta| meta.get("documentId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .is_some() || !node_id.starts_with("local-dir:"), }) }) .collect() }) .unwrap_or_default(); let parent_by_id = rows .iter() .map(|row| (row.node_id.clone(), row.parent_node_id.clone())) .collect::>(); for row in &mut rows { if row.depth == 0 && row.parent_node_id.is_some() { let mut depth = 0_u32; let mut cursor = row.parent_node_id.as_deref(); while let Some(parent_id) = cursor { depth += 1; cursor = parent_by_id .get(parent_id) .and_then(|parent| parent.as_deref()); if depth > 32 { break; } } row.depth = depth; } } rows } fn normalize_filetree_mindmap_title(raw_title: &str) -> String { let title = raw_title.trim(); if title.is_empty() { "无标题".to_string() } else { title.to_string() } } pub(crate) fn collect_filetree_render_rows( projection: &Value, active_document_id: Option<&str>, active_row_id: Option<&str>, ) -> Vec { let active_document_id = active_document_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let active_row_id = active_row_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let selected_ids = active_row_id .as_deref() .map(|row_id| BTreeSet::from([row_id.to_string()])) .or_else(|| { active_document_id .as_deref() .map(|document_id| BTreeSet::from([format!("doc:{document_id}")])) }) .unwrap_or_default(); let allow_document_fallback = active_row_id.is_none(); let active_document_id_for_fallback = active_document_id .as_deref() .filter(|_| allow_document_fallback); 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); let 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); let selected = selected_ids.contains(row_id) || active_document_id_for_fallback.is_some_and(|active_id| { document_id.as_deref() == Some(active_id) && item .get("rowKind") .and_then(Value::as_str) .is_some_and(|kind| kind == "document") }); let row_kind = item .get("rowKind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("document") .to_string(); let 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); let relative_path = item .get("relativePath") .and_then(Value::as_str) .or_else(|| { resource_meta .and_then(|meta| meta.get("workspacePath")) .and_then(|workspace_path| workspace_path.get("relativePath")) .and_then(Value::as_str) }) .or_else(|| { resource_meta .and_then(|meta| meta.get("extra")) .and_then(|extra| extra.get("source")) .and_then(|source| source.get("relativePath")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let 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(); let raw_title = item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题"); Some(FileTreeRenderRow { row_id: row_id.to_string(), row_kind: row_kind.clone(), 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: normalize_filetree_mindmap_title(raw_title), 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, document_id, asset_id, relative_path, object_identity: resource_meta .and_then(|meta| meta.get("objectIdentity")) .and_then(|value| serde_json::to_string(value).ok()), index_status: item .get("indexStatus") .and_then(Value::as_str) .or_else(|| { resource_meta .and_then(|meta| meta.get("indexStatus")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| { *value == "indexed" || *value == "indexing" || *value == "failed" }) .map(ToOwned::to_owned), selected, }) }) .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", "tree.resource.archive", "tree.resource.restore", "tree.resource.purge", "tree.resource.rename", ] .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}")]) }) .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 .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if let Some(actor_id) = actor_id { next.auth.actor_id = actor_id; next.auth.actor_type = "user".into(); } next } fn generate_tree_document_id() -> String { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); let counter = TREE_DOCUMENT_COUNTER.fetch_add(1, Ordering::Relaxed); format!("tree_{millis}_{counter}") } fn build_tree_shell_html( workspace_id: &str, root_node_id: Option<&str>, active_document_id: Option<&str>, focused_document_id: Option<&str>, active_picker_item_key: Option<&str>, channel: &str, host: Option<&str>, context: &RequestContext, projection: &Value, mode: &str, allow_root_pick: bool, exclude_ids: &[String], dataset: &Value, ) -> String { let source_kind = projection .get("sourceKind") .and_then(Value::as_str) .unwrap_or("convex_workspace"); let root_uri = projection .get("rootUri") .and_then(Value::as_str) .unwrap_or(""); let watch_revision = projection .get("watchRevision") .cloned() .unwrap_or(Value::Null); 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, "activeDocumentId": active_document_id, "focusedDocumentId": focused_document_id, "activePickerItemKey": active_picker_item_key, "actorId": context.auth.actor_id, "channel": channel, "host": host, "mode": mode, "sourceKind": source_kind, "rootUri": root_uri, "localWatchRevision": watch_revision.clone(), "allowRootPick": allow_root_pick, "excludeIds": exclude_ids, "rendererInput": renderer_input, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "commandPath": "/api/tree/commands", "items": projection.get("items").cloned().unwrap_or_else(|| Value::Array(Vec::new())), "mediaAssets": dataset.get("media_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())), "mindmapAssets": dataset.get("mindmap_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())), "tableAssets": dataset.get("table_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())), "mindmapAssetChildren": dataset.get("mindmap_asset_children").cloned().unwrap_or_else(|| json!({})), }); 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 tree_live_bootstrap = serde_json::json!({ "schema": "mnote.tree_live_bootstrap.v1", "disabled": false, "transport": if source_kind == "local_folder" { "local-folder-events" } else { "tree-live-ws" }, "endpoint": "/api/tree/events", "wsEndpoint": "/api/realtime/ws", "workspaceId": workspace_id, "rootIds": root_node_id .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| vec![value]) .unwrap_or_default(), "initialRevision": watch_revision, }); let tree_live_bootstrap_json = serde_json::to_string(&tree_live_bootstrap).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, None), }), "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##" mnote Tree Shell
Sidebar / Page Tree
workspace=__WORKSPACE_ID__ · root=__ROOT_LABEL__ · active=__ACTIVE_LABEL__
__INITIAL_TREE_HTML__
"##; template .replace("__WORKSPACE_ID__", &escape_html(workspace_id)) .replace("__ROOT_LABEL__", &escape_html(root_label)) .replace("__ACTIVE_LABEL__", &escape_html(active_label)) .replace("__ROOT_URI__", &escape_html(root_uri)) .replace("__PROJECTION_JSON__", &escape_html(&projection_json)) .replace("__INITIAL_TREE_HTML__", &initial_tree_html) .replace("__APP_STATE__", &escape_inline_json(&app_state_json)) .replace( "__TREE_SHELL_RUNTIME_SRC__", &crate::routes::web_shell::mnote_browser_runtime_src("tree-shell-runtime.js"), ) .replace( "__TREE_LIVE_CONTROLLER_SRC__", &crate::routes::web_shell::mnote_browser_runtime_src("tree-live-controller.js"), ) .replace( "__TREE_LIVE_BOOTSTRAP__", &escape_inline_json(&tree_live_bootstrap_json), ) } fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { ( StatusCode::OK, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "result": result, })), ) } pub async fn local_folder_watch( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let revision = local_folder_watch_revision(&query.root_uri)?; Ok(json_response( &context, json!({ "sourceKind": "local_folder", "rootUri": revision.root_uri, "revision": revision.revision, "entryCount": revision.entry_count, "latestModifiedMs": revision.latest_modified_ms, }), )) } pub async fn filetree_drop_preflight( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, Json), WebError> { let workspace_id = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()) .ok_or_else(|| { WebError::bad_request_code( "filetree_drop_preflight_workspace_missing", "缺少 workspaceId", ) .with_context(&context) })?; let target_document_id = payload .get("targetDocumentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let envelope_context = TreeCommandEnvelopeContext::default(); let command = RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: format!("filetree_drop_preflight_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(&context, &workspace_id, &envelope_context), target: Some(build_tree_target(&workspace_id, target_document_id, None)), payload, preflight_data: None, reason: Some("filetree-drop-preflight tree.filetree.drop.preflight".into()), refs: vec!["file-tree-shell".into()], dry_run: true, validate_only: true, }; let plan = build_runtime_command_plan(&context, Some(&workspace_id), command)?; let file_tree_drop_plan = plan .args_json .get("fileTreeDropPlan") .cloned() .ok_or_else(|| { WebError::internal("filetree drop preflight 未返回计划").with_context(&context) })?; Ok(( StatusCode::OK, Json(json!({ "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "plan": file_tree_drop_plan, })), )) } pub async fn tree_shell( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let effective_context = override_actor_context(&context, query.actor_id.as_deref()); let mode = normalize_tree_mode(query.mode.as_deref()); let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false); let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref()); let source_kind = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let (effective_workspace_id, snapshot) = if source_kind == Some("local_folder") { let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri) .map_err(|error| error.with_context(&effective_context))?; ( local_workspace_id_from_root_uri(root_uri)?, if mode == "filetree" { load_local_folder_file_tree_snapshot(root_uri)? } else { load_local_folder_page_tree_snapshot(root_uri)? }, ) } else { let effective_workspace_id = resolve_effective_workspace_id( &effective_context, query.workspace_id.as_deref(), true, )? .expect("workspace_required 已确保存在"); let snapshot = load_projection_snapshot( state.config(), &effective_context, &ProjectionSnapshotSpec { 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 { KernelProjectionKind::PageTree }, }, ) .await?; (effective_workspace_id, snapshot) }; let html = build_tree_shell_html( &effective_workspace_id, query.root_node_id.as_deref(), query.active_document_id.as_deref(), query.focused_document_id.as_deref(), query.active_picker_item_key.as_deref(), &normalize_channel(query.channel), query.host.as_deref(), &effective_context, &snapshot.projection, mode, allow_root_pick, &exclude_ids, &snapshot.dataset, ); let mut response = Html(html).into_response(); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); Ok(response) } fn create_command_wire( context: &RequestContext, workspace_id: &str, request: TreeCommandRequest, envelope_context: &TreeCommandEnvelopeContext, ) -> Result { match request { TreeCommandRequest::Create { workspace_id: _, document_id, parent_id, title, access_scope, content, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; let parent_id = read_optional_non_empty(parent_id); let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into()); Ok(RuntimeCommandEnvelopeWire { name: "tree.node.create".into(), command_id: format!("tree_create_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "workspaceId": workspace_id, "parentId": parent_id, "title": title, "accessScope": access_scope, "content": content.unwrap_or_else(|| Value::Array(Vec::new())), }), envelope_context, ), preflight_data: None, reason: Some("tree-shell create".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::CreateFolder { .. } => Err(WebError::bad_request_code( "tree_command_validation", "convex_workspace 暂不支持 folder create capability", ) .with_context(context) .with_header("x-error-phase", "tree_command_validate")), TreeCommandRequest::Rename { workspace_id: _, document_id, title, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; Ok(RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: format!("tree_rename_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "title": title, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell rename".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::Move { workspace_id: _, document_id, parent_id, sort_order, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; let sort_order = ensure_sort_order(sort_order, context)?; let parent_id = read_optional_non_empty(parent_id); Ok(RuntimeCommandEnvelopeWire { name: "tree.subtree.move".into(), command_id: format!("tree_move_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "parentId": parent_id, "sortOrder": sort_order, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell move".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::Archive { workspace_id: _, document_id, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; Ok(RuntimeCommandEnvelopeWire { name: "tree.node.archive".into(), command_id: format!("tree_archive_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "workspaceId": workspace_id, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell archive".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::Restore { workspace_id: _, document_id, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; Ok(RuntimeCommandEnvelopeWire { name: "tree.node.restore".into(), command_id: format!("tree_restore_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "workspaceId": workspace_id, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell restore".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::Copy { workspace_id: _, document_id, target_parent_id, items, title: _, } => { let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?; let copy_items = if items.is_empty() { vec![json!({ "documentId": fallback_document_id, "recursive": true, })] } else { items .into_iter() .map(|item| { Ok(json!({ "documentId": ensure_non_empty(&item.document_id, "items.documentId", context)?, "recursive": item.recursive, })) }) .collect::, WebError>>()? }; let target_parent_id = read_optional_non_empty(target_parent_id); Ok(RuntimeCommandEnvelopeWire { name: "tree.subtree.copy".into(), command_id: format!("tree_copy_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, target_parent_id.as_deref(), None, )), payload: attach_tree_command_envelope_context( json!({ "workspaceId": workspace_id, "targetParentId": target_parent_id, "items": copy_items, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell copy".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } TreeCommandRequest::DropFiles { .. } => Err(WebError::bad_request_code( "tree_command_validation", "convex_workspace 外部文件 drop 需要走上传 preflight / object storage executor", ) .with_context(context) .with_header("x-error-phase", "tree_command_validate")), TreeCommandRequest::Purge { workspace_id: _, document_id, } => { let document_id = ensure_non_empty(&document_id, "documentId", context)?; Ok(RuntimeCommandEnvelopeWire { name: "tree.node.purge".into(), command_id: format!("tree_purge_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: bridge_runtime::RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: attach_tree_command_envelope_context( json!({ "documentId": document_id, "workspaceId": workspace_id, }), envelope_context, ), preflight_data: None, reason: Some("tree-shell purge".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } } } async fn load_tree_move_preflight_data( state: &AppState, context: &RequestContext, workspace_id: &str, ) -> Result { let spec = ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth: Some(99), projection: KernelProjectionKind::SidebarTree, query: None, max_results: None, }; let snapshot = load_projection_snapshot(state.config(), context, &spec) .await .map_err(|error| { WebError::bad_gateway_code( "tree_move_preflight_snapshot_failed", format!("移动前排序快照加载失败: {}", error.message()), ) .with_context(context) .with_header("x-error-phase", "tree_move_preflight_snapshot") })?; let documents = snapshot .dataset .get("documents") .cloned() .unwrap_or_else(|| json!([])); Ok(json!({ "documents": documents })) } async fn resolve_tree_create_workspace_id( state: &AppState, context: &RequestContext, requested_workspace_id: Option<&str>, parent_id: Option<&str>, ) -> Result { if let Some(workspace_id) = resolve_effective_workspace_id(context, requested_workspace_id, false)? { return Ok(workspace_id); } if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) { let parent_meta = fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?; if let Some(workspace_id) = parent_meta .get("workspace_id") .or_else(|| parent_meta.get("workspaceId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return Ok(workspace_id.to_string()); } } let bootstrap = execute_retired_mutation_by_name( state.config(), context, "workspaces:ensureDefaultWorkspace", json!({ "fallbackName": context.auth.actor_id, "workspaceIdIfCreate": generate_tree_document_id(), }), None, None, "tree_command_workspace_bootstrap", ) .await?; bootstrap .get("activeWorkspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| { WebError::bad_gateway_code( "workspace_bootstrap_bad_response", "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId", ) .with_context(context) .with_header("x-error-phase", "tree_command_workspace_bootstrap") }) } fn operation_resource_relative_path(value: &Value, key: &str) -> Option { value .get(key) .and_then(Value::as_object) .and_then(|object| object.get("relativePath")) .and_then(Value::as_str) .map(str::trim) .filter(|path| !path.is_empty()) .map(ToOwned::to_owned) } fn operation_resource_document_id(value: &Value, key: &str) -> Option { value .get(key) .and_then(Value::as_object) .and_then(|object| object.get("documentId")) .and_then(Value::as_str) .map(str::trim) .filter(|path| path.starts_with("local-md:")) .map(ToOwned::to_owned) } fn apply_local_file_operation_participants( buffer_store: &crate::document_buffer_store::BufferStore, workspace_id: &str, root_uri: &str, action: &str, execution: &Value, ) { let previous_relative_path = operation_resource_relative_path(execution, "previousResource"); let previous_document_id = operation_resource_document_id(execution, "previousResource"); let next_relative_path = operation_resource_relative_path(execution, "resource"); let next_document_id = operation_resource_document_id(execution, "resource"); match action { "rename" | "move" => { if let ( Some(previous_relative_path), Some(previous_document_id), Some(next_relative_path), Some(next_document_id), ) = ( previous_relative_path.as_deref(), previous_document_id.as_deref(), next_relative_path.as_deref(), next_document_id.as_deref(), ) { let _ = buffer_store.rekey_local_folder_markdown( workspace_id, root_uri, previous_relative_path, previous_document_id, next_relative_path, next_document_id, ); } } "delete" | "archive" | "trash" | "purge" => { if let (Some(previous_relative_path), Some(previous_document_id)) = ( previous_relative_path.as_deref(), previous_document_id.as_deref(), ) { let _ = buffer_store.mark_local_folder_markdown_deleted( workspace_id, root_uri, previous_relative_path, previous_document_id, ); } } _ => {} } } pub async fn tree_command( State(state): State, Extension(context): Extension, body: String, ) -> Result<(StatusCode, Json), WebError> { let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| { WebError::bad_request_code( "tree_command_invalid_json", format!("tree command 请求体非法: {error}"), ) .with_context(&context) .with_header("x-error-phase", "tree_command_decode") })?; let mut envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request); if envelope_context.source_kind.is_none() && envelope_context .root_uri .as_deref() .is_some_and(|root_uri| root_uri.trim().starts_with("file://")) { envelope_context.source_kind = Some("local_folder".into()); } let request = match raw_request.action.trim() { "create" => TreeCommandRequest::Create { workspace_id: raw_request.workspace_id, document_id: read_optional_non_empty(raw_request.document_id) .unwrap_or_else(generate_tree_document_id), parent_id: raw_request.parent_id, title: normalize_title(raw_request.title), access_scope: raw_request.access_scope, content: raw_request.content, }, "createFolder" | "create_folder" | "folder.create" => TreeCommandRequest::CreateFolder { workspace_id: raw_request.workspace_id, document_id: read_optional_non_empty(raw_request.document_id) .unwrap_or_else(generate_tree_document_id), parent_id: raw_request.parent_id, title: normalize_title(raw_request.title.or_else(|| Some("新建文件夹".into()))), }, "rename" => TreeCommandRequest::Rename { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), title: normalize_title(raw_request.title), }, "move" => TreeCommandRequest::Move { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), parent_id: raw_request.parent_id, sort_order: raw_request.sort_order.unwrap_or(-1), }, "archive" | "delete" | "trash" => TreeCommandRequest::Archive { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), }, "restore" => TreeCommandRequest::Restore { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), }, "copy" => TreeCommandRequest::Copy { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), target_parent_id: raw_request.target_parent_id.or(raw_request.parent_id), items: raw_request.items, title: raw_request.title, }, "dropFiles" | "drop_files" => TreeCommandRequest::DropFiles { workspace_id: raw_request.workspace_id, parent_id: raw_request.parent_id, files_json: match raw_request.content { Some(Value::String(value)) => value, Some(value) => value.to_string(), None => "[]".into(), }, }, "purge" => TreeCommandRequest::Purge { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), }, other => { return Err(WebError::bad_request_code( "tree_command_validation", format!("不支持的 tree action: {other}"), ) .with_context(&context) .with_header("x-error-phase", "tree_command_validate")); } }; let requested_workspace_id = match &request { TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::CreateFolder { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Archive { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Restore { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Copy { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::DropFiles { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(), }; let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) = match &request { TreeCommandRequest::Create { document_id, parent_id, title, .. } => ( "create", document_id.clone(), parent_id.clone(), Some(title.clone()), None, ), TreeCommandRequest::CreateFolder { document_id, parent_id, title, .. } => ( "createFolder", document_id.clone(), parent_id.clone(), Some(title.clone()), None, ), TreeCommandRequest::Rename { document_id, title, .. } => ( "rename", document_id.clone(), None, Some(title.clone()), None, ), TreeCommandRequest::Move { document_id, parent_id, sort_order, .. } => ( "move", document_id.clone(), parent_id.clone(), None, Some(*sort_order), ), TreeCommandRequest::Archive { document_id, .. } => { ("delete", document_id.clone(), None, None, None) } TreeCommandRequest::Restore { document_id, .. } => { ("restore", document_id.clone(), None, None, None) } TreeCommandRequest::Copy { document_id, target_parent_id, title, .. } => ( "copy", document_id.clone(), target_parent_id.clone(), title.clone(), None, ), TreeCommandRequest::DropFiles { parent_id, files_json, .. } => ( "dropFiles", String::new(), parent_id.clone(), Some(files_json.clone()), None, ), TreeCommandRequest::Purge { document_id, .. } => { ("purge", document_id.clone(), None, None, None) } }; if envelope_context.source_kind.as_deref() == Some("local_folder") { let root_uri = envelope_context .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access_with_state( &state, &context, root_uri, LocalAccessMode::Write, ) .map_err(|error| error.with_context(&context))?; let execution = execute_local_tree_command_with_sort( root_uri, action, &requested_document_id, requested_parent_id.as_deref(), requested_title.as_deref(), requested_sort_order, ) .map_err(|error| { error .with_context(&context) .with_header("x-error-phase", "tree_local_executor") })?; let local_workspace_id = local_workspace_id_from_root_uri(root_uri)?; apply_local_file_operation_participants( &state.buffer_store, &local_workspace_id, root_uri, action, &execution, ); return Ok(json_response( &context, json!({ "workspaceId": local_workspace_id, "action": action, "documentId": execution .get("documentId") .and_then(Value::as_str) .unwrap_or(&requested_document_id), "parentId": requested_parent_id, "title": requested_title, "sortOrder": requested_sort_order, "affectedParents": execution.get("affectedParents").cloned().unwrap_or(Value::Null), "revealTarget": execution.get("revealTarget").cloned().unwrap_or(Value::Null), "selectTarget": execution.get("selectTarget").cloned().unwrap_or(Value::Null), "operationId": execution.get("operationId").cloned().unwrap_or(Value::Null), "batchId": envelope_context.batch_id.clone(), "schema": execution.get("schema").cloned().unwrap_or(Value::Null), "updatedAt": Value::Null, "execution": execution, "artifacts": Value::Null, "artifactError": Value::Null, }), )); } let effective_workspace_id = match &request { TreeCommandRequest::Create { parent_id, .. } => { resolve_tree_create_workspace_id( &state, &context, requested_workspace_id, parent_id.as_deref(), ) .await? } TreeCommandRequest::CreateFolder { .. } => { return Err(WebError::bad_request_code( "tree_command_validation", "convex_workspace 暂不支持 folder create capability", ) .with_context(&context) .with_header("x-error-phase", "tree_command_validate")); } _ => resolve_effective_workspace_id(&context, requested_workspace_id, true)? .expect("workspace_required 已确保存在"), }; let needs_move_preflight = matches!(&request, TreeCommandRequest::Move { .. }); let mut command_wire = create_command_wire( &context, &effective_workspace_id, request, &envelope_context, )?; if needs_move_preflight { command_wire.preflight_data = Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?); } let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, Some(&effective_workspace_id), command_wire, ) .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, json!({ "workspaceId": effective_workspace_id, "action": action, "documentId": response_document_id, "parentId": requested_parent_id, "title": requested_title, "sortOrder": requested_sort_order, "updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null), "execution": execution.result, "artifacts": artifacts, "artifactError": artifact_error, }), )) } pub async fn reduce_tree_shell_runtime( Json(body): Json, ) -> Json { Json(reduce_tree_shell_runtime_request(body)) } #[cfg(test)] mod tests { const TREE_SHELL_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-runtime.js"); const TREE_SHELL_RENDER_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-render-runtime.js"); const TREE_SHELL_FILETREE_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-filetree-runtime.js"); const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js"); const TREE_SHELL_FILETREE_DND_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); use super::{ collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, }; use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::Value; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, 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":"table","file_name":"预算.table","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#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"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"},"documents:purge":{"ok":true,"deletedCount":1},"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(), })) .layer(axum::middleware::from_fn(inject_test_actor)) } async fn inject_test_actor( mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { request .headers_mut() .entry("x-mnote-actor-id") .or_insert(HeaderValue::from_static("user_test")); request .headers_mut() .entry("x-mnote-actor-type") .or_insert(HeaderValue::from_static("user")); next.run(request).await } fn init_local_workspace(root: &std::path::Path, actor_id: &str) { crate::routes::local_folder_source::initialize_local_workspace_for_actor( actor_id, &format!("file://{}", root.display()), ) .expect("init local workspace"); } #[test] fn filetree_rows_select_active_mindmap_asset_in_object_shell() { let projection = serde_json::json!({ "items": [ { "rowId": "doc:doc_1", "rowKind": "document", "nodeId": "doc_1", "title": "页面.md", "resourceMeta": { "documentId": "doc_1", "objectIdentity": { "objectKind": "page", "documentId": "doc_1", "assetId": null } } }, { "rowId": "asset:mind_1", "rowKind": "asset", "nodeId": "asset:mind_1", "parentNodeId": "doc_1", "title": "思维导图.json", "iconHint": "mindmap", "resourceMeta": { "documentId": "doc_1", "assetId": "mind_1", "objectIdentity": { "objectKind": "mindmap", "documentId": "doc_1", "assetId": "mind_1" } } } ] }); let rows = collect_filetree_render_rows(&projection, Some("doc_1"), Some("asset:mind_1")); let doc_row = rows .iter() .find(|row| row.row_id == "doc:doc_1") .expect("doc row"); let mindmap_row = rows .iter() .find(|row| row.row_id == "asset:mind_1") .expect("mindmap row"); assert!( !doc_row.selected, "对象页应避免把父页面行重新选中,防止 mindmap 文件行点击后闪回父页面" ); assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中"); } #[tokio::test] async fn tree_shell_returns_interactive_html_document() { let response = app() .oneshot( Request::builder() .uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let content_type = response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!(content_type.contains("text/html")); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("tree-create-root")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.ready")); assert!(html.contains("test-shell")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree-action-menu")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.context-menu")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.expand.changed")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.focus.changed")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.shell.state.patch")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyPageKeyboardAction")); assert!(TREE_SHELL_RUNTIME_JS.contains("reducePageActionWithRuntime")); let apply_page_action_start = TREE_SHELL_RUNTIME_JS .find("const applyPageKeyboardAction = (action, item, sourceElement) => {") .expect("applyPageKeyboardAction should be in external runtime"); let apply_page_action_end = TREE_SHELL_RUNTIME_JS[apply_page_action_start..] .find("\n const postPickerFocusChange") .expect("applyPageKeyboardAction should end before picker focus handler"); let apply_page_action_body = &TREE_SHELL_RUNTIME_JS [apply_page_action_start..apply_page_action_start + apply_page_action_end]; assert!( !apply_page_action_body.contains("toggleExpand("), "page keyboard/expand should prefer runtime result instead of directly toggling local expansion state" ); assert!(TREE_SHELL_RUNTIME_JS.contains("patchPageTreeActiveDom")); assert!(TREE_SHELL_RUNTIME_JS.contains("patchPageTreeExpansionDom")); assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-rendered-row=\"page\"")); assert!(html.contains("data-rust-action=\"toggle\"")); assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree")); assert!(TREE_SHELL_RUNTIME_JS .contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS .contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node")); assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到")); assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")")); assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"aria-level\"")); } #[tokio::test] async fn tree_shell_uses_external_browser_runtime_module() { let response = app() .oneshot( Request::builder() .uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell") .body(Body::empty()) .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 html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("id=\"tree-shell-state\"")); assert!(html .contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\"")); assert!( !html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"), "debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML" ); } #[tokio::test] async fn tree_shell_picker_mode_hides_command_toolbar_and_supports_root_pick() { let response = app() .oneshot( Request::builder() .uri("/tree?workspaceId=ws_demo&mode=picker&allowRootPick=1&excludeIds=page_child") .body(Body::empty()) .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 html = String::from_utf8(body.to_vec()).expect("utf8"); 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!(TREE_SHELL_RUNTIME_JS.contains("tree.pick.root")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.picker.command")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.picker.focus.changed")); assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction")); assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost")); assert!(TREE_SHELL_RUNTIME_JS .contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); assert!(TREE_SHELL_RUNTIME_JS .contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true")); assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement")); assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree")); assert!(html.contains("tabindex=\"")); assert!(TREE_SHELL_RUNTIME_JS .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); } #[tokio::test] async fn tree_shell_filetree_mode_embeds_asset_state() { let response = app() .oneshot( Request::builder() .uri("/tree?workspaceId=ws_demo&mode=filetree") .body(Body::empty()) .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 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!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.selection.changed")); assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyFileTreeSelectionAction")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree.filetree.internal-drop")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree.filetree.external-drop")); assert!(html.contains("\"rowKind\":\"asset_folder\"")); assert!(html.contains("\"resourceMeta\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-render-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-menu-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-dnd-runtime.js")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("function createTreeShellRenderer(context)")); assert!( TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)") ); assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS .contains("function buildFileTreeMenuTarget(context")); assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS .contains("function createTreeShellFileTreeDndRuntime(context)")); assert!(TREE_SHELL_RENDER_RUNTIME_JS .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); assert!( TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))") ); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("if (getFileTreeRowDocumentId(item))")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree")); assert!(TREE_SHELL_RUNTIME_JS .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); } #[tokio::test] async fn tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot() { let root = std::env::temp_dir().join(format!("mnote-local-folder-source-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create local docs dir"); std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md"); std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md"); std::fs::write(root.join("image.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("local_folder")); assert!(html.contains("README.md")); assert!(html.contains("docs")); assert!(!html.contains("child.md")); assert!(html.contains("image.png")); assert!(html.contains("data-row-kind=\"folder\"")); assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-document-id=\"local-md:README.md\"")); } #[tokio::test] async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() { let root = std::env::temp_dir().join(format!( "mnote-local-folder-sqlite-read-grant-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); std::fs::write(root.join("README.md"), "# Shared\n").expect("write local md"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "owner_user"); let state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.into()), email: Some(format!("{user_id}@example.com")), username: user_id.into(), display_name: user_id.into(), role: role.map(str::to_string), password_hash: None, }) .expect("upsert grant user"); } state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "shujuan".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "read".into(), recursive: true, capabilities: vec![], source: "admin".into(), created_by: Some("liaibo".into()), }) .expect("grant sqlite read"); let response = build_app(state) .oneshot( Request::builder() .uri(format!( "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "shujuan") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn local_folder_tree_shell_does_not_reload_page_for_refresh() { let root = std::env::temp_dir().join(format!( "mnote-local-folder-no-reload-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local folder root"); std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md"); std::fs::write(root.join("asset.txt"), "asset").expect("write local asset"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); assert!(html.contains("/api/mnote-browser-runtime/tree-live-controller.js")); assert!(html.contains("data-mnote-root-uri=")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree:local-folder-watch-batch")); assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot")); assert!(!TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch")); assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload")); } #[tokio::test] async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() { let root = std::env::temp_dir().join(format!( "mnote-local-page-tree-source-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create local docs dir"); std::fs::write( root.join("README.md"), "---\ntitle: Frontmatter Title\n---\n# Ignored H1\n", ) .expect("write local md"); std::fs::write(root.join("docs").join("child.md"), "# Child H1\n").expect("write child md"); std::fs::write(root.join("image.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("local_folder")); assert!(html.contains("README")); assert!(html.contains("child")); // 本地 Markdown 的树标题统一来自文件名,frontmatter title 和 H1 不作为树标题。 assert!(!html.contains("Frontmatter Title")); assert!(!html.contains("Child H1")); assert!(html.contains(">docs<") || html.contains("docs")); assert!(!html.contains("image.png")); } #[tokio::test] async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint( ) { let root = std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let create_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"新页面"}}"# ))) .expect("request"), ) .await .expect("response"); let create_status = create_response.status(); let body = axum::body::to_bytes(create_response.into_body(), usize::MAX) .await .expect("body"); assert_eq!( create_status, StatusCode::OK, "{}", String::from_utf8_lossy(&body) ); let payload: Value = serde_json::from_slice(&body).expect("json"); let document_id = payload["result"]["documentId"] .as_str() .expect("document id") .to_string(); let created_relative_path = payload["result"]["execution"]["relativePath"] .as_str() .expect("created relative path"); let (created_dir, created_file) = created_relative_path .split_once('/') .expect("created nested bundle path"); assert!(created_dir.starts_with("新页面")); assert_eq!(created_file, format!("{created_dir}.md")); assert!(root.join(created_dir).join(created_file).exists()); assert_eq!( document_id, format!( "local-md:{}", crate::routes::local_folder_source::encode_local_id_segment(created_relative_path) ) ); assert!(!root.join(".mnote").join("page-ids.json").exists()); let rename_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"rename","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","title":"重命名页面"}}"# ))) .expect("request"), ) .await .expect("response"); let rename_status = rename_response.status(); let rename_body = axum::body::to_bytes(rename_response.into_body(), usize::MAX) .await .expect("body"); assert_eq!( rename_status, StatusCode::OK, "{}", String::from_utf8_lossy(&rename_body) ); assert!(!root.join(created_dir).exists()); assert!(root.join("重命名页面").join("重命名页面.md").exists()); let rename_payload: Value = serde_json::from_slice(&rename_body).expect("rename json"); let renamed_document_id = rename_payload["result"]["documentId"] .as_str() .expect("renamed document id") .to_string(); assert_eq!( renamed_document_id, "local-md:~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md" ); assert!(!root.join(".mnote").join("page-ids.json").exists()); std::fs::create_dir_all(root.join("docs")).expect("create docs dir"); let move_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{renamed_document_id}","parentId":"local-dir:docs","sortOrder":0}}"# ))) .expect("request"), ) .await .expect("response"); let move_status = move_response.status(); let move_body = axum::body::to_bytes(move_response.into_body(), usize::MAX) .await .expect("move body"); assert_eq!( move_status, StatusCode::OK, "{}", String::from_utf8_lossy(&move_body) ); assert!(!root.join("重命名页面").exists()); assert!(root .join("docs") .join("重命名页面") .join("重命名页面.md") .exists()); let move_payload: Value = serde_json::from_slice(&move_body).expect("move json"); let moved_document_id = move_payload["result"]["documentId"] .as_str() .expect("moved document id") .to_string(); assert_eq!( moved_document_id, "local-md:docs~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md" ); assert!(!root.join(".mnote").join("page-ids.json").exists()); let copy_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}","parentId":"local-dir:docs"}}"# ))) .expect("request"), ) .await .expect("response"); let copy_status = copy_response.status(); let copy_body = axum::body::to_bytes(copy_response.into_body(), usize::MAX) .await .expect("body"); assert_eq!( copy_status, StatusCode::OK, "{}", String::from_utf8_lossy(©_body) ); let copy_payload: Value = serde_json::from_slice(©_body).expect("copy json"); let copied_document_id = copy_payload["result"]["documentId"] .as_str() .expect("copied document id") .to_string(); assert!(root .join("docs") .join("重命名页面 2") .join("重命名页面 2.md") .exists()); let folder_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"createFolder","sourceKind":"local_folder","rootUri":"{root_uri}","title":"资料"}}"# ))) .expect("request"), ) .await .expect("response"); assert_eq!(folder_response.status(), StatusCode::OK); assert!(root.join("资料").is_dir()); let delete_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"# ))) .expect("request"), ) .await .expect("response"); assert_eq!(delete_response.status(), StatusCode::OK); assert!(!root.join("docs").join("重命名页面").exists()); assert!(root .join(".mnote") .join("trash") .join("重命名页面") .join("重命名页面.md") .exists()); assert!(root.join(".mnote").join("trash-index.json").exists()); assert!(!root.join(".mnote").join("page-ids.json").exists()); let restore_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"# ))) .expect("request"), ) .await .expect("response"); let restore_status = restore_response.status(); let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX) .await .expect("restore body"); assert_eq!( restore_status, StatusCode::OK, "{}", String::from_utf8_lossy(&restore_body) ); assert!(root .join("docs") .join("重命名页面") .join("重命名页面.md") .exists()); let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json"); assert_eq!( restore_payload["result"]["documentId"].as_str(), Some(moved_document_id.as_str()) ); assert!(!root.join(".mnote").join("page-ids.json").exists()); let purge_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{copied_document_id}"}}"# ))) .expect("request"), ) .await .expect("response"); assert_eq!(purge_response.status(), StatusCode::OK); assert!(!root.join("docs").join("重命名页面 2").exists()); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index() { let root = std::env::temp_dir().join(format!("mnote-local-asset-trash-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let asset_id = "local-file:docs/photo.png"; let delete_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"# ))) .expect("request"), ) .await .expect("response"); let delete_status = delete_response.status(); let delete_body = axum::body::to_bytes(delete_response.into_body(), usize::MAX) .await .expect("body"); assert_eq!( delete_status, StatusCode::OK, "{}", String::from_utf8_lossy(&delete_body) ); let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json"); assert_eq!( delete_payload["result"]["execution"]["canonicalCommand"], "tree.resource.archive" ); assert_eq!( delete_payload["result"]["execution"]["resourceKind"], "local_file" ); assert_eq!( delete_payload["result"]["execution"]["originalFilePath"], "docs/photo.png" ); assert!(!root.join("docs").join("photo.png").exists()); assert!(root.join(".mnote").join("trash").join("photo.png").exists()); let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index"); assert!(trash_index.contains("local-file:docs/photo.png")); assert!(trash_index.contains("resourceKind")); let restore_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"# ))) .expect("request"), ) .await .expect("response"); let restore_status = restore_response.status(); let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX) .await .expect("restore body"); assert_eq!( restore_status, StatusCode::OK, "{}", String::from_utf8_lossy(&restore_body) ); let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json"); assert_eq!( restore_payload["result"]["execution"]["canonicalCommand"], "tree.resource.restore" ); assert!(root.join("docs").join("photo.png").exists()); assert!(!root.join(".mnote").join("trash").join("photo.png").exists()); let delete_again_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"delete","rootUri":"{root_uri}","documentId":"{asset_id}"}}"# ))) .expect("request"), ) .await .expect("response"); let delete_again_status = delete_again_response.status(); let delete_again_body = axum::body::to_bytes(delete_again_response.into_body(), usize::MAX) .await .expect("delete again body"); assert_eq!( delete_again_status, StatusCode::OK, "{}", String::from_utf8_lossy(&delete_again_body) ); let delete_again_payload: Value = serde_json::from_slice(&delete_again_body).expect("delete again json"); assert_eq!( delete_again_payload["result"]["execution"]["canonicalCommand"], "tree.resource.archive" ); let purge_response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"# ))) .expect("request"), ) .await .expect("response"); let purge_status = purge_response.status(); let purge_body = axum::body::to_bytes(purge_response.into_body(), usize::MAX) .await .expect("purge body"); assert_eq!( purge_status, StatusCode::OK, "{}", String::from_utf8_lossy(&purge_body) ); let purge_payload: Value = serde_json::from_slice(&purge_body).expect("purge json"); assert_eq!( purge_payload["result"]["execution"]["canonicalCommand"], "tree.resource.purge" ); assert!(!root.join("docs").join("photo.png").exists()); assert!(!root.join(".mnote").join("trash").join("photo.png").exists()); let trash_index_after = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index after purge"); assert!(!trash_index_after.contains("local-file:docs/photo.png")); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() { let root = std::env::temp_dir().join(format!( "mnote-local-tree-root-escape-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(format!( r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","parentId":"local:folder:../outside","title":"逃逸"}}"# ))) .expect("request"), ) .await .expect("response"); let status = response.status(); let headers = response.headers().clone(); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("error json"); let _ = std::fs::remove_dir_all(&root); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(payload["ok"], false); assert_eq!(payload["code"], "local_folder_root_escape"); assert!(payload["message"] .as_str() .unwrap_or_default() .contains("root")); assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0); assert_eq!( headers .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("local_folder_root_escape") ); assert_eq!( headers .get("x-error-phase") .and_then(|value| value.to_str().ok()), Some("tree_local_executor") ); } #[tokio::test] async fn tree_command_local_folder_rejects_non_owner_root() { let root = std::env::temp_dir().join(format!( "mnote-local-tree-owner-denied-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); init_local_workspace(&root, "owner_user"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .header("x-mnote-actor-id", "other_user") .header("x-mnote-actor-type", "user") .body(Body::from(format!( r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"# ))) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("error json"); let _ = std::fs::remove_dir_all(&root); assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(payload["ok"], false); assert_eq!(payload["code"], "local_workspace_access_denied"); } #[tokio::test] async fn tree_command_local_folder_allows_sqlite_directory_write_grant() { let root = std::env::temp_dir().join(format!( "mnote-local-tree-sqlite-write-grant-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); init_local_workspace(&root, "owner_user"); let root_uri = format!("file://{}", root.display()); let state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.into()), email: Some(format!("{user_id}@example.com")), username: user_id.into(), display_name: user_id.into(), role: role.map(str::to_string), password_hash: None, }) .expect("upsert grant user"); } state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "shujuan".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "write".into(), recursive: true, capabilities: vec![], source: "admin".into(), created_by: Some("liaibo".into()), }) .expect("grant sqlite write"); let response = build_app(state) .oneshot( Request::builder() .method(Method::POST) .uri("/api/tree/commands") .header("content-type", "application/json") .header("x-mnote-actor-id", "shujuan") .header("x-mnote-actor-type", "user") .body(Body::from(format!( r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"# ))) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); } #[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\":[\"doc:page_root\"]")); assert!(!filetree_html.contains("\"selectedRowIds\":[\"index:page_root\"")); assert!(filetree_html.contains("\"focusedRowId\":\"doc:page_root\"")); assert!(filetree_html.contains("data-row-id=\"doc:page_root\"")); assert!(!filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\"")); assert!(filetree_html.contains("\"commandDispatcher\"")); assert!(filetree_html.contains("\"runtimeArtifact\"")); assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); assert!(filetree_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\"")); assert!(filetree_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); assert!(filetree_html .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); assert!(filetree_html.contains( "\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]" )); 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\"]")); assert!(picker_html.contains("\"runtimeArtifact\"")); assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); assert!(picker_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\"")); assert!(picker_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); assert!(picker_html .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); } #[tokio::test] async fn tree_runtime_reduce_endpoint_returns_filetree_runtime_result() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/runtime/reduce") .header("content-type", "application/json") .body(Body::from( r#"{"mode":"fileTree","requestId":"req-filetree-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"selectRow","rowId":"asset:image","modifiers":{"shiftKey":false,"ctrlKey":false,"metaKey":false}}}"#, )) .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["mode"], Value::String("fileTree".into())); assert_eq!( payload["requestId"], Value::String("req-filetree-route".into()) ); assert_eq!( payload["domPatches"][0]["kind"], Value::String("fileTreeState".into()) ); assert_eq!( payload["domPatches"][0]["selectedRowIds"][0], Value::String("asset:image".into()) ); let drop_target_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/runtime/reduce") .header("content-type", "application/json") .body(Body::from( r#"{"mode":"fileTree","requestId":"req-filetree-drop-target-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"updateDropTarget","rowId":"asset:image"}}"#, )) .expect("request"), ) .await .expect("response"); assert_eq!(drop_target_response.status(), StatusCode::OK); let drop_target_body = axum::body::to_bytes(drop_target_response.into_body(), usize::MAX) .await .expect("body"); let drop_target_payload: Value = serde_json::from_slice(&drop_target_body).expect("json"); assert_eq!( drop_target_payload["domPatches"][0]["dropTargetRowId"], Value::String("asset:image".into()) ); } #[tokio::test] async fn tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results() { let page_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/runtime/reduce") .header("content-type", "application/json") .body(Body::from( r#"{"mode":"page","requestId":"req-page-route","environment":{"visibleNodeIds":["doc:root","doc:child"],"expandableNodeIds":["doc:root"]},"state":{"focusedId":"doc:root","expandedIds":[],"dropFeedback":null},"action":{"kind":"moveNext"}}"#, )) .expect("request"), ) .await .expect("response"); assert_eq!(page_response.status(), StatusCode::OK); let page_body = axum::body::to_bytes(page_response.into_body(), usize::MAX) .await .expect("body"); let page_payload: Value = serde_json::from_slice(&page_body).expect("json"); assert_eq!(page_payload["mode"], Value::String("page".into())); assert_eq!( page_payload["requestId"], Value::String("req-page-route".into()) ); assert_eq!( page_payload["domPatches"][0]["kind"], Value::String("pageState".into()) ); assert_eq!( page_payload["domPatches"][0]["focusedId"], Value::String("doc:child".into()) ); let picker_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/runtime/reduce") .header("content-type", "application/json") .body(Body::from( r#"{"mode":"picker","requestId":"req-picker-route","environment":{"items":[{"itemKey":"doc:root","documentId":"doc:root","pickable":true},{"itemKey":"doc:child","documentId":"doc:child","pickable":true}],"excludedIds":[],"allowRootPick":false},"state":{"activeItemKey":"doc:child"},"action":{"kind":"pick"}}"#, )) .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_payload: Value = serde_json::from_slice(&picker_body).expect("json"); assert_eq!(picker_payload["mode"], Value::String("picker".into())); assert_eq!( picker_payload["requestId"], Value::String("req-picker-route".into()) ); assert_eq!( picker_payload["hostEvents"][0]["kind"], Value::String("pickerPickDocument".into()) ); assert_eq!( picker_payload["hostEvents"][0]["documentId"], Value::String("doc:child".into()) ); } #[tokio::test] async fn tree_command_create_generates_document_id_when_missing() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from( r#"{"action":"create","workspaceId":"ws_demo","parentId":"page_root","title":"新页面","accessScope":"private","content":[]}"#, )) .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"]["action"], Value::String("create".into())); assert_eq!( payload["result"]["documentId"], Value::String("page_new".into()) ); } #[tokio::test] async fn tree_command_create_uses_default_workspace_when_workspace_missing() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from(r#"{"action":"create","title":"新页面"}"#)) .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"]["action"], Value::String("create".into())); assert_eq!( payload["result"]["workspaceId"], Value::String("ws_demo".into()) ); assert_eq!( payload["result"]["documentId"], Value::String("page_new".into()) ); } #[tokio::test] async fn tree_command_purge_uses_tree_command_protocol() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from( r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#, )) .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"]["action"], Value::String("purge".into())); assert_eq!( payload["result"]["documentId"], Value::String("page_child".into()) ); assert_eq!(payload["result"]["sortOrder"], Value::Null); assert_eq!( payload["result"]["execution"]["deletedCount"], Value::from(1) ); } #[tokio::test] async fn tree_command_rejects_negative_sort_order() { 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::BAD_REQUEST); } #[tokio::test] async fn tree_command_rename_defaults_blank_title_to_untitled() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/commands") .header("content-type", "application/json") .body(Body::from( r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":" "}"#, )) .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"]["title"], Value::String("无标题".into())); } #[tokio::test] async fn tree_command_move_returns_structured_payload() { 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"]["action"], Value::String("move".into())); assert_eq!( payload["result"]["documentId"], Value::String("page_child".into()) ); } #[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"]["domainEvent"]["payload"]["streamDelta"]["sortOrder"], Value::from(1) ); assert_eq!( payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"], Value::String("move_document".into()) ); assert_eq!( payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["sortOrder"], Value::from(1) ); } #[tokio::test] async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/tree/commands") .header("content-type", "application/json") .header("Authorization", "Bearer demo-token") .body(Body::from( r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#, )) .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!(payload["requestId"].as_str().is_some()); assert!(payload["traceId"].as_str().is_some()); assert_eq!(payload["result"]["workspaceId"], "ws_demo"); } #[tokio::test] async fn tree_route_contracts_compat_sidebar_route_is_not_registered_by_default() { let response = app() .oneshot( Request::builder() .uri("/api/compat/next/sidebar?workspaceId=ws_demo") .header("Authorization", "Bearer demo-token") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[test] fn tree_commands_prefer_tree_protocol_names_in_command_wire() { let context = RequestContext::from_http_parts( &Method::POST, &"/api/tree/commands".parse::().expect("uri"), &HeaderMap::new(), ); let create_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Create { workspace_id: Some("ws_demo".into()), document_id: "page_new".into(), parent_id: Some("page_root".into()), title: "新页面".into(), access_scope: Some("private".into()), content: Some(Value::Array(Vec::new())), }, &TreeCommandEnvelopeContext::default(), ) .expect("create wire"); assert_eq!(create_wire.name, "tree.node.create"); assert_eq!( create_wire.source.source_kind.as_deref(), Some("convex_workspace") ); assert_eq!( create_wire.source.root_uri.as_deref(), Some("convex://workspace/ws_demo") ); assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo")); assert!(create_wire .source .capabilities .iter() .any(|capability| capability == "execute-command")); let rename_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Rename { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), title: "重命名".into(), }, &TreeCommandEnvelopeContext::default(), ) .expect("rename wire"); assert_eq!(rename_wire.name, "tree.node.rename"); let move_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Move { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), parent_id: Some("page_root".into()), sort_order: 1, }, &TreeCommandEnvelopeContext::default(), ) .expect("move wire"); assert_eq!(move_wire.name, "tree.subtree.move"); } #[test] fn tree_command_wire_carries_source_and_target_context_from_unified_envelope() { let context = RequestContext::from_http_parts( &Method::POST, &"/api/tree/commands".parse::().expect("uri"), &HeaderMap::new(), ); let envelope_context = TreeCommandEnvelopeContext { source_kind: Some("convex_workspace".into()), root_uri: Some("convex://workspace/ws_demo".into()), source_capabilities: vec![ "load-snapshot".into(), "preflight-command".into(), "execute-command".into(), ], target_node_id: Some("doc:page_child".into()), target_resource_meta: Some(serde_json::json!({ "resourceKind": "document", "documentId": "page_child", "workspaceId": "ws_demo" })), selection: Some(serde_json::json!({ "rowIds": ["doc:page_child"] })), operation: Some("tree.node.rename".into()), batch_id: None, }; let rename_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Rename { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), title: "重命名".into(), }, &envelope_context, ) .expect("rename wire"); assert_eq!( rename_wire.source.source_kind.as_deref(), Some("convex_workspace") ); assert_eq!( rename_wire.source.root_uri.as_deref(), Some("convex://workspace/ws_demo") ); assert_eq!( rename_wire.payload["targetNodeId"], serde_json::json!("doc:page_child") ); assert_eq!( rename_wire.payload["targetResourceMeta"]["documentId"], serde_json::json!("page_child") ); assert_eq!( rename_wire.payload["selection"]["rowIds"][0], serde_json::json!("doc:page_child") ); assert_eq!( rename_wire.payload["operation"], serde_json::json!("tree.node.rename") ); } #[test] fn tree_commands_use_protocol_names_in_runtime_plan() { let context = RequestContext::from_http_parts( &Method::POST, &"/api/tree/commands".parse::().expect("uri"), &HeaderMap::new(), ); let tree_create_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Create { workspace_id: Some("ws_demo".into()), document_id: "page_new".into(), parent_id: Some("page_root".into()), title: "新页面".into(), access_scope: Some("private".into()), content: Some(Value::Array(Vec::new())), }, &TreeCommandEnvelopeContext::default(), ) .expect("tree create wire"); let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire { name: "documents.create".into(), ..tree_create_wire.clone() }; let tree_create_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire) .expect("tree create plan"); let compat_create_plan = build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire) .expect("compat create plan"); assert_eq!(tree_create_plan.function_name, "tree.node.create"); assert_eq!(compat_create_plan.function_name, "documents.create"); let tree_rename_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Rename { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), title: "重命名".into(), }, &TreeCommandEnvelopeContext::default(), ) .expect("tree rename wire"); let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire { name: "documents.title.update".into(), ..tree_rename_wire.clone() }; let tree_rename_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire) .expect("tree rename plan"); let compat_rename_plan = build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire) .expect("compat rename plan"); assert_eq!(tree_rename_plan.function_name, "tree.node.rename"); assert_eq!(compat_rename_plan.function_name, "documents.title.update"); let tree_move_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Move { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), parent_id: Some("page_root".into()), sort_order: 1, }, &TreeCommandEnvelopeContext::default(), ) .expect("tree move wire"); let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire { name: "documents.move".into(), ..tree_move_wire.clone() }; let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire) .expect("tree move plan"); let compat_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire) .expect("compat move plan"); assert_eq!(tree_move_plan.function_name, "tree.subtree.move"); assert_eq!(compat_move_plan.function_name, "documents.move"); } #[test] fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() { let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG; assert!(aliases .iter() .all(|entry| entry.compat_command.starts_with("documents."))); assert!(aliases .iter() .all(|entry| entry.preferred_command.starts_with("tree."))); assert!(aliases .iter() .all(|entry| entry.source_kind == "convex_workspace")); assert!(aliases .iter() .all(|entry| entry.retained_for.contains("legacy cloud"))); assert!(aliases .iter() .all(|entry| entry.retirement_condition.contains("emit tree."))); assert!(aliases.iter().any(|entry| { entry.compat_command == "documents.delete" && entry.preferred_command == "tree.node.archive" })); assert!(aliases.iter().any(|entry| { entry.compat_command == "documents.copy_tree" && entry.preferred_command == "tree.subtree.copy" })); } }