20260513 mindmap优化01
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user