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::legacy_cloud_guard::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<String>,
    pub root_node_id: Option<String>,
    pub depth: Option<u32>,
    pub active_document_id: Option<String>,
    pub focused_document_id: Option<String>,
    pub active_picker_item_key: Option<String>,
    pub actor_id: Option<String>,
    pub channel: Option<String>,
    pub host: Option<String>,
    pub mode: Option<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    pub allow_root_pick: Option<String>,
    pub exclude_ids: Option<String>,
}

#[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<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    #[serde(default)]
    pub source_capabilities: Vec<String>,
    pub target_node_id: Option<String>,
    pub target_resource_meta: Option<Value>,
    pub selection: Option<Value>,
    pub operation: Option<String>,
    pub batch_id: Option<String>,
    pub document_id: Option<String>,
    pub parent_id: Option<String>,
    pub target_parent_id: Option<String>,
    pub title: Option<String>,
    pub access_scope: Option<String>,
    pub content: Option<Value>,
    pub sort_order: Option<i64>,
    #[serde(default)]
    pub items: Vec<TreeCommandCopyItem>,
}

#[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<String>,
    pub root_uri: Option<String>,
    pub source_capabilities: Vec<String>,
    pub target_node_id: Option<String>,
    pub target_resource_meta: Option<Value>,
    pub selection: Option<Value>,
    pub operation: Option<String>,
    pub batch_id: Option<String>,
}

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<String>,
        document_id: String,
        parent_id: Option<String>,
        title: String,
        access_scope: Option<String>,
        content: Option<Value>,
    },
    CreateFolder {
        workspace_id: Option<String>,
        document_id: String,
        parent_id: Option<String>,
        title: String,
    },
    Rename {
        workspace_id: Option<String>,
        document_id: String,
        title: String,
    },
    Move {
        workspace_id: Option<String>,
        document_id: String,
        parent_id: Option<String>,
        sort_order: i64,
    },
    Archive {
        workspace_id: Option<String>,
        document_id: String,
    },
    Restore {
        workspace_id: Option<String>,
        document_id: String,
    },
    Copy {
        workspace_id: Option<String>,
        document_id: String,
        target_parent_id: Option<String>,
        items: Vec<TreeCommandCopyItem>,
        title: Option<String>,
    },
    DropFiles {
        workspace_id: Option<String>,
        parent_id: Option<String>,
        files_json: String,
    },
    Purge {
        workspace_id: Option<String>,
        document_id: String,
    },
}

fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn escape_inline_json(input: &str) -> String {
    input
        .replace('&', "\\u0026")
        .replace('<', "\\u003c")
        .replace('>', "\\u003e")
}

fn normalize_title(value: Option<String>) -> String {
    let trimmed = value.unwrap_or_default().trim().to_string();
    if trimmed.is_empty() {
        "无标题".into()
    } else {
        trimmed
    }
}

