20260513 mindmap优化01

This commit is contained in:
lix-2026
2026-05-13 22:43:16 +08:00
parent 17c003976b
commit b4a452a8b7
89 changed files with 11557 additions and 707 deletions
@@ -49,6 +49,7 @@ import {
type MindmapProjection,
type MindmapRouteMeta,
} from "@/lib/mindmap/mindmap-projection";
import { buildInitialMindmapAssetRefreshPayload } from "@/lib/mindmap/mindmap-initial-sync";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => {
@@ -1588,14 +1589,15 @@ const MindmapSurfaceView = ({
}, [persistData]);
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = canonicalizeMindmapData(
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
);
(async () => {
try {
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = canonicalizeMindmapData(
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
);
(async () => {
let refreshPayload: ReturnType<typeof buildInitialMindmapAssetRefreshPayload> = null;
try {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1605,19 +1607,18 @@ const MindmapSurfaceView = ({
} else {
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
syncMindmapRouteMeta(payload?.meta);
refreshPayload = buildInitialMindmapAssetRefreshPayload({
ok: true,
docId,
mindmapId,
});
}
} catch {} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: mindmapId,
document_id: docId,
asset_type: "mindmap",
file_name: fileName,
file_url: `/documents/${docId}/${fileName}`,
});
}
})();
if (refreshPayload) {
emitAssetsChanged(docId, refreshPayload);
}
}
})();
}, [docId, mindmap, mindmapId, syncMindmapRouteMeta]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const ASSET_CONTEXT_MENU_SOURCE = path.join(process.cwd(), "src/components/sidebar/asset-context-menu.tsx");
describe("asset context menu source", () => {
it("删除按钮应把删除语义交给外层,而不是在菜单内硬编码单项 asset id", () => {
const source = fs.readFileSync(ASSET_CONTEXT_MENU_SOURCE, "utf8");
expect(source).toContain("onDelete: () => void;");
expect(source).toContain("onClick={() => onDelete()}");
expect(source).not.toContain("onDelete: (assetIds: string[]) => void;");
expect(source).not.toContain("onClick={() => onDelete([asset.id])}");
});
});
@@ -1,22 +1,21 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
onOpen: (asset: MediaAsset) => void;
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: (assetIds: string[]) => void;
onDownload: (asset: MediaAsset) => void;
}
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: () => void;
onDownload: (asset: MediaAsset) => void;
}
export function AssetContextMenu({
asset,
@@ -93,14 +92,14 @@ export function AssetContextMenu({
<Move className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete([asset.id])}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete()}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
</div>
);
}
@@ -12,4 +12,20 @@ describe("sidebar file tree delete preflight source", () => {
expect(source).toContain("buildFileTreeShellDeletePreflightPayload(");
expect(source).not.toContain("computeFileTreeShellDeleteTargets(");
});
it("filetree DOM host 删除回调应直连 sidebar 删除入口", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("const handleFileTreeShellDeleteSelection = useCallback(");
expect(source).toContain("void handleDeleteResourceSelection(payload);");
expect(source).toContain("onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}");
});
it("附件右键菜单删除应复用当前文件树 selection 删除入口", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("const handleDeleteFromAssetContextMenu = useCallback(");
expect(source).toContain('if (viewMode === "filesystem") {');
expect(source).toContain("await handleDeleteResourceSelection();");
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
import {
buildSidebarDocumentOpenTarget,
buildSidebarMindmapOpenTarget,
} from "./sidebar-navigation";
describe("sidebar-navigation", () => {
it("页面树普通打开应留在当前窗口", () => {
@@ -15,4 +18,18 @@ describe("sidebar-navigation", () => {
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
});
});
it("文件树里的 mindmap 主打开应进入 mindmap 对象编辑页", () => {
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "main", "http://127.0.0.1:3000")).toEqual({
kind: "same-window",
path: "/mindmap/doc_1/mind_1",
});
});
it("显式新标签打开 mindmap 也应使用同一对象编辑页", () => {
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
kind: "new-window",
url: "http://127.0.0.1:3000/mindmap/doc_1/mind_1",
});
});
});
@@ -17,3 +17,18 @@ export function buildSidebarDocumentOpenTarget(
const base = origin ? `${origin}${path}` : path;
return { kind: "new-window", url: `${base}?preview=sidebar` };
}
export function buildSidebarMindmapOpenTarget(
documentId: string,
mindmapId: string,
mode: SidebarDocumentOpenMode,
origin?: string | null,
): SidebarDocumentOpenTarget {
const path = `/mindmap/${documentId}/${mindmapId}`;
if (mode === "main") {
return { kind: "same-window", path };
}
const url = origin ? `${origin}${path}` : path;
return { kind: "new-window", url };
}
@@ -107,6 +107,7 @@ import {
} from "@/components/sidebar/tree-pane-bindings";
import {
buildSidebarDocumentOpenTarget,
buildSidebarMindmapOpenTarget,
type SidebarDocumentOpenMode,
} from "@/components/sidebar/sidebar-navigation";
import type { MediaAsset } from "@/types/media";
@@ -290,6 +291,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
const resourceRendererSelectionRef = useRef<TreePaneSelectionState>(
createEmptyFileTreeSelectionState(),
);
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
@@ -870,10 +874,18 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
});
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
const handleOpenAssetInMain = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
setOpen(false);
const target = buildSidebarMindmapOpenTarget(
asset.document_id,
asset.id,
"main",
typeof window !== "undefined" ? window.location.origin : null,
);
if (target.kind === "same-window") {
router.push(target.path);
setOpen(false);
}
return;
}
if (asset.asset_type === "luckysheet") {
@@ -949,6 +961,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
})();
}, [activeId, editorBridge, router, setOpen]);
const handleOpenAssetInNewTab = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
const target = buildSidebarMindmapOpenTarget(
asset.document_id,
asset.id,
"sidebar",
typeof window !== "undefined" ? window.location.origin : null,
);
if (target.kind === "new-window" && typeof window !== "undefined") {
window.open(target.url, "_blank", "noopener,noreferrer");
setOpen(false);
}
return;
}
if (asset.asset_type === "luckysheet") {
if (typeof window !== "undefined") {
window.open(buildTableUrl(asset.id), "_blank", "noopener,noreferrer");
}
setOpen(false);
return;
}
void handleOpenAssetInMain(asset);
}, [handleOpenAssetInMain, setOpen]);
const handleResourcePaneBlankMouseDown = useCallback(() => {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}, []);
@@ -998,12 +1036,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const handleResourceRowDoubleClick = useCallback(
(row: TreePaneRow, _event?: React.MouseEvent) => {
if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset);
handleOpenAssetInMain(row.asset);
return;
}
handleOpenDocument(row.docId, "main");
},
[handleOpenAsset, handleOpenDocument],
[handleOpenAssetInMain, handleOpenDocument],
);
const handleResourceRowContextMenu = useCallback(
@@ -1129,12 +1167,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
setResourceRendererSelection(
materializeRendererSelectionSnapshot({
payload,
hasRowId: (rowId) => resourceShellRowById.has(rowId),
}),
);
const nextSelection = materializeRendererSelectionSnapshot({
payload,
hasRowId: (rowId) => resourceShellRowById.has(rowId),
});
resourceRendererSelectionRef.current = nextSelection;
setResourceRendererSelection(nextSelection);
},
[resourceShellRowById],
);
@@ -1145,9 +1183,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
if (!asset) {
return;
}
handleOpenAsset(asset);
handleOpenAssetInMain(asset);
},
[assetById, handleOpenAsset],
[assetById, handleOpenAssetInMain],
);
const handleTreeShellMutation = useCallback(() => {
@@ -1581,8 +1619,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const handleDeleteResourceSelection = useCallback(async () => {
const selectedRowIds = Array.from(resourceSelection.selectedRowIds);
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
const effectiveSelection =
selectionOverride
? {
selectedRowIds: new Set(selectionOverride.selectedRowIds),
anchorRowId: selectionOverride.anchorRowId,
focusedRowId: selectionOverride.focusedRowId,
}
: isRustFamilyTreeRenderer
? resourceRendererSelectionRef.current
: resourceSelection;
const selectedRowIds = Array.from(effectiveSelection.selectedRowIds);
let shellDeleteTargets: {
docIds: string[];
assetIds: string[];
@@ -1614,7 +1666,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const legacyDeleteTargets = !isRustFamilyTreeRenderer
? computeTreePaneDeleteTargets({
visibleRows: resourceRows,
selectedRowIds: resourceSelection.selectedRowIds,
selectedRowIds: effectiveSelection.selectedRowIds,
parentById: docParentById,
})
: null;
@@ -1636,10 +1688,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const selectedAssetHints =
shellDeleteTargets?.assetHints ??
Array.from(
Array.from(
new Map(
resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter((row) => effectiveSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
@@ -1706,13 +1758,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
isRustFamilyTreeRenderer,
resourceRows,
resourceShellRowById,
resourceSelection.selectedRowIds,
resourceSelection,
handleDeleteAssets,
refreshTree,
router,
sidebarData.activeWorkspaceId,
]);
const handleFileTreeShellDeleteSelection = useCallback(
(payload: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
void handleDeleteResourceSelection(payload);
},
[handleDeleteResourceSelection],
);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -2116,43 +2179,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[handleDelete, handleDeleteResourceSelection, viewMode],
);
const handleDeleteFromAssetContextMenu = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
if (uniqueAssetIds.length === 0) return;
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);
const parts: string[] = [];
if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`);
if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`);
const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`);
if (!ok) return;
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets],
);
const handleDeleteFromAssetContextMenu = useCallback(async () => {
if (viewMode === "filesystem") {
await handleDeleteResourceSelection();
return;
}
}, [handleDeleteResourceSelection, viewMode]);
const handleConvertToChild = useCallback(
async (documentId: string) => {
@@ -2889,6 +2921,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
onNavigate={handleFileTreeShellNavigate}
onFileTreeContextMenu={handleFileTreeShellContextMenu}
onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}
onAssetOpen={handleFileTreeShellAssetOpen}
onTreeMutation={handleTreeShellMutation}
/>
@@ -3029,12 +3062,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
asset={assetMenu.asset}
position={{ x: assetMenu.x, y: assetMenu.y }}
onClose={() => setAssetMenu(null)}
onOpen={handleOpenAsset}
onOpen={handleOpenAssetInNewTab}
onCopyLink={handleCopyAssetLink}
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
onDelete={() => void handleDeleteFromAssetContextMenu()}
onDownload={handleDownloadAsset}
/>
)}
@@ -12,11 +12,16 @@ import {
type TreeShellPickerItem,
} from "@/components/sidebar/tree-shell-dom-model";
import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellHostMode,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
import {
normalizeFileTreeSelectionForVisibleRows as normalizeVisibleFileTreeSelection,
reduceFileTreeSelection,
} from "@/lib/file-tree/selection";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import { cn } from "@/lib/utils";
@@ -91,6 +96,7 @@ type TreeShellRustDomShellHostProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -201,6 +207,35 @@ function normalizeRuntimeState<T>(result: TreeShellRuntimeResult | null, mode: "
return result.state.state as T;
}
function areFileTreeSelectionsEqual(
left: FileTreeRuntimeState["selection"],
right: FileTreeRuntimeState["selection"],
) {
if (left.anchorRowId !== right.anchorRowId || left.focusedRowId !== right.focusedRowId) {
return false;
}
if (left.selectedRowIds.length !== right.selectedRowIds.length) {
return false;
}
return left.selectedRowIds.every((rowId, index) => rowId === right.selectedRowIds[index]);
}
function toLocalSelectionState(selection: FileTreeRuntimeState["selection"]) {
return {
selectedRowIds: new Set(selection.selectedRowIds),
anchorRowId: selection.anchorRowId,
focusedRowId: selection.focusedRowId,
};
}
function fromLocalSelectionState(selection: ReturnType<typeof toLocalSelectionState>) {
return {
selectedRowIds: Array.from(selection.selectedRowIds),
anchorRowId: selection.anchorRowId,
focusedRowId: selection.focusedRowId,
};
}
async function loadTreeShellRuntimeWasm() {
if (!treeShellRuntimeWasmPromise) {
treeShellRuntimeWasmPromise = (async () => {
@@ -271,6 +306,7 @@ export function TreeShellRustDomShellHost({
onPageFocusChange,
onFileTreeContextMenu,
onFileTreeSelectionChange,
onFileTreeDeleteSelection,
onInternalDrop,
onDropFiles,
onAssetOpen,
@@ -295,6 +331,9 @@ export function TreeShellRustDomShellHost({
activeItemKey: activePickerItemKey,
}));
const pickerCommandSeqRef = useRef<number | null>(null);
const fileTreeStateRef = useRef(fileTreeState);
const fileTreeSelectionVersionRef = useRef(0);
const fileTreeSelectionInitializedRef = useRef(false);
const pageItems = useMemo(() => {
if (mode !== "page") return [];
@@ -319,10 +358,7 @@ export function TreeShellRustDomShellHost({
() => new Map(items.map((item) => [item.nodeId, item])),
[items],
);
const filetreeItemByRowId = useMemo(
() => new Map(fileTreeItems.map((item) => [toRowId(item), item])),
[fileTreeItems],
);
const visibleFileTreeRowIds = useMemo(() => fileTreeItems.map(toRowId), [fileTreeItems]);
const visiblePageItems = useMemo(() => {
const expanded = new Set(pageState.expandedIds);
const visible: TreeShellDomProjectionItem[] = [];
@@ -426,8 +462,55 @@ export function TreeShellRustDomShellHost({
[childrenByParent, pageItems, pageState, reduceRuntime, visiblePageItems],
);
useEffect(() => {
fileTreeStateRef.current = fileTreeState;
}, [fileTreeState]);
const updateFileTreeState = useCallback(
(updater: (prev: FileTreeRuntimeState) => FileTreeRuntimeState) => {
let nextStateSnapshot = fileTreeStateRef.current;
setFileTreeState((prev) => {
const nextState = updater(prev);
nextStateSnapshot = nextState;
fileTreeStateRef.current = nextState;
return nextState;
});
return nextStateSnapshot;
},
[],
);
const commitFileTreeSelection = useCallback(
(nextSelection: FileTreeRuntimeState["selection"]) => {
let changed = false;
const nextState = updateFileTreeState((prev) => {
if (areFileTreeSelectionsEqual(prev.selection, nextSelection)) {
return prev;
}
changed = true;
return {
...prev,
selection: nextSelection,
};
});
fileTreeSelectionInitializedRef.current = true;
if (changed) {
fileTreeSelectionVersionRef.current += 1;
onFileTreeSelectionChange?.(nextSelection);
}
return nextState;
},
[onFileTreeSelectionChange, updateFileTreeState],
);
const readFileTreeState = useCallback(() => fileTreeStateRef.current, []);
const reduceFileTreeAction = useCallback(
async (action: Record<string, unknown>, stateSnapshot: FileTreeRuntimeState = fileTreeState) => {
async (
action: Record<string, unknown>,
stateSnapshot: FileTreeRuntimeState = readFileTreeState(),
options?: { selectionVersion?: number },
) => {
const requestId = `filetree-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const result = await reduceRuntime({
mode: "fileTree",
@@ -446,13 +529,20 @@ export function TreeShellRustDomShellHost({
});
const nextState = normalizeRuntimeState<FileTreeRuntimeState>(result, "fileTree");
if (nextState) {
setFileTreeState(nextState);
onFileTreeSelectionChange?.(nextState.selection);
return { result, state: nextState };
const shouldApplySelection =
options?.selectionVersion === undefined || options.selectionVersion === fileTreeSelectionVersionRef.current;
const mergedState = updateFileTreeState((prev) => ({
...nextState,
selection: shouldApplySelection ? nextState.selection : prev.selection,
}));
if (shouldApplySelection && !areFileTreeSelectionsEqual(stateSnapshot.selection, mergedState.selection)) {
onFileTreeSelectionChange?.(mergedState.selection);
}
return { result, state: mergedState };
}
return { result: null, state: stateSnapshot };
},
[fileTreeItems, fileTreeState, onFileTreeSelectionChange, reduceRuntime],
[fileTreeItems, onFileTreeSelectionChange, readFileTreeState, reduceRuntime, updateFileTreeState],
);
const reducePickerAction = useCallback(
@@ -645,15 +735,25 @@ export function TreeShellRustDomShellHost({
useEffect(() => {
if (mode !== "filetree") return;
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
setFileTreeState({
const shouldBackfillSelection =
!fileTreeSelectionInitializedRef.current || readFileTreeState().selection.selectedRowIds.length === 0;
if (shouldBackfillSelection) {
commitFileTreeSelection(selection);
}
updateFileTreeState((prev) => ({
...prev,
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
dropTargetRowId: null,
});
onFileTreeSelectionChange?.(selection);
}, [activeDocumentId, mode, onFileTreeSelectionChange]);
selection: shouldBackfillSelection ? selection : prev.selection,
}));
}, [activeDocumentId, commitFileTreeSelection, mode, readFileTreeState, updateFileTreeState]);
useEffect(() => {
if (mode !== "filetree") return;
const normalizedSelection = fromLocalSelectionState(
normalizeVisibleFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), visibleFileTreeRowIds),
);
commitFileTreeSelection(normalizedSelection);
}, [commitFileTreeSelection, mode, readFileTreeState, visibleFileTreeRowIds]);
useEffect(() => {
if (mode !== "picker") return;
@@ -837,8 +937,17 @@ export function TreeShellRustDomShellHost({
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
event.preventDefault();
const rowId = toRowId(item);
const { state } = await reduceFileTreeAction({ kind: "selectContextRow", rowId });
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state);
const state = commitFileTreeSelection(
fromLocalSelectionState(
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
type: "contextmenu",
rowId,
}),
),
);
const selectionVersion = fileTreeSelectionVersionRef.current;
void reduceFileTreeAction({ kind: "selectContextRow", rowId }, state, { selectionVersion });
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state, { selectionVersion });
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
rowKind: toShellRowKind(item),
@@ -846,13 +955,28 @@ export function TreeShellRustDomShellHost({
y: event.clientY,
});
},
[applyFileTreeHostEvents, reduceFileTreeAction],
[applyFileTreeHostEvents, commitFileTreeSelection, readFileTreeState, reduceFileTreeAction],
);
const handleFileTreeSelect = useCallback(
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
(item: TreeShellDomProjectionItem, event: MouseEvent) => {
const rowId = toRowId(item);
await reduceFileTreeAction({
const state = commitFileTreeSelection(
fromLocalSelectionState(
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
type: "click",
rowId,
visibleRowIds: visibleFileTreeRowIds,
modifiers: {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
}),
),
);
const selectionVersion = fileTreeSelectionVersionRef.current;
void reduceFileTreeAction({
kind: "selectRow",
rowId,
modifiers: {
@@ -860,9 +984,9 @@ export function TreeShellRustDomShellHost({
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
});
}, state, { selectionVersion });
},
[reduceFileTreeAction],
[commitFileTreeSelection, readFileTreeState, reduceFileTreeAction, visibleFileTreeRowIds],
);
const handleFileTreeKeyDown = useCallback(
@@ -889,11 +1013,16 @@ export function TreeShellRustDomShellHost({
if (!action) return;
event.preventDefault();
const rowId = toRowId(item);
const currentFileTreeState = readFileTreeState();
if (action.kind === "deleteSelection") {
onFileTreeDeleteSelection?.(currentFileTreeState.selection);
return;
}
const { result } = await reduceFileTreeAction(action, {
...fileTreeState,
...currentFileTreeState,
selection: {
...fileTreeState.selection,
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
...currentFileTreeState.selection,
focusedRowId: currentFileTreeState.selection.focusedRowId ?? rowId,
},
});
applyFileTreeHostEvents(result?.hostEvents, {
@@ -901,7 +1030,7 @@ export function TreeShellRustDomShellHost({
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
[applyFileTreeHostEvents, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
@@ -1095,7 +1224,11 @@ export function TreeShellRustDomShellHost({
data-testid="filetree-action-menu"
aria-label="更多操作"
className="rounded-md px-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
onClick={(event) => void handleFileTreeContextMenu(item, event)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleFileTreeContextMenu(item, event);
}}
>
</button>
@@ -1,14 +1,23 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TreeShellRustDomShellHost } from "./tree-shell-dom-host";
import { TreeShellHost } from "./tree-shell-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type PendingRuntimeRequest = {
request: Record<string, unknown>;
resolve: (runtimeResult?: Record<string, unknown> | null) => void;
};
describe("tree-shell-host", () => {
let container: HTMLDivElement;
let root: Root;
let previousLegacyFlag: string | undefined;
let originalFetch: typeof fetch | undefined;
let pendingRuntimeRequests: PendingRuntimeRequest[];
beforeEach(() => {
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
@@ -16,13 +25,35 @@ describe("tree-shell-host", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
originalFetch = global.fetch;
pendingRuntimeRequests = [];
global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
return new Promise((resolve) => {
pendingRuntimeRequests.push({
request,
resolve: (runtimeResult = null) => {
resolve({
ok: true,
json: async () => runtimeResult,
} as Response);
},
});
});
}) as typeof fetch;
});
afterEach(() => {
pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null));
act(() => {
root.unmount();
});
container.remove();
if (originalFetch) {
global.fetch = originalFetch;
} else {
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
}
if (previousLegacyFlag === undefined) {
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
} else {
@@ -30,6 +61,109 @@ describe("tree-shell-host", () => {
}
});
const fileTreeItems: KernelFileTreeProjectionItem[] = [
{
projectionKind: "file_tree",
rowId: "index:doc_1",
rowKind: "index",
nodeId: "doc_1:index",
parentNodeId: null,
title: "Doc 1 索引",
depth: 0,
childCount: 0,
position: 0,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_1",
workspaceId: "ws_1",
iconHint: "page",
},
},
{
projectionKind: "file_tree",
rowId: "index:doc_2",
rowKind: "index",
nodeId: "doc_2:index",
parentNodeId: null,
title: "Doc 2 索引",
depth: 0,
childCount: 0,
position: 1,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_2",
workspaceId: "ws_1",
iconHint: "page",
},
},
{
projectionKind: "file_tree",
rowId: "index:doc_3",
rowKind: "index",
nodeId: "doc_3:index",
parentNodeId: null,
title: "Doc 3 索引",
depth: 0,
childCount: 0,
position: 2,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_3",
workspaceId: "ws_1",
iconHint: "page",
},
},
];
function queryFileTreeRow(rowId: string) {
return container.querySelector(`[data-row-id="${rowId}"]`) as HTMLElement | null;
}
function expectSelectedRowIds(rowIds: string[]) {
const selected = Array.from(container.querySelectorAll('[data-shell-mode="filetree"][data-selected="true"]'))
.map((element) => element.getAttribute("data-row-id"))
.filter((rowId): rowId is string => Boolean(rowId));
expect(selected).toEqual(rowIds);
}
function renderDomHost(input: {
activeDocumentId?: string | null;
onFileTreeDeleteSelection?: (payload: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
} = {}) {
act(() => {
root.render(
<TreeShellRustDomShellHost
mode="filetree"
surfaceTestId="sidebar-file-tree-shell"
workspaceId="ws_1"
activeDocumentId={input.activeDocumentId ?? null}
inlineFileTreeItems={fileTreeItems}
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
/>,
);
});
}
async function flushRuntimeDispatch() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function renderHost() {
act(() => {
root.render(
@@ -79,4 +213,158 @@ describe("tree-shell-host", () => {
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
});
it("filetree DOM host 应先本地提交 Ctrl/Shift selection,再异步等 runtime 对账", async () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2"]);
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
it("旧 runtime selection 回写不应覆盖更新后的多选,Delete 应使用当前稳定 selection", async () => {
const handleDeleteSelection = vi.fn();
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
await flushRuntimeDispatch();
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
await flushRuntimeDispatch();
act(() => {
pendingRuntimeRequests[0]?.resolve({
requestId: String(pendingRuntimeRequests[0]?.request.requestId ?? "filetree-stale"),
mode: "fileTree",
state: {
mode: "fileTree",
state: {
activeRowId: null,
selection: {
selectedRowIds: ["index:doc_2"],
anchorRowId: "index:doc_2",
focusedRowId: "index:doc_2",
},
dragRowIds: [],
dragEffect: null,
dropTargetRowId: null,
},
},
});
});
await flushRuntimeDispatch();
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }));
});
await flushRuntimeDispatch();
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
selectedRowIds: ["index:doc_2", "index:doc_3"],
anchorRowId: "index:doc_3",
focusedRowId: "index:doc_3",
});
});
it("Backspace 也应复用当前稳定多选并触发删除回调", async () => {
const handleDeleteSelection = vi.fn();
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
await flushRuntimeDispatch();
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace" }));
});
await flushRuntimeDispatch();
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
selectedRowIds: ["index:doc_2", "index:doc_3"],
anchorRowId: "index:doc_3",
focusedRowId: "index:doc_3",
});
});
it("点击行内更多操作时不应因为事件冒泡把多选压成单选", async () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
const row3Menu = container.querySelector(
'[data-row-id="index:doc_3"] [data-testid="filetree-action-menu"]',
) as HTMLElement | null;
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
expect(row3Menu).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3Menu?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushRuntimeDispatch();
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
it("activeDocumentId 变化时,已有 filetree selection 不应被无条件重置", () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
renderDomHost({ activeDocumentId: "doc_1" });
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
});
@@ -40,6 +40,12 @@ export type FileTreeShellExternalDropPayload = {
files: FileList | File[];
};
export type FileTreeShellDeleteSelectionPayload = {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
};
type TreeShellHostProps = {
mode: TreeShellHostMode;
surfaceTestId: string;
@@ -79,6 +85,7 @@ type TreeShellHostProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -114,6 +121,7 @@ export function TreeShellHost({
onPageFocusChange,
onFileTreeContextMenu,
onFileTreeSelectionChange,
onFileTreeDeleteSelection,
onInternalDrop,
onDropFiles,
onAssetOpen,
@@ -181,6 +189,7 @@ export function TreeShellHost({
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
@@ -211,6 +220,7 @@ export function TreeShellHost({
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
@@ -3,6 +3,7 @@
import type { DragEvent, MouseEvent } from "react";
import {
TreeShellHost,
type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellPickerCommand,
@@ -71,6 +72,7 @@ type SidebarFileTreeSurfaceProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
};
@@ -153,6 +155,7 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
onFileTreeDeleteSelection={props.mode === "filetree" ? props.onFileTreeDeleteSelection : undefined}
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
@@ -72,7 +72,7 @@ describe("usePreferredSidebarSnapshot", () => {
container.remove();
});
it("tree stream live 时即使 query 更新也应继续优先使用 stream 快照", async () => {
it("tree stream 仍停在 initial 时,query 新快照不应被旧 stream 压住", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
const refreshedQuery = buildSidebarData([
@@ -81,6 +81,12 @@ describe("usePreferredSidebarSnapshot", () => {
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const caughtUpTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
@@ -112,10 +118,29 @@ describe("usePreferredSidebarSnapshot", () => {
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={caughtUpTreeStream}
treeStreamStatus="live"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
@@ -20,8 +20,16 @@ export function usePreferredSidebarSnapshot(input: {
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const streamIsPreferred = input.treeStreamStatus !== "fallback";
const queryHasFreshSnapshot =
input.sidebarQueryData != null &&
querySyncKey != null &&
querySyncKey !== initialSyncKey &&
treeStreamSyncKey === initialSyncKey;
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (queryHasFreshSnapshot) {
return "query";
}
if (input.treeStreamData && streamIsPreferred) {
return "tree_stream";
}
@@ -32,6 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
}, [
input.sidebarQueryData,
input.treeStreamData,
queryHasFreshSnapshot,
streamIsPreferred,
]);