diff --git a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md
index 1f1daba6..5e25c8b9 100644
--- a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md
+++ b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md
@@ -496,6 +496,44 @@ Reasonix read-only audit:
- 结果:`ok=true`
- 证据:命令 stdout JSON,`assetId=local:asset:README/side-target-resource.md`、`officeAssetId=local:asset:README/side-target-office.docx`。
+### 9.6 Batch 5 执行记录(2026-05-24)
+
+Reasonix read-only audit:
+
+- run id: `reasonix-2026-05-24T15-57-06-903Z-51c4452a`
+- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T15-57-06-903Z-51c4452a/process-handoff.md`
+- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、`process-handoff.json`。
+- 采纳结论:可采纳;本批是 debug `/tree` shell runtime 同构迁出,未改变 `/api/tree/runtime/reduce` 合同和 page/filetree/picker 行为。
+
+采纳内容:
+
+- 新增 `rust/crates/mnote-web/browser/tree-shell-runtime.js`,从 `tree.rs` 原 inline runtime 同构迁出。
+- `build_tree_shell_html()` 保留 `tree-shell-state` bootstrap JSON,改为加载 `/api/mnote-browser-runtime/tree-shell-runtime.js` module script。
+- 新增 `/api/mnote-browser-runtime/tree-shell-runtime.js` 路由,返回 `application/javascript; charset=utf-8`。
+- `tree.rs` 测试继续通过 `include_str!("../../browser/tree-shell-runtime.js")` 校验 tree shell runtime 的 page/filetree/picker 关键字符串。
+- 主控复核后额外采纳一个小型结构化 hoist:`buildFileTreeRuntimeEnvironment`、`reducePageActionWithRuntime`、`reconcilePageRuntimeResult` 从 IIFE 内部移到文件顶层,通过 `runtimeContext` 显式接收依赖,使 CodeGraph 可以定位这些函数;未改变 runtime API 合同。
+
+已通过验证:
+
+- `node --check rust/crates/mnote-web/browser/tree-shell-runtime.js`
+- `cargo fmt --manifest-path rust/Cargo.toml --all --check`
+- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
+- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell`
+ - 结果:42 个 tree_shell 相关测试通过。
+- `GET /api/mnote-browser-runtime/tree-shell-runtime.js`
+ - 结果:`200`,`content-type=application/javascript; charset=utf-8`,响应包含 `tree.ready`、`hydrateInitialFileTree`。
+- `codegraph_search reducePageActionWithRuntime` / `buildFileTreeRuntimeEnvironment` / `reconcilePageRuntimeResult`
+ - 结果:3 个函数均可定位到 `rust/crates/mnote-web/browser/tree-shell-runtime.js`。
+- `MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES=1` 下 `/tree` 运行态说明:
+ - Convex workspace 参数返回 `503 convex_unavailable`,因为本地未启动 Convex upstream。
+ - local_folder 参数返回 `401 local_workspace_auth_required`,因为本次未注入浏览器登录态。
+ - 因此运行态 `/tree` HTML 由 Rust route 单测覆盖,本批不把这些环境失败视为实现失败。
+
+剩余风险:
+
+- `type="module"` 自带作用域,`tree-shell-runtime.js` 外层 IIFE 现在是冗余但无害;本批保持同构迁出,暂不做额外重构。
+- debug tree shell runtime 与主壳 `SIDEBAR_TREE_JS` 仍是两套 runtime,后续 P4/P5 继续拆主壳 filetree runtime 和 document pane host adapter。
+
## 10. 分阶段执行清单
### P0:定位辅助与基线保护
@@ -549,11 +587,12 @@ Reasonix read-only audit:
### P3:外置 debug `/tree` shell runtime bridge
-- [ ] 将 `tree.rs` 中 `tree-shell-state` 后的大块 JS 迁到 `browser/tree-shell-runtime.js`。
-- [ ] `build_tree_shell_html()` 只渲染 HTML、CSS、bootstrap JSON 和 module script。
-- [ ] 保持 `/api/tree/runtime/reduce` 合同不变。
-- [ ] debug shell page/filetree/picker 三个模式 smoke 保持通过。
-- [ ] 验证 CodeGraph 能定位 `reducePageActionWithRuntime`、`buildFileTreeRuntimeEnvironment`、`reconcilePageRuntimeResult` 等符号。
+- [x] 将 `tree.rs` 中 `tree-shell-state` 后的大块 JS 迁到 `browser/tree-shell-runtime.js`。
+- [x] `build_tree_shell_html()` 只渲染 HTML、CSS、bootstrap JSON 和 module script。
+- [x] 保持 `/api/tree/runtime/reduce` 合同不变。
+- [x] debug shell page/filetree/picker 三个模式 smoke 保持通过。
+ - 2026-05-24:`cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell` 通过 42 个 tree shell 相关测试;运行态 `/tree` 需 debug route + auth/Convex 或 local workspace 登录态,不作为本批硬门槛。
+- [x] 验证 CodeGraph 能定位 `reducePageActionWithRuntime`、`buildFileTreeRuntimeEnvironment`、`reconcilePageRuntimeResult` 等符号。
### P4:抽出主壳 filetree runtime
diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js
new file mode 100644
index 00000000..d857c400
--- /dev/null
+++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js
@@ -0,0 +1,4577 @@
+// MNote debug /tree shell 浏览器运行时外置模块。
+// 该文件由 tree.rs 原 inline runtime 同构迁出,语义仍由 Rust tree_shell reducer 主导。
+
+function buildFileTreeRuntimeEnvironment(runtimeContext) {
+ const fileTreeRowById = runtimeContext.fileTreeRowById || new Map();
+ return {
+ visibleRowIds: Array.isArray(runtimeContext.visibleFileTreeRowIds)
+ ? runtimeContext.visibleFileTreeRowIds
+ : [],
+ rows: Array.from(fileTreeRowById.values()).map((entry) => ({
+ rowId: entry.rowId,
+ rowKind: entry.rowKind,
+ documentId: entry.documentId || null,
+ assetId: entry.assetId || null,
+ })),
+ rootUri: runtimeContext.sourceKind === "local_folder" ? runtimeContext.rootUri || null : null,
+ };
+}
+
+async function reducePageActionWithRuntime(runtimeContext, action, item) {
+ if (runtimeContext.mode !== "page" || !runtimeContext.runtimeReduceEndpoint) {
+ return null;
+ }
+ const runtimeAction = runtimeContext.buildPageRuntimeAction(action, item);
+ if (!runtimeAction) return null;
+ const response = await fetch(runtimeContext.runtimeReduceEndpoint, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ mode: "page",
+ requestId: `page-runtime-${Date.now()}`,
+ environment: runtimeContext.buildPageRuntimeEnvironment(),
+ state: runtimeContext.readPageRuntimeState(action, item),
+ action: runtimeAction,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await runtimeContext.readErrorMessage(response));
+ }
+ return response.json();
+}
+
+function reconcilePageRuntimeResult(runtimeContext, runtimeResult, item) {
+ const result = runtimeContext.normalizePageRuntimeResult(runtimeResult);
+ if (!result) return false;
+ const previousExpanded = new Set(runtimeContext.expanded);
+ let shouldPatchTree = false;
+ if (Array.isArray(result.expandedIds)) {
+ shouldPatchTree =
+ runtimeContext.commitPageExpandedIds(result.expandedIds) || shouldPatchTree;
+ }
+ const currentFocusedNodeId = runtimeContext.getFocusedNodeId();
+ if (result.focusedId && result.focusedId !== currentFocusedNodeId) {
+ runtimeContext.setFocusedNodeId(result.focusedId);
+ runtimeContext.postPageFocusChange(result.focusedId);
+ shouldPatchTree = true;
+ }
+ const itemNodeId = runtimeContext.normalizeText(item?.nodeId);
+ if (
+ itemNodeId &&
+ previousExpanded.has(itemNodeId) !== runtimeContext.expanded.has(itemNodeId)
+ ) {
+ runtimeContext.postPageExpandChange(itemNodeId, runtimeContext.expanded.has(itemNodeId));
+ }
+ if (shouldPatchTree) {
+ runtimeContext.patchPageTreeAfterRuntimeState(
+ Array.isArray(result.expandedIds) ? [itemNodeId, ...result.expandedIds] : [itemNodeId],
+ result.focusedId || runtimeContext.getFocusedNodeId(),
+ );
+ }
+ return shouldPatchTree;
+}
+
+function startTreeShellRuntime() {
+ 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 sourceKind =
+ typeof state.sourceKind === "string" && state.sourceKind.trim()
+ ? state.sourceKind.trim()
+ : "convex_workspace";
+ const rootUri =
+ typeof state.rootUri === "string" && state.rootUri.trim()
+ ? state.rootUri.trim()
+ : "";
+ const initialLocalWatchRevision =
+ state.localWatchRevision &&
+ typeof state.localWatchRevision === "object" &&
+ typeof state.localWatchRevision.revision === "string"
+ ? state.localWatchRevision.revision
+ : "";
+ 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";
+ let mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
+ let mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
+ let 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 initialRenameRowId = (() => {
+ try {
+ return normalizeText(new URL(window.location.href).searchParams.get("renameRowId"));
+ } catch {
+ return "";
+ }
+ })();
+ 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 === "folder") return "folder";
+ if (normalized === "markdown") return "markdown";
+ 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: "",
+ objectIdentity: null,
+ blockAssetRelation: null,
+ };
+ }
+ const objectIdentity =
+ value?.objectIdentity && typeof value.objectIdentity === "object"
+ ? value.objectIdentity
+ : null;
+ const blockAssetRelation =
+ value?.blockAssetRelation && typeof value.blockAssetRelation === "object"
+ ? value.blockAssetRelation
+ : null;
+ return {
+ resourceKind: normalizeText(value?.resourceKind),
+ documentId: normalizeText(value?.documentId),
+ assetId: normalizeText(value?.assetId),
+ assetKind: normalizeText(value?.assetKind),
+ objectIdentity,
+ blockAssetRelation,
+ };
+ };
+
+ const compareItems = (left, right) => {
+ const byPosition = left.position - right.position;
+ if (byPosition !== 0) return byPosition;
+ return left.title.localeCompare(right.title, "zh-CN");
+ };
+
+ const normalizeTreeItems = (items) =>
+ Array.isArray(items)
+ ? items
+ .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))
+ : [];
+
+ let rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
+ let normalizedItems = normalizeTreeItems(rawItems);
+ let itemById = new Map();
+ let fileTreeRowById = new Map();
+ let childrenByParentId = new Map();
+ let roots = [];
+ let assetsByDocId = new Map();
+
+ const rebuildTreeIndexes = () => {
+ itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
+ fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
+ childrenByParentId = new Map();
+ roots = [];
+ 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);
+ });
+ 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));
+ };
+ rebuildTreeIndexes();
+
+ 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}`] : []
+ );
+ 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 activeFileTreeDropPosition = null;
+ let activeFileTreeRootDrop = false;
+ let fileTreeHoverExpandTimer = 0;
+ let fileTreeClipboard = { action: null, rowIds: [] };
+ let inlineRenameState = { mode: null, id: null, committing: false };
+ let activeFileTreeMenuElement = null;
+ let activeFileTreePreflightElement = null;
+
+ 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.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
+ if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
+ return "";
+ };
+
+ const getFileTreeRowOwnerDocumentId = (item) => {
+ if (!item) return "";
+ if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
+ return getFileTreeRowDocumentId(item);
+ };
+
+ 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 === "folder") return "folder";
+ if (item.rowKind === "markdown") return "file";
+ 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 === "folder") {
+ return "本地文件夹";
+ }
+ if (item.rowKind === "markdown") {
+ return `${getFileTreeRowDocumentId(item)} · Markdown`;
+ }
+ 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",
+ nodeId: null,
+ documentId: null,
+ ownerDocumentId: null,
+ assetId: null,
+ };
+ }
+ return {
+ rowId: normalizeText(row.dataset.rowId) || null,
+ rowKind: normalizeText(row.dataset.rowKind, "document"),
+ nodeId: normalizeText(row.dataset.nodeId) || null,
+ documentId: normalizeText(row.dataset.documentId) || null,
+ ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
+ assetId: normalizeText(row.dataset.assetId) || null,
+ };
+ };
+
+ const getFileTreeDropTargetFromEvent = (event) => {
+ const target = getFileTreeDropTargetFromElement(event.target);
+ if (!target.rowId) return { ...target, dropPosition: "inside" };
+ const row = event.target instanceof Element
+ ? event.target.closest('.tree-row[data-shell-mode="filetree"]')
+ : null;
+ if (!(row instanceof HTMLElement)) return { ...target, dropPosition: "inside" };
+ const rect = row.getBoundingClientRect();
+ const offset = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
+ const item = fileTreeRowById.get(target.rowId);
+ if (offset < 0.25) return { ...target, dropPosition: "before" };
+ if (offset > 0.75) return { ...target, dropPosition: "after" };
+ return {
+ ...target,
+ dropPosition: item && canExpandFileTreeRow(item) ? "inside" : "after",
+ };
+ };
+
+ const resolveLocalFileTreeParentId = (target) => {
+ if (!target || target.rowKind === "root") return null;
+ const targetItem = target.rowId ? fileTreeRowById.get(target.rowId) : null;
+ if (targetItem && targetItem.rowKind === "folder") {
+ return targetItem.nodeId;
+ }
+ if (targetItem && targetItem.parentNodeId) {
+ return targetItem.parentNodeId;
+ }
+ return null;
+ };
+
+ const isFileTreeDescendantOf = (candidate, ancestor) => {
+ let cursor = candidate;
+ while (cursor && cursor.parentNodeId) {
+ if (cursor.parentNodeId === ancestor.nodeId) return true;
+ cursor = itemById.get(cursor.parentNodeId) || null;
+ }
+ return false;
+ };
+
+ const validateFileTreeInternalDrop = (target, rowIds, copy) => {
+ const sourceRowIds = Array.isArray(rowIds) ? rowIds.filter(Boolean) : [];
+ if (sourceRowIds.length === 0) return { ok: false, reason: "empty" };
+ const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
+ if (targetItem && targetItem.capabilities.some((capability) =>
+ capability === "readonly" || capability === "readOnly" || capability === "permissionDenied"
+ )) {
+ return { ok: false, reason: "readonly" };
+ }
+ for (const rowId of sourceRowIds) {
+ const sourceItem = fileTreeRowById.get(rowId);
+ if (!sourceItem) return { ok: false, reason: "cross_workspace" };
+ if (targetItem && targetItem.rowId === sourceItem.rowId) {
+ return { ok: false, reason: "self" };
+ }
+ if (targetItem && sourceItem.rowKind === "folder" && isFileTreeDescendantOf(targetItem, sourceItem)) {
+ return { ok: false, reason: "descendant" };
+ }
+ if (copy && sourceItem.rowKind === "folder") {
+ return { ok: false, reason: "copy_folder_unsupported" };
+ }
+ }
+ return { ok: true, reason: "" };
+ };
+
+ const filterRedundantFileTreeRowIds = (rowIds) => {
+ const selected = new Set(Array.isArray(rowIds) ? rowIds.filter(Boolean) : []);
+ return Array.from(selected).filter((rowId) => {
+ const item = fileTreeRowById.get(rowId);
+ if (!item) return false;
+ let cursor = item;
+ while (cursor && cursor.parentNodeId) {
+ const parent = itemById.get(cursor.parentNodeId) || null;
+ if (parent && selected.has(parent.rowId)) {
+ return false;
+ }
+ cursor = parent;
+ }
+ return true;
+ });
+ };
+
+ const validateFileTreeWritableTarget = (target) => {
+ const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
+ if (
+ targetItem &&
+ targetItem.capabilities.some((capability) =>
+ capability === "readonly" ||
+ capability === "readOnly" ||
+ capability === "permissionDenied"
+ )
+ ) {
+ return { ok: false, reason: "readonly", targetItem };
+ }
+ return { ok: true, reason: "", targetItem };
+ };
+
+ const getFileTreeRowLabel = (item) =>
+ item?.title || item?.rowId || item?.nodeId || "选中项";
+
+ const getFileTreeTargetLabel = (target) => {
+ if (!target || target.rowKind === "root" || !target.rowId) return "Explorer 根目录";
+ const item = fileTreeRowById.get(target.rowId);
+ if (!item) return target.rowId;
+ if (item.rowKind === "markdown" || item.rowKind === "document" || item.rowKind === "index") {
+ return `${item.title} 的父目录`;
+ }
+ return item.title;
+ };
+
+ const showFileTreePreflight = (preflight) =>
+ new Promise((resolve) => {
+ if (activeFileTreePreflightElement) {
+ activeFileTreePreflightElement.remove();
+ activeFileTreePreflightElement = null;
+ }
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "tree-preflight-backdrop";
+ backdrop.dataset.testid = "tree-preflight";
+ backdrop.dataset.preflightKind = preflight.kind || "";
+ const dialog = document.createElement("div");
+ dialog.className = "tree-preflight-dialog";
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ const body = document.createElement("div");
+ body.className = "tree-preflight-body";
+ const title = document.createElement("h2");
+ title.className = "tree-preflight-title";
+ title.textContent = preflight.title || "操作预检";
+ body.appendChild(title);
+
+ const list = document.createElement("ul");
+ list.className = "tree-preflight-list";
+ (preflight.lines || []).forEach((line) => {
+ const item = document.createElement("li");
+ item.textContent = line;
+ list.appendChild(item);
+ });
+ body.appendChild(list);
+ dialog.appendChild(body);
+
+ const actions = document.createElement("div");
+ actions.className = "tree-preflight-actions";
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.dataset.role = "cancel";
+ cancel.textContent = "取消";
+ const confirm = document.createElement("button");
+ confirm.type = "button";
+ confirm.dataset.role = "confirm";
+ confirm.textContent = preflight.confirmLabel || "继续";
+ actions.appendChild(cancel);
+ actions.appendChild(confirm);
+ dialog.appendChild(actions);
+ backdrop.appendChild(dialog);
+
+ const finish = (accepted) => {
+ backdrop.remove();
+ activeFileTreePreflightElement = null;
+ document.removeEventListener("keydown", onKeyDown, true);
+ resolve(accepted);
+ };
+ const onKeyDown = (event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ finish(false);
+ }
+ };
+ cancel.addEventListener("click", () => finish(false));
+ confirm.addEventListener("click", () => finish(true));
+ document.addEventListener("keydown", onKeyDown, true);
+ document.body.appendChild(backdrop);
+ activeFileTreePreflightElement = backdrop;
+ cancel.focus({ preventScroll: true });
+ });
+
+ const runFileTreePreflight = async (kind, context = {}) => {
+ const sourceRowIds = Array.isArray(context.rowIds) ? context.rowIds.filter(Boolean) : [];
+ const sourceItems = sourceRowIds
+ .map((rowId) => fileTreeRowById.get(rowId))
+ .filter(Boolean);
+ const targetLabel = getFileTreeTargetLabel(context.target);
+ const sourceLabel =
+ sourceItems.length === 0
+ ? "外部文件"
+ : sourceItems.map(getFileTreeRowLabel).join("、");
+ const fileNames = Array.isArray(context.files)
+ ? context.files.map((file) => file.name || "未命名文件").filter(Boolean)
+ : [];
+
+ if (context.validation && context.validation.ok === false) {
+ if (context.validation.reason === "readonly") {
+ await showFileTreePreflight({
+ kind: "readonly",
+ title: "操作预检失败",
+ confirmLabel: "知道了",
+ lines: [
+ "目标: readonly",
+ "原因: 当前目标为只读或权限不足。",
+ "结果: 不会发送 execute。",
+ ],
+ });
+ return false;
+ }
+ setLastAction(`预检拒绝: ${context.validation.reason}`, "error");
+ return false;
+ }
+
+ const writableTarget = validateFileTreeWritableTarget(context.target);
+ if (!writableTarget.ok) {
+ await showFileTreePreflight({
+ kind: "readonly",
+ title: "操作预检失败",
+ confirmLabel: "知道了",
+ lines: [
+ `目标: ${getFileTreeRowLabel(writableTarget.targetItem)}`,
+ "原因: 当前目标为只读或权限不足。",
+ "结果: 不会发送 execute。",
+ ],
+ });
+ return false;
+ }
+
+ const preflight = (() => {
+ if (kind === "delete") {
+ return {
+ kind,
+ title: "删除预检",
+ confirmLabel: "删除",
+ lines: [
+ `目标: ${sourceLabel}`,
+ `影响: ${Math.max(1, sourceItems.length)} 个文件树节点`,
+ sourceKind === "local_folder"
+ ? "删除后进入本地 .mnote/trash。"
+ : "删除将进入云端回收站。",
+ ],
+ };
+ }
+ if (kind === "paste") {
+ return {
+ kind,
+ title: context.copy ? "粘贴复制预检" : "粘贴移动预检",
+ confirmLabel: "粘贴",
+ lines: [
+ `来源: ${sourceLabel}`,
+ `目标: ${targetLabel}`,
+ "命名冲突策略: 使用递增命名,不静默覆盖。",
+ ],
+ };
+ }
+ if (kind === "dropFiles") {
+ return {
+ kind,
+ title: "外部拖入预检",
+ confirmLabel: sourceKind === "local_folder" ? "拖入" : "发送",
+ lines: [
+ `文件: ${fileNames.join("、") || "外部文件"}`,
+ `目标: ${targetLabel}`,
+ sourceKind === "local_folder"
+ ? "目标可写时复制进本地文件夹;命名冲突使用递增命名。"
+ : "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
+ ],
+ };
+ }
+ return {
+ kind: context.copy ? "copy" : "move",
+ title: context.copy ? "复制预检" : "移动预检",
+ confirmLabel: context.copy ? "复制" : "移动",
+ lines: [
+ `来源: ${sourceLabel}`,
+ `目标: ${targetLabel}`,
+ context.copy
+ ? "命名冲突策略: 使用递增命名,不静默覆盖。"
+ : "会检查自拖自身、后代目标和跨 workspace 来源。",
+ ],
+ };
+ })();
+
+ return await showFileTreePreflight(preflight);
+ };
+
+ const executeLocalFileTreeInternalDrop = async (target, rowIds, copy, trigger = "drop") => {
+ const validation = validateFileTreeInternalDrop(target, rowIds, copy);
+ if (!validation.ok) {
+ setLastAction(`已拒绝非法拖拽: ${validation.reason}`, "error");
+ return false;
+ }
+ const accepted = await runFileTreePreflight(trigger === "paste" ? "paste" : "move", {
+ target,
+ rowIds,
+ copy,
+ validation,
+ });
+ if (!accepted) return false;
+ const parentId = resolveLocalFileTreeParentId(target);
+ const sourceRowIds = filterRedundantFileTreeRowIds(rowIds);
+ if (sourceRowIds.length === 0) return false;
+ for (const rowId of sourceRowIds) {
+ const sourceItem = fileTreeRowById.get(rowId);
+ const documentId = getFileTreeRowDocumentId(sourceItem) || sourceItem?.nodeId || "";
+ if (!documentId) continue;
+ await sendCommand({
+ action: copy ? "copy" : "move",
+ workspaceId,
+ documentId,
+ parentId,
+ sortOrder: 0,
+ });
+ }
+ setStatus(copy ? "复制成功" : "移动成功");
+ setLastAction(copy ? "本地文件树复制完成" : "本地文件树移动完成");
+ scheduleRefresh();
+ return true;
+ };
+
+ const executeLocalFileTreeExternalDrop = async (target, files) => {
+ const droppedFiles = Array.from(files || []);
+ if (droppedFiles.length === 0) return false;
+ const accepted = await runFileTreePreflight("dropFiles", {
+ target,
+ files: droppedFiles,
+ });
+ if (!accepted) return false;
+ const parentId = resolveLocalFileTreeParentId(target);
+ const payload = await Promise.all(
+ droppedFiles.map(async (file) => ({
+ name: file.name || "dropped-file",
+ text: await file.text(),
+ })),
+ );
+ await sendCommand({
+ action: "dropFiles",
+ workspaceId,
+ parentId,
+ content: payload,
+ });
+ setStatus("外部文件拖入成功");
+ setLastAction(`已拖入 ${payload.length} 个本地文件`);
+ scheduleRefresh();
+ return true;
+ };
+
+ const clearFileTreeDropFeedback = () => {
+ if (fileTreeHoverExpandTimer) {
+ window.clearTimeout(fileTreeHoverExpandTimer);
+ fileTreeHoverExpandTimer = 0;
+ }
+ if (activeFileTreeDropRowId) {
+ const previousRow = appElement.querySelector(
+ `.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
+ );
+ if (previousRow instanceof HTMLElement) {
+ previousRow.dataset.dropTarget = "false";
+ delete previousRow.dataset.dropPosition;
+ }
+ }
+ activeFileTreeDropRowId = null;
+ activeFileTreeDropPosition = 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;
+ const nextDropPosition = target?.dropPosition || "inside";
+ 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";
+ delete previousRow.dataset.dropPosition;
+ }
+ }
+ if (nextRowId) {
+ const nextRow = appElement.querySelector(
+ `.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
+ );
+ if (nextRow instanceof HTMLElement) {
+ nextRow.dataset.dropTarget = "true";
+ nextRow.dataset.dropPosition = nextDropPosition;
+ }
+ activeFileTreeDropRowId = nextRowId;
+ activeFileTreeDropPosition = nextDropPosition;
+ activeFileTreeRootDrop = false;
+ if (fileTreeHoverExpandTimer) {
+ window.clearTimeout(fileTreeHoverExpandTimer);
+ fileTreeHoverExpandTimer = 0;
+ }
+ const targetItem = fileTreeRowById.get(nextRowId);
+ if (
+ nextDropPosition === "inside" &&
+ targetItem &&
+ canExpandFileTreeRow(targetItem) &&
+ !expanded.has(targetItem.nodeId)
+ ) {
+ fileTreeHoverExpandTimer = window.setTimeout(() => {
+ expanded.add(targetItem.nodeId);
+ fileTreeHoverExpandTimer = 0;
+ renderTree();
+ }, 650);
+ }
+ appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
+ if (element instanceof HTMLElement) {
+ element.dataset.dropTarget = "false";
+ }
+ });
+ return;
+ }
+ activeFileTreeDropRowId = null;
+ activeFileTreeDropPosition = 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 patchFileTreeCutDecoration = () => {
+ appElement.querySelectorAll('[data-rust-rendered-row="filetree"], .tree-row[data-shell-mode="filetree"]').forEach((row) => {
+ if (!(row instanceof HTMLElement)) return;
+ row.dataset.cut = String(
+ fileTreeClipboard.action === "cut" &&
+ fileTreeClipboard.rowIds.includes(normalizeText(row.dataset.rowId)),
+ );
+ });
+ };
+
+ const focusFileTreeRowByOffset = (offset) => {
+ if (visibleFileTreeRowIds.length === 0) return;
+ const currentIndex = Math.max(0, visibleFileTreeRowIds.indexOf(fileTreeFocusedRowId));
+ const nextIndex = Math.max(0, Math.min(visibleFileTreeRowIds.length - 1, currentIndex + offset));
+ const rowId = visibleFileTreeRowIds[nextIndex];
+ if (!rowId) return;
+ commitFileTreeSelection({ selectedRowIds: [rowId], anchorRowId: rowId, focusedRowId: rowId });
+ const row = appElement.querySelector(`.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(rowId)}"]`);
+ if (row instanceof HTMLElement) row.focus({ preventScroll: true });
+ };
+
+ const setFileTreeClipboard = (action) => {
+ const rowIds = selectedFileTreeRowIds.size > 0
+ ? Array.from(selectedFileTreeRowIds)
+ : fileTreeFocusedRowId
+ ? [fileTreeFocusedRowId]
+ : [];
+ fileTreeClipboard = { action, rowIds };
+ patchFileTreeCutDecoration();
+ setLastAction(action === "cut" ? "已剪切选中文件树节点" : "已复制选中文件树节点");
+ };
+
+ const resolveFileTreeActionRowIds = (rowId) => {
+ const normalizedRowId = normalizeText(rowId);
+ if (normalizedRowId && selectedFileTreeRowIds.has(normalizedRowId)) {
+ return Array.from(selectedFileTreeRowIds);
+ }
+ if (normalizedRowId) return [normalizedRowId];
+ if (selectedFileTreeRowIds.size > 0) return Array.from(selectedFileTreeRowIds);
+ return fileTreeFocusedRowId ? [fileTreeFocusedRowId] : [];
+ };
+
+ const runFileTreeDelete = async (rowId) => {
+ const rowIds = resolveFileTreeActionRowIds(rowId);
+ const deletableItems = rowIds
+ .map((id) => fileTreeRowById.get(id))
+ .filter((item) => Boolean(getFileTreeRowDocumentId(item)) || (sourceKind === "local_folder" && item?.rowKind === "asset"));
+ if (deletableItems.length === 0) {
+ setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error");
+ return false;
+ }
+ const accepted = await runFileTreePreflight("delete", {
+ rowIds: deletableItems.map((item) => item.rowId),
+ });
+ if (!accepted) return false;
+ if (sourceKind === "local_folder") {
+ for (const item of deletableItems) {
+ await sendCommand({
+ action: "delete",
+ workspaceId,
+ documentId: getFileTreeRowDocumentId(item) || item.rowId,
+ });
+ }
+ scheduleRefresh();
+ return true;
+ }
+ if (sourceKind === "convex_workspace") {
+ for (const item of deletableItems) {
+ const documentId = getFileTreeRowDocumentId(item);
+ await sendCommand({
+ action: "delete",
+ workspaceId,
+ documentId,
+ });
+ applyRemovedDocumentLocally(documentId);
+ }
+ if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
+ scheduleRefresh();
+ }
+ return true;
+ }
+ postToHost("tree.filetree.delete", {
+ workspaceId,
+ rowIds: deletableItems.map((item) => item.rowId),
+ payload: {
+ workspaceId,
+ rowIds: deletableItems.map((item) => item.rowId),
+ documentIds: deletableItems.map(getFileTreeRowDocumentId),
+ },
+ });
+ return true;
+ };
+
+ const pasteFileTreeClipboardInto = async (targetRowId) => {
+ if (!fileTreeClipboard.action || fileTreeClipboard.rowIds.length === 0) return;
+ const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
+ const target = targetItem
+ ? {
+ rowId: targetItem.rowId,
+ rowKind: targetItem.rowKind,
+ nodeId: targetItem.nodeId,
+ documentId: getFileTreeRowDocumentId(targetItem) || null,
+ ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
+ assetId: getFileTreeRowAssetId(targetItem) || null,
+ }
+ : { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
+ if (sourceKind === "local_folder") {
+ const pasted = await executeLocalFileTreeInternalDrop(
+ target,
+ fileTreeClipboard.rowIds,
+ fileTreeClipboard.action === "copy",
+ "paste",
+ );
+ if (pasted && fileTreeClipboard.action === "cut") {
+ fileTreeClipboard = { action: null, rowIds: [] };
+ }
+ patchFileTreeCutDecoration();
+ } else {
+ const accepted = await runFileTreePreflight("paste", {
+ target,
+ rowIds: fileTreeClipboard.rowIds,
+ copy: fileTreeClipboard.action === "copy",
+ });
+ if (!accepted) return;
+ if (sourceKind === "convex_workspace") {
+ const sourceRowIds = filterRedundantFileTreeRowIds(fileTreeClipboard.rowIds);
+ for (const rowId of sourceRowIds) {
+ const sourceItem = fileTreeRowById.get(rowId);
+ const documentId = getFileTreeRowDocumentId(sourceItem);
+ if (!documentId) continue;
+ await sendCommand({
+ action: fileTreeClipboard.action === "copy" ? "copy" : "move",
+ workspaceId,
+ documentId,
+ parentId: target.documentId || null,
+ targetParentId: target.documentId || null,
+ sortOrder: 0,
+ });
+ }
+ if (fileTreeClipboard.action === "cut") {
+ fileTreeClipboard = { action: null, rowIds: [] };
+ }
+ patchFileTreeCutDecoration();
+ scheduleRefresh();
+ } else {
+ postToHost("tree.filetree.paste", {
+ workspaceId,
+ rowIds: fileTreeClipboard.rowIds,
+ action: fileTreeClipboard.action,
+ targetRowId: target.rowId,
+ targetRowKind: target.rowKind,
+ documentId: target.documentId,
+ payload: {
+ workspaceId,
+ rowIds: fileTreeClipboard.rowIds,
+ action: fileTreeClipboard.action,
+ targetRowId: target.rowId,
+ targetRowKind: target.rowKind,
+ documentId: target.documentId,
+ },
+ });
+ }
+ }
+ };
+
+ const handleFileTreeKeyDown = (event, item) => {
+ if (mode !== "filetree") return;
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
+ event.preventDefault();
+ setFileTreeClipboard("copy");
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "x") {
+ event.preventDefault();
+ setFileTreeClipboard("cut");
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
+ event.preventDefault();
+ void pasteFileTreeClipboardInto(item?.rowId || fileTreeFocusedRowId);
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
+ event.preventDefault();
+ commitFileTreeSelection({
+ selectedRowIds: visibleFileTreeRowIds,
+ anchorRowId: visibleFileTreeRowIds[0] || null,
+ focusedRowId: visibleFileTreeRowIds[visibleFileTreeRowIds.length - 1] || null,
+ });
+ renderTree();
+ return;
+ }
+ if (event.key === "F2") {
+ event.preventDefault();
+ const rowId = item?.rowId || fileTreeFocusedRowId;
+ const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
+ if (rowId && getFileTreeRowDocumentId(renameItem)) {
+ beginInlineRename("filetree", rowId);
+ } else {
+ setLastAction("当前资源暂不支持重命名", "error");
+ }
+ return;
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ focusFileTreeRowByOffset(1);
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ focusFileTreeRowByOffset(-1);
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ focusFileTreeRowByOffset(-visibleFileTreeRowIds.length);
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ focusFileTreeRowByOffset(visibleFileTreeRowIds.length);
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (item) {
+ const documentId = getFileTreeRowDocumentId(item);
+ if (documentId) {
+ handleNavigate(documentId);
+ } else {
+ openHydratedFileTreeItem(item);
+ }
+ }
+ return;
+ }
+ if (event.key === "Delete" || event.key === "Backspace") {
+ event.preventDefault();
+ void runFileTreeDelete(item?.rowId || fileTreeFocusedRowId);
+ }
+ };
+
+ const parseTreeShellStateFromHtml = (html) => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const nextStateElement = doc.getElementById("tree-shell-state");
+ if (!nextStateElement) return null;
+ try {
+ return JSON.parse(nextStateElement.textContent || "{}");
+ } catch {
+ return null;
+ }
+ };
+
+ const applyTreeShellStateSnapshot = (nextState, options = {}) => {
+ if (!nextState || typeof nextState !== "object") return false;
+ mediaAssets = Array.isArray(nextState.mediaAssets) ? nextState.mediaAssets : [];
+ mindmapAssets = Array.isArray(nextState.mindmapAssets) ? nextState.mindmapAssets : [];
+ tableAssets = Array.isArray(nextState.tableAssets) ? nextState.tableAssets : [];
+ rawItems = Array.isArray(nextState.items) ? nextState.items : [];
+ normalizedItems = normalizeTreeItems(rawItems);
+ rebuildTreeIndexes();
+ if (currentActiveDocumentId && !itemById.has(currentActiveDocumentId)) {
+ currentActiveDocumentId = roots[0]?.nodeId || "";
+ }
+ if (currentFocusedDocumentId && !itemById.has(currentFocusedDocumentId)) {
+ currentFocusedDocumentId = currentActiveDocumentId;
+ }
+ normalizedItems
+ .filter((item) => item.childCount > 0 && item.expandedByDefault)
+ .forEach((item) => expanded.add(item.nodeId));
+ focusedNodeId = resolveFocusedNodeIdFromHostState();
+ renderTree();
+ if (mode === "filetree") {
+ emitFileTreeSelectionChange();
+ }
+ const renameRowId = normalizeText(options.renameRowId);
+ if (renameRowId) {
+ window.setTimeout(() => {
+ if (fileTreeRowById.has(renameRowId)) {
+ beginInlineRename("filetree", renameRowId);
+ }
+ }, 80);
+ }
+ return true;
+ };
+
+ const refreshLocalFolderSnapshot = async (options = {}) => {
+ const response = await fetch(window.location.href, {
+ headers: { "accept": "text/html" },
+ });
+ if (!response.ok) return false;
+ const nextState = parseTreeShellStateFromHtml(await response.text());
+ return applyTreeShellStateSnapshot(nextState, options);
+ };
+
+ const scheduleRefresh = (options = {}) => {
+ window.setTimeout(() => {
+ void refreshLocalFolderSnapshot(options);
+ }, 80);
+ };
+
+ const addTreeItemLocally = (item) => {
+ if (!item?.nodeId || itemById.has(item.nodeId)) return false;
+ normalizedItems.push(item);
+ itemById.set(item.nodeId, item);
+ fileTreeRowById.set(item.rowId, item);
+ const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
+ if (parentId) {
+ const bucket = childrenByParentId.get(parentId) || [];
+ bucket.push(item);
+ bucket.sort(compareItems);
+ childrenByParentId.set(parentId, bucket);
+ const parentItem = itemById.get(parentId);
+ if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length);
+ expanded.add(parentId);
+ } else {
+ roots.push(item);
+ roots.sort(compareItems);
+ }
+ return true;
+ };
+
+ const applyCreatedDocumentLocally = (result, parentId, title) => {
+ const documentId =
+ typeof result?.documentId === "string" && result.documentId.trim()
+ ? result.documentId.trim()
+ : typeof result?.id === "string" && result.id.trim()
+ ? result.id.trim()
+ : "";
+ if (!documentId) return false;
+ const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at);
+ const parentNodeId = parentId && itemById.has(parentId) ? parentId : null;
+ const item = {
+ rowId: `doc:${documentId}`,
+ rowKind: "document",
+ nodeId: documentId,
+ parentNodeId,
+ title: normalizeText(result?.title, title || "无标题"),
+ depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0,
+ childCount: 0,
+ position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()),
+ expandedByDefault: true,
+ iconHint: "page",
+ capabilities: ["open", "rename", "delete", "move", "create"],
+ resourceMeta: {
+ resourceKind: "document",
+ documentId,
+ assetId: "",
+ assetKind: "",
+ objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null },
+ blockAssetRelation: null,
+ },
+ updatedAt: createdAt,
+ };
+ if (!addTreeItemLocally(item)) return false;
+ currentFocusedDocumentId = documentId;
+ focusedNodeId = documentId;
+ currentActiveDocumentId = documentId;
+ renderTree();
+ return true;
+ };
+
+ const removeTreeItemEverywhere = (item) => {
+ if (!item) return;
+ const itemIndex = normalizedItems.indexOf(item);
+ if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1);
+ itemById.delete(item.nodeId);
+ fileTreeRowById.delete(item.rowId);
+ const rootIndex = roots.indexOf(item);
+ if (rootIndex >= 0) roots.splice(rootIndex, 1);
+ const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null;
+ if (bucket) {
+ const bucketIndex = bucket.indexOf(item);
+ if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
+ if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId);
+ }
+ };
+
+ const applyRemovedDocumentLocally = (documentId) => {
+ const normalizedDocumentId = normalizeText(documentId);
+ if (!normalizedDocumentId) return false;
+ const removedNodeIds = new Set([normalizedDocumentId]);
+ let changed = false;
+ let expandedDuringScan = true;
+ while (expandedDuringScan) {
+ expandedDuringScan = false;
+ normalizedItems.forEach((item) => {
+ if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) {
+ removedNodeIds.add(item.nodeId);
+ expandedDuringScan = true;
+ }
+ });
+ }
+ normalizedItems.slice().forEach((item) => {
+ const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
+ if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
+ removeTreeItemEverywhere(item);
+ changed = true;
+ }
+ });
+ if (!changed) return false;
+ if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || "";
+ if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId;
+ focusedNodeId = resolveFocusedNodeIdFromHostState();
+ renderTree();
+ return true;
+ };
+
+ if (sourceKind === "local_folder" && rootUri) {
+ let localWatchRevision = initialLocalWatchRevision;
+ let localWatchRefreshTimer = 0;
+ const refreshFromLocalWatch = () => {
+ if (localWatchRefreshTimer) return;
+ localWatchRefreshTimer = window.setTimeout(() => {
+ localWatchRefreshTimer = 0;
+ void refreshLocalFolderSnapshot();
+ }, 180);
+ };
+ const pollLocalFolderRevision = async () => {
+ if (busy || document.hidden) return;
+ const url = new URL("/api/tree/local-folder-watch", window.location.origin);
+ url.searchParams.set("rootUri", rootUri);
+ const response = await fetch(url.toString(), { headers: { "accept": "application/json" } });
+ if (!response.ok) return;
+ const payload = await response.json();
+ const nextRevision =
+ payload &&
+ payload.result &&
+ typeof payload.result.revision === "string"
+ ? payload.result.revision
+ : "";
+ if (!nextRevision) return;
+ if (!localWatchRevision) {
+ localWatchRevision = nextRevision;
+ return;
+ }
+ if (nextRevision !== localWatchRevision) {
+ localWatchRevision = nextRevision;
+ refreshFromLocalWatch();
+ }
+ };
+ window.setInterval(() => {
+ void pollLocalFolderRevision();
+ }, 1200);
+ }
+
+ 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: `
+
+ `,
+ more: `
+
+ `,
+ edit: `
+
+ `,
+ up: `
+
+ `,
+ page: `
+
+ `,
+ index: `
+
+ `,
+ mindmap: `
+
+ `,
+ table: `
+
+ `,
+ pdf: `
+
+ `,
+ book: `
+
+ `,
+ image: `
+
+ `,
+ video: `
+
+ `,
+ audio: `
+
+ `,
+ file: `
+
+ `,
+ };
+
+ const sendCommand = async (payload) => {
+ setBusy(true);
+ setStatus("正在提交树命令…");
+ try {
+ const commandPayload = Object.assign(
+ {},
+ payload,
+ sourceKind ? { sourceKind } : {},
+ rootUri ? { rootUri } : {},
+ );
+ 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(commandPayload),
+ });
+ 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 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 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 pageRuntimeBridge = {
+ mode,
+ runtimeReduceEndpoint,
+ buildPageRuntimeAction,
+ buildPageRuntimeEnvironment,
+ readPageRuntimeState,
+ readErrorMessage,
+ normalizePageRuntimeResult,
+ commitPageExpandedIds,
+ normalizeText,
+ postPageFocusChange,
+ postPageExpandChange,
+ patchPageTreeAfterRuntimeState,
+ get expanded() {
+ return expanded;
+ },
+ getFocusedNodeId: () => focusedNodeId,
+ setFocusedNodeId: (nextFocusedNodeId) => {
+ focusedNodeId = nextFocusedNodeId;
+ },
+ };
+
+ 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(pageRuntimeBridge, action, runtimeItem)
+ .then((runtimeResult) => {
+ const replayedHostEvent = replayPageRuntimeHostEvents(
+ runtimeResult,
+ runtimeItem,
+ sourceElement,
+ );
+ const reconciledState = reconcilePageRuntimeResult(
+ pageRuntimeBridge,
+ 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 closeFileTreeContextMenu = () => {
+ if (!activeFileTreeMenuElement) return;
+ activeFileTreeMenuElement.remove();
+ activeFileTreeMenuElement = null;
+ };
+
+ const buildFileTreeMenuTarget = (rowId, rowKind, documentId, assetId) => {
+ const item = rowId ? fileTreeRowById.get(rowId) : null;
+ return {
+ item,
+ rowId: rowId || null,
+ rowKind: rowKind || item?.rowKind || "root",
+ documentId: documentId || getFileTreeRowDocumentId(item) || null,
+ assetId: assetId || getFileTreeRowAssetId(item) || null,
+ };
+ };
+
+ const createFileTreeMenuItem = (kind, label, options = {}) => ({
+ kind,
+ label,
+ disabled: options.disabled === true,
+ reason: normalizeText(options.reason),
+ separatorBefore: options.separatorBefore === true,
+ });
+
+ const buildFileTreeContextMenuProfile = (target) => {
+ const selectedCount = selectedFileTreeRowIds.size;
+ const isMulti = selectedCount > 1 && target.rowId && selectedFileTreeRowIds.has(target.rowId);
+ const item = target.item;
+ const rowKind = target.rowKind || "root";
+ const canPaste = Boolean(fileTreeClipboard.action && fileTreeClipboard.rowIds.length > 0);
+ const hasDocument = Boolean(getFileTreeRowDocumentId(item));
+ const hasAsset = Boolean(getFileTreeRowAssetId(item));
+ const localSource = sourceKind === "local_folder";
+ const convexSource = sourceKind === "convex_workspace";
+ const canCreateFolder = localSource;
+ const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document";
+ const canRename =
+ rowKind === "markdown" ||
+ rowKind === "document" ||
+ rowKind === "folder" ||
+ rowKind === "index";
+ const canCopyCut =
+ rowKind === "markdown" ||
+ rowKind === "document" ||
+ rowKind === "index";
+ const canDelete =
+ rowKind === "markdown" ||
+ rowKind === "document" ||
+ rowKind === "index" ||
+ (localSource && rowKind === "asset");
+
+ if (rowKind === "root") {
+ return [
+ createFileTreeMenuItem("newPage", "新建页面", {
+ disabled: !canCreatePage,
+ reason: "当前 root 不支持新建页面",
+ }),
+ createFileTreeMenuItem("newFolder", "新建文件夹", {
+ disabled: !canCreateFolder,
+ reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
+ }),
+ createFileTreeMenuItem("upload", "上传/导入", {
+ disabled: true,
+ reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入",
+ }),
+ createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
+ createFileTreeMenuItem("collapseAll", "全部折叠"),
+ ];
+ }
+
+ if (isMulti) {
+ return [
+ createFileTreeMenuItem("copy", "复制"),
+ createFileTreeMenuItem("cut", "剪切"),
+ createFileTreeMenuItem("delete", "删除", { disabled: false }),
+ createFileTreeMenuItem("moveTo", "移动到", {
+ separatorBefore: true,
+ disabled: true,
+ reason: "目标选择器尚未接入 filetree 菜单",
+ }),
+ ];
+ }
+
+ const items = [];
+ if (rowKind === "folder") {
+ items.push(createFileTreeMenuItem("newPage", "新建页面", {
+ disabled: !canCreatePage,
+ reason: "当前文件夹不支持新建页面",
+ }));
+ items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
+ disabled: !canCreateFolder,
+ reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
+ }));
+ items.push(createFileTreeMenuItem("paste", "粘贴", {
+ disabled: !canPaste,
+ reason: "剪贴板为空",
+ }));
+ items.push(createFileTreeMenuItem("rename", "重命名", {
+ separatorBefore: true,
+ disabled: !canRename,
+ reason: "当前文件夹不支持重命名",
+ }));
+ items.push(createFileTreeMenuItem("delete", "删除", {
+ disabled: true,
+ reason: "目录删除尚未收口到统一 executor",
+ }));
+ return items;
+ }
+
+ items.push(createFileTreeMenuItem("open", "打开", {
+ disabled: !hasDocument && !hasAsset,
+ reason: "当前行没有可打开资源",
+ }));
+ items.push(createFileTreeMenuItem("rename", "重命名", {
+ disabled: !canRename,
+ reason: "当前资源暂不支持重命名",
+ }));
+ items.push(createFileTreeMenuItem("copy", "复制", {
+ disabled: !canCopyCut,
+ reason: "当前资源暂不支持复制",
+ }));
+ items.push(createFileTreeMenuItem("cut", "剪切", {
+ disabled: !canCopyCut,
+ reason: "当前资源暂不支持剪切",
+ }));
+ items.push(createFileTreeMenuItem("paste", "粘贴", {
+ disabled: !canPaste,
+ reason: "剪贴板为空",
+ }));
+ items.push(createFileTreeMenuItem("moveTo", "移动到", {
+ disabled: true,
+ reason: "目标选择器尚未接入 filetree 菜单",
+ }));
+ items.push(createFileTreeMenuItem("delete", "删除", {
+ disabled: !canDelete,
+ reason: "当前资源暂不支持删除",
+ }));
+ items.push(createFileTreeMenuItem("reveal", "Reveal", { separatorBefore: true }));
+ if (hasAsset) {
+ items.push(createFileTreeMenuItem("download", "下载", {
+ disabled: true,
+ reason: "下载 executor 尚未接入",
+ }));
+ }
+ if (item?.capabilities?.includes("share")) {
+ items.push(createFileTreeMenuItem("share", "分享", { separatorBefore: true }));
+ }
+ if (item?.capabilities?.includes("publish")) {
+ items.push(createFileTreeMenuItem("publish", "发布"));
+ }
+ return items;
+ };
+
+ const localCreatedRowIdFromCommandResult = (result, rowKind) => {
+ const relativePath =
+ typeof result?.execution?.relativePath === "string" && result.execution.relativePath.trim()
+ ? result.execution.relativePath.trim()
+ : typeof result?.relativePath === "string" && result.relativePath.trim()
+ ? result.relativePath.trim()
+ : "";
+ if (!relativePath) return "";
+ return `local:${rowKind}:${relativePath}`;
+ };
+
+ const executeFileTreeContextMenuAction = async (kind, target) => {
+ closeFileTreeContextMenu();
+ const item = target.item;
+ if (kind === "open") {
+ if (item) openHydratedFileTreeItem(item);
+ return;
+ }
+ if (kind === "rename") {
+ if (target.rowId && getFileTreeRowDocumentId(item)) {
+ beginInlineRename("filetree", target.rowId);
+ }
+ return;
+ }
+ if (kind === "copy" || kind === "cut") {
+ if (target.rowId && !selectedFileTreeRowIds.has(target.rowId)) {
+ commitFileTreeSelection({
+ selectedRowIds: [target.rowId],
+ anchorRowId: target.rowId,
+ focusedRowId: target.rowId,
+ });
+ syncFileTreeSelectionDom();
+ }
+ setFileTreeClipboard(kind);
+ return;
+ }
+ if (kind === "paste") {
+ await pasteFileTreeClipboardInto(target.rowId);
+ return;
+ }
+ if (kind === "delete") {
+ await runFileTreeDelete(target.rowId);
+ return;
+ }
+ if (kind === "newPage") {
+ const parentId = item && item.rowKind === "folder"
+ ? item.nodeId
+ : item && item.rowKind === "document"
+ ? item.nodeId
+ : null;
+ await handleCreate(parentId);
+ return;
+ }
+ if (kind === "newFolder") {
+ const parentId = item && item.rowKind === "folder" ? item.nodeId : null;
+ const result = await sendCommand({
+ action: "createFolder",
+ workspaceId,
+ documentId: "",
+ parentId,
+ title: "新建文件夹",
+ });
+ setStatus("创建文件夹成功");
+ setLastAction("已创建新建文件夹");
+ scheduleRefresh({
+ renameRowId: localCreatedRowIdFromCommandResult(result, "folder"),
+ });
+ return result;
+ }
+ if (kind === "refresh") {
+ await refreshLocalFolderSnapshot();
+ return;
+ }
+ if (kind === "collapseAll") {
+ expanded.clear();
+ renderTree();
+ return;
+ }
+ if (kind === "reveal") {
+ setLastAction(`Reveal ${target.documentId || target.assetId || target.rowId || "root"}`);
+ postToHost("tree.filetree.reveal", {
+ documentId: target.documentId,
+ assetId: target.assetId,
+ rowId: target.rowId,
+ payload: target,
+ });
+ return;
+ }
+ postToHost(`tree.filetree.${kind}`, {
+ documentId: target.documentId,
+ assetId: target.assetId,
+ rowId: target.rowId,
+ payload: target,
+ });
+ };
+
+ const openFileTreeContextMenu = ({
+ documentId,
+ assetId,
+ rowId,
+ rowKind,
+ clientX,
+ clientY,
+ }) => {
+ closeFileTreeContextMenu();
+ const target = buildFileTreeMenuTarget(rowId, rowKind, documentId, assetId);
+ const profile = buildFileTreeContextMenuProfile(target);
+ const menu = document.createElement("div");
+ menu.className = "tree-context-menu";
+ menu.dataset.testid = "filetree-context-menu";
+ menu.dataset.sourceKind = sourceKind;
+ menu.dataset.rowKind = target.rowKind;
+ menu.setAttribute("role", "menu");
+
+ profile.forEach((entry) => {
+ if (entry.separatorBefore) {
+ const separator = document.createElement("div");
+ separator.className = "tree-menu-separator";
+ separator.setAttribute("role", "separator");
+ menu.appendChild(separator);
+ }
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tree-menu-item";
+ button.dataset.menuAction = entry.kind;
+ button.setAttribute("role", "menuitem");
+ button.textContent = entry.label;
+ if (entry.disabled) {
+ button.disabled = true;
+ if (entry.reason) {
+ button.title = entry.reason;
+ button.dataset.disabledReason = entry.reason;
+ }
+ } else {
+ button.addEventListener("click", () => {
+ void executeFileTreeContextMenuAction(entry.kind, target);
+ });
+ }
+ menu.appendChild(button);
+ });
+
+ const closeOnOutside = (event) => {
+ if (activeFileTreeMenuElement && !activeFileTreeMenuElement.contains(event.target)) {
+ closeFileTreeContextMenu();
+ document.removeEventListener("mousedown", closeOnOutside, true);
+ document.removeEventListener("keydown", closeOnKeyDown, true);
+ }
+ };
+ const closeOnKeyDown = (event) => {
+ if (event.key === "Escape") {
+ closeFileTreeContextMenu();
+ document.removeEventListener("mousedown", closeOnOutside, true);
+ document.removeEventListener("keydown", closeOnKeyDown, true);
+ }
+ };
+
+ document.body.appendChild(menu);
+ const x = Number.isFinite(clientX) ? clientX : 16;
+ const y = Number.isFinite(clientY) ? clientY : 16;
+ const rect = menu.getBoundingClientRect();
+ menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))}px`;
+ menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))}px`;
+ activeFileTreeMenuElement = menu;
+ window.setTimeout(() => {
+ document.addEventListener("mousedown", closeOnOutside, true);
+ document.addEventListener("keydown", closeOnKeyDown, true);
+ }, 0);
+ 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();
+ beginInlineRename("page", 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 (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
+ if (mode === "filetree") {
+ beginInlineRename("filetree", `doc:${documentId}`);
+ } else {
+ beginInlineRename("page", documentId);
+ }
+ return;
+ }
+ if (documentId) {
+ postToHost("tree.navigate", {
+ documentId,
+ target: { documentId },
+ payload: { documentId },
+ });
+ }
+ scheduleRefresh({
+ renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "创建页面失败";
+ setStatus(message, "error");
+ setLastAction("创建页面失败", "error");
+ window.alert(message);
+ }
+ };
+
+ const focusInlineRenameInput = (id) => {
+ window.requestAnimationFrame(() => {
+ const input = appElement.querySelector(
+ `.tree-rename-input[data-rename-id="${CSS.escape(id)}"]`,
+ );
+ if (!(input instanceof HTMLInputElement)) return;
+ input.focus();
+ const dotIndex = input.value.lastIndexOf(".");
+ const end = dotIndex > 0 ? dotIndex : input.value.length;
+ input.setSelectionRange(0, end);
+ });
+ };
+
+ const beginInlineRename = (renameMode, id) => {
+ inlineRenameState = { mode: renameMode, id, committing: false };
+ renderTree();
+ focusInlineRenameInput(id);
+ };
+
+ const cancelInlineRename = () => {
+ inlineRenameState = { mode: null, id: null, committing: false };
+ renderTree();
+ };
+
+ const commitInlineRename = async (title) => {
+ if (!inlineRenameState.mode || !inlineRenameState.id || inlineRenameState.committing) return;
+ const trimmed = normalizeText(title);
+ if (!trimmed) {
+ cancelInlineRename();
+ return;
+ }
+ inlineRenameState.committing = true;
+ const renameMode = inlineRenameState.mode;
+ const id = inlineRenameState.id;
+ const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
+ const documentId = renameMode === "filetree"
+ ? getFileTreeRowDocumentId(item)
+ : id;
+ if (!documentId) {
+ inlineRenameState = { mode: null, id: null, committing: false };
+ setLastAction("当前资源暂不支持重命名", "error");
+ renderTree();
+ return;
+ }
+ try {
+ const result = await sendCommand({
+ action: "rename",
+ workspaceId,
+ documentId,
+ title: trimmed,
+ });
+ const resultDocumentId =
+ typeof result.documentId === "string" && result.documentId.trim()
+ ? result.documentId.trim()
+ : id;
+ setStatus("重命名成功");
+ setLastAction(`已重命名为 ${trimmed}`);
+ postToHost("tree.node.renamed", {
+ documentId: resultDocumentId,
+ target: { documentId: resultDocumentId },
+ payload: { documentId: resultDocumentId },
+ });
+ inlineRenameState = { mode: null, id: null, committing: false };
+ scheduleRefresh();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "重命名失败";
+ setStatus(message, "error");
+ setLastAction("重命名失败", "error");
+ inlineRenameState = { mode: null, id: null, committing: false };
+ renderTree();
+ window.alert(message);
+ }
+ };
+
+ const attachInlineRenameInput = (input, originalTitle) => {
+ input.addEventListener("click", (event) => event.stopPropagation());
+ input.addEventListener("dblclick", (event) => event.stopPropagation());
+ input.addEventListener("keydown", (event) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void commitInlineRename(input.value);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelInlineRename();
+ }
+ });
+ input.addEventListener("blur", () => {
+ if (!inlineRenameState.mode || inlineRenameState.committing) return;
+ if (input.value === originalTitle) {
+ cancelInlineRename();
+ return;
+ }
+ void commitInlineRename(input.value);
+ });
+ };
+
+ 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") {
+ beginInlineRename("page", 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 ownerDocumentId = getFileTreeRowOwnerDocumentId(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: ownerDocumentId || null,
+ assetId: assetId || null,
+ objectIdentity: item.resourceMeta?.objectIdentity || null,
+ target: { documentId: ownerDocumentId || null },
+ payload: {
+ documentId: ownerDocumentId || null,
+ assetId: assetId || null,
+ rowId: item.rowId,
+ rowKind: item.rowKind,
+ objectIdentity: item.resourceMeta?.objectIdentity || null,
+ },
+ });
+ };
+
+ 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("contextmenu", (event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ clearFileTreeSelection();
+ openFileTreeContextMenu({
+ documentId: null,
+ assetId: null,
+ rowId: null,
+ rowKind: "root",
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ });
+ 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 = getFileTreeDropTargetFromEvent(event);
+ if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
+ clearFileTreeDropFeedback();
+ return;
+ }
+ 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 = getFileTreeDropTargetFromEvent(event);
+ if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
+ clearFileTreeDropFeedback();
+ return;
+ }
+ if (files.length > 0) {
+ if (sourceKind === "local_folder") {
+ void executeLocalFileTreeExternalDrop(target, files);
+ } else {
+ void (async () => {
+ const accepted = await runFileTreePreflight("dropFiles", { target, files });
+ if (!accepted) return;
+ setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
+ postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, {
+ files,
+ });
+ })();
+ }
+ } else {
+ if (sourceKind === "local_folder") {
+ void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
+ } else {
+ void (async () => {
+ const accepted = await runFileTreePreflight("move", {
+ target,
+ rowIds: internalRowIds,
+ copy: event.altKey === true,
+ });
+ if (!accepted) return;
+ 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 ownerDocumentId = getFileTreeRowOwnerDocumentId(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.ownerDocumentId = ownerDocumentId || "";
+ row.dataset.assetId = assetId || "";
+ row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
+ ? JSON.stringify(item.resourceMeta.objectIdentity)
+ : "";
+ row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
+ row.dataset.shellMode = "filetree";
+ row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
+ row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
+ if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
+ row.dataset.dropPosition = activeFileTreeDropPosition;
+ }
+ 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);
+ row.focus({ preventScroll: true });
+ syncFileTreeSelectionDom();
+ });
+ row.addEventListener("dblclick", () => {
+ openHydratedFileTreeItem(item);
+ });
+ row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, 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);
+ patchFileTreeCutDecoration();
+ 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);
+ });
+
+ if (inlineRenameState.mode === "page" && inlineRenameState.id === item.nodeId) {
+ const renameInput = document.createElement("input");
+ renameInput.type = "text";
+ renameInput.className = "tree-rename-input";
+ renameInput.value = item.title;
+ renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
+ renameInput.dataset.renameId = item.nodeId;
+ attachInlineRenameInput(renameInput, item.title);
+ linkButton.appendChild(renameInput);
+ } else {
+ 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}`,
+ () => beginInlineRename("page", 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 ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
+ const assetId = getFileTreeRowAssetId(item);
+ if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
+ handleNavigate(documentId || item.nodeId);
+ return;
+ }
+ setLastAction(`准备打开资源 ${assetId || item.rowId}`);
+ postToHost("tree.asset.open", {
+ documentId: ownerDocumentId || null,
+ assetId: assetId || null,
+ objectIdentity: item.resourceMeta?.objectIdentity || null,
+ target: { documentId: ownerDocumentId || null },
+ payload: {
+ documentId: ownerDocumentId || null,
+ assetId: assetId || null,
+ rowId: item.rowId,
+ rowKind: item.rowKind,
+ objectIdentity: item.resourceMeta?.objectIdentity || null,
+ },
+ });
+ };
+
+ 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 ownerDocumentId = getFileTreeRowOwnerDocumentId(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.ownerDocumentId = ownerDocumentId || "";
+ row.dataset.assetId = assetId || "";
+ row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
+ ? JSON.stringify(item.resourceMeta.objectIdentity)
+ : "";
+ row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
+ row.dataset.shellMode = "filetree";
+ row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
+ row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
+ if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
+ row.dataset.dropPosition = activeFileTreeDropPosition;
+ }
+ 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();
+ const nextRow = appElement.querySelector(
+ `.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(item.rowId)}"]`,
+ );
+ if (nextRow instanceof HTMLElement) {
+ nextRow.focus({ preventScroll: true });
+ }
+ });
+ row.addEventListener("dblclick", () => {
+ openFileTreeItem(item);
+ });
+ row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, 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));
+ if (inlineRenameState.mode === "filetree" && inlineRenameState.id === item.rowId) {
+ const renameInput = document.createElement("input");
+ renameInput.type = "text";
+ renameInput.className = "tree-rename-input";
+ renameInput.value = item.title;
+ renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
+ renameInput.dataset.renameId = item.rowId;
+ attachInlineRenameInput(renameInput, item.title);
+ linkButton.appendChild(renameInput);
+ } else {
+ const title = document.createElement("span");
+ title.className = "tree-link-title";
+ title.textContent = item.title;
+ linkButton.appendChild(title);
+ }
+ const meta = document.createElement("span");
+ meta.className = "tree-link-meta";
+ meta.textContent = getFileTreeRowMetaLabel(item);
+ linkButton.appendChild(meta);
+ row.appendChild(linkButton);
+
+ const actions = document.createElement("div");
+ actions.className = "tree-actions";
+ if (getFileTreeRowDocumentId(item)) {
+ actions.appendChild(
+ createActionButton(
+ ICONS.edit,
+ "filetree-action-rename",
+ `重命名 ${item.title}`,
+ () => beginInlineRename("filetree", item.rowId),
+ false,
+ ),
+ );
+ }
+ 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 = getFileTreeDropTargetFromEvent(event);
+ if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
+ clearFileTreeDropFeedback();
+ return;
+ }
+ 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("contextmenu", (event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ clearFileTreeSelection();
+ openFileTreeContextMenu({
+ documentId: null,
+ assetId: null,
+ rowId: null,
+ rowKind: "root",
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ });
+ 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 = getFileTreeDropTargetFromEvent(event);
+ if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
+ clearFileTreeDropFeedback();
+ return;
+ }
+ if (files.length > 0) {
+ if (sourceKind === "local_folder") {
+ void executeLocalFileTreeExternalDrop(target, files);
+ } else {
+ void (async () => {
+ const accepted = await runFileTreePreflight("dropFiles", { target, files });
+ if (!accepted) return;
+ setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
+ postFileTreeDropToHost("tree.filetree.external-drop", target, {
+ files,
+ });
+ })();
+ }
+ } else {
+ if (sourceKind === "local_folder") {
+ void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
+ } else {
+ void (async () => {
+ const accepted = await runFileTreePreflight("move", {
+ target,
+ rowIds: internalRowIds,
+ copy: event.altKey === true,
+ });
+ if (!accepted) return;
+ 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);
+ patchFileTreeCutDecoration();
+ };
+
+ 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();
+ }
+ if (mode === "filetree" && initialRenameRowId) {
+ const url = new URL(window.location.href);
+ url.searchParams.delete("renameRowId");
+ window.history.replaceState(null, "", url.toString());
+ window.setTimeout(() => {
+ if (fileTreeRowById.has(initialRenameRowId)) {
+ beginInlineRename("filetree", initialRenameRowId);
+ }
+ }, 120);
+ }
+ setStatus(
+ mode === "picker"
+ ? "Tree picker 已就绪,可以展开目录并选择目标页面。"
+ : mode === "filetree"
+ ? "File tree shell 已就绪,可以打开页面与附件。"
+ : "Tree shell 已就绪,可以进行展开、创建、重命名和排序操作。",
+ );
+ setLastAction("已向宿主发送 ready 消息。");
+ emitReady();
+ window.setTimeout(emitReady, 300);
+ window.setTimeout(emitReady, 1200);
+}
+
+startTreeShellRuntime();
diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs
index 18e8cd59..d43c2431 100644
--- a/rust/crates/mnote-web/src/routes/mod.rs
+++ b/rust/crates/mnote-web/src/routes/mod.rs
@@ -106,6 +106,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
)
+ .route(
+ "/api/mnote-browser-runtime/tree-shell-runtime.js",
+ get(web_shell::tree_shell_runtime_asset),
+ )
.route("/api/search/documents", post(search::documents))
.route(
"/api/search/local-index/refresh",
diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs
index e91b73f7..282c5dc6 100644
--- a/rust/crates/mnote-web/src/routes/tree.rs
+++ b/rust/crates/mnote-web/src/routes/tree.rs
@@ -1520,4544 +1520,7 @@ fn build_tree_shell_html(
-
+