Files
mnote/rust/crates/mnote-web/src/routes/tree.rs
T

5467 lines
208 KiB
Rust

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
};
use crate::tree_shell::picker_renderer::{
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TREE_DOCUMENT_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellQuery {
pub workspace_id: Option<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 allow_root_pick: Option<String>,
pub exclude_ids: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeCommandEnvelope {
pub action: String,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
pub parent_id: Option<String>,
pub title: Option<String>,
pub access_scope: Option<String>,
pub content: Option<Value>,
pub sort_order: Option<i64>,
}
#[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>,
},
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,
},
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 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> {
projection
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let node_id = item
.get("nodeId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let row_kind = item
.get("rowKind")
.and_then(Value::as_str)
.unwrap_or_default();
if row_kind != "document" {
return None;
}
Some(PageTreeRenderRow {
node_id: node_id.to_string(),
parent_node_id: item
.get("parentNodeId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
title: item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题")
.to_string(),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
}),
expanded: item
.get("expandedByDefault")
.and_then(Value::as_bool)
.unwrap_or(false),
})
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn collect_filetree_render_rows(
projection: &Value,
active_document_id: Option<&str>,
) -> Vec<FileTreeRenderRow> {
let selected_ids = active_document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|document_id| {
[format!("doc:{document_id}"), format!("index:{document_id}")]
.into_iter()
.collect::<BTreeSet<_>>()
})
.unwrap_or_default();
projection
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let row_id = item
.get("rowId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let node_id = item
.get("nodeId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let resource_meta = item.get("resourceMeta").and_then(Value::as_object);
Some(FileTreeRenderRow {
row_id: row_id.to_string(),
row_kind: item
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("document")
.to_string(),
node_id: node_id.to_string(),
parent_node_id: item
.get("parentNodeId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
title: item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题")
.to_string(),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
}),
expanded: item
.get("expandedByDefault")
.and_then(Value::as_bool)
.unwrap_or(false),
icon_kind: item
.get("iconHint")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("iconHint"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file")
.to_string(),
document_id: resource_meta
.and_then(|meta| meta.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
asset_id: resource_meta
.and_then(|meta| meta.get("assetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
selected: selected_ids.contains(row_id),
})
})
.collect()
})
.unwrap_or_default()
}
fn collect_picker_render_rows(
projection: &Value,
active_picker_item_key: Option<&str>,
active_document_id: Option<&str>,
exclude_ids: &[String],
) -> Vec<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",
]
.into_iter()
.map(ToOwned::to_owned)
.collect(),
}
}
fn build_tree_shell_renderer_input(
projection: &Value,
mode: &str,
channel: &str,
active_document_id: Option<&str>,
focused_document_id: Option<&str>,
active_picker_item_key: Option<&str>,
exclude_ids: &[String],
) -> TreeShellRendererInput {
let projection_item_ids = collect_projection_item_ids(projection);
let expanded_ids = collect_expanded_ids(projection);
let command_dispatcher = build_tree_shell_command_dispatcher(channel);
match mode {
"filetree" => {
let selection = active_document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|document_id| {
FileTreeSelectionState::from_selected(&[
format!("doc:{document_id}"),
format!("index:{document_id}"),
])
})
.unwrap_or_default();
TreeShellRendererInput::filetree(FileTreeRendererInput {
projection_item_ids,
expanded_ids,
filetree_selection: selection,
command_dispatcher,
})
}
"picker" => TreeShellRendererInput::picker(PickerRendererInput {
projection_item_ids,
expanded_ids,
active_picker_item: active_picker_item_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
excluded_picker_ids: exclude_ids.iter().cloned().collect(),
command_dispatcher,
}),
_ => TreeShellRendererInput::page(PageTreeRendererInput {
projection_item_ids,
expanded_ids,
focused_id: focused_document_id
.or(active_document_id)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
command_dispatcher,
}),
}
}
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
let mut next = context.clone();
let actor_id = actor_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(actor_id) = actor_id {
next.auth.actor_id = actor_id;
next.auth.actor_type = "user".into();
}
next
}
fn generate_tree_document_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let counter = TREE_DOCUMENT_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("tree_{millis}_{counter}")
}
fn build_tree_shell_html(
workspace_id: &str,
root_node_id: Option<&str>,
active_document_id: Option<&str>,
focused_document_id: Option<&str>,
active_picker_item_key: Option<&str>,
channel: &str,
host: Option<&str>,
context: &RequestContext,
projection: &Value,
mode: &str,
allow_root_pick: bool,
exclude_ids: &[String],
dataset: &Value,
) -> String {
let renderer_input = build_tree_shell_renderer_input(
projection,
mode,
channel,
active_document_id,
focused_document_id,
active_picker_item_key,
exclude_ids,
);
let app_state = json!({
"workspaceId": workspace_id,
"rootNodeId": root_node_id,
"activeDocumentId": active_document_id,
"focusedDocumentId": focused_document_id,
"activePickerItemKey": active_picker_item_key,
"actorId": context.auth.actor_id,
"channel": channel,
"host": host,
"mode": mode,
"allowRootPick": allow_root_pick,
"excludeIds": exclude_ids,
"rendererInput": renderer_input,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"commandPath": "/api/tree/commands",
"items": projection.get("items").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
"mediaAssets": dataset.get("media_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
"mindmapAssets": dataset.get("mindmap_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
"tableAssets": dataset.get("table_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
"mindmapAssetChildren": dataset.get("mindmap_asset_children").cloned().unwrap_or_else(|| json!({})),
});
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
let initial_tree_html = match mode {
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: collect_page_tree_render_rows(projection),
active_node_id: active_document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
focused_node_id: focused_document_id
.or(active_document_id)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
}),
"filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput {
rows: collect_filetree_render_rows(projection, active_document_id),
}),
"picker" => render_initial_picker_html(&PickerInitialRenderInput {
rows: collect_picker_render_rows(
projection,
active_picker_item_key,
active_document_id,
exclude_ids,
),
allow_root_pick,
root_active: active_picker_item_key
.map(str::trim)
.map(|value| value == "__root__")
.unwrap_or(false),
}),
_ => String::new(),
};
let root_label = root_node_id.unwrap_or("workspace_root");
let active_label = active_document_id.unwrap_or("未指定");
let template = r##"<!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-root[data-drop-target="true"] {
background: rgba(15, 108, 132, 0.05);
}
.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-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>
<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>
(() => {
const stateElement = document.getElementById("tree-shell-state");
const appElement = document.getElementById("tree-shell-app");
const statusElement = document.getElementById("tree-shell-status");
const lastActionElement = document.getElementById("tree-shell-last-action");
const createRootButton = document.getElementById("tree-create-root");
if (!stateElement || !appElement || !statusElement || !lastActionElement || !createRootButton) {
return;
}
const parseState = () => {
try {
return JSON.parse(stateElement.textContent || "{}");
} catch {
return {};
}
};
const state = parseState();
const rendererInput =
state.rendererInput && typeof state.rendererInput === "object"
? state.rendererInput
: {};
const rendererFiletreeSelection =
rendererInput.filetreeSelection && typeof rendererInput.filetreeSelection === "object"
? rendererInput.filetreeSelection
: {};
const filetreeSelectionReducer =
rendererInput.filetreeSelectionReducer &&
typeof rendererInput.filetreeSelectionReducer === "object"
? rendererInput.filetreeSelectionReducer
: {};
const pickerStateReducer =
rendererInput.pickerStateReducer &&
typeof rendererInput.pickerStateReducer === "object"
? rendererInput.pickerStateReducer
: {};
const pageFocusKeyboardReducer =
rendererInput.pageFocusKeyboardReducer &&
typeof rendererInput.pageFocusKeyboardReducer === "object"
? rendererInput.pageFocusKeyboardReducer
: {};
const runtimeArtifact =
rendererInput.runtimeArtifact && typeof rendererInput.runtimeArtifact === "object"
? rendererInput.runtimeArtifact
: {};
const runtimeApi =
runtimeArtifact.runtimeApi && typeof runtimeArtifact.runtimeApi === "object"
? runtimeArtifact.runtimeApi
: {};
const normalizeStringArray = (value) =>
Array.isArray(value)
? value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean)
: [];
const hostOverride =
window.__MNOTE_TREE_SHELL_OVERRIDE__ &&
typeof window.__MNOTE_TREE_SHELL_OVERRIDE__ === "object"
? window.__MNOTE_TREE_SHELL_OVERRIDE__
: {};
const channel =
typeof state.channel === "string" && state.channel.trim()
? state.channel.trim()
: "mnote-tree-shell-v1";
const workspaceId =
typeof state.workspaceId === "string" && state.workspaceId.trim()
? state.workspaceId.trim()
: "";
const actorId =
typeof state.actorId === "string" && state.actorId.trim()
? state.actorId.trim()
: "";
const activeDocumentId =
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
? state.activeDocumentId.trim()
: "";
const focusedDocumentId =
typeof state.focusedDocumentId === "string" && state.focusedDocumentId.trim()
? state.focusedDocumentId.trim()
: "";
const activePickerItemKey =
typeof rendererInput.activePickerItem === "string" && rendererInput.activePickerItem.trim()
? rendererInput.activePickerItem.trim()
: typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim()
? state.activePickerItemKey.trim()
: "";
const mode = (() => {
const rawMode =
typeof state.mode === "string" ? state.mode.trim() : "";
if (rawMode === "picker") return "picker";
if (rawMode === "filetree") return "filetree";
return "page";
})();
const allowRootPick = state.allowRootPick === true;
const excludedIds = new Set(
normalizeStringArray(rendererInput.excludedPickerIds).length > 0
? normalizeStringArray(rendererInput.excludedPickerIds)
: normalizeStringArray(state.excludeIds),
);
const pickerStateReducerContractName =
typeof pickerStateReducer.contractName === "string" &&
pickerStateReducer.contractName.trim()
? pickerStateReducer.contractName.trim()
: "";
const pickerStateReducerActions = new Set(
normalizeStringArray(pickerStateReducer.actions),
);
const pageFocusKeyboardReducerContractName =
typeof pageFocusKeyboardReducer.contractName === "string" &&
pageFocusKeyboardReducer.contractName.trim()
? pageFocusKeyboardReducer.contractName.trim()
: "";
const pageFocusKeyboardReducerActions = new Set(
normalizeStringArray(pageFocusKeyboardReducer.actions),
);
const commandPath =
typeof state.commandPath === "string" && state.commandPath.trim()
? state.commandPath.trim()
: "/api/tree/commands";
const mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
const mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
const tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
const mindmapAssetChildren =
state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object"
? state.mindmapAssetChildren
: {};
const titleElement = document.getElementById("tree-shell-title");
const summaryElement = document.getElementById("tree-shell-summary");
const toolbarElement = document.getElementById("tree-shell-toolbar");
const targetOrigin = (() => {
try {
if (!document.referrer) return "*";
return new URL(document.referrer).origin || "*";
} catch {
return "*";
}
})();
const normalizeText = (value, fallback = "") => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed || fallback;
};
const runtimeReduceEndpoint = normalizeText(
runtimeApi.reduceEndpoint,
"/api/tree/runtime/reduce",
);
const normalizeParent = (value) => {
const normalized = normalizeText(value);
return normalized || null;
};
const normalizeNumber = (value, fallback = Number.MAX_SAFE_INTEGER) => {
return Number.isFinite(value) ? Number(value) : fallback;
};
const normalizeRowKind = (value) => {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "index") return "index";
if (normalized === "asset") return "asset";
if (normalized === "asset_folder") return "asset_folder";
return "document";
};
const normalizeCapabilities = (value) =>
Array.isArray(value)
? value
.map((item) => normalizeText(item))
.filter(Boolean)
: [];
const normalizeResourceMeta = (value) => {
if (!value || typeof value !== "object") {
return {
resourceKind: "",
documentId: "",
assetId: "",
assetKind: "",
};
}
return {
resourceKind: normalizeText(value?.resourceKind),
documentId: normalizeText(value?.documentId),
assetId: normalizeText(value?.assetId),
assetKind: normalizeText(value?.assetKind),
};
};
const rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
const normalizedItems = Array.isArray(rawItems)
? rawItems
.map((item) => {
const nodeId = normalizeText(item?.nodeId);
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const rowKind = normalizeRowKind(item?.rowKind);
const fallbackRowId =
rowKind === "index"
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
const itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
const fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
const childrenByParentId = new Map();
const roots = [];
const assetsByDocId = new Map();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
const documentId = normalizeText(asset?.document_id);
const assetId = normalizeText(asset?.id);
if (!documentId || !assetId) return;
const bucket = assetsByDocId.get(documentId) || [];
bucket.push({
id: assetId,
documentId,
assetType: normalizeText(asset?.asset_type, "file"),
fileName: normalizeText(asset?.file_name, "附件"),
storagePath: normalizeText(asset?.storage_path),
});
assetsByDocId.set(documentId, bucket);
});
const compareItems = (left, right) => {
const byPosition = left.position - right.position;
if (byPosition !== 0) return byPosition;
return left.title.localeCompare(right.title, "zh-CN");
};
normalizedItems.forEach((item) => {
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
if (!parentId) {
roots.push(item);
return;
}
const bucket = childrenByParentId.get(parentId) || [];
bucket.push(item);
childrenByParentId.set(parentId, bucket);
});
roots.sort(compareItems);
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);
const expanded = new Set(
rendererExpandedIds.length > 0
? rendererExpandedIds
: normalizedItems
.filter((item) => item.childCount > 0 && item.expandedByDefault)
.map((item) => item.nodeId),
);
let currentActiveDocumentId = activeDocumentId;
let currentFocusedDocumentId = focusedDocumentId;
let currentActivePickerItemKey = activePickerItemKey;
const resolvePickerRootFocused = () =>
mode === "picker" && currentActivePickerItemKey === "__root__";
const resolveFocusedNodeIdFromHostState = () => {
const pickerRootFocused = resolvePickerRootFocused();
return mode === "picker"
? currentActivePickerItemKey &&
currentActivePickerItemKey !== "__root__" &&
itemById.has(currentActivePickerItemKey)
? currentActivePickerItemKey
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
? currentActiveDocumentId
: pickerRootFocused
? ""
: roots[0]?.nodeId || ""
: currentFocusedDocumentId && itemById.has(currentFocusedDocumentId)
? currentFocusedDocumentId
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
? currentActiveDocumentId
: roots[0]?.nodeId || "";
};
let focusedNodeId = resolveFocusedNodeIdFromHostState();
const rendererSelectedFileTreeRowIds = normalizeStringArray(
rendererFiletreeSelection.selectedRowIds,
);
const rendererAnchorRowId =
typeof rendererFiletreeSelection.anchorRowId === "string" &&
rendererFiletreeSelection.anchorRowId.trim()
? rendererFiletreeSelection.anchorRowId.trim()
: null;
const rendererFocusedRowId =
typeof rendererFiletreeSelection.focusedRowId === "string" &&
rendererFiletreeSelection.focusedRowId.trim()
? rendererFiletreeSelection.focusedRowId.trim()
: null;
const filetreeSelectionReducerContractName =
typeof filetreeSelectionReducer.contractName === "string" &&
filetreeSelectionReducer.contractName.trim()
? filetreeSelectionReducer.contractName.trim()
: "";
const filetreeSelectionReducerActions = new Set(
normalizeStringArray(filetreeSelectionReducer.actions),
);
let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : []
);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let visibleFileTreeRowIds = [];
let draggingPageNodeId = "";
let activePageDropNodeId = null;
let draggingFileTreeRowIds = [];
let activeFileTreeDropRowId = null;
let activeFileTreeRootDrop = false;
let activeCursor = itemById.get(currentActiveDocumentId) || null;
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
expanded.add(activeCursor.parentNodeId);
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
}
assetsByDocId.forEach((_, documentId) => {
if (itemById.has(documentId)) {
expanded.add(documentId);
}
});
if (titleElement) {
titleElement.textContent =
mode === "picker"
? "页面选择"
: mode === "filetree"
? "资源管理器"
: "页面树";
}
if (summaryElement) {
summaryElement.textContent =
mode === "picker"
? "这个页面复用统一 projection 协议,以轻量选择器模式承载 move / embed picker。当前阶段只负责树浏览与目标选择,不承接命令写链。"
: mode === "filetree"
? "这个页面以文件树模式消费 Rust 侧 projection,并承载页面、index 与附件浏览。当前阶段仍是过渡验证壳,但 filetree 协议与渲染分支必须保持闭环。"
: "这个页面直接消费 Rust 侧 projection,并通过统一 command route 回写树操作。当前阶段先交付最小可交互壳,用于主 Sidebar 页面树切流与真实网页验证。";
}
if (toolbarElement && mode === "picker") {
toolbarElement.style.display = "none";
}
let busy = false;
const setBusy = (nextBusy) => {
busy = nextBusy;
createRootButton.disabled = nextBusy;
appElement.querySelectorAll("button").forEach((button) => {
button.disabled = nextBusy;
});
};
const setStatus = (message, tone = "normal") => {
statusElement.textContent = message;
statusElement.dataset.tone = tone;
};
const setLastAction = (message, tone = "normal") => {
lastActionElement.textContent = message;
lastActionElement.dataset.tone = tone;
};
const focusRowElement = (nodeId) => {
if (!nodeId) return;
window.requestAnimationFrame(() => {
const row = appElement.querySelector(`.tree-row[data-node-id="${nodeId}"]`);
if (!(row instanceof HTMLElement)) return;
row.focus({ preventScroll: true });
row.scrollIntoView({ block: "nearest" });
});
};
const postToHost = (type, extra = {}) => {
if (window.parent === window) return;
const payload = Object.assign({ channel, type }, extra);
window.parent.postMessage(payload, targetOrigin);
};
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
const getFileTreeRowDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
if (item.rowKind === "document") return item.nodeId;
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
return "";
};
const getFileTreeRowAssetId = (item) => {
if (!item) return "";
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
if (item.rowKind === "asset") return item.nodeId.replace(/^asset:/, "");
if (item.rowKind === "asset_folder") return item.nodeId.replace(/^asset-folder:/, "");
return "";
};
const getFileTreeRowIconKind = (item) => {
if (!item) return "file";
const iconHint = normalizeText(item.iconHint).toLowerCase();
if (iconHint === "mindmap") return "mindmap";
if (iconHint === "table") return "table";
if (iconHint === "index") return "index";
if (iconHint === "page") return "page";
if (item.rowKind === "document") return "page";
if (item.rowKind === "index") return "index";
if (item.rowKind === "asset_folder") return "mindmap";
if (item.resourceMeta?.resourceKind === "table") return "table";
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
return "file";
};
const getFileTreeRowMetaLabel = (item) => {
if (!item) return "";
if (item.rowKind === "document") {
return `${getFileTreeRowDocumentId(item)} · 页面`;
}
if (item.rowKind === "index") {
return "页面正文";
}
if (item.rowKind === "asset_folder") {
return `${item.resourceMeta?.resourceKind || "mindmap"} · 资源目录`;
}
return `${item.resourceMeta?.resourceKind || item.resourceMeta?.assetKind || "asset"} · ${getFileTreeRowAssetId(item)}`;
};
const canExpandFileTreeRow = (item) => {
if (!item) return false;
return item.childCount > 0 || item.capabilities.includes("expand");
};
const getFileTreeDropTargetFromElement = (element) => {
const row = element instanceof Element
? element.closest('.tree-row[data-shell-mode="filetree"]')
: null;
if (!(row instanceof HTMLElement)) {
return {
rowId: null,
rowKind: "root",
documentId: null,
assetId: null,
};
}
return {
rowId: normalizeText(row.dataset.rowId) || null,
rowKind: normalizeText(row.dataset.rowKind, "document"),
documentId: normalizeText(row.dataset.documentId) || null,
assetId: normalizeText(row.dataset.assetId) || null,
};
};
const clearFileTreeDropFeedback = () => {
if (activeFileTreeDropRowId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropTarget = "false";
}
}
activeFileTreeDropRowId = null;
activeFileTreeRootDrop = false;
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
if (element instanceof HTMLElement) {
element.dataset.dropTarget = "false";
}
});
};
const setFileTreeDropFeedback = (target) => {
const nextRowId = target?.rowId || null;
if (activeFileTreeDropRowId && activeFileTreeDropRowId !== nextRowId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropTarget = "false";
}
}
if (nextRowId) {
const nextRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
);
if (nextRow instanceof HTMLElement) {
nextRow.dataset.dropTarget = "true";
}
activeFileTreeDropRowId = nextRowId;
activeFileTreeRootDrop = false;
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
if (element instanceof HTMLElement) {
element.dataset.dropTarget = "false";
}
});
return;
}
activeFileTreeDropRowId = null;
const root = appElement.querySelector(".tree-root");
if (root instanceof HTMLElement) {
root.dataset.dropTarget = "true";
}
activeFileTreeRootDrop = true;
};
const updateFileTreeDropFeedback = (rowId) => {
if (rowId) {
setFileTreeDropFeedback({ rowId });
return;
}
clearFileTreeDropFeedback();
};
const inferDefaultFileTreeDropDocumentId = () => {
const candidateRowIds = [
fileTreeFocusedRowId,
fileTreeAnchorRowId,
...Array.from(selectedFileTreeRowIds),
].filter(Boolean);
for (const rowId of candidateRowIds) {
if (!rowId) continue;
const item = fileTreeRowById.get(rowId);
const documentId = getFileTreeRowDocumentId(item);
if (documentId) {
return documentId;
}
}
const firstDocRowId = visibleFileTreeRowIds.find((rowId) => rowId.startsWith("doc:"));
const firstDocItem = firstDocRowId ? fileTreeRowById.get(firstDocRowId) : null;
return getFileTreeRowDocumentId(firstDocItem) || null;
};
const isExternalFileDrag = (event) => {
const types = event.dataTransfer?.types;
return Array.isArray(types)
? types.includes("Files")
: types instanceof DOMStringList
? types.contains("Files")
: false;
};
const isInternalFileTreeDrag = (event) => {
const types = event.dataTransfer?.types;
return Array.isArray(types)
? types.includes(FILETREE_DRAG_MIME)
: types instanceof DOMStringList
? types.contains(FILETREE_DRAG_MIME)
: false;
};
const emitFileTreeSelectionChange = () => {
if (mode !== "filetree") return;
postToHost("tree.filetree.selection.changed", {
selectedRowIds: Array.from(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: fileTreeFocusedRowId,
payload: {
selectedRowIds: Array.from(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: fileTreeFocusedRowId,
},
});
};
const getFileTreeRangeRowIds = (fromId, toId) => {
const fromIndex = visibleFileTreeRowIds.indexOf(fromId);
const toIndex = visibleFileTreeRowIds.indexOf(toId);
if (fromIndex < 0 || toIndex < 0) {
return [toId];
}
const lo = Math.min(fromIndex, toIndex);
const hi = Math.max(fromIndex, toIndex);
return visibleFileTreeRowIds.slice(lo, hi + 1);
};
const normalizeFileTreeSelectionState = (selection) => {
const selectedRowIds =
selection?.selectedRowIds instanceof Set
? new Set(
Array.from(selection.selectedRowIds)
.map((rowId) => normalizeText(rowId))
.filter(Boolean),
)
: new Set(normalizeStringArray(selection?.selectedRowIds));
const anchorRowId = normalizeText(selection?.anchorRowId) || null;
const focusedRowId = normalizeText(selection?.focusedRowId) || null;
return {
selectedRowIds,
anchorRowId,
focusedRowId,
};
};
const readFileTreeSelectionState = () =>
normalizeFileTreeSelectionState({
selectedRowIds: Array.from(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: fileTreeFocusedRowId,
});
const commitFileTreeSelection = (nextSelection) => {
const normalizedSelection = normalizeFileTreeSelectionState(nextSelection);
selectedFileTreeRowIds = normalizedSelection.selectedRowIds;
fileTreeAnchorRowId = normalizedSelection.anchorRowId;
fileTreeFocusedRowId = normalizedSelection.focusedRowId;
emitFileTreeSelectionChange();
return normalizedSelection;
};
const computeFileTreeSelectionActionResult = (action) => {
const currentSelection = readFileTreeSelectionState();
if (
mode !== "filetree" ||
filetreeSelectionReducerContractName !== "rust_filetree_selection_reducer_v1" ||
!filetreeSelectionReducerActions.has(action?.kind || "")
) {
return {
nextSelection: currentSelection,
dragRowIds: action?.kind === "resolve_drag_rows"
? [normalizeText(action?.rowId)].filter(Boolean)
: null,
};
}
if (action.kind === "select_row") {
const rowId = normalizeText(action.rowId);
if (!rowId) {
return { nextSelection: currentSelection, dragRowIds: null };
}
const shiftKey = action.modifiers?.shiftKey === true;
const metaKey = action.modifiers?.metaKey === true;
const ctrlKey = action.modifiers?.ctrlKey === true;
const toggleSelection = metaKey || ctrlKey;
if (shiftKey) {
const anchor =
currentSelection.anchorRowId || currentSelection.focusedRowId || rowId;
const nextSelection = toggleSelection
? new Set(currentSelection.selectedRowIds)
: new Set();
getFileTreeRangeRowIds(anchor, rowId).forEach((id) => {
nextSelection.add(id);
});
return {
nextSelection: {
selectedRowIds: nextSelection,
anchorRowId: currentSelection.anchorRowId || anchor,
focusedRowId: rowId,
},
dragRowIds: null,
};
}
if (toggleSelection) {
const nextSelection = new Set(currentSelection.selectedRowIds);
if (nextSelection.has(rowId)) {
nextSelection.delete(rowId);
} else {
nextSelection.add(rowId);
}
return {
nextSelection: {
selectedRowIds: nextSelection,
anchorRowId: rowId,
focusedRowId: rowId,
},
dragRowIds: null,
};
}
return {
nextSelection: {
selectedRowIds: new Set([rowId]),
anchorRowId: rowId,
focusedRowId: rowId,
},
dragRowIds: null,
};
}
if (action.kind === "select_context_row") {
const rowId = normalizeText(action.rowId);
if (!rowId) {
return { nextSelection: currentSelection, dragRowIds: null };
}
if (currentSelection.selectedRowIds.has(rowId)) {
return {
nextSelection: {
selectedRowIds: new Set(currentSelection.selectedRowIds),
anchorRowId: currentSelection.anchorRowId,
focusedRowId: rowId,
},
dragRowIds: null,
};
}
return {
nextSelection: {
selectedRowIds: new Set([rowId]),
anchorRowId: rowId,
focusedRowId: rowId,
},
dragRowIds: null,
};
}
if (action.kind === "normalize_visible_rows") {
const visibleSet = new Set(normalizeStringArray(action.visibleRowIds));
const nextSelectedRowIds = Array.from(currentSelection.selectedRowIds).filter((rowId) =>
visibleSet.has(rowId),
);
return {
nextSelection: {
selectedRowIds: new Set(nextSelectedRowIds),
anchorRowId:
currentSelection.anchorRowId && visibleSet.has(currentSelection.anchorRowId)
? currentSelection.anchorRowId
: null,
focusedRowId:
currentSelection.focusedRowId && visibleSet.has(currentSelection.focusedRowId)
? currentSelection.focusedRowId
: null,
},
dragRowIds: null,
};
}
if (action.kind === "clear") {
return {
nextSelection: {
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
},
dragRowIds: null,
};
}
if (action.kind === "resolve_drag_rows") {
const rowId = normalizeText(action.rowId);
if (!rowId) {
return { nextSelection: currentSelection, dragRowIds: [] };
}
return {
nextSelection: currentSelection,
dragRowIds: currentSelection.selectedRowIds.has(rowId)
? Array.from(currentSelection.selectedRowIds)
: [rowId],
};
}
return { nextSelection: currentSelection, dragRowIds: null };
};
const applyFileTreeSelectionAction = (action) => {
const result = computeFileTreeSelectionActionResult(action);
if (action?.kind !== "resolve_drag_rows") {
commitFileTreeSelection(result.nextSelection);
}
return result;
};
const selectFileTreeRow = (rowId, modifiers = {}) => {
applyFileTreeSelectionAction({
kind: "select_row",
rowId,
modifiers: {
shiftKey: modifiers.shiftKey === true,
ctrlKey: modifiers.ctrlKey === true,
metaKey: modifiers.metaKey === true,
},
});
};
const selectFileTreeContextRow = (rowId) => {
applyFileTreeSelectionAction({
kind: "select_context_row",
rowId,
});
};
const clearFileTreeSelection = () => {
if (mode !== "filetree") return;
if (
selectedFileTreeRowIds.size === 0 &&
!fileTreeAnchorRowId &&
!fileTreeFocusedRowId
) {
return;
}
applyFileTreeSelectionAction({ kind: "clear" });
renderTree();
};
const normalizeFileTreeSelectionForVisibleRows = () => {
if (mode !== "filetree") return;
const currentSelection = readFileTreeSelectionState();
const nextSelection = computeFileTreeSelectionActionResult({
kind: "normalize_visible_rows",
visibleRowIds: visibleFileTreeRowIds,
}).nextSelection;
if (
nextSelection.selectedRowIds.size === currentSelection.selectedRowIds.size &&
Array.from(nextSelection.selectedRowIds).every((rowId) =>
currentSelection.selectedRowIds.has(rowId),
) &&
nextSelection.anchorRowId === currentSelection.anchorRowId &&
nextSelection.focusedRowId === currentSelection.focusedRowId
) {
return;
}
commitFileTreeSelection(nextSelection);
};
const resolveFileTreeDraggedRowIds = (rowId) =>
computeFileTreeSelectionActionResult({
kind: "resolve_drag_rows",
rowId,
}).dragRowIds || [];
const scheduleRefresh = () => {
window.setTimeout(() => {
window.location.reload();
}, 80);
};
const readErrorMessage = async (response) => {
const text = await response.text();
try {
const payload = text ? JSON.parse(text) : null;
const fromMessage =
payload && typeof payload.message === "string" ? payload.message :
payload && typeof payload.error === "string" ? payload.error :
payload && payload.result && typeof payload.result.message === "string" ? payload.result.message :
"";
if (fromMessage) return fromMessage;
} catch {
// 忽略 JSON 解析失败,继续返回文本片段
}
return text ? text.slice(0, 180) : "树命令执行失败";
};
const ICONS = {
add: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
`,
more: `
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="4" cy="8" r="1.2"/>
<circle cx="8" cy="8" r="1.2"/>
<circle cx="12" cy="8" r="1.2"/>
</svg>
`,
edit: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
up: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
page: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M9.2 2.8v2.8H12" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
`,
index: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M6 7h4M6 9h4M6 11h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
`,
mindmap: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="1.6" fill="currentColor"/>
<circle cx="4" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
<circle cx="12" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
<circle cx="12" cy="11.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
<path d="M6.8 7 4.9 5.4M9.2 7l1.9-1.6M9.1 9l2 1.6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
table: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="3.2" y="3.2" width="9.6" height="9.6" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
</svg>
`,
pdf: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M6 10.8V6.5h1.5a1.2 1.2 0 1 1 0 2.4H6m3.2-2.4v4.3m0 0c1.1 0 1.8-.8 1.8-2.1 0-1.3-.7-2.2-1.8-2.2m-1.7 4.3h1.7" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
book: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4.2 3.2h6.2a1.6 1.6 0 0 1 1.6 1.6v7.4H5.4a1.2 1.2 0 0 0-1.2 1.2V4.4a1.2 1.2 0 0 1 1.2-1.2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M5.4 12.2V4.1M7 6h3.1M7 8.2h3.1" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
image: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="3" y="3" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<circle cx="6.2" cy="6.2" r="1.1" stroke="currentColor" stroke-width="1"/>
<path d="M4.5 11 7.1 8.6l1.8 1.7 1.7-1.5L12 11" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
video: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="3" y="3.4" width="7.8" height="9.2" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
<path d="m9.8 7 2.8-1.7v5.4L9.8 9" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
`,
audio: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6.4 4.2v7.6a1.5 1.5 0 1 1-1-1.4V5.6l5.2-1.2v5.2a1.5 1.5 0 1 1-1-1.4V3.5L6.4 4.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
`,
file: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M6 8.2h4M6 10.4h2.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
`,
};
const sendCommand = async (payload) => {
setBusy(true);
setStatus("正在提交树命令…");
try {
const response = await fetch(commandPath, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-workspace-id": workspaceId,
"x-mnote-source-channel": "mnote_web_tree_shell",
"x-mnote-source-client": "mnote-web",
...(actorId
? {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
}
: {}),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const data = await response.json().catch(() => null);
if (!data || typeof data !== "object") {
throw new Error("tree command 返回了无效响应");
}
if (data.ok === true && data.result) {
return data.result;
}
if (data.result && typeof data.result === "object") {
return data.result;
}
return data;
} finally {
setBusy(false);
}
};
const promptTitle = (message, defaultValue) => {
const value = window.prompt(message, defaultValue || "无标题");
if (value === null) return null;
const trimmed = value.trim();
return trimmed || "无标题";
};
const getSiblings = (parentId) => {
if (!parentId) return roots.slice();
return (childrenByParentId.get(parentId) || []).slice();
};
const PAGE_DRAG_MIME = "application/x-mnote-page-tree-node";
const clearPageDropFeedback = () => {
if (!activePageDropNodeId) {
return;
}
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropFeedback = "false";
}
activePageDropNodeId = null;
};
const setPageDropFeedback = (nodeId) => {
const nextNodeId = normalizeText(nodeId);
if (activePageDropNodeId && activePageDropNodeId !== nextNodeId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropFeedback = "false";
}
}
if (!nextNodeId) {
activePageDropNodeId = null;
return;
}
const nextRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${nextNodeId}"]`,
);
if (nextRow instanceof HTMLElement) {
nextRow.dataset.dropFeedback = "true";
}
activePageDropNodeId = nextNodeId;
};
const resolvePageDropTargetNodeId = (element) => {
const row = element instanceof Element
? element.closest('.tree-row[data-shell-mode="page"]')
: null;
if (!(row instanceof HTMLElement)) {
return "";
}
return normalizeText(row.dataset.nodeId);
};
const readPageDragNodeId = (event) => {
const raw =
event.dataTransfer?.getData(PAGE_DRAG_MIME) ||
event.dataTransfer?.getData("text/plain") ||
draggingPageNodeId ||
"";
return normalizeText(raw);
};
const canAcceptPageDrop = (sourceNodeId, targetNodeId) => {
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
return false;
}
const sourceItem = itemById.get(sourceNodeId);
const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) {
return false;
}
return sourceItem.parentNodeId === targetItem.parentNodeId;
};
const postPageExpandChange = (nodeId, nextExpanded) => {
if (mode !== "page" || !nodeId) return;
postToHost("tree.page.expand.changed", {
documentId: nodeId,
expanded: nextExpanded === true,
target: { documentId: nodeId },
payload: { documentId: nodeId, expanded: nextExpanded === true },
});
};
const postPageFocusChange = (nodeId) => {
if (mode !== "page" || !nodeId) return;
postToHost("tree.page.focus.changed", {
documentId: nodeId,
target: { documentId: nodeId },
payload: { documentId: nodeId },
});
};
const commitPageExpandedIds = (expandedIds) => {
const nextExpanded = new Set(normalizeStringArray(expandedIds));
let changed = nextExpanded.size !== expanded.size;
if (!changed) {
changed = Array.from(nextExpanded).some((nodeId) => !expanded.has(nodeId));
}
if (!changed) return false;
expanded.clear();
nextExpanded.forEach((nodeId) => expanded.add(nodeId));
return true;
};
const patchPageTreeAfterRuntimeState = (changedNodeIds, focusedId) => {
if (mode !== "page") return;
if (usedRustInitialRenderer) {
const ids = normalizeStringArray(changedNodeIds);
ids.forEach((nodeId) => {
patchPageTreeExpansionDom(nodeId);
});
patchPageTreeActiveDom();
if (focusedId) focusRowElement(focusedId);
return;
}
renderTree();
if (focusedId) focusRowElement(focusedId);
};
const applyLocalPageExpansionFallback = (nodeId, nextExpanded) => {
if (nextExpanded) expanded.add(nodeId);
else expanded.delete(nodeId);
postPageExpandChange(nodeId, nextExpanded);
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
focusRowElement(nodeId);
return;
}
renderTree();
};
const toggleExpand = (nodeId) => {
applyLocalPageExpansionFallback(nodeId, !expanded.has(nodeId));
};
const getVisiblePageItems = () => {
const visible = [];
const walk = (entries) => {
entries.forEach((item) => {
visible.push(item);
if (item.childCount > 0 && expanded.has(item.nodeId)) {
walk(getSiblings(item.nodeId));
}
});
};
walk(roots);
return visible;
};
const getVisiblePickerEntries = () => {
if (mode !== "picker") {
return [];
}
const visible = [];
if (allowRootPick) {
visible.push({
pickerItemKey: "__root__",
item: null,
});
}
const walk = (entries) => {
entries.forEach((item) => {
visible.push({
pickerItemKey: item.nodeId,
item,
});
if (item.childCount > 0 && expanded.has(item.nodeId)) {
walk(getSiblings(item.nodeId));
}
});
};
walk(roots);
return visible;
};
const isPickerEntryPickable = (entry) => {
if (!entry) return false;
if (entry.pickerItemKey === "__root__") {
return allowRootPick;
}
const documentId = normalizeText(entry.item?.nodeId || entry.pickerItemKey);
return Boolean(documentId && !excludedIds.has(documentId));
};
const getPickablePickerEntries = () =>
getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry));
const normalizePickerItemKey = (pickerItemKey) => {
const normalizedItemKey = normalizeText(pickerItemKey);
if (normalizedItemKey === "__root__" && allowRootPick) {
return "__root__";
}
if (normalizedItemKey && itemById.has(normalizedItemKey) && !excludedIds.has(normalizedItemKey)) {
return normalizedItemKey;
}
return "";
};
const resolveCurrentPickerItemKey = () => {
const fromActive = normalizePickerItemKey(currentActivePickerItemKey);
if (fromActive) return fromActive;
const fromDocument = normalizePickerItemKey(currentActiveDocumentId);
if (fromDocument) return fromDocument;
return getPickablePickerEntries()[0]?.pickerItemKey || "";
};
const computePickerStateActionResult = (action) => {
const currentPickerItemKey = resolveCurrentPickerItemKey();
if (
mode !== "picker" ||
pickerStateReducerContractName !== "rust_picker_state_reducer_v1" ||
!pickerStateReducerActions.has(action?.kind || "")
) {
return {
nextItemKey: currentPickerItemKey,
pickedDocumentId:
action?.kind === "pick" && currentPickerItemKey !== "__root__"
? currentPickerItemKey || null
: null,
pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__",
};
}
const pickable = getPickablePickerEntries();
if (pickable.length === 0) {
return {
nextItemKey: "",
pickedDocumentId: null,
pickedRoot: false,
};
}
const currentIndex = pickable.findIndex(
(entry) => entry.pickerItemKey === currentPickerItemKey,
);
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
const actionKind = action.kind;
if (actionKind === "normalize") {
return {
nextItemKey: pickable[resolvedIndex]?.pickerItemKey || "",
pickedDocumentId: null,
pickedRoot: false,
};
}
if (actionKind === "focus") {
const nextItemKey = normalizePickerItemKey(action.itemKey);
return {
nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "",
pickedDocumentId: null,
pickedRoot: false,
};
}
if (actionKind === "pick") {
const target = pickable[resolvedIndex];
const targetKey = target?.pickerItemKey || "";
return {
nextItemKey: targetKey,
pickedDocumentId:
targetKey && targetKey !== "__root__" ? targetKey : null,
pickedRoot: targetKey === "__root__",
};
}
let nextIndex = resolvedIndex;
if (actionKind === "next") {
nextIndex = Math.min(pickable.length - 1, resolvedIndex + 1);
} else if (actionKind === "previous") {
nextIndex = Math.max(0, resolvedIndex - 1);
} else if (actionKind === "home") {
nextIndex = 0;
} else if (actionKind === "end") {
nextIndex = pickable.length - 1;
} else {
return {
nextItemKey: currentPickerItemKey,
pickedDocumentId: null,
pickedRoot: false,
};
}
return {
nextItemKey: pickable[nextIndex]?.pickerItemKey || "",
pickedDocumentId: null,
pickedRoot: false,
};
};
const focusNode = (nodeId) => {
if (!nodeId || !itemById.has(nodeId)) return;
if (focusedNodeId === nodeId) {
focusRowElement(nodeId);
return;
}
focusedNodeId = nodeId;
postPageFocusChange(nodeId);
if (usedRustInitialRenderer) {
patchPageTreeActiveDom();
focusRowElement(nodeId);
return;
}
renderTree();
focusRowElement(nodeId);
};
const resolvePageActionItem = (action, item) => {
const actionNodeId = normalizeText(action?.nodeId);
if (actionNodeId && itemById.has(actionNodeId)) {
return itemById.get(actionNodeId);
}
if (item?.nodeId && itemById.has(item.nodeId)) {
return item;
}
return focusedNodeId && itemById.has(focusedNodeId)
? itemById.get(focusedNodeId)
: null;
};
const buildPageRuntimeAction = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
if (actionKind === "focus") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: "focus", nodeId } : null;
}
if (actionKind === "move_next") return { kind: "moveNext" };
if (actionKind === "move_previous") return { kind: "movePrevious" };
if (actionKind === "move_home") return { kind: "moveHome" };
if (actionKind === "move_end") return { kind: "moveEnd" };
if (actionKind === "open") return { kind: "openFocused" };
if (actionKind === "context_menu") return { kind: "contextMenuFocused" };
if (actionKind === "expand" || actionKind === "collapse" || actionKind === "toggle") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: actionKind, nodeId } : null;
}
return null;
};
const buildPageRuntimeEnvironment = () => ({
visibleNodeIds: getVisiblePageItems().map((entry) => entry.nodeId),
expandableNodeIds: normalizedItems
.filter((entry) => entry.childCount > 0 && getSiblings(entry.nodeId).length > 0)
.map((entry) => entry.nodeId),
});
const readPageRuntimeState = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
const focusedId =
(actionKind === "open" || actionKind === "context_menu") && itemById.has(nodeId)
? nodeId
: focusedNodeId || null;
return {
focusedId,
expandedIds: Array.from(expanded),
dropFeedback: null,
};
};
const reducePageActionWithRuntime = async (action, item) => {
if (mode !== "page" || !runtimeReduceEndpoint) {
return null;
}
const runtimeAction = buildPageRuntimeAction(action, item);
if (!runtimeAction) return null;
const response = await fetch(runtimeReduceEndpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: "page",
requestId: `page-runtime-${Date.now()}`,
environment: buildPageRuntimeEnvironment(),
state: readPageRuntimeState(action, item),
action: runtimeAction,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.json();
};
const normalizePageRuntimeResult = (runtimeResult) => {
if (!runtimeResult || runtimeResult.mode !== "page") {
return null;
}
const stateSnapshot =
runtimeResult.state && runtimeResult.state.mode === "page"
? runtimeResult.state.state
: null;
const pagePatch = Array.isArray(runtimeResult.domPatches)
? runtimeResult.domPatches.find((patch) => patch?.kind === "pageState")
: null;
const focusedId =
typeof pagePatch?.focusedId === "string"
? pagePatch.focusedId
: typeof stateSnapshot?.focusedId === "string"
? stateSnapshot.focusedId
: "";
const expandedIds = Array.isArray(pagePatch?.expandedIds)
? pagePatch.expandedIds
: Array.isArray(stateSnapshot?.expandedIds)
? stateSnapshot.expandedIds
: null;
return {
focusedId: normalizeText(focusedId),
expandedIds: expandedIds ? normalizeStringArray(expandedIds) : null,
hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [],
};
};
const replayPageRuntimeHostEvents = (runtimeResult, item, sourceElement) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
let replayed = false;
result.hostEvents.forEach((event) => {
if (!event || typeof event !== "object") return;
if (event.kind === "pageOpen") {
const nodeId = normalizeText(event.nodeId);
if (nodeId) {
handleNavigate(nodeId);
replayed = true;
}
return;
}
if (event.kind === "pageContextMenu") {
const nodeId = normalizeText(event.nodeId || item?.nodeId);
if (!nodeId) return;
const rect = sourceElement?.getBoundingClientRect?.();
openContextMenu(
nodeId,
rect ? rect.left + Math.min(rect.width - 12, 28) : 0,
rect ? rect.top + Math.min(rect.height - 12, 18) : 0,
);
replayed = true;
}
});
return replayed;
};
const reconcilePageRuntimeResult = (runtimeResult, item) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
const previousExpanded = new Set(expanded);
let shouldPatchTree = false;
if (Array.isArray(result.expandedIds)) {
shouldPatchTree = commitPageExpandedIds(result.expandedIds) || shouldPatchTree;
}
if (result.focusedId && result.focusedId !== focusedNodeId) {
focusedNodeId = result.focusedId;
postPageFocusChange(result.focusedId);
shouldPatchTree = true;
}
const itemNodeId = normalizeText(item?.nodeId);
if (itemNodeId && previousExpanded.has(itemNodeId) !== expanded.has(itemNodeId)) {
postPageExpandChange(itemNodeId, expanded.has(itemNodeId));
}
if (shouldPatchTree) {
patchPageTreeAfterRuntimeState(
Array.isArray(result.expandedIds) ? [itemNodeId, ...result.expandedIds] : [itemNodeId],
result.focusedId || focusedNodeId,
);
}
return shouldPatchTree;
};
const applyLocalPageActionFallback = (action, item, sourceElement) => {
if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
return;
}
if (actionKind === "focus") {
const nextFocusId = normalizeText(action?.nodeId);
if (nextFocusId && itemById.has(nextFocusId)) {
focusNode(nextFocusId);
}
return;
}
const visible = getVisiblePageItems();
const visibleIds = visible.map((entry) => entry.nodeId);
if (visibleIds.length === 0) {
return;
}
const currentFocusId =
visibleIds.includes(focusedNodeId) ? focusedNodeId : visibleIds[0];
const currentIndex = visibleIds.indexOf(currentFocusId);
if (actionKind === "move_next") {
const next = visible[currentIndex + 1];
if (next) focusNode(next.nodeId);
return;
}
if (actionKind === "move_previous") {
const previous = visible[currentIndex - 1];
if (previous) focusNode(previous.nodeId);
return;
}
if (actionKind === "move_home") {
focusNode(visibleIds[0]);
return;
}
if (actionKind === "move_end") {
focusNode(visibleIds[visibleIds.length - 1]);
return;
}
if (actionKind === "expand") {
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
applyLocalPageExpansionFallback(item.nodeId, true);
focusRowElement(item.nodeId);
return;
}
const firstChild = item ? getSiblings(item.nodeId)[0] : null;
if (firstChild) {
focusNode(firstChild.nodeId);
}
return;
}
if (actionKind === "collapse") {
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
applyLocalPageExpansionFallback(item.nodeId, false);
focusRowElement(item.nodeId);
return;
}
if (item?.parentNodeId && itemById.has(item.parentNodeId)) {
focusNode(item.parentNodeId);
}
return;
}
if (actionKind === "open") {
if (item?.nodeId) {
handleNavigate(item.nodeId);
}
return;
}
if (actionKind === "context_menu") {
if (!item?.nodeId) {
return;
}
const rect = sourceElement?.getBoundingClientRect?.();
if (!rect) {
return;
}
openContextMenu(
item.nodeId,
rect.left + Math.min(rect.width - 12, 28),
rect.top + Math.min(rect.height - 12, 18),
);
}
};
const applyPageKeyboardAction = (action, item, sourceElement) => {
if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
return;
}
const runtimeItem = resolvePageActionItem(action, item);
const runtimeAction = buildPageRuntimeAction(action, runtimeItem);
if (!runtimeAction) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
return;
}
void reducePageActionWithRuntime(action, runtimeItem)
.then((runtimeResult) => {
const replayedHostEvent = replayPageRuntimeHostEvents(
runtimeResult,
runtimeItem,
sourceElement,
);
const reconciledState = reconcilePageRuntimeResult(runtimeResult, runtimeItem);
if (!replayedHostEvent && !reconciledState) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
}
})
.catch(() => {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
});
};
const postPickerFocusChange = (pickerItemKey) => {
if (mode !== "picker") return;
const normalizedItemKey = normalizeText(pickerItemKey);
const documentId =
normalizedItemKey && normalizedItemKey !== "__root__"
? normalizedItemKey
: null;
postToHost("tree.picker.focus.changed", {
documentId,
itemKey: normalizedItemKey || null,
pickerItemKey: normalizedItemKey || null,
target: { documentId },
payload: {
documentId,
itemKey: normalizedItemKey || null,
},
});
};
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
if (mode !== "picker") return;
const result = computePickerStateActionResult({
kind: "focus",
itemKey: pickerItemKey,
});
const shouldFocusDom = options.focusDom === true;
const nextPickerItemKey = result.nextItemKey || "";
const nextDocumentId =
nextPickerItemKey && nextPickerItemKey !== "__root__"
? nextPickerItemKey
: null;
currentActivePickerItemKey = nextPickerItemKey;
currentActiveDocumentId = nextDocumentId;
focusedNodeId = nextDocumentId || "";
if (usedRustInitialRenderer) {
patchPickerActiveDom();
if (shouldFocusDom) focusPickerRowElement(nextPickerItemKey);
} else {
renderTree();
}
if (shouldFocusDom && nextDocumentId) {
focusRowElement(nextDocumentId);
}
postPickerFocusChange(nextPickerItemKey || null);
};
const applyPickerStateAction = (action) => {
if (mode !== "picker") {
return {
nextItemKey: "",
pickedDocumentId: null,
pickedRoot: false,
};
}
const result = computePickerStateActionResult(action);
if (action?.kind !== "pick") {
applyPickerFocusByItemKey(result.nextItemKey);
}
return result;
};
const postPickerPickResultToHost = (result) => {
if (mode !== "picker" || !result) return;
if (result.pickedRoot) {
setLastAction("已选择根目录");
postToHost("tree.pick.root", {
documentId: null,
target: { documentId: null },
payload: { documentId: null },
});
return;
}
if (result.pickedDocumentId) {
postToHost("tree.pick", {
documentId: result.pickedDocumentId,
itemKey: result.nextItemKey || result.pickedDocumentId,
target: { documentId: result.pickedDocumentId },
payload: { documentId: result.pickedDocumentId },
});
}
};
const handlePickerCommand = (command) => {
if (mode !== "picker") return;
const normalizedCommand = normalizeText(command);
if (!pickerStateReducerActions.has(normalizedCommand)) {
return;
}
if (normalizedCommand === "pick") {
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
return;
}
applyPickerStateAction({ kind: normalizedCommand });
};
const openFileTreeContextMenu = ({
documentId,
assetId,
rowId,
rowKind,
clientX,
clientY,
}) => {
setLastAction(
assetId
? `已打开资源 ${assetId} 的更多操作`
: `已打开文件树节点 ${documentId || rowId || "unknown"} 的更多操作`,
);
postToHost("tree.filetree.context-menu", {
documentId,
assetId,
rowId,
rowKind,
payload: {
documentId,
assetId,
rowId,
rowKind,
x: clientX,
y: clientY,
},
x: clientX,
y: clientY,
});
};
const openContextMenu = (nodeId, clientX, clientY) => {
if (!nodeId || mode !== "page") return;
setLastAction(`已打开页面 ${nodeId} 的上下文菜单`);
postToHost("tree.page.context-menu", {
documentId: nodeId,
target: { documentId: nodeId },
payload: { documentId: nodeId, x: clientX, y: clientY },
x: clientX,
y: clientY,
});
};
const getElementCenter = (element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
};
const readFileTreeInternalDropPayload = (event) => {
const raw =
event.dataTransfer?.getData("application/x-mnote-file-tree") ||
event.dataTransfer?.getData("text/plain") ||
"";
if (!raw) return null;
try {
const payload = JSON.parse(raw);
if (
payload?.type !== "mnote-file-tree-dnd" ||
payload.version !== 1 ||
!Array.isArray(payload.rowIds)
) {
return null;
}
const rowIds = payload.rowIds
.map((value) => normalizeText(value))
.filter(Boolean);
return rowIds.length > 0 ? rowIds : null;
} catch {
return null;
}
};
const handleRowKeyDown = (event, item) => {
if (mode !== "page") return;
if (event.key === "ArrowDown") {
event.preventDefault();
applyPageKeyboardAction({ kind: "move_next" }, item, event.currentTarget);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
applyPageKeyboardAction({ kind: "move_previous" }, item, event.currentTarget);
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
applyPageKeyboardAction({ kind: "expand" }, item, event.currentTarget);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
applyPageKeyboardAction({ kind: "collapse" }, item, event.currentTarget);
return;
}
if (event.key === "Enter") {
event.preventDefault();
applyPageKeyboardAction({ kind: "open" }, item, event.currentTarget);
return;
}
if (event.key === "F2") {
event.preventDefault();
void handleRename(item.nodeId);
return;
}
if (
event.key === "ContextMenu" ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget);
}
};
const handleNavigate = (nodeId) => {
if (!nodeId) return;
if (mode === "picker") {
setLastAction(`已选择页面 ${nodeId}`);
postToHost("tree.pick", {
documentId: nodeId,
target: { documentId: nodeId },
payload: { documentId: nodeId },
});
return;
}
setLastAction(`准备打开页面 ${nodeId}`);
postToHost("tree.navigate", {
documentId: nodeId,
target: { documentId: nodeId },
payload: { documentId: nodeId },
});
};
const handleCreate = async (parentId) => {
const title = "无标题";
try {
const result = await sendCommand({
action: "create",
workspaceId,
parentId,
title,
accessScope: "private",
content: [],
});
const documentId =
typeof result.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: "";
setStatus("创建页面成功");
setLastAction(parentId ? "已创建无标题子页面" : "已创建无标题页面");
postToHost("tree.node.created", {
documentId,
target: { documentId },
payload: { documentId },
});
if (documentId) {
postToHost("tree.navigate", {
documentId,
target: { documentId },
payload: { documentId },
});
}
scheduleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : "创建页面失败";
setStatus(message, "error");
setLastAction("创建页面失败", "error");
window.alert(message);
}
};
const handleRename = async (nodeId) => {
const item = itemById.get(nodeId);
if (!item) return;
const title = promptTitle("输入新的页面标题", item.title);
if (title === null) return;
try {
const result = await sendCommand({
action: "rename",
workspaceId,
documentId: nodeId,
title,
});
const documentId =
typeof result.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: nodeId;
setStatus("重命名成功");
setLastAction(`页面已重命名为 ${title}`);
postToHost("tree.node.renamed", {
documentId,
target: { documentId },
payload: { documentId },
});
scheduleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : "重命名失败";
setStatus(message, "error");
setLastAction("重命名失败", "error");
window.alert(message);
}
};
const handleMove = async (nodeId, delta) => {
const item = itemById.get(nodeId);
if (!item) return;
const siblings = getSiblings(item.parentNodeId);
const currentIndex = siblings.findIndex((entry) => entry.nodeId === nodeId);
if (currentIndex === -1) return;
const nextIndex = currentIndex + delta;
if (nextIndex < 0 || nextIndex >= siblings.length) return;
try {
const result = await sendCommand({
action: "move",
workspaceId,
documentId: nodeId,
parentId: item.parentNodeId,
sortOrder: nextIndex,
});
const documentId =
typeof result.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: nodeId;
setStatus("移动页面成功");
setLastAction(delta < 0 ? "页面已上移" : "页面已下移");
postToHost("tree.subtree.moved", {
documentId,
target: { documentId },
payload: { documentId },
});
scheduleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : "移动页面失败";
setStatus(message, "error");
setLastAction("移动页面失败", "error");
window.alert(message);
}
};
const handlePageDropMove = async (sourceNodeId, targetNodeId) => {
const sourceItem = itemById.get(sourceNodeId);
const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) return;
const siblings = getSiblings(targetItem.parentNodeId);
const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId);
if (targetIndex < 0) return;
try {
const result = await sendCommand({
action: "move",
workspaceId,
documentId: sourceNodeId,
parentId: targetItem.parentNodeId,
sortOrder: targetIndex,
});
const documentId =
typeof result.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: sourceNodeId;
setStatus("移动页面成功");
setLastAction(`页面已拖放到 ${targetItem.title}`);
postToHost("tree.subtree.moved", {
documentId,
target: { documentId },
payload: { documentId },
});
scheduleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : "拖拽移动失败";
setStatus(message, "error");
setLastAction("拖拽移动失败", "error");
window.alert(message);
}
};
const bindPageRowEvents = (row, item) => {
if (!(row instanceof HTMLElement) || !item) return;
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
row.dataset.focused = String(item.nodeId === focusedNodeId);
row.dataset.nodeId = item.nodeId;
row.dataset.shellMode = "page";
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute("aria-expanded", item.childCount > 0 ? String(expanded.has(item.nodeId)) : "false");
row.draggable = true;
row.dataset.draggable = "true";
row.addEventListener("focus", () => {
if (focusedNodeId !== item.nodeId) {
applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row);
}
});
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
row.addEventListener("contextmenu", (event) => {
event.preventDefault();
openContextMenu(item.nodeId, event.clientX, event.clientY);
});
row.addEventListener("dragstart", (event) => {
draggingPageNodeId = item.nodeId;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId);
event.dataTransfer.setData("text/plain", item.nodeId);
}
setLastAction(`开始拖拽页面 ${item.title}`);
});
row.addEventListener("dragover", (event) => {
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
clearPageDropFeedback();
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
setPageDropFeedback(targetNodeId);
});
row.addEventListener("dragleave", (event) => {
const relatedTarget =
event.relatedTarget instanceof Node ? event.relatedTarget : null;
if (relatedTarget && row.contains(relatedTarget)) {
return;
}
if (activePageDropNodeId === item.nodeId) {
clearPageDropFeedback();
}
});
row.addEventListener("drop", (event) => {
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
clearPageDropFeedback();
draggingPageNodeId = "";
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
return;
}
event.preventDefault();
void handlePageDropMove(sourceNodeId, targetNodeId);
});
row.addEventListener("dragend", () => {
draggingPageNodeId = "";
clearPageDropFeedback();
});
row.querySelectorAll("[data-rust-action]").forEach((element) => {
if (!(element instanceof HTMLElement)) return;
element.addEventListener("click", (event) => {
event.stopPropagation();
const action = normalizeText(element.dataset.rustAction);
if (action === "toggle") {
event.preventDefault();
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, element);
} else if (action === "open") {
applyPageKeyboardAction({ kind: "open", nodeId: item.nodeId }, item, element);
} else if (action === "create") {
void handleCreate(item.nodeId);
} else if (action === "rename") {
void handleRename(item.nodeId);
} else if (action === "menu") {
applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element);
}
});
});
};
const patchPageTreeActiveDom = () => {
if (mode !== "page") return;
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const nodeId = normalizeText(row.dataset.nodeId);
const isFocused = nodeId === focusedNodeId;
row.dataset.active = String(nodeId === currentActiveDocumentId);
row.dataset.focused = String(isFocused);
row.tabIndex = isFocused ? 0 : -1;
row.dataset.dropFeedback = String(activePageDropNodeId === nodeId);
});
};
const patchPageTreeExpansionDom = (nodeId) => {
if (mode !== "page") return false;
const normalizedNodeId = normalizeText(nodeId);
if (!normalizedNodeId) return false;
const item = itemById.get(normalizedNodeId);
if (!item) return false;
const nodeElement = appElement.querySelector(
`.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
);
if (!(nodeElement instanceof HTMLElement)) return false;
const row = nodeElement.querySelector(
`:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
);
const children = getSiblings(normalizedNodeId);
const hasChildren = item.childCount > 0 && children.length > 0;
const isExpanded = hasChildren && expanded.has(normalizedNodeId);
if (row instanceof HTMLElement) {
row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false");
const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]');
if (toggleButton instanceof HTMLButtonElement) {
toggleButton.textContent = isExpanded ? "▾" : "▸";
toggleButton.setAttribute(
"aria-label",
`${isExpanded ? "折叠" : "展开"} ${item.title}`,
);
}
}
if (!hasChildren) {
patchPageTreeActiveDom();
return true;
}
let childrenList = Array.from(nodeElement.children).find(
(child) => child instanceof HTMLElement && child.classList.contains("tree-children"),
);
if (isExpanded) {
if (!(childrenList instanceof HTMLElement)) {
childrenList = document.createElement("ul");
childrenList.className = "tree-children";
children.forEach((child) => {
childrenList.appendChild(renderNode(child));
});
nodeElement.appendChild(childrenList);
}
childrenList.hidden = false;
childrenList.style.display = "";
} else if (childrenList instanceof HTMLElement) {
childrenList.hidden = true;
childrenList.style.display = "none";
}
patchPageTreeActiveDom();
return true;
};
const hydrateInitialPageTree = () => {
if (mode !== "page") return false;
const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]');
if (!(root instanceof HTMLElement)) {
return false;
}
root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const nodeId = normalizeText(row.dataset.nodeId);
const item = itemById.get(nodeId);
if (!item) return;
bindPageRowEvents(row, item);
});
if (focusedNodeId) {
focusRowElement(focusedNodeId);
}
return true;
};
const syncFileTreeSelectionDom = () => {
if (mode !== "filetree") return;
appElement.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const rowId = normalizeText(row.dataset.rowId);
row.dataset.selected = String(Boolean(rowId && selectedFileTreeRowIds.has(rowId)));
});
};
const openHydratedFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index") {
handleNavigate(documentId || item.nodeId);
return;
}
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
assetId: assetId || null,
target: { documentId: documentId || null },
payload: {
documentId: documentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
},
});
};
const postHydratedFileTreeDropToHost = (type, target, extra = {}) => {
postToHost(type, {
workspaceId,
rowId: target.rowId,
rowKind: target.rowKind,
targetRowId: target.rowId,
targetRowKind: target.rowKind,
documentId: target.documentId,
assetId: target.assetId,
...extra,
payload: {
workspaceId,
rowId: target.rowId,
rowKind: target.rowKind,
targetRowId: target.rowId,
targetRowKind: target.rowKind,
documentId: target.documentId,
assetId: target.assetId,
...extra,
},
});
};
const attachHydratedFileTreeDragSource = (row, item) => {
row.draggable = true;
row.addEventListener("dragstart", (event) => {
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
draggingFileTreeRowIds = rowIds;
if (event.dataTransfer) {
const payload = JSON.stringify({
type: "mnote-file-tree-dnd",
version: 1,
rowIds,
});
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
event.dataTransfer.setData("text/plain", payload);
}
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
});
row.addEventListener("dragend", () => {
draggingFileTreeRowIds = [];
clearFileTreeDropFeedback();
});
};
const bindFileTreeRootEvents = (root) => {
root.dataset.dropTarget = String(activeFileTreeRootDrop);
root.addEventListener("mousedown", (event) => {
if (event.target !== event.currentTarget) return;
clearFileTreeSelection();
});
root.addEventListener("dragover", (event) => {
const internalRowIds = readFileTreeInternalDropPayload(event);
const files = Array.from(event.dataTransfer?.files || []);
if (!internalRowIds && files.length === 0) {
return;
}
event.preventDefault();
const target = getFileTreeDropTargetFromElement(event.target);
if (event.dataTransfer) {
event.dataTransfer.dropEffect =
files.length > 0 || event.altKey ? "copy" : "move";
}
setFileTreeDropFeedback(target);
});
root.addEventListener("dragleave", (event) => {
const relatedTarget =
event.relatedTarget instanceof Node ? event.relatedTarget : null;
if (relatedTarget && root.contains(relatedTarget)) {
return;
}
clearFileTreeDropFeedback();
});
root.addEventListener("drop", (event) => {
const internalRowIds = readFileTreeInternalDropPayload(event);
const files = Array.from(event.dataTransfer?.files || []);
if (!internalRowIds && files.length === 0) {
return;
}
event.preventDefault();
const target = getFileTreeDropTargetFromElement(event.target);
if (files.length > 0) {
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, {
files,
});
} else {
setLastAction(
event.altKey
? `已发送复制拖放到 ${target.rowId || "根目录"}`
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
);
postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, {
rowIds: internalRowIds,
copy: event.altKey === true,
});
}
draggingFileTreeRowIds = [];
clearFileTreeDropFeedback();
});
};
const bindFileTreeRowEvents = (row, item) => {
if (!(row instanceof HTMLElement) || !item) return;
const documentId = getFileTreeRowDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
row.dataset.active = String(
item.rowKind === "document" && documentId === currentActiveDocumentId
);
row.dataset.nodeId = item.nodeId;
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.assetId = assetId || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
row.tabIndex = 0;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute(
"aria-expanded",
canExpandFileTreeRow(item) ? String(expanded.has(item.nodeId)) : "false",
);
row.addEventListener("click", (event) => {
selectFileTreeRow(item.rowId, event);
syncFileTreeSelectionDom();
});
row.addEventListener("dblclick", () => {
openHydratedFileTreeItem(item);
});
row.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectFileTreeContextRow(item.rowId);
syncFileTreeSelectionDom();
openFileTreeContextMenu({
documentId,
assetId,
rowId: item.rowId,
rowKind: item.rowKind,
clientX: event.clientX,
clientY: event.clientY,
});
});
attachHydratedFileTreeDragSource(row, item);
row.querySelectorAll("[data-rust-action]").forEach((element) => {
if (!(element instanceof HTMLElement)) return;
element.addEventListener("click", (event) => {
const action = normalizeText(element.dataset.rustAction);
if (action === "open") {
openHydratedFileTreeItem(item);
return;
}
if (action === "menu") {
event.preventDefault();
event.stopPropagation();
selectFileTreeContextRow(item.rowId);
syncFileTreeSelectionDom();
const center = getElementCenter(element);
openFileTreeContextMenu({
documentId,
assetId,
rowId: item.rowId,
rowKind: item.rowKind,
clientX: center.x,
clientY: center.y,
});
}
});
});
};
const hydrateInitialFileTree = () => {
if (mode !== "filetree") return false;
const root = appElement.querySelector('[data-rust-filetree-renderer="initial_v1"]');
if (!(root instanceof HTMLElement)) {
return false;
}
visibleFileTreeRowIds = [];
bindFileTreeRootEvents(root);
root.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const rowId = normalizeText(row.dataset.rowId);
const nodeId = normalizeText(row.dataset.nodeId);
const item = fileTreeRowById.get(rowId) || itemById.get(nodeId);
if (!item) return;
visibleFileTreeRowIds.push(item.rowId);
bindFileTreeRowEvents(row, item);
});
normalizeFileTreeSelectionForVisibleRows();
syncFileTreeSelectionDom();
return true;
};
const bindPickerRootEvents = (row) => {
if (!(row instanceof HTMLElement)) return;
row.dataset.focused = String(resolvePickerRootFocused());
row.addEventListener("click", () => {
applyPickerFocusByItemKey("__root__", { focusDom: true });
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
});
};
const bindPickerRowEvents = (row, item) => {
if (!(row instanceof HTMLElement) || !item) return;
row.dataset.nodeId = item.nodeId;
row.dataset.shellMode = "picker";
row.dataset.focused = String(
currentActivePickerItemKey === item.nodeId ||
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
);
row.addEventListener("click", () => {
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
});
};
const focusPickerRowElement = (pickerItemKey) => {
const normalizedItemKey = normalizeText(pickerItemKey);
window.requestAnimationFrame(() => {
const row =
normalizedItemKey === "__root__"
? appElement.querySelector('[data-rust-rendered-row="picker-root"]')
: appElement.querySelector(
`.tree-row[data-node-id="${CSS.escape(normalizedItemKey)}"]`,
);
if (!(row instanceof HTMLElement)) return;
row.focus({ preventScroll: true });
row.scrollIntoView({ block: "nearest" });
});
};
const patchPickerActiveDom = () => {
if (mode !== "picker") return;
appElement
.querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]')
.forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const nodeId = normalizeText(row.dataset.nodeId);
const isRoot = row.dataset.rustRenderedRow === "picker-root";
const isFocused = isRoot
? currentActivePickerItemKey === "__root__"
: currentActivePickerItemKey === nodeId ||
(!currentActivePickerItemKey && currentActiveDocumentId === nodeId);
row.dataset.focused = String(isFocused);
row.tabIndex = isFocused ? 0 : -1;
});
};
const hydrateInitialPickerTree = () => {
if (mode !== "picker") return false;
const root = appElement.querySelector('[data-rust-picker-renderer="initial_v1"]');
if (!(root instanceof HTMLElement)) {
return false;
}
root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => {
bindPickerRootEvents(row);
});
root.querySelectorAll('[data-rust-rendered-row="picker"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
const nodeId = normalizeText(row.dataset.nodeId);
const item = itemById.get(nodeId);
if (!item) return;
bindPickerRowEvents(row, item);
});
return true;
};
const hydrateInitialRenderer = () => {
if (mode === "page") {
const usedRustInitialPageRenderer = hydrateInitialPageTree();
return usedRustInitialPageRenderer;
}
if (mode === "filetree") {
return hydrateInitialFileTree();
}
if (mode === "picker") {
return hydrateInitialPickerTree();
}
return false;
};
const createKindBadge = (kind) => {
const badge = document.createElement("span");
badge.className = "tree-kind-badge";
badge.dataset.kind = kind;
badge.innerHTML =
kind === "mindmap"
? ICONS.mindmap
: kind === "table"
? ICONS.table
: kind === "pdf"
? ICONS.pdf
: kind === "book"
? ICONS.book
: kind === "image"
? ICONS.image
: kind === "video"
? ICONS.video
: kind === "audio"
? ICONS.audio
: kind === "index"
? ICONS.index
: kind === "page"
? ICONS.page
: ICONS.file;
return badge;
};
const createActionButton = (icon, testId, title, onClick, disabled) => {
const button = document.createElement("button");
button.type = "button";
button.className = "tree-action";
button.dataset.testid = testId;
button.title = title;
button.setAttribute("aria-label", title);
button.innerHTML = icon;
button.disabled = disabled || busy;
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick(button);
});
return button;
};
const renderNode = (item) => {
const hasChildren = item.childCount > 0;
const row = document.createElement("div");
row.className = "tree-row";
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
row.dataset.focused = String(item.nodeId === focusedNodeId);
row.dataset.nodeId = item.nodeId;
row.dataset.shellMode = mode;
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
row.draggable = mode === "page";
row.dataset.draggable = String(mode === "page");
row.addEventListener("focus", () => {
if (focusedNodeId !== item.nodeId) {
applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row);
}
});
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
row.addEventListener("contextmenu", (event) => {
if (mode !== "page") return;
event.preventDefault();
openContextMenu(item.nodeId, event.clientX, event.clientY);
});
row.addEventListener("dragstart", (event) => {
if (mode !== "page") return;
draggingPageNodeId = item.nodeId;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId);
event.dataTransfer.setData("text/plain", item.nodeId);
}
setLastAction(`开始拖拽页面 ${item.title}`);
});
row.addEventListener("dragover", (event) => {
if (mode !== "page") return;
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
clearPageDropFeedback();
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
setPageDropFeedback(targetNodeId);
});
row.addEventListener("dragleave", (event) => {
if (mode !== "page") return;
const relatedTarget =
event.relatedTarget instanceof Node ? event.relatedTarget : null;
if (relatedTarget && row.contains(relatedTarget)) {
return;
}
if (activePageDropNodeId === item.nodeId) {
clearPageDropFeedback();
}
});
row.addEventListener("drop", (event) => {
if (mode !== "page") return;
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
clearPageDropFeedback();
draggingPageNodeId = "";
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
return;
}
event.preventDefault();
void handlePageDropMove(sourceNodeId, targetNodeId);
});
row.addEventListener("dragend", () => {
if (mode !== "page") return;
draggingPageNodeId = "";
clearPageDropFeedback();
});
if (hasChildren) {
const toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "tree-toggle";
toggleButton.setAttribute("data-testid", "tree-node-toggle");
toggleButton.setAttribute(
"aria-label",
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
);
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
toggleButton.addEventListener("click", (event) => {
event.stopPropagation();
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);
});
row.appendChild(toggleButton);
} else {
const spacer = document.createElement("div");
spacer.className = "tree-spacer";
row.appendChild(spacer);
}
row.appendChild(createKindBadge("page"));
const linkButton = document.createElement("button");
linkButton.type = "button";
linkButton.className = "tree-link";
linkButton.setAttribute("data-testid", "tree-node-open");
linkButton.setAttribute("aria-label", `打开 ${item.title}`);
linkButton.addEventListener("click", () => {
if (mode === "picker") {
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
return;
}
handleNavigate(item.nodeId);
});
const titleElement = document.createElement("span");
titleElement.className = "tree-link-title";
titleElement.textContent = item.title;
linkButton.appendChild(titleElement);
const metaElement = document.createElement("span");
metaElement.className = "tree-link-meta";
metaElement.textContent = `${item.nodeId} · ${item.childCount} 个子页面`;
linkButton.appendChild(metaElement);
row.appendChild(linkButton);
const actions = document.createElement("div");
actions.className = "tree-actions";
const siblingRows = getSiblings(item.parentNodeId);
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
actions.appendChild(
createActionButton(
ICONS.add,
"tree-action-create",
`在 ${item.title} 下新建子页面`,
() => handleCreate(item.nodeId),
false,
),
);
if (mode === "page") {
actions.appendChild(
createActionButton(
ICONS.edit,
"tree-action-rename",
`重命名 ${item.title}`,
() => void handleRename(item.nodeId),
false,
),
);
actions.appendChild(
createActionButton(
ICONS.up,
"tree-action-move-up",
`上移 ${item.title}`,
() => void handleMove(item.nodeId, -1),
siblingIndex <= 0,
),
);
actions.appendChild(
createActionButton(
ICONS.more,
"tree-action-menu",
`打开 ${item.title} 的更多操作`,
(button) => {
const center = getElementCenter(button);
openContextMenu(
item.nodeId,
center.x,
center.y,
);
},
false,
),
);
}
row.appendChild(actions);
const nodeElement = document.createElement("li");
nodeElement.className = "tree-node";
nodeElement.dataset.nodeId = item.nodeId;
nodeElement.appendChild(row);
if (hasChildren && expanded.has(item.nodeId)) {
const children = getSiblings(item.nodeId);
if (children.length > 0) {
const childrenList = document.createElement("ul");
childrenList.className = "tree-children";
children.forEach((child) => {
childrenList.appendChild(renderNode(child));
});
nodeElement.appendChild(childrenList);
}
}
return nodeElement;
};
const renderFileTree = () => {
appElement.innerHTML = "";
visibleFileTreeRowIds = [];
clearFileTreeDropFeedback();
const fileRoot = document.createElement("div");
fileRoot.className = "tree-root";
fileRoot.setAttribute("role", "tree");
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
fileRoot.addEventListener("mousedown", (event) => {
if (event.target !== event.currentTarget) return;
clearFileTreeSelection();
});
const openFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index") {
handleNavigate(documentId || item.nodeId);
return;
}
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
assetId: assetId || null,
target: { documentId: documentId || null },
payload: {
documentId: documentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
},
});
};
const postFileTreeDropToHost = (type, target, extra = {}) => {
postToHost(type, {
workspaceId,
rowId: target.rowId,
rowKind: target.rowKind,
targetRowId: target.rowId,
targetRowKind: target.rowKind,
documentId: target.documentId,
assetId: target.assetId,
...extra,
payload: {
workspaceId,
rowId: target.rowId,
rowKind: target.rowKind,
targetRowId: target.rowId,
targetRowKind: target.rowKind,
documentId: target.documentId,
assetId: target.assetId,
...extra,
},
});
};
const attachFileTreeDragSource = (row, item) => {
row.draggable = true;
row.addEventListener("dragstart", (event) => {
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
draggingFileTreeRowIds = rowIds;
if (event.dataTransfer) {
const payload = JSON.stringify({
type: "mnote-file-tree-dnd",
version: 1,
rowIds,
});
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
event.dataTransfer.setData("text/plain", payload);
}
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
});
row.addEventListener("dragend", () => {
draggingFileTreeRowIds = [];
clearFileTreeDropFeedback();
});
};
const appendFileTreeRow = (container, item) => {
const children = getSiblings(item.nodeId);
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
const documentId = getFileTreeRowDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
const row = document.createElement("div");
row.className = "tree-row";
row.style.marginLeft = `${item.depth * 22}px`;
row.dataset.active = String(
item.rowKind === "document" && documentId === currentActiveDocumentId
);
row.dataset.nodeId = item.nodeId;
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.assetId = assetId || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
row.setAttribute(
"data-testid",
item.rowKind === "document"
? "filetree-doc-row"
: item.rowKind === "index"
? "filetree-index-row"
: "filetree-asset-row",
);
row.tabIndex = 0;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
visibleFileTreeRowIds.push(item.rowId);
row.addEventListener("click", (event) => {
selectFileTreeRow(item.rowId, event);
renderTree();
});
row.addEventListener("dblclick", () => {
openFileTreeItem(item);
});
row.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectFileTreeContextRow(item.rowId);
renderTree();
openFileTreeContextMenu({
documentId,
assetId,
rowId: item.rowId,
rowKind: item.rowKind,
clientX: event.clientX,
clientY: event.clientY,
});
});
attachFileTreeDragSource(row, item);
if (hasBranches) {
const toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "tree-toggle";
toggleButton.setAttribute(
"aria-label",
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
);
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
toggleButton.addEventListener("click", (event) => {
event.stopPropagation();
toggleExpand(item.nodeId);
});
row.appendChild(toggleButton);
} else {
const spacer = document.createElement("div");
spacer.className = "tree-spacer";
row.appendChild(spacer);
}
row.appendChild(createKindBadge(getFileTreeRowIconKind(item)));
const linkButton = document.createElement("button");
linkButton.type = "button";
linkButton.className = "tree-link";
if (item.rowKind === "document") {
linkButton.setAttribute("data-testid", "filetree-doc-open");
} else if (item.rowKind === "asset" || item.rowKind === "asset_folder") {
linkButton.setAttribute("data-testid", "filetree-asset-open");
}
linkButton.addEventListener("click", () => openFileTreeItem(item));
const title = document.createElement("span");
title.className = "tree-link-title";
title.textContent = item.title;
const meta = document.createElement("span");
meta.className = "tree-link-meta";
meta.textContent = getFileTreeRowMetaLabel(item);
linkButton.appendChild(title);
linkButton.appendChild(meta);
row.appendChild(linkButton);
const actions = document.createElement("div");
actions.className = "tree-actions";
actions.appendChild(
createActionButton(
ICONS.more,
"filetree-action-menu",
`打开 ${item.title} 的更多操作`,
(button) => {
const center = getElementCenter(button);
openFileTreeContextMenu({
documentId,
assetId,
rowId: item.rowId,
rowKind: item.rowKind,
clientX: center.x,
clientY: center.y,
});
},
false,
),
);
row.appendChild(actions);
container.appendChild(row);
if (!hasBranches || !expanded.has(item.nodeId)) return;
children.forEach((child) => appendFileTreeRow(container, child));
};
fileRoot.addEventListener("dragover", (event) => {
const internalRowIds = readFileTreeInternalDropPayload(event);
const files = Array.from(event.dataTransfer?.files || []);
if (!internalRowIds && files.length === 0) {
return;
}
event.preventDefault();
const target = getFileTreeDropTargetFromElement(event.target);
if (event.dataTransfer) {
event.dataTransfer.dropEffect =
files.length > 0 || event.altKey ? "copy" : "move";
}
setFileTreeDropFeedback(target);
});
fileRoot.addEventListener("dragleave", (event) => {
const relatedTarget =
event.relatedTarget instanceof Node ? event.relatedTarget : null;
if (relatedTarget && fileRoot.contains(relatedTarget)) {
return;
}
clearFileTreeDropFeedback();
});
fileRoot.addEventListener("drop", (event) => {
const internalRowIds = readFileTreeInternalDropPayload(event);
const files = Array.from(event.dataTransfer?.files || []);
if (!internalRowIds && files.length === 0) {
return;
}
event.preventDefault();
const target = getFileTreeDropTargetFromElement(event.target);
if (files.length > 0) {
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
postFileTreeDropToHost("tree.filetree.external-drop", target, {
files,
});
} else {
setLastAction(
event.altKey
? `已发送复制拖放到 ${target.rowId || "根目录"}`
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
);
postFileTreeDropToHost("tree.filetree.internal-drop", target, {
rowIds: internalRowIds,
copy: event.altKey === true,
});
}
draggingFileTreeRowIds = [];
clearFileTreeDropFeedback();
});
if (roots.length === 0) {
const empty = document.createElement("div");
empty.className = "tree-empty";
empty.textContent = "当前 file tree 没有可渲染的页面。";
fileRoot.appendChild(empty);
} else {
roots.forEach((item) => appendFileTreeRow(fileRoot, item));
}
normalizeFileTreeSelectionForVisibleRows();
appElement.appendChild(fileRoot);
};
const renderTree = () => {
if (mode === "filetree") {
renderFileTree();
return;
}
appElement.innerHTML = "";
if (mode === "picker" && allowRootPick) {
const rootButton = document.createElement("button");
rootButton.type = "button";
rootButton.className = "tree-row";
rootButton.setAttribute("data-testid", "tree-picker-root");
rootButton.dataset.focused = String(resolvePickerRootFocused());
rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1;
rootButton.addEventListener("click", () => {
applyPickerFocusByItemKey("__root__", { focusDom: true });
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
});
const spacer = document.createElement("div");
spacer.className = "tree-spacer";
rootButton.appendChild(spacer);
const label = document.createElement("div");
label.className = "tree-link";
const title = document.createElement("span");
title.className = "tree-link-title";
title.textContent = "根目录";
const meta = document.createElement("span");
meta.className = "tree-link-meta";
meta.textContent = "选择工作空间根目录";
label.appendChild(title);
label.appendChild(meta);
rootButton.appendChild(label);
appElement.appendChild(rootButton);
}
if (roots.length === 0) {
const empty = document.createElement("div");
empty.className = "tree-empty";
empty.textContent =
mode === "picker"
? "当前 projection 没有可选择的页面。"
: "当前 projection 没有可渲染的页面,点击上方按钮先创建一个根页面。";
appElement.appendChild(empty);
return;
}
const list = document.createElement("ul");
list.className = "tree-root";
list.setAttribute("role", "tree");
roots.forEach((item) => {
list.appendChild(renderNode(item));
});
appElement.appendChild(list);
if (mode === "page" && focusedNodeId) {
focusRowElement(focusedNodeId);
}
};
window.addEventListener("message", (event) => {
const payload = event.data;
if (!payload || typeof payload !== "object") {
return;
}
if (normalizeText(payload.channel) !== channel) {
return;
}
const messageType = normalizeText(payload.type);
if (messageType === "tree.picker.command") {
handlePickerCommand(payload.command);
return;
}
if (messageType !== "tree.shell.state.patch") {
return;
}
let changed = false;
const nextActiveDocumentId = normalizeText(payload.activeDocumentId);
const nextFocusedDocumentId = normalizeText(payload.focusedDocumentId);
const nextActivePickerItemKey = normalizeText(payload.activePickerItemKey);
if (nextActiveDocumentId !== currentActiveDocumentId) {
currentActiveDocumentId = nextActiveDocumentId;
changed = true;
}
if (nextFocusedDocumentId !== currentFocusedDocumentId) {
currentFocusedDocumentId = nextFocusedDocumentId;
changed = true;
}
if (nextActivePickerItemKey !== currentActivePickerItemKey) {
currentActivePickerItemKey = nextActivePickerItemKey;
changed = true;
}
if (!changed) {
return;
}
focusedNodeId = resolveFocusedNodeIdFromHostState();
if (mode === "picker" && usedRustInitialRenderer) {
patchPickerActiveDom();
return;
}
renderTree();
if (mode === "page" && focusedNodeId) {
focusRowElement(focusedNodeId);
}
});
createRootButton.addEventListener("click", () => {
if (mode === "picker") return;
void handleCreate(null);
});
const emitReady = () => {
postToHost("tree.ready", {
workspaceId,
payload: { workspaceId },
});
};
const usedRustInitialRenderer = hydrateInitialRenderer();
if (!usedRustInitialRenderer) {
renderTree();
}
if (mode === "page" && focusedNodeId) {
postPageFocusChange(focusedNodeId);
}
if (mode === "filetree") {
emitFileTreeSelectionChange();
}
setStatus(
mode === "picker"
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
: mode === "filetree"
? "File tree shell 已就绪,可以打开页面与附件。"
: "Tree shell 已就绪,可以进行展开、创建、重命名和排序操作。",
);
setLastAction("已向宿主发送 ready 消息。");
emitReady();
window.setTimeout(emitReady, 300);
window.setTimeout(emitReady, 1200);
})();
</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("__PROJECTION_JSON__", &escape_html(&projection_json))
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
}
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
})),
)
}
pub async fn tree_shell(
State(state): State<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 effective_workspace_id =
resolve_effective_workspace_id(&effective_context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let mode = normalize_tree_mode(query.mode.as_deref());
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
let snapshot = load_projection_snapshot(
state.config(),
&effective_context,
&ProjectionSnapshotSpec {
workspace_id: &effective_workspace_id,
root_node_id: query.root_node_id.as_deref(),
depth: query.depth,
query: None,
max_results: None,
projection: if mode == "filetree" {
KernelProjectionKind::FileTree
} else {
KernelProjectionKind::PageTree
},
},
)
.await?;
let html = build_tree_shell_html(
&effective_workspace_id,
query.root_node_id.as_deref(),
query.active_document_id.as_deref(),
query.focused_document_id.as_deref(),
query.active_picker_item_key.as_deref(),
&normalize_channel(query.channel),
query.host.as_deref(),
&effective_context,
&snapshot.projection,
mode,
allow_root_pick,
&exclude_ids,
&snapshot.dataset,
);
let mut response = Html(html).into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(response)
}
fn create_command_wire(
context: &RequestContext,
workspace_id: &str,
request: TreeCommandRequest,
) -> Result<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: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": access_scope,
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
}),
preflight_data: None,
reason: Some("tree-shell create".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
TreeCommandRequest::Rename {
workspace_id: _,
document_id,
title,
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire {
name: "tree.node.rename".into(),
command_id: format!("tree_rename_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"title": title,
}),
preflight_data: None,
reason: Some("tree-shell rename".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
TreeCommandRequest::Move {
workspace_id: _,
document_id,
parent_id,
sort_order,
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
let sort_order = ensure_sort_order(sort_order, context)?;
let parent_id = read_optional_non_empty(parent_id);
Ok(RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: format!("tree_move_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"parentId": parent_id,
"sortOrder": sort_order,
}),
preflight_data: None,
reason: Some("tree-shell move".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
TreeCommandRequest::Purge {
workspace_id: _,
document_id,
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire {
name: "tree.node.purge".into(),
command_id: format!("tree_purge_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("tree-shell purge".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
}
}
async fn resolve_tree_create_workspace_id(
state: &AppState,
context: &RequestContext,
requested_workspace_id: Option<&str>,
parent_id: Option<&str>,
) -> Result<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_convex(state.config(), context, None, parent_id).await?;
if let Some(workspace_id) = parent_meta
.get("workspace_id")
.or_else(|| parent_meta.get("workspaceId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(workspace_id.to_string());
}
}
let bootstrap = execute_convex_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
json!({
"fallbackName": context.auth.actor_id,
"workspaceIdIfCreate": generate_tree_document_id(),
}),
None,
None,
"tree_command_workspace_bootstrap",
)
.await?;
bootstrap
.get("activeWorkspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
WebError::bad_gateway_code(
"workspace_bootstrap_bad_response",
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
)
.with_context(context)
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
})
}
pub async fn tree_command(
State(state): State<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 request = match raw_request.action.trim() {
"create" => TreeCommandRequest::Create {
workspace_id: raw_request.workspace_id,
document_id: read_optional_non_empty(raw_request.document_id)
.unwrap_or_else(generate_tree_document_id),
parent_id: raw_request.parent_id,
title: normalize_title(raw_request.title),
access_scope: raw_request.access_scope,
content: raw_request.content,
},
"rename" => TreeCommandRequest::Rename {
workspace_id: raw_request.workspace_id,
document_id: raw_request.document_id.unwrap_or_default(),
title: normalize_title(raw_request.title),
},
"move" => TreeCommandRequest::Move {
workspace_id: raw_request.workspace_id,
document_id: raw_request.document_id.unwrap_or_default(),
parent_id: raw_request.parent_id,
sort_order: raw_request.sort_order.unwrap_or(-1),
},
"purge" => TreeCommandRequest::Purge {
workspace_id: raw_request.workspace_id,
document_id: raw_request.document_id.unwrap_or_default(),
},
other => {
return Err(WebError::bad_request_code(
"tree_command_validation",
format!("不支持的 tree action: {other}"),
)
.with_context(&context)
.with_header("x-error-phase", "tree_command_validate"));
}
};
let requested_workspace_id = match &request {
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(),
};
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
match &request {
TreeCommandRequest::Create {
document_id,
parent_id,
title,
..
} => (
"create",
document_id.clone(),
parent_id.clone(),
Some(title.clone()),
None,
),
TreeCommandRequest::Rename {
document_id, title, ..
} => (
"rename",
document_id.clone(),
None,
Some(title.clone()),
None,
),
TreeCommandRequest::Move {
document_id,
parent_id,
sort_order,
..
} => (
"move",
document_id.clone(),
parent_id.clone(),
None,
Some(*sort_order),
),
TreeCommandRequest::Purge { document_id, .. } => {
("purge", document_id.clone(), None, None, None)
}
};
let effective_workspace_id = match &request {
TreeCommandRequest::Create { parent_id, .. } => {
resolve_tree_create_workspace_id(
&state,
&context,
requested_workspace_id,
parent_id.as_deref(),
)
.await?
}
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
.expect("workspace_required 已确保存在"),
};
let command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
Some(&effective_workspace_id),
command_wire,
)
.await?;
let response_document_id = execution
.result
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or(requested_document_id);
let artifacts = execution
.artifacts
.as_ref()
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
.unwrap_or(Value::Null);
let artifact_error = execution
.artifact_error
.as_ref()
.map(|message| Value::String(message.clone()))
.unwrap_or(Value::Null);
Ok(json_response(
&context,
json!({
"workspaceId": effective_workspace_id,
"action": action,
"documentId": response_document_id,
"parentId": requested_parent_id,
"title": requested_title,
"sortOrder": requested_sort_order,
"updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null),
"execution": execution.result,
"artifacts": artifacts,
"artifactError": artifact_error,
}),
))
}
pub async fn reduce_tree_shell_runtime(
Json(body): Json<TreeShellRuntimeRequest>,
) -> Json<TreeShellRuntimeResult> {
Json(reduce_tree_shell_runtime_request(body))
}
#[cfg(test)]
mod tests {
use super::{create_command_wire, TreeCommandRequest};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn tree_shell_returns_interactive_html_document() {
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(content_type.contains("text/html"));
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("tree-create-root"));
assert!(html.contains("tree.ready"));
assert!(html.contains("test-shell"));
assert!(html.contains("tree-action-menu"));
assert!(html.contains("tree.page.context-menu"));
assert!(html.contains("tree.page.expand.changed"));
assert!(html.contains("tree.page.focus.changed"));
assert!(html.contains("tree.shell.state.patch"));
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
assert!(html.contains("applyPageKeyboardAction"));
assert!(html.contains("reducePageActionWithRuntime"));
let apply_page_action_start = html
.find("const applyPageKeyboardAction = (action, item, sourceElement) => {")
.expect("applyPageKeyboardAction should be embedded");
let apply_page_action_end = html[apply_page_action_start..]
.find("\n const postPickerFocusChange")
.expect("applyPageKeyboardAction should end before picker focus handler");
let apply_page_action_body =
&html[apply_page_action_start..apply_page_action_start + apply_page_action_end];
assert!(
!apply_page_action_body.contains("toggleExpand("),
"page keyboard/expand should prefer runtime result instead of directly toggling local expansion state"
);
assert!(html.contains("patchPageTreeActiveDom"));
assert!(html.contains("patchPageTreeExpansionDom"));
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"page\""));
assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(html.contains("hydrateInitialPageTree"));
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
assert!(html.contains("application/x-mnote-page-tree-node"));
assert!(html.contains("页面已拖放到"));
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
assert!(html.contains("setAttribute(\"aria-level\""));
}
#[tokio::test]
async fn tree_shell_picker_mode_hides_command_toolbar_and_supports_root_pick() {
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&mode=picker&allowRootPick=1&excludeIds=page_child")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("\"mode\":\"picker\""));
assert!(html.contains("\"allowRootPick\":true"));
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
assert!(html.contains("tree.pick.root"));
assert!(html.contains("tree.picker.command"));
assert!(html.contains("tree.picker.focus.changed"));
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
assert!(html.contains("applyPickerStateAction"));
assert!(html.contains("postPickerPickResultToHost"));
assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
assert!(html.contains("const shouldFocusDom = options.focusDom === true"));
assert!(html.contains("if (shouldFocusDom) focusPickerRowElement"));
assert!(html.contains("patchPickerActiveDom"));
assert!(html.contains("hydrateInitialPickerTree"));
assert!(html.contains("tabindex=\""));
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}
#[tokio::test]
async fn tree_shell_filetree_mode_embeds_asset_state() {
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&mode=filetree")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("filetree-doc-row"));
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
assert!(html.contains("\"mediaAssets\""));
assert!(html.contains("tree.filetree.selection.changed"));
assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\""));
assert!(html.contains("applyFileTreeSelectionAction"));
assert!(html.contains("tree.filetree.internal-drop"));
assert!(html.contains("tree.filetree.external-drop"));
assert!(html.contains("\"rowKind\":\"asset_folder\""));
assert!(html.contains("\"resourceMeta\""));
assert!(html.contains("dragover"));
assert!(html.contains("hydrateInitialFileTree"));
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
}
#[tokio::test]
async fn tree_shell_embeds_renderer_input_contract() {
let filetree_response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&mode=filetree&activeDocumentId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(filetree_response.status(), StatusCode::OK);
let filetree_body = axum::body::to_bytes(filetree_response.into_body(), usize::MAX)
.await
.expect("body");
let filetree_html = String::from_utf8(filetree_body.to_vec()).expect("utf8");
assert!(filetree_html.contains("\"rendererInput\""));
assert!(filetree_html.contains("\"mode\":\"fileTree\""));
assert!(filetree_html.contains("\"filetreeSelection\""));
assert!(filetree_html.contains("\"selectedRowIds\""));
assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(filetree_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
let picker_response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&mode=picker&activePickerItemKey=page_child&excludeIds=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(picker_response.status(), StatusCode::OK);
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
.await
.expect("body");
let picker_html = String::from_utf8(picker_body.to_vec()).expect("utf8");
assert!(picker_html.contains("\"mode\":\"picker\""));
assert!(picker_html.contains("\"activePickerItem\":\"page_child\""));
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
assert!(picker_html.contains("\"runtimeArtifact\""));
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(picker_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_filetree_runtime_result() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"selectRow","rowId":"asset:image","modifiers":{"shiftKey":false,"ctrlKey":false,"metaKey":false}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["mode"], Value::String("fileTree".into()));
assert_eq!(
payload["requestId"],
Value::String("req-filetree-route".into())
);
assert_eq!(
payload["domPatches"][0]["kind"],
Value::String("fileTreeState".into())
);
assert_eq!(
payload["domPatches"][0]["selectedRowIds"][0],
Value::String("asset:image".into())
);
let drop_target_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-drop-target-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"updateDropTarget","rowId":"asset:image"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(drop_target_response.status(), StatusCode::OK);
let drop_target_body = axum::body::to_bytes(drop_target_response.into_body(), usize::MAX)
.await
.expect("body");
let drop_target_payload: Value = serde_json::from_slice(&drop_target_body).expect("json");
assert_eq!(
drop_target_payload["domPatches"][0]["dropTargetRowId"],
Value::String("asset:image".into())
);
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results() {
let page_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"page","requestId":"req-page-route","environment":{"visibleNodeIds":["doc:root","doc:child"],"expandableNodeIds":["doc:root"]},"state":{"focusedId":"doc:root","expandedIds":[],"dropFeedback":null},"action":{"kind":"moveNext"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(page_response.status(), StatusCode::OK);
let page_body = axum::body::to_bytes(page_response.into_body(), usize::MAX)
.await
.expect("body");
let page_payload: Value = serde_json::from_slice(&page_body).expect("json");
assert_eq!(page_payload["mode"], Value::String("page".into()));
assert_eq!(
page_payload["requestId"],
Value::String("req-page-route".into())
);
assert_eq!(
page_payload["domPatches"][0]["kind"],
Value::String("pageState".into())
);
assert_eq!(
page_payload["domPatches"][0]["focusedId"],
Value::String("doc:child".into())
);
let picker_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"picker","requestId":"req-picker-route","environment":{"items":[{"itemKey":"doc:root","documentId":"doc:root","pickable":true},{"itemKey":"doc:child","documentId":"doc:child","pickable":true}],"excludedIds":[],"allowRootPick":false},"state":{"activeItemKey":"doc:child"},"action":{"kind":"pick"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(picker_response.status(), StatusCode::OK);
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
.await
.expect("body");
let picker_payload: Value = serde_json::from_slice(&picker_body).expect("json");
assert_eq!(picker_payload["mode"], Value::String("picker".into()));
assert_eq!(
picker_payload["requestId"],
Value::String("req-picker-route".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["kind"],
Value::String("pickerPickDocument".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["documentId"],
Value::String("doc:child".into())
);
}
#[tokio::test]
async fn tree_command_create_generates_document_id_when_missing() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"create","workspaceId":"ws_demo","parentId":"page_root","title":"新页面","accessScope":"private","content":[]}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
}
#[tokio::test]
async fn tree_command_create_uses_default_workspace_when_workspace_missing() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(r#"{"action":"create","title":"新页面"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(
payload["result"]["workspaceId"],
Value::String("ws_demo".into())
);
assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
}
#[tokio::test]
async fn tree_command_purge_uses_tree_command_protocol() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("purge".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(
payload["result"]["execution"]["deletedCount"],
Value::from(1)
);
}
#[tokio::test]
async fn tree_command_rejects_negative_sort_order() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":-1}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn tree_command_rename_defaults_blank_title_to_untitled() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":" "}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["title"], Value::String("无标题".into()));
}
#[tokio::test]
async fn tree_command_move_returns_structured_payload() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("move".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
}
#[tokio::test]
async fn tree_command_response_includes_rust_artifact_plan_for_domain_event() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(
payload["result"]["artifacts"]["domainEvent"]["eventType"],
Value::String("tree.subtree.moved".into())
);
assert_eq!(
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
Value::String("move_document".into())
);
assert_eq!(
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"],
Value::String("move_document".into())
);
}
#[tokio::test]
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.header("Authorization", "Bearer demo-token")
.body(Body::from(
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
}
#[tokio::test]
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
.header("Authorization", "Bearer demo-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["boundary"], "next_sidebar_compat");
assert_eq!(payload["workspaceId"], "ws_demo");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
}
#[test]
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<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())),
},
)
.expect("create wire");
assert_eq!(create_wire.name, "tree.node.create");
let rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("rename wire");
assert_eq!(rename_wire.name, "tree.node.rename");
let move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("move wire");
assert_eq!(move_wire.name, "tree.subtree.move");
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<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())),
},
)
.expect("tree create wire");
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.create".into(),
..tree_create_wire.clone()
};
let tree_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
.expect("tree create plan");
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
let tree_rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("tree rename wire");
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.title.update".into(),
..tree_rename_wire.clone()
};
let tree_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
.expect("tree rename plan");
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
let tree_move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("tree move wire");
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.move".into(),
..tree_move_wire.clone()
};
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
.expect("tree move plan");
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
}
}