fn normalize_channel(value: Option<String>) -> 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<String> {
    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<String> {
    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<String> {
    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<PageTreeRenderRow> {
    let mut rows: Vec<PageTreeRenderRow> = 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::<BTreeMap<_, _>>();
    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<FileTreeRenderRow> {
    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<PickerRenderRow> {
    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::<BTreeSet<_>>();

    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##"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>mnote Tree Shell</title>
    <style>
      :root {
        color-scheme: light;
        --bg: #f3efe7;
        --panel: rgba(255, 252, 247, 0.96);
        --panel-strong: #ffffff;
        --ink: #18222f;
        --muted: #64748b;
        --line: rgba(148, 163, 184, 0.28);
        --accent: #0f6c84;
        --accent-soft: rgba(15, 108, 132, 0.12);
        --danger: #b42318;
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
        color: var(--ink);
        background:
          radial-gradient(circle at top left, rgba(15, 108, 132, 0.18), transparent 32%),
          linear-gradient(180deg, #fbf8f3 0%, var(--bg) 100%);
      }
      main {
        min-height: 100vh;
        display: grid;
        grid-template-rows: auto auto minmax(0, 1fr);
        gap: 16px;
        padding: 18px;
      }
      .hero,
      .status-card,
      .tree-card {
        background: var(--panel);
        border: 1px solid var(--line);
        border-radius: 22px;
        box-shadow: 0 18px 40px rgba(24, 34, 47, 0.08);
      }
      .hero {
        padding: 18px 20px;
        display: grid;
        gap: 8px;
      }
      .eyebrow {
        font-size: 12px;
        letter-spacing: 0.12em;
        text-transform: uppercase;
        color: var(--accent);
      }
      h1 {
        margin: 0;
        font-size: clamp(24px, 4vw, 34px);
        line-height: 1.05;
      }
      .summary {
        margin: 0;
        color: var(--muted);
        line-height: 1.55;
      }
      .meta-grid {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
        gap: 10px;
      }
      .meta-pill {
        border-radius: 14px;
        padding: 10px 12px;
        background: rgba(255, 255, 255, 0.78);
        border: 1px solid rgba(148, 163, 184, 0.2);
      }
      .meta-pill strong {
        display: block;
        margin-bottom: 4px;
        font-size: 12px;
        color: var(--muted);
      }
      .status-card {
        padding: 14px 16px;
        display: grid;
        gap: 10px;
      }
      .toolbar {
        display: flex;
        gap: 10px;
        flex-wrap: wrap;
        align-items: center;
      }
      .toolbar button,
      .tree-action,
      .tree-toggle,
      .tree-link {
        font: inherit;
      }
      .toolbar button {
        border: 0;
        border-radius: 12px;
        padding: 10px 14px;
        background: var(--accent);
        color: #fff;
        cursor: pointer;
      }
      .toolbar button:hover {
        filter: brightness(0.97);
      }
      .toolbar button:disabled {
        opacity: 0.55;
        cursor: progress;
      }
      .status-text {
        font-size: 14px;
        color: var(--muted);
      }
      .status-text[data-tone="error"] {
        color: var(--danger);
      }
      .tree-card {
        display: grid;
        grid-template-rows: auto minmax(0, 1fr) auto;
        min-height: 0;
        overflow: hidden;
      }
      .tree-card-header {
        display: flex;
        justify-content: space-between;
        gap: 12px;
        align-items: baseline;
        padding: 16px 18px 12px;
        border-bottom: 1px solid rgba(148, 163, 184, 0.18);
      }
      .tree-card-title {
        font-size: 18px;
        font-weight: 700;
      }
      .tree-card-meta {
        font-size: 12px;
        color: var(--muted);
      }
      .tree-scroll {
        min-height: 0;
        overflow: auto;
        padding: 14px 12px 18px;
      }
      .tree-root,
      .tree-children {
        list-style: none;
        margin: 0;
        padding: 0;
      }
      .tree-children {
        margin-left: 22px;
        padding-left: 14px;
        border-left: 1px dashed rgba(15, 108, 132, 0.2);
      }
      .tree-node + .tree-node {
        margin-top: 8px;
      }
      .tree-row {
        display: grid;
        grid-template-columns: auto minmax(0, 1fr) auto;
        gap: 8px;
        align-items: center;
        border-radius: 16px;
        border: 1px solid rgba(148, 163, 184, 0.18);
        background: var(--panel-strong);
        padding: 8px;
      }
      .tree-row[data-focused="true"] {
        border-color: rgba(15, 108, 132, 0.48);
        box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.18);
      }
      .tree-row[data-active="true"] {
        border-color: rgba(15, 108, 132, 0.32);
        box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.12);
        background: linear-gradient(180deg, rgba(15, 108, 132, 0.08), rgba(255, 255, 255, 0.96));
      }
      .tree-row[data-selected="true"] {
        background: rgba(15, 108, 132, 0.1);
        border-color: rgba(15, 108, 132, 0.24);
      }
      .tree-row[data-drop-feedback="true"] {
        background: rgba(148, 163, 184, 0.18);
        border-color: rgba(15, 108, 132, 0.28);
      }
      .tree-row[data-draggable="true"] {
        cursor: grab;
      }
      .tree-row[data-draggable="true"]:active {
        cursor: grabbing;
      }
      .tree-toggle,
      .tree-action {
        border: 0;
        border-radius: 12px;
        background: transparent;
        color: var(--muted);
        cursor: pointer;
        padding: 6px 8px;
      }
      .tree-toggle:hover,
      .tree-action:hover {
        background: rgba(15, 108, 132, 0.08);
        color: var(--ink);
      }
      .tree-toggle[disabled],
      .tree-action[disabled] {
        opacity: 0.4;
        cursor: not-allowed;
      }
      .tree-spacer {
        width: 34px;
        height: 30px;
      }
      .tree-link {
        border: 0;
        background: transparent;
        padding: 6px 8px;
        text-align: left;
        cursor: pointer;
        display: grid;
        gap: 4px;
        min-width: 0;
      }
      .tree-link-title {
        font-size: 14px;
        font-weight: 600;
        color: var(--ink);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .tree-link-meta {
        font-size: 12px;
        color: var(--muted);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .tree-kind-badge {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 22px;
        height: 22px;
        border-radius: 999px;
        background: rgba(15, 108, 132, 0.08);
        color: var(--accent);
        font-size: 11px;
        font-weight: 700;
      }
      .tree-actions {
        display: flex;
        gap: 4px;
        flex-wrap: wrap;
        justify-content: flex-end;
        opacity: 0;
        pointer-events: none;
        transition: opacity 140ms ease;
      }
      .tree-row:hover .tree-actions,
      .tree-row[data-focused="true"] .tree-actions,
      .tree-row:focus-within .tree-actions {
        opacity: 1;
        pointer-events: auto;
      }
      .tree-link:focus-visible,
      .tree-toggle:focus-visible,
      .tree-action:focus-visible,
      .tree-row:focus-visible {
        outline: 2px solid rgba(15, 108, 132, 0.35);
        outline-offset: 2px;
      }
      .tree-empty {
        border-radius: 18px;
        border: 1px dashed rgba(148, 163, 184, 0.36);
        padding: 24px 18px;
        text-align: center;
        color: var(--muted);
        background: rgba(255, 255, 255, 0.64);
      }
      .tree-card-footer {
        border-top: 1px solid rgba(148, 163, 184, 0.18);
        padding: 12px 16px 16px;
      }
      details {
        border-radius: 16px;
        border: 1px solid rgba(148, 163, 184, 0.22);
        background: rgba(255, 255, 255, 0.72);
        overflow: hidden;
      }
      summary {
        cursor: pointer;
        list-style: none;
        padding: 12px 14px;
        font-weight: 600;
      }
      summary::-webkit-details-marker {
        display: none;
      }
      pre {
        margin: 0;
        padding: 0 14px 14px;
        overflow: auto;
        font-size: 12px;
        line-height: 1.55;
        color: #243447;
      }
      /* 说明：这里用覆盖式样式把原本的开发态卡片壳压成真正的侧栏树视图，
         避免继续保留 hero/status/debug 大面板。 */
      body {
        background: #ffffff;
      }
      main {
        min-height: 100vh;
        display: grid;
        grid-template-rows: auto minmax(0, 1fr);
        gap: 0;
        padding: 0;
      }
      .hero,
      .status-card,
      .tree-card-footer,
      details,
      summary,
      pre {
        display: none !important;
      }
      .tree-card {
        border: 0;
        border-radius: 0;
        box-shadow: none;
        background: #ffffff;
      }
      .tree-card-header {
        padding: 8px 10px 6px;
        border-bottom: 1px solid rgba(31, 35, 40, 0.08);
        background: #ffffff;
        min-height: 40px;
      }
      .tree-card-title {
        font-size: 12px;
        font-weight: 600;
        color: #6b7280;
      }
      .tree-card-meta {
        display: none;
      }
      .toolbar {
        gap: 6px;
        justify-content: flex-end;
      }
      .toolbar button {
        border-radius: 6px;
        padding: 4px 8px;
        background: transparent;
        color: #6b7280;
        font-size: 12px;
      }
      .toolbar button:hover {
        background: rgba(15, 23, 42, 0.05);
        filter: none;
        color: #1f2328;
      }
      .tree-scroll {
        padding: 6px 4px 12px;
      }
      .tree-children {
        margin-left: 18px;
        padding-left: 10px;
        border-left: 0;
      }
      .tree-node + .tree-node {
        margin-top: 1px;
      }
      .tree-row {
        grid-template-columns: auto auto minmax(0, 1fr) auto;
        gap: 4px;
        min-height: 26px;
        border-radius: 6px;
        border: 0;
        background: transparent;
        padding: 1px 4px;
        position: relative;
      }
      .tree-row:hover {
        background: rgba(15, 23, 42, 0.04);
      }
      .tree-row[data-focused="true"] {
        border-color: transparent;
        box-shadow: none;
        background: rgba(37, 99, 235, 0.08);
      }
      .tree-row[data-active="true"],
      .tree-row[data-selected="true"] {
        border-color: transparent;
        box-shadow: none;
        background: rgba(37, 99, 235, 0.12);
      }
      .tree-row[data-drop-target="true"] {
        background: rgba(15, 108, 132, 0.14);
        outline: 1px solid rgba(15, 108, 132, 0.35);
      }
      .tree-row[data-cut="true"] {
        opacity: 0.55;
      }
      .tree-row[data-drop-position="before"]::before,
      .tree-row[data-drop-position="after"]::after {
        content: "";
        position: absolute;
        left: 20px;
        right: 8px;
        height: 2px;
        border-radius: 999px;
        background: rgba(37, 99, 235, 0.82);
      }
      .tree-row[data-drop-position="before"]::before {
        top: 0;
      }
      .tree-row[data-drop-position="after"]::after {
        bottom: 0;
      }
      .tree-root[data-drop-target="true"] {
        background: rgba(15, 108, 132, 0.05);
      }
      .tree-context-menu {
        position: fixed;
        z-index: 50;
        min-width: 188px;
        padding: 4px;
        border: 1px solid rgba(31, 35, 40, 0.12);
        border-radius: 6px;
        background: #ffffff;
        box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
      }
      .tree-context-menu[hidden] {
        display: none;
      }
      .tree-menu-item {
        width: 100%;
        min-height: 26px;
        border: 0;
        border-radius: 4px;
        padding: 4px 8px;
        background: transparent;
        color: #1f2328;
        font-size: 12px;
        text-align: left;
      }
      .tree-menu-item:hover:not(:disabled),
      .tree-menu-item:focus-visible:not(:disabled) {
        background: rgba(37, 99, 235, 0.08);
        outline: none;
      }
      .tree-menu-item:disabled {
        color: #9ca3af;
        cursor: default;
      }
      .tree-menu-separator {
        height: 1px;
        margin: 4px 2px;
        background: rgba(31, 35, 40, 0.08);
      }
      .tree-preflight-backdrop {
        position: fixed;
        inset: 0;
        z-index: 60;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 20px;
        background: rgba(15, 23, 42, 0.28);
      }
      .tree-preflight-dialog {
        width: min(420px, 100%);
        border-radius: 8px;
        border: 1px solid rgba(31, 35, 40, 0.12);
        background: #ffffff;
        box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
      }
      .tree-preflight-body {
        padding: 16px;
      }
      .tree-preflight-title {
        margin: 0 0 8px;
        color: #1f2328;
        font-size: 14px;
        font-weight: 600;
      }
      .tree-preflight-list {
        margin: 0;
        padding-left: 18px;
        color: #4b5563;
        font-size: 12px;
        line-height: 1.5;
      }
      .tree-preflight-actions {
        display: flex;
        justify-content: flex-end;
        gap: 8px;
        padding: 10px 16px;
        border-top: 1px solid rgba(31, 35, 40, 0.08);
      }
      .tree-preflight-actions button {
        min-width: 64px;
        min-height: 28px;
        border: 1px solid rgba(31, 35, 40, 0.12);
        border-radius: 6px;
        padding: 4px 10px;
        background: #ffffff;
        color: #1f2328;
        font-size: 12px;
      }
      .tree-preflight-actions [data-role="confirm"] {
        border-color: #2563eb;
        background: #2563eb;
        color: #ffffff;
      }
      .tree-toggle,
      .tree-action {
        border-radius: 4px;
        width: 20px;
        height: 20px;
        padding: 0;
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }
      .tree-spacer {
        width: 16px;
        height: 16px;
      }
      .tree-link {
        padding: 2px 4px;
        gap: 0;
        min-width: 0;
      }
      .tree-link-title {
        font-size: 13px;
        font-weight: 500;
        line-height: 1.35;
        color: #1f2328;
      }
      .tree-rename-input {
        width: 100%;
        min-width: 80px;
        height: 22px;
        border: 1px solid rgba(37, 99, 235, 0.8);
        border-radius: 4px;
        padding: 0 4px;
        background: #fff;
        color: #1f2328;
        font: inherit;
        line-height: 20px;
        outline: none;
      }
      .tree-link-meta {
        display: none;
      }
      .tree-kind-badge {
        min-width: 16px;
        width: 16px;
        height: 16px;
        border-radius: 4px;
        background: transparent;
        color: #6b7280;
        font-size: 10px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }
      .tree-kind-badge svg,
      .tree-action svg {
        width: 14px;
        height: 14px;
        display: block;
      }
      .tree-kind-badge[data-kind="page"] {
        color: #2563eb;
      }
      .tree-kind-badge[data-kind="index"] {
        color: #0f766e;
      }
      .tree-kind-badge[data-kind="mindmap"] {
        color: #7c3aed;
      }
      .tree-kind-badge[data-kind="table"] {
        color: #b45309;
      }
      .tree-kind-badge[data-kind="pdf"] {
        color: #dc2626;
      }
      .tree-kind-badge[data-kind="book"] {
        color: #0f766e;
      }
      .tree-kind-badge[data-kind="image"] {
        color: #0891b2;
      }
      .tree-kind-badge[data-kind="video"] {
        color: #ea580c;
      }
      .tree-kind-badge[data-kind="audio"] {
        color: #16a34a;
      }
      .tree-kind-badge[data-kind="file"] {
        color: #64748b;
      }
      .tree-actions {
        gap: 2px;
        align-items: center;
        flex-wrap: nowrap;
      }
      .tree-action {
        color: #6b7280;
      }
      .tree-action:hover {
        color: #1f2328;
        background: rgba(15, 23, 42, 0.06);
      }
      .tree-row[data-shell-mode="page"] .tree-link {
        padding-right: 42px;
      }
      .tree-row[data-shell-mode="page"] .tree-actions {
        position: absolute;
        right: 6px;
        top: 50%;
        transform: translateY(-50%);
      }
      .tree-row[data-shell-mode="filetree"] .tree-link {
        padding-right: 30px;
      }
      .tree-row[data-shell-mode="filetree"] .tree-actions {
        position: absolute;
        right: 6px;
        top: 50%;
        transform: translateY(-50%);
      }
      .tree-empty {
        border: 0;
        border-radius: 0;
        padding: 16px 10px;
        text-align: left;
        background: transparent;
      }
      @media (max-width: 860px) {
        main {
          padding: 0;
        }
        .tree-row {
          grid-template-columns: auto auto minmax(0, 1fr);
        }
        .tree-actions {
          position: static;
          justify-content: flex-end;
          padding-left: 0;
        }
      }
    </style>
  </head>
  <body data-mnote-root-uri="__ROOT_URI__">
    <main>
      <section class="tree-card">
        <div class="tree-card-header">
          <div class="tree-card-title" id="tree-shell-title">Sidebar / Page Tree</div>
          <div class="tree-card-meta" id="tree-shell-summary">workspace=__WORKSPACE_ID__ · root=__ROOT_LABEL__ · active=__ACTIVE_LABEL__</div>
          <div class="toolbar" id="tree-shell-toolbar">
            <button id="tree-create-root" data-testid="tree-create-root" type="button">新建</button>
          </div>
        </div>
        <div class="tree-scroll" id="tree-shell-app">__INITIAL_TREE_HTML__</div>
      </section>
      <div class="status-text" id="tree-shell-status" data-tone="normal" hidden>Tree shell 已加载</div>
      <div class="status-text" id="tree-shell-last-action" data-tone="normal" hidden></div>
    </main>

    <script id="tree-shell-state" type="application/json">__APP_STATE__</script>
    <script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json">__TREE_LIVE_BOOTSTRAP__</script>
    <script type="module" src="__TREE_SHELL_RUNTIME_SRC__"></script>
    <script type="module" src="__TREE_LIVE_CONTROLLER_SRC__"></script>
  </body>
</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<Value>) {
    (
        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<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<LocalFolderWatchQuery>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<TreeShellQuery>,
) -> Result<Response, WebError> {
    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<RuntimeCommandEnvelopeWire, WebError> {
    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::<Result<Vec<_>, 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<Value, WebError> {
    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<String, WebError> {
    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<String> {
    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<String> {
    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<AppState>,
    Extension(context): Extension<RequestContext>,
    body: String,
) -> Result<(StatusCode, Json<Value>), 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<TreeShellRuntimeRequest>,
) -> Json<TreeShellRuntimeResult> {
    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(&copy_body)
        );
        let copy_payload: Value = serde_json::from_slice(&copy_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::<Uri>().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::<Uri>().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::<Uri>().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"
        }));
    }
}
