// MNote debug /tree shell 浏览器运行时外置模块。 // 该文件由 tree.rs 原 inline runtime 同构迁出,语义仍由 Rust tree_shell reducer 主导。 import { reconcilePageRuntimeResult, reducePageActionWithRuntime, } from "./tree-shell-page-runtime.js"; import { TREE_SHELL_ICONS as ICONS, createTreeShellActionButton, createTreeShellKindBadge as createKindBadge, } from "./tree-shell-icons-runtime.js"; 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, }; } 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 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 createActionButton = (icon, testId, title, onClick, disabled) => createTreeShellActionButton(icon, testId, title, onClick, disabled, busy); 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();