2026-05-25 00:03:12 +08:00
|
|
|
// MNote debug /tree shell 浏览器运行时外置模块。
|
|
|
|
|
// 该文件由 tree.rs 原 inline runtime 同构迁出,语义仍由 Rust tree_shell reducer 主导。
|
|
|
|
|
|
2026-05-26 02:42:07 +08:00
|
|
|
import {
|
|
|
|
|
reconcilePageRuntimeResult,
|
|
|
|
|
reducePageActionWithRuntime,
|
|
|
|
|
} from "./tree-shell-page-runtime.js";
|
2026-05-26 02:53:12 +08:00
|
|
|
import {
|
|
|
|
|
buildFileTreeRuntimeEnvironment,
|
|
|
|
|
compareTreeShellItems,
|
|
|
|
|
getFileTreeRowAssetId,
|
|
|
|
|
getFileTreeRowDocumentId,
|
|
|
|
|
getFileTreeRowIconKind,
|
|
|
|
|
getFileTreeRowMetaLabel,
|
|
|
|
|
getFileTreeRowOwnerDocumentId,
|
|
|
|
|
normalizeTreeShellTreeItems,
|
|
|
|
|
} from "./tree-shell-filetree-runtime.js";
|
2026-05-26 03:35:10 +08:00
|
|
|
import {
|
|
|
|
|
createTreeShellFileTreeMenuRuntime,
|
|
|
|
|
} from "./tree-shell-filetree-menu-runtime.js";
|
2026-05-26 03:42:03 +08:00
|
|
|
import {
|
|
|
|
|
createTreeShellFileTreeDndRuntime,
|
|
|
|
|
TREE_SHELL_FILETREE_DRAG_MIME as FILETREE_DRAG_MIME,
|
|
|
|
|
} from "./tree-shell-filetree-dnd-runtime.js";
|
2026-05-26 03:51:06 +08:00
|
|
|
import {
|
|
|
|
|
createTreeShellRenderer,
|
|
|
|
|
} from "./tree-shell-render-runtime.js";
|
2026-05-26 03:02:15 +08:00
|
|
|
import {
|
|
|
|
|
focusTreeShellPickerRowElement,
|
|
|
|
|
hydrateTreeShellInitialPageTree,
|
|
|
|
|
hydrateTreeShellInitialPickerTree,
|
|
|
|
|
patchTreeShellPageActiveDom,
|
|
|
|
|
patchTreeShellPageExpansionDom,
|
|
|
|
|
patchTreeShellPickerActiveDom,
|
|
|
|
|
} from "./tree-shell-dom-runtime.js";
|
2026-05-26 02:56:16 +08:00
|
|
|
import {
|
|
|
|
|
computeTreeShellPickerStateActionResult,
|
|
|
|
|
getTreeShellVisiblePickerEntries,
|
|
|
|
|
isTreeShellPickerEntryPickable,
|
|
|
|
|
normalizeTreeShellPickerItemKey,
|
|
|
|
|
resolveTreeShellCurrentPickerItemKey,
|
|
|
|
|
} from "./tree-shell-picker-runtime.js";
|
2026-05-26 02:48:30 +08:00
|
|
|
import {
|
|
|
|
|
normalizeTreeShellNumber as normalizeNumber,
|
|
|
|
|
normalizeTreeShellStringArray as normalizeStringArray,
|
|
|
|
|
normalizeTreeShellText as normalizeText,
|
|
|
|
|
parseTreeShellState,
|
|
|
|
|
parseTreeShellStateFromHtml,
|
|
|
|
|
resolveTreeShellMode,
|
|
|
|
|
resolveTreeShellTargetOrigin,
|
|
|
|
|
} from "./tree-shell-state-runtime.js";
|
2026-05-26 02:42:07 +08:00
|
|
|
|
2026-05-25 00:03:12 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 02:48:30 +08:00
|
|
|
const state = parseTreeShellState(stateElement);
|
2026-05-25 00:03:12 +08:00
|
|
|
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()
|
|
|
|
|
: "";
|
2026-05-26 02:48:30 +08:00
|
|
|
const mode = resolveTreeShellMode(state);
|
2026-05-25 00:03:12 +08:00
|
|
|
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");
|
2026-05-26 02:48:30 +08:00
|
|
|
const targetOrigin = resolveTreeShellTargetOrigin(document);
|
2026-05-25 00:03:12 +08:00
|
|
|
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;
|
2026-05-26 02:53:12 +08:00
|
|
|
let normalizedItems = normalizeTreeShellTreeItems(rawItems, { excludedIds });
|
2026-05-25 00:03:12 +08:00
|
|
|
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);
|
|
|
|
|
});
|
2026-05-26 02:53:12 +08:00
|
|
|
roots.sort(compareTreeShellItems);
|
|
|
|
|
childrenByParentId.forEach((bucket) => bucket.sort(compareTreeShellItems));
|
2026-05-25 00:03:12 +08:00
|
|
|
};
|
|
|
|
|
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 draggingFileTreeRowIds = [];
|
2026-05-26 03:42:03 +08:00
|
|
|
let activePageDropNodeId = null;
|
2026-05-25 00:03:12 +08:00
|
|
|
let fileTreeClipboard = { action: null, rowIds: [] };
|
|
|
|
|
let inlineRenameState = { mode: null, id: null, committing: false };
|
2026-05-26 03:51:06 +08:00
|
|
|
let renderNode = () => {
|
|
|
|
|
throw new Error("tree shell renderer is not ready");
|
|
|
|
|
};
|
|
|
|
|
let renderTree = () => {
|
|
|
|
|
throw new Error("tree shell renderer is not ready");
|
|
|
|
|
};
|
|
|
|
|
let syncFileTreeSelectionDom = () => {};
|
|
|
|
|
let openHydratedFileTreeItem = () => {};
|
|
|
|
|
let hydrateInitialFileTree = () => false;
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
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 canExpandFileTreeRow = (item) => {
|
|
|
|
|
if (!item) return false;
|
|
|
|
|
return item.childCount > 0 || item.capabilities.includes("expand");
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 03:42:03 +08:00
|
|
|
const fileTreeDndRuntime = createTreeShellFileTreeDndRuntime({
|
|
|
|
|
appElement,
|
|
|
|
|
expanded,
|
|
|
|
|
sourceKind,
|
|
|
|
|
workspaceId,
|
|
|
|
|
canExpandFileTreeRow,
|
|
|
|
|
getFileTreeAnchorRowId: () => fileTreeAnchorRowId,
|
|
|
|
|
getFileTreeFocusedRowId: () => fileTreeFocusedRowId,
|
|
|
|
|
getFileTreeRowById: () => fileTreeRowById,
|
|
|
|
|
getItemById: () => itemById,
|
|
|
|
|
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
|
|
|
|
|
getVisibleFileTreeRowIds: () => visibleFileTreeRowIds,
|
|
|
|
|
renderTree: () => renderTree(),
|
|
|
|
|
scheduleRefresh: (...args) => scheduleRefresh(...args),
|
|
|
|
|
sendCommand: (...args) => sendCommand(...args),
|
|
|
|
|
setLastAction,
|
|
|
|
|
setStatus,
|
|
|
|
|
});
|
|
|
|
|
const clearFileTreeDropFeedback = fileTreeDndRuntime.clearFileTreeDropFeedback;
|
|
|
|
|
const executeLocalFileTreeExternalDrop = fileTreeDndRuntime.executeLocalFileTreeExternalDrop;
|
|
|
|
|
const executeLocalFileTreeInternalDrop = fileTreeDndRuntime.executeLocalFileTreeInternalDrop;
|
|
|
|
|
const filterRedundantFileTreeRowIds = fileTreeDndRuntime.filterRedundantFileTreeRowIds;
|
|
|
|
|
const getActiveFileTreeDropPosition = fileTreeDndRuntime.getActiveFileTreeDropPosition;
|
|
|
|
|
const getActiveFileTreeDropRowId = fileTreeDndRuntime.getActiveFileTreeDropRowId;
|
|
|
|
|
const getActiveFileTreeRootDrop = fileTreeDndRuntime.getActiveFileTreeRootDrop;
|
|
|
|
|
const getFileTreeDropTargetFromEvent = fileTreeDndRuntime.getFileTreeDropTargetFromEvent;
|
|
|
|
|
const inferDefaultFileTreeDropDocumentId = fileTreeDndRuntime.inferDefaultFileTreeDropDocumentId;
|
|
|
|
|
const runFileTreePreflight = fileTreeDndRuntime.runFileTreePreflight;
|
|
|
|
|
const setFileTreeDropFeedback = fileTreeDndRuntime.setFileTreeDropFeedback;
|
|
|
|
|
const updateFileTreeDropFeedback = fileTreeDndRuntime.updateFileTreeDropFeedback;
|
|
|
|
|
const validateFileTreeInternalDrop = fileTreeDndRuntime.validateFileTreeInternalDrop;
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
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 : [];
|
2026-05-26 02:53:12 +08:00
|
|
|
normalizedItems = normalizeTreeShellTreeItems(rawItems, { excludedIds });
|
2026-05-25 00:03:12 +08:00
|
|
|
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);
|
2026-05-26 02:53:12 +08:00
|
|
|
bucket.sort(compareTreeShellItems);
|
2026-05-25 00:03:12 +08:00
|
|
|
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);
|
2026-05-26 02:53:12 +08:00
|
|
|
roots.sort(compareTreeShellItems);
|
2026-05-25 00:03:12 +08:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
const getVisiblePickerEntries = () =>
|
|
|
|
|
getTreeShellVisiblePickerEntries({
|
|
|
|
|
mode,
|
|
|
|
|
allowRootPick,
|
|
|
|
|
roots,
|
|
|
|
|
expanded,
|
|
|
|
|
getSiblings,
|
|
|
|
|
});
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
const isPickerEntryPickable = (entry) =>
|
|
|
|
|
isTreeShellPickerEntryPickable(entry, {
|
|
|
|
|
allowRootPick,
|
|
|
|
|
excludedIds,
|
|
|
|
|
});
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
const getPickablePickerEntries = () =>
|
|
|
|
|
getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry));
|
|
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
const normalizePickerItemKey = (pickerItemKey) =>
|
|
|
|
|
normalizeTreeShellPickerItemKey(pickerItemKey, {
|
|
|
|
|
allowRootPick,
|
|
|
|
|
itemById,
|
|
|
|
|
excludedIds,
|
|
|
|
|
});
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
const resolveCurrentPickerItemKey = () =>
|
|
|
|
|
resolveTreeShellCurrentPickerItemKey({
|
|
|
|
|
currentActivePickerItemKey,
|
|
|
|
|
currentActiveDocumentId,
|
|
|
|
|
normalizePickerItemKey,
|
|
|
|
|
getPickablePickerEntries,
|
|
|
|
|
});
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
const computePickerStateActionResult = (action) =>
|
|
|
|
|
computeTreeShellPickerStateActionResult({
|
|
|
|
|
mode,
|
|
|
|
|
pickerStateReducerContractName,
|
|
|
|
|
pickerStateReducerActions,
|
|
|
|
|
resolveCurrentPickerItemKey,
|
|
|
|
|
getPickablePickerEntries,
|
|
|
|
|
normalizePickerItemKey,
|
|
|
|
|
}, action);
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 03:35:10 +08:00
|
|
|
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;
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const patchPageTreeActiveDom = () =>
|
|
|
|
|
patchTreeShellPageActiveDom({
|
|
|
|
|
mode,
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
focusedNodeId,
|
|
|
|
|
currentActiveDocumentId,
|
|
|
|
|
activePageDropNodeId,
|
2026-05-25 00:03:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const patchPageTreeExpansionDom = (nodeId) =>
|
|
|
|
|
patchTreeShellPageExpansionDom({
|
|
|
|
|
mode,
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
itemById,
|
|
|
|
|
expanded,
|
|
|
|
|
getSiblings,
|
|
|
|
|
renderNode,
|
|
|
|
|
patchPageTreeActiveDom,
|
|
|
|
|
}, nodeId);
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const hydrateInitialPageTree = () =>
|
|
|
|
|
hydrateTreeShellInitialPageTree({
|
|
|
|
|
mode,
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
itemById,
|
|
|
|
|
bindPageRowEvents,
|
|
|
|
|
focusedNodeId,
|
|
|
|
|
focusRowElement,
|
2026-05-25 00:03:12 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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" }));
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const focusPickerRowElement = (pickerItemKey) =>
|
|
|
|
|
focusTreeShellPickerRowElement({
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
}, pickerItemKey);
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const patchPickerActiveDom = () =>
|
|
|
|
|
patchTreeShellPickerActiveDom({
|
|
|
|
|
mode,
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
currentActivePickerItemKey,
|
|
|
|
|
currentActiveDocumentId,
|
|
|
|
|
});
|
2026-05-25 00:03:12 +08:00
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
const hydrateInitialPickerTree = () =>
|
|
|
|
|
hydrateTreeShellInitialPickerTree({
|
|
|
|
|
mode,
|
|
|
|
|
appElement,
|
|
|
|
|
normalizeText,
|
|
|
|
|
itemById,
|
|
|
|
|
bindPickerRootEvents,
|
|
|
|
|
bindPickerRowEvents,
|
2026-05-25 00:03:12 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hydrateInitialRenderer = () => {
|
|
|
|
|
if (mode === "page") {
|
|
|
|
|
const usedRustInitialPageRenderer = hydrateInitialPageTree();
|
|
|
|
|
return usedRustInitialPageRenderer;
|
|
|
|
|
}
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
return hydrateInitialFileTree();
|
|
|
|
|
}
|
|
|
|
|
if (mode === "picker") {
|
|
|
|
|
return hydrateInitialPickerTree();
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 03:51:06 +08:00
|
|
|
const treeShellRenderer = createTreeShellRenderer({
|
|
|
|
|
FILETREE_DRAG_MIME,
|
|
|
|
|
PAGE_DRAG_MIME,
|
|
|
|
|
allowRootPick,
|
|
|
|
|
appElement,
|
|
|
|
|
applyPageKeyboardAction,
|
|
|
|
|
applyPickerFocusByItemKey,
|
|
|
|
|
applyPickerStateAction,
|
|
|
|
|
attachInlineRenameInput,
|
|
|
|
|
beginInlineRename,
|
|
|
|
|
canAcceptPageDrop,
|
|
|
|
|
canExpandFileTreeRow,
|
|
|
|
|
clearFileTreeDropFeedback,
|
|
|
|
|
clearFileTreeSelection,
|
|
|
|
|
clearPageDropFeedback,
|
|
|
|
|
executeLocalFileTreeExternalDrop,
|
|
|
|
|
executeLocalFileTreeInternalDrop,
|
|
|
|
|
expanded,
|
|
|
|
|
getActiveFileTreeDropPosition,
|
|
|
|
|
getActiveFileTreeDropRowId,
|
|
|
|
|
getActiveFileTreeRootDrop,
|
|
|
|
|
getActivePageDropNodeId: () => activePageDropNodeId,
|
|
|
|
|
getCurrentActiveDocumentId: () => currentActiveDocumentId,
|
|
|
|
|
getElementCenter,
|
|
|
|
|
getFileTreeDropTargetFromEvent,
|
|
|
|
|
getFileTreeRowById: () => fileTreeRowById,
|
|
|
|
|
focusRowElement,
|
|
|
|
|
getFocusedNodeId: () => focusedNodeId,
|
|
|
|
|
getInlineRenameState: () => inlineRenameState,
|
|
|
|
|
getItemById: () => itemById,
|
|
|
|
|
getRoots: () => roots,
|
|
|
|
|
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
|
|
|
|
|
getSiblings,
|
|
|
|
|
handleCreate,
|
|
|
|
|
handleFileTreeKeyDown,
|
|
|
|
|
handleMove,
|
|
|
|
|
handleNavigate,
|
|
|
|
|
handlePageDropMove,
|
|
|
|
|
handleRowKeyDown,
|
|
|
|
|
isBusy: () => busy,
|
|
|
|
|
mode,
|
|
|
|
|
normalizeText,
|
|
|
|
|
normalizeFileTreeSelectionForVisibleRows,
|
|
|
|
|
openContextMenu,
|
|
|
|
|
openFileTreeContextMenu,
|
|
|
|
|
patchFileTreeCutDecoration,
|
|
|
|
|
postPickerPickResultToHost,
|
|
|
|
|
postToHost,
|
|
|
|
|
pushVisibleFileTreeRowId: (rowId) => {
|
|
|
|
|
visibleFileTreeRowIds.push(rowId);
|
|
|
|
|
},
|
|
|
|
|
readFileTreeInternalDropPayload,
|
|
|
|
|
readPageDragNodeId,
|
|
|
|
|
resolveFileTreeDraggedRowIds,
|
|
|
|
|
resolvePageDropTargetNodeId,
|
|
|
|
|
resolvePickerRootFocused,
|
|
|
|
|
runFileTreePreflight,
|
|
|
|
|
selectFileTreeContextRow,
|
|
|
|
|
selectFileTreeRow,
|
|
|
|
|
setFileTreeDropFeedback,
|
|
|
|
|
setDraggingFileTreeRowIds: (rowIds) => {
|
|
|
|
|
draggingFileTreeRowIds = rowIds;
|
|
|
|
|
},
|
|
|
|
|
setDraggingPageNodeId: (nodeId) => {
|
|
|
|
|
draggingPageNodeId = nodeId;
|
|
|
|
|
},
|
|
|
|
|
setLastAction,
|
|
|
|
|
setPageDropFeedback,
|
|
|
|
|
setVisibleFileTreeRowIds: (rowIds) => {
|
|
|
|
|
visibleFileTreeRowIds = rowIds;
|
|
|
|
|
},
|
|
|
|
|
sourceKind,
|
|
|
|
|
toggleExpand,
|
|
|
|
|
validateFileTreeInternalDrop,
|
|
|
|
|
workspaceId,
|
|
|
|
|
});
|
|
|
|
|
renderNode = treeShellRenderer.renderNode;
|
|
|
|
|
renderTree = treeShellRenderer.renderTree;
|
|
|
|
|
syncFileTreeSelectionDom = treeShellRenderer.syncFileTreeSelectionDom;
|
|
|
|
|
openHydratedFileTreeItem = treeShellRenderer.openHydratedFileTreeItem;
|
|
|
|
|
hydrateInitialFileTree = treeShellRenderer.hydrateInitialFileTree;
|
2026-05-25 00:03:12 +08:00
|
|
|
|
|
|
|
|
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();
|