use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; use crate::routes::query_support::{ fetch_documents_meta_via_convex, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use crate::transport::convex::execute_convex_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::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 allow_root_pick: Option, pub exclude_ids: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TreeCommandEnvelope { pub action: String, pub workspace_id: Option, pub document_id: Option, pub parent_id: Option, pub title: Option, pub access_scope: Option, pub content: Option, pub sort_order: Option, } #[derive(Debug)] pub enum TreeCommandRequest { Create { workspace_id: Option, document_id: String, parent_id: Option, title: String, access_scope: Option, content: Option, }, Rename { workspace_id: Option, document_id: String, title: String, }, Move { workspace_id: Option, document_id: String, parent_id: Option, sort_order: i64, }, 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 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 { projection .get("items") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let node_id = item .get("nodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let row_kind = item .get("rowKind") .and_then(Value::as_str) .unwrap_or_default(); if row_kind != "document" { return None; } Some(PageTreeRenderRow { node_id: node_id.to_string(), parent_node_id: item .get("parentNodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), title: item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题") .to_string(), depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, expandable: item .get("expandable") .and_then(Value::as_bool) .unwrap_or_else(|| { item.get("childCount") .and_then(Value::as_u64) .map(|count| count > 0) .unwrap_or(false) }), expanded: item .get("expandedByDefault") .and_then(Value::as_bool) .unwrap_or(false), }) }) .collect() }) .unwrap_or_default() } pub(crate) fn collect_filetree_render_rows( projection: &Value, active_document_id: Option<&str>, ) -> Vec { let selected_ids = active_document_id .map(str::trim) .filter(|value| !value.is_empty()) .map(|document_id| { [format!("doc:{document_id}"), format!("index:{document_id}")] .into_iter() .collect::>() }) .unwrap_or_default(); projection .get("items") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let row_id = item .get("rowId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let node_id = item .get("nodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let resource_meta = item.get("resourceMeta").and_then(Value::as_object); Some(FileTreeRenderRow { row_id: row_id.to_string(), row_kind: item .get("rowKind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("document") .to_string(), node_id: node_id.to_string(), parent_node_id: item .get("parentNodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), title: item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题") .to_string(), depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, expandable: item .get("expandable") .and_then(Value::as_bool) .unwrap_or_else(|| { item.get("childCount") .and_then(Value::as_u64) .map(|count| count > 0) .unwrap_or(false) }), expanded: item .get("expandedByDefault") .and_then(Value::as_bool) .unwrap_or(false), icon_kind: item .get("iconHint") .and_then(Value::as_str) .or_else(|| { resource_meta .and_then(|meta| meta.get("iconHint")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("file") .to_string(), document_id: resource_meta .and_then(|meta| meta.get("documentId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), asset_id: resource_meta .and_then(|meta| meta.get("assetId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), selected: selected_ids.contains(row_id), }) }) .collect() }) .unwrap_or_default() } fn collect_picker_render_rows( projection: &Value, active_picker_item_key: Option<&str>, active_document_id: Option<&str>, exclude_ids: &[String], ) -> Vec { let active_key = active_picker_item_key .or(active_document_id) .map(str::trim) .filter(|value| !value.is_empty()); let excluded_ids = exclude_ids.iter().cloned().collect::>(); projection .get("items") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let node_id = item .get("nodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; if excluded_ids.contains(node_id) { return None; } let row_kind = item .get("rowKind") .and_then(Value::as_str) .unwrap_or_default(); if row_kind != "document" { return None; } Some(PickerRenderRow { node_id: node_id.to_string(), parent_node_id: item .get("parentNodeId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), title: item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题") .to_string(), depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, expandable: item .get("expandable") .and_then(Value::as_bool) .unwrap_or_else(|| { item.get("childCount") .and_then(Value::as_u64) .map(|count| count > 0) .unwrap_or(false) }), expanded: item .get("expandedByDefault") .and_then(Value::as_bool) .unwrap_or(false), active: active_key .map(|active_key| active_key == node_id) .unwrap_or(false), }) }) .collect() }) .unwrap_or_default() } fn build_tree_shell_command_dispatcher(channel: &str) -> TreeShellCommandDispatcher { TreeShellCommandDispatcher { channel: channel.into(), command_names: [ "tree.node.create", "tree.node.rename", "tree.subtree.move", "tree.resource.copy", "tree.resource.move", "tree.resource.upload", ] .into_iter() .map(ToOwned::to_owned) .collect(), } } fn build_tree_shell_renderer_input( projection: &Value, mode: &str, channel: &str, active_document_id: Option<&str>, focused_document_id: Option<&str>, active_picker_item_key: Option<&str>, exclude_ids: &[String], ) -> TreeShellRendererInput { let projection_item_ids = collect_projection_item_ids(projection); let expanded_ids = collect_expanded_ids(projection); let command_dispatcher = build_tree_shell_command_dispatcher(channel); match mode { "filetree" => { let selection = active_document_id .map(str::trim) .filter(|value| !value.is_empty()) .map(|document_id| { FileTreeSelectionState::from_selected(&[ format!("doc:{document_id}"), format!("index:{document_id}"), ]) }) .unwrap_or_default(); TreeShellRendererInput::filetree(FileTreeRendererInput { projection_item_ids, expanded_ids, filetree_selection: selection, command_dispatcher, }) } "picker" => TreeShellRendererInput::picker(PickerRendererInput { projection_item_ids, expanded_ids, active_picker_item: active_picker_item_key .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), excluded_picker_ids: exclude_ids.iter().cloned().collect(), command_dispatcher, }), _ => TreeShellRendererInput::page(PageTreeRendererInput { projection_item_ids, expanded_ids, focused_id: focused_document_id .or(active_document_id) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), command_dispatcher, }), } } fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext { let mut next = context.clone(); let actor_id = actor_id .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 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, "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 initial_tree_html = match mode { "page" => render_initial_page_tree_html(&PageTreeInitialRenderInput { rows: collect_page_tree_render_rows(projection), active_node_id: active_document_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), focused_node_id: focused_document_id .or(active_document_id) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), }), "filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput { rows: collect_filetree_render_rows(projection, active_document_id), }), "picker" => render_initial_picker_html(&PickerInitialRenderInput { rows: collect_picker_render_rows( projection, active_picker_item_key, active_document_id, exclude_ids, ), allow_root_pick, root_active: active_picker_item_key .map(str::trim) .map(|value| value == "__root__") .unwrap_or(false), }), _ => String::new(), }; let root_label = root_node_id.unwrap_or("workspace_root"); let active_label = active_document_id.unwrap_or("未指定"); let template = r##" 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("__PROJECTION_JSON__", &escape_html(&projection_json)) .replace("__INITIAL_TREE_HTML__", &initial_tree_html) .replace("__APP_STATE__", &escape_inline_json(&app_state_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 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 effective_workspace_id = resolve_effective_workspace_id(&effective_context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); 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 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?; 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, ) -> 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: bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), }, target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: json!({ "documentId": document_id, "workspaceId": workspace_id, "parentId": parent_id, "title": title, "accessScope": access_scope, "content": content.unwrap_or_else(|| Value::Array(Vec::new())), }), preflight_data: None, reason: Some("tree-shell create".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } 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: bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), }, target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: json!({ "documentId": document_id, "title": title, }), 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: bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), }, target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: json!({ "documentId": document_id, "parentId": parent_id, "sortOrder": sort_order, }), preflight_data: None, reason: Some("tree-shell move".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } 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: bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), }, target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), payload: json!({ "documentId": document_id, "workspaceId": workspace_id, }), preflight_data: None, reason: Some("tree-shell purge".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }) } } } 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_convex(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_convex_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") }) } 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 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, }, "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), }, "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::Rename { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Move { 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::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::Purge { document_id, .. } => { ("purge", document_id.clone(), None, None, None) } }; 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? } _ => resolve_effective_workspace_id(&context, requested_workspace_id, true)? .expect("workspace_required 已确保存在"), }; let command_wire = create_command_wire(&context, &effective_workspace_id, request)?; let execution = execute_runtime_command_via_convex_with_artifacts( state.config(), &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 { use super::{create_command_wire, TreeCommandRequest}; 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, Method, Request, StatusCode, Uri}; 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, 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":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()), mutation_fixtures_json: Some(r#"{"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(), })) } #[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!(html.contains("tree.ready")); assert!(html.contains("test-shell")); assert!(html.contains("tree-action-menu")); assert!(html.contains("tree.page.context-menu")); assert!(html.contains("tree.page.expand.changed")); assert!(html.contains("tree.page.focus.changed")); assert!(html.contains("tree.shell.state.patch")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); assert!(html.contains("applyPageKeyboardAction")); assert!(html.contains("reducePageActionWithRuntime")); let apply_page_action_start = html .find("const applyPageKeyboardAction = (action, item, sourceElement) => {") .expect("applyPageKeyboardAction should be embedded"); let apply_page_action_end = html[apply_page_action_start..] .find("\n const postPickerFocusChange") .expect("applyPageKeyboardAction should end before picker focus handler"); let apply_page_action_body = &html[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!(html.contains("patchPageTreeActiveDom")); assert!(html.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!(html.contains("hydrateInitialPageTree")); assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(html.contains("application/x-mnote-page-tree-node")); assert!(html.contains("页面已拖放到")); assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); assert!(html.contains("setAttribute(\"aria-level\"")); } #[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!(html.contains("tree.pick.root")); assert!(html.contains("tree.picker.command")); assert!(html.contains("tree.picker.focus.changed")); assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); assert!(html.contains("applyPickerStateAction")); assert!(html.contains("postPickerPickResultToHost")); assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); assert!(html.contains("const shouldFocusDom = options.focusDom === true")); assert!(html.contains("if (shouldFocusDom) focusPickerRowElement")); assert!(html.contains("patchPickerActiveDom")); assert!(html.contains("hydrateInitialPickerTree")); assert!(html.contains("tabindex=\"")); assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); assert!(html.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!(html.contains("tree.filetree.selection.changed")); assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\"")); assert!(html.contains("applyFileTreeSelectionAction")); assert!(html.contains("tree.filetree.internal-drop")); assert!(html.contains("tree.filetree.external-drop")); assert!(html.contains("\"rowKind\":\"asset_folder\"")); assert!(html.contains("\"resourceMeta\"")); assert!(html.contains("dragover")); assert!(html.contains("hydrateInitialFileTree")); assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); } #[tokio::test] async fn tree_shell_embeds_renderer_input_contract() { let filetree_response = app() .oneshot( Request::builder() .uri("/tree?workspaceId=ws_demo&mode=filetree&activeDocumentId=page_root") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(filetree_response.status(), StatusCode::OK); let filetree_body = axum::body::to_bytes(filetree_response.into_body(), usize::MAX) .await .expect("body"); let filetree_html = String::from_utf8(filetree_body.to_vec()).expect("utf8"); assert!(filetree_html.contains("\"rendererInput\"")); assert!(filetree_html.contains("\"mode\":\"fileTree\"")); assert!(filetree_html.contains("\"filetreeSelection\"")); assert!(filetree_html.contains("\"selectedRowIds\"")); assert!(filetree_html.contains("\"commandDispatcher\"")); 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"]["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"]["commandLog"]["payload"]["streamDelta"]["op"], Value::String("move_document".into()) ); } #[tokio::test] async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() { let response = app() .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_keeps_boundary_and_trace_fields() { 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::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["boundary"], "next_sidebar_compat"); assert_eq!(payload["workspaceId"], "ws_demo"); assert!(payload["requestId"].as_str().is_some()); assert!(payload["traceId"].as_str().is_some()); } #[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())), }, ) .expect("create wire"); assert_eq!(create_wire.name, "tree.node.create"); let rename_wire = create_command_wire( &context, "ws_demo", TreeCommandRequest::Rename { workspace_id: Some("ws_demo".into()), document_id: "page_child".into(), title: "重命名".into(), }, ) .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, }, ) .expect("move wire"); assert_eq!(move_wire.name, "tree.subtree.move"); } #[test] fn tree_commands_keep_documents_alias_mapping_for_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())), }, ) .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, "documents:createWithParentReference" ); assert_eq!( tree_create_plan.function_name, compat_create_plan.function_name ); 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(), }, ) .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, "documents:updateTitle"); assert_eq!( tree_rename_plan.function_name, compat_rename_plan.function_name ); 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, }, ) .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, "documents:move"); assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name); } }