From c3fbadb4567e69cc8c3db6254436ac20b9acea23 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 25 May 2026 00:03:12 +0800 Subject: [PATCH] refactor: externalize tree shell runtime --- ...eb-browser-runtime-module-extraction-v1.md | 49 +- .../mnote-web/browser/tree-shell-runtime.js | 4577 ++++++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 4 + rust/crates/mnote-web/src/routes/tree.rs | 4695 +---------------- rust/crates/mnote-web/src/routes/web_shell.rs | 14 + 5 files changed, 4733 insertions(+), 4606 deletions(-) create mode 100644 rust/crates/mnote-web/browser/tree-shell-runtime.js 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( - + "##; @@ -6929,6 +2392,8 @@ pub async fn reduce_tree_shell_runtime( #[cfg(test)] mod tests { + const TREE_SHELL_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-runtime.js"); + use super::{ collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, TreeCommandRequest, @@ -7069,46 +2534,73 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("tree-create-root")); - assert!(html.contains("tree.ready")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.ready")); assert!(html.contains("test-shell")); - assert!(html.contains("tree-action-menu")); - assert!(html.contains("tree.page.context-menu")); - assert!(html.contains("tree.page.expand.changed")); - assert!(html.contains("tree.page.focus.changed")); - assert!(html.contains("tree.shell.state.patch")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree-action-menu")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.context-menu")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.expand.changed")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.focus.changed")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.shell.state.patch")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); - assert!(html.contains("applyPageKeyboardAction")); - assert!(html.contains("reducePageActionWithRuntime")); - let apply_page_action_start = html + assert!(TREE_SHELL_RUNTIME_JS.contains("applyPageKeyboardAction")); + assert!(TREE_SHELL_RUNTIME_JS.contains("reducePageActionWithRuntime")); + let apply_page_action_start = TREE_SHELL_RUNTIME_JS .find("const applyPageKeyboardAction = (action, item, sourceElement) => {") - .expect("applyPageKeyboardAction should be embedded"); - let apply_page_action_end = html[apply_page_action_start..] - .find("\n const postPickerFocusChange") + .expect("applyPageKeyboardAction should be in external runtime"); + let apply_page_action_end = TREE_SHELL_RUNTIME_JS[apply_page_action_start..] + .find("\n const postPickerFocusChange") .expect("applyPageKeyboardAction should end before picker focus handler"); - let apply_page_action_body = - &html[apply_page_action_start..apply_page_action_start + apply_page_action_end]; + let apply_page_action_body = &TREE_SHELL_RUNTIME_JS + [apply_page_action_start..apply_page_action_start + apply_page_action_end]; assert!( !apply_page_action_body.contains("toggleExpand("), "page keyboard/expand should prefer runtime result instead of directly toggling local expansion state" ); - assert!(html.contains("patchPageTreeActiveDom")); - assert!(html.contains("patchPageTreeExpansionDom")); + assert!(TREE_SHELL_RUNTIME_JS.contains("patchPageTreeActiveDom")); + assert!(TREE_SHELL_RUNTIME_JS.contains("patchPageTreeExpansionDom")); assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-rendered-row=\"page\"")); assert!(html.contains("data-rust-action=\"toggle\"")); assert!(html.contains("data-testid=\"tree-node-toggle\"")); - assert!(html.contains("hydrateInitialPageTree")); - assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); - assert!(html.contains("applyCreatedDocumentLocally")); - assert!(html.contains("applyRemovedDocumentLocally")); - assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); + assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); + assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally")); + assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); + assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node")); + assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到")); + assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")")); + assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"aria-level\"")); + } + + #[tokio::test] + async fn tree_shell_uses_external_browser_runtime_module() { + let response = app() + .oneshot( + Request::builder() + .uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("id=\"tree-shell-state\"")); + assert!(html + .contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\"")); assert!( - html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))") + !html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"), + "debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML" ); - assert!(html.contains("application/x-mnote-page-tree-node")); - assert!(html.contains("页面已拖放到")); - assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); - assert!(html.contains("setAttribute(\"aria-level\"")); } #[tokio::test] @@ -7133,21 +2625,24 @@ mod tests { assert!(html.contains("\"excludeIds\":[\"page_child\"]")); assert!(html.contains("data-rust-picker-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-rendered-row=\"picker-root\"")); - assert!(html.contains("tree.pick.root")); - assert!(html.contains("tree.picker.command")); - assert!(html.contains("tree.picker.focus.changed")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.pick.root")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.picker.command")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.picker.focus.changed")); assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); - assert!(html.contains("applyPickerStateAction")); - assert!(html.contains("postPickerPickResultToHost")); - assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); - assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); - assert!(html.contains("const shouldFocusDom = options.focusDom === true")); - assert!(html.contains("if (shouldFocusDom) focusPickerRowElement")); - assert!(html.contains("patchPickerActiveDom")); - assert!(html.contains("hydrateInitialPickerTree")); + assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction")); + assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); + assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true")); + assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement")); + assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom")); + assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree")); assert!(html.contains("tabindex=\"")); - assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); - assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); + assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); } #[tokio::test] @@ -7171,21 +2666,25 @@ mod tests { assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-rendered-row=\"filetree\"")); assert!(html.contains("\"mediaAssets\"")); - assert!(html.contains("tree.filetree.selection.changed")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.selection.changed")); assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\"")); - assert!(html.contains("applyFileTreeSelectionAction")); - assert!(html.contains("tree.filetree.internal-drop")); - assert!(html.contains("tree.filetree.external-drop")); + assert!(TREE_SHELL_RUNTIME_JS.contains("applyFileTreeSelectionAction")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.internal-drop")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.external-drop")); assert!(html.contains("\"rowKind\":\"asset_folder\"")); assert!(html.contains("\"resourceMeta\"")); - assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {")); - assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); - assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")); - assert!(html.contains("if (getFileTreeRowDocumentId(item))")); - assert!(html.contains("documentId: ownerDocumentId || null")); - assert!(html.contains("dragover")); - assert!(html.contains("hydrateInitialFileTree")); - assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); + assert!(TREE_SHELL_RUNTIME_JS.contains("const getFileTreeRowOwnerDocumentId = (item) => {")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); + assert!( + TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))") + ); + assert!(TREE_SHELL_RUNTIME_JS.contains("if (getFileTreeRowDocumentId(item))")); + assert!(TREE_SHELL_RUNTIME_JS.contains("documentId: ownerDocumentId || null")); + assert!(TREE_SHELL_RUNTIME_JS.contains("dragover")); + assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); } #[tokio::test] @@ -7342,13 +2841,9 @@ mod tests { let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let html = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(html.contains("/api/tree/local-folder-watch")); - assert!(html.contains("refreshLocalFolderSnapshot")); - assert!(!html.contains("window.location.reload")); + assert!(TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch")); + assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot")); + assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload")); } #[tokio::test] @@ -7393,11 +2888,9 @@ mod tests { assert!(html.contains("local_folder")); assert!(html.contains("README")); assert!(html.contains("child")); - // 标题优先级:frontmatter title > H1 > 文件名。 - // README.md 有 frontmatter title "Frontmatter Title",因此应显示。 - assert!(html.contains("Frontmatter Title")); - // child.md 无 frontmatter title,使用 H1 "Child H1"。 - assert!(html.contains("Child H1")); + // 本地 Markdown 的树标题统一来自文件名,frontmatter title 和 H1 不作为树标题。 + assert!(!html.contains("Frontmatter Title")); + assert!(!html.contains("Child H1")); assert!(html.contains(">docs<") || html.contains("docs")); assert!(!html.contains("image.png")); } diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 18b25b8f..76977b20 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4409,6 +4409,20 @@ pub async fn tree_live_controller_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn tree_shell_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/tree-shell-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn leptos_tiptap_manifest() -> Response { let manifest = json!({ "entryAssetPath": "mnote-leptos-tiptap-spike-island.js",