Files
mnote/rust/crates/mnote-web/browser/tree-shell-runtime.js
T

3744 lines
127 KiB
JavaScript

// 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";
import {
buildFileTreeRuntimeEnvironment,
compareTreeShellItems,
getFileTreeRowAssetId,
getFileTreeRowDocumentId,
getFileTreeRowIconKind,
getFileTreeRowMetaLabel,
getFileTreeRowOwnerDocumentId,
normalizeTreeShellTreeItems,
} from "./tree-shell-filetree-runtime.js";
import {
createTreeShellFileTreeMenuRuntime,
} from "./tree-shell-filetree-menu-runtime.js";
import {
focusTreeShellPickerRowElement,
hydrateTreeShellInitialPageTree,
hydrateTreeShellInitialPickerTree,
patchTreeShellPageActiveDom,
patchTreeShellPageExpansionDom,
patchTreeShellPickerActiveDom,
} from "./tree-shell-dom-runtime.js";
import {
computeTreeShellPickerStateActionResult,
getTreeShellVisiblePickerEntries,
isTreeShellPickerEntryPickable,
normalizeTreeShellPickerItemKey,
resolveTreeShellCurrentPickerItemKey,
} from "./tree-shell-picker-runtime.js";
import {
normalizeTreeShellNumber as normalizeNumber,
normalizeTreeShellStringArray as normalizeStringArray,
normalizeTreeShellText as normalizeText,
parseTreeShellState,
parseTreeShellStateFromHtml,
resolveTreeShellMode,
resolveTreeShellTargetOrigin,
} from "./tree-shell-state-runtime.js";
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 state = parseTreeShellState(stateElement);
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 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 = resolveTreeShellMode(state);
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 = resolveTreeShellTargetOrigin(document);
const initialRenameRowId = (() => {
try {
return normalizeText(new URL(window.location.href).searchParams.get("renameRowId"));
} catch {
return "";
}
})();
const runtimeReduceEndpoint = normalizeText(
runtimeApi.reduceEndpoint,
"/api/tree/runtime/reduce",
);
let rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
let normalizedItems = normalizeTreeShellTreeItems(rawItems, { excludedIds });
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(compareTreeShellItems);
childrenByParentId.forEach((bucket) => bucket.sort(compareTreeShellItems));
};
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 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 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 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 = normalizeTreeShellTreeItems(rawItems, { excludedIds });
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(compareTreeShellItems);
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(compareTreeShellItems);
}
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 = () =>
getTreeShellVisiblePickerEntries({
mode,
allowRootPick,
roots,
expanded,
getSiblings,
});
const isPickerEntryPickable = (entry) =>
isTreeShellPickerEntryPickable(entry, {
allowRootPick,
excludedIds,
});
const getPickablePickerEntries = () =>
getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry));
const normalizePickerItemKey = (pickerItemKey) =>
normalizeTreeShellPickerItemKey(pickerItemKey, {
allowRootPick,
itemById,
excludedIds,
});
const resolveCurrentPickerItemKey = () =>
resolveTreeShellCurrentPickerItemKey({
currentActivePickerItemKey,
currentActiveDocumentId,
normalizePickerItemKey,
getPickablePickerEntries,
});
const computePickerStateActionResult = (action) =>
computeTreeShellPickerStateActionResult({
mode,
pickerStateReducerContractName,
pickerStateReducerActions,
resolveCurrentPickerItemKey,
getPickablePickerEntries,
normalizePickerItemKey,
}, action);
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 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 fileTreeMenuRuntime = createTreeShellFileTreeMenuRuntime({
sourceKind,
workspaceId,
getFileTreeRowById: () => fileTreeRowById,
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
getFileTreeClipboard: () => fileTreeClipboard,
setFileTreeClipboard,
commitFileTreeSelection,
syncFileTreeSelectionDom,
pasteFileTreeClipboardInto,
runFileTreeDelete,
handleCreate: (...args) => handleCreate(...args),
sendCommand,
setStatus,
setLastAction,
scheduleRefresh,
refreshLocalFolderSnapshot,
expanded,
renderTree: () => renderTree(),
postToHost,
openHydratedFileTreeItem: (...args) => openHydratedFileTreeItem(...args),
beginInlineRename: (...args) => beginInlineRename(...args),
localCreatedRowIdFromCommandResult,
});
const openFileTreeContextMenu = fileTreeMenuRuntime.openFileTreeContextMenu;
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 = () =>
patchTreeShellPageActiveDom({
mode,
appElement,
normalizeText,
focusedNodeId,
currentActiveDocumentId,
activePageDropNodeId,
});
const patchPageTreeExpansionDom = (nodeId) =>
patchTreeShellPageExpansionDom({
mode,
appElement,
normalizeText,
itemById,
expanded,
getSiblings,
renderNode,
patchPageTreeActiveDom,
}, nodeId);
const hydrateInitialPageTree = () =>
hydrateTreeShellInitialPageTree({
mode,
appElement,
normalizeText,
itemById,
bindPageRowEvents,
focusedNodeId,
focusRowElement,
});
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) =>
focusTreeShellPickerRowElement({
appElement,
normalizeText,
}, pickerItemKey);
const patchPickerActiveDom = () =>
patchTreeShellPickerActiveDom({
mode,
appElement,
normalizeText,
currentActivePickerItemKey,
currentActiveDocumentId,
});
const hydrateInitialPickerTree = () =>
hydrateTreeShellInitialPickerTree({
mode,
appElement,
normalizeText,
itemById,
bindPickerRootEvents,
bindPickerRowEvents,
});
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();