4-10 树域 Rust 家族化
This commit is contained in:
@@ -23,6 +23,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
|
||||
initialData,
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,12 +39,14 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseDocumentSearch = vi.fn(() => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-document-search", () => ({
|
||||
useDocumentSearch: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
@@ -100,6 +102,13 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
});
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
mockUseDocumentSearch.mockReset();
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||
});
|
||||
|
||||
it("空查询时会使用统一 picker surface 并透传根目录选择", async () => {
|
||||
@@ -131,4 +140,130 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
expect(onPick).toHaveBeenCalledWith("move", null);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("搜索结果也应继续复用统一 picker surface", async () => {
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
matchField: "title",
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const onPick = vi.fn(async () => undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveEmbedPickerDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId="ws_test"
|
||||
defaultMode="move"
|
||||
allowRoot
|
||||
excludeIds={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||
configurable: true,
|
||||
value: "目标",
|
||||
});
|
||||
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const pickerSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||
expect(pickerSurface).not.toBeNull();
|
||||
expect(pickerRows).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pickerRows[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith("move", "doc_target");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "rust_family",
|
||||
};
|
||||
|
||||
const onPick = vi.fn(async () => undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveEmbedPickerDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId="ws_test"
|
||||
defaultMode="move"
|
||||
allowRoot
|
||||
excludeIds={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(
|
||||
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
matchField: "title",
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||
configurable: true,
|
||||
value: "目标",
|
||||
});
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(
|
||||
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
|
||||
@@ -106,6 +106,7 @@ function MoveEmbedPickerDialogBody({
|
||||
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||
|
||||
const handleModeChange = useCallback((value: string) => {
|
||||
setMode(value as MoveEmbedMode);
|
||||
@@ -207,6 +208,11 @@ function MoveEmbedPickerDialogBody({
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<TreePickerSurface
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={workspaceId}
|
||||
treeShellEnabled={isEmptyQuery}
|
||||
allowRootPick={allowRoot && mode === "move"}
|
||||
excludeIds={excludeIds}
|
||||
items={items}
|
||||
highlighted={highlighted}
|
||||
onHighlight={setHighlighted}
|
||||
@@ -278,28 +284,21 @@ function MoveEmbedPickerDialogBody({
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
key={item.kind === "root" ? "root" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
idx === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => void handlePick(item.id)}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TreePickerSurface
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={workspaceId}
|
||||
treeShellEnabled={Boolean(workspaceId)}
|
||||
allowRootPick={false}
|
||||
excludeIds={excludeIds}
|
||||
treeShellItems={items}
|
||||
items={items}
|
||||
highlighted={highlighted}
|
||||
className="py-2"
|
||||
onHighlight={setHighlighted}
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
|
||||
type SidebarTreeSnapshot = {
|
||||
id: string;
|
||||
@@ -42,6 +43,20 @@ function toMillis(value: string | null | undefined): number {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizeKernelFileTreeProjection(
|
||||
projection: SidebarInitialData["kernelFileTreeProjection"] | undefined,
|
||||
): KernelFileTreeProjection {
|
||||
return (
|
||||
projection ?? {
|
||||
projectionId: "kernel_projection:file_tree:missing",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
|
||||
return JSON.stringify(toSidebarTreeSnapshot(nodes));
|
||||
}
|
||||
@@ -51,9 +66,31 @@ export function buildMediaAssetListSyncKey(assets: MediaAsset[]): string {
|
||||
}
|
||||
|
||||
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||
return JSON.stringify({
|
||||
activeWorkspaceId: data.activeWorkspaceId,
|
||||
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
||||
fileTree: {
|
||||
projectionId: fileTreeProjection.projectionId,
|
||||
rootNodeId: fileTreeProjection.rootNodeId,
|
||||
items: fileTreeProjection.items.map((item) => ({
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
nodeId: item.nodeId,
|
||||
parentNodeId: item.parentNodeId,
|
||||
title: item.title,
|
||||
depth: item.depth,
|
||||
position: item.position,
|
||||
childCount: item.childCount,
|
||||
expandable: item.expandable,
|
||||
expandedByDefault: item.expandedByDefault,
|
||||
iconHint: item.iconHint ?? null,
|
||||
resourceKind: item.resourceMeta.resourceKind,
|
||||
documentId: item.resourceMeta.documentId ?? null,
|
||||
assetId: item.resourceMeta.assetId ?? null,
|
||||
assetKind: item.resourceMeta.assetKind ?? null,
|
||||
})),
|
||||
},
|
||||
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
|
||||
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
|
||||
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
|
||||
@@ -73,6 +110,8 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||
export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||
const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||
const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||
const fileTreeTimes = fileTreeProjection.items.map((item) => item.position ?? 0);
|
||||
const assetTimes = [
|
||||
...(data.mediaAssets ?? []),
|
||||
...(data.mindmapAssets ?? []),
|
||||
@@ -87,6 +126,7 @@ export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||
0,
|
||||
...documentTimes,
|
||||
...treeTimes,
|
||||
...fileTreeTimes,
|
||||
...assetTimes,
|
||||
...trashedDocumentTimes,
|
||||
);
|
||||
|
||||
@@ -199,6 +199,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
initialData,
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
});
|
||||
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
||||
|
||||
@@ -211,6 +212,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const { signOut } = useAuthActions();
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -673,18 +675,28 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const resourceRows = useMemo(
|
||||
() =>
|
||||
buildVisibleRows({
|
||||
fileTreeItems:
|
||||
filter.trim().length === 0
|
||||
? sidebarData.kernelFileTreeProjection.items
|
||||
: undefined,
|
||||
pageRows: visibleFilteredPrivatePageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
nodeById,
|
||||
assetById,
|
||||
}),
|
||||
[
|
||||
assetById,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
expandedAssetFolders,
|
||||
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
nodeById,
|
||||
sidebarData.kernelFileTreeProjection.items,
|
||||
visibleFilteredPrivatePageRows,
|
||||
filter,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -967,6 +979,118 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePageTreeShellNavigate = useCallback(
|
||||
(documentId: string) => {
|
||||
handleOpenDocument(documentId, "sidebar");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeShellNavigate = useCallback(
|
||||
(documentId: string) => {
|
||||
handleOpenDocument(documentId, "main");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
|
||||
const handlePageTreeShellContextMenu = useCallback(
|
||||
(payload: { documentId: string; x: number; y: number }) => {
|
||||
const node = nodeById.get(payload.documentId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x: payload.x,
|
||||
y: payload.y,
|
||||
});
|
||||
},
|
||||
[nodeById],
|
||||
);
|
||||
|
||||
const handleFileTreeShellContextMenu = useCallback(
|
||||
(payload: {
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
rowId: string | null;
|
||||
rowKind: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => {
|
||||
if (payload.assetId) {
|
||||
const asset = assetById.get(payload.assetId);
|
||||
if (asset) {
|
||||
setAssetMenu({
|
||||
asset,
|
||||
x: payload.x,
|
||||
y: payload.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
|
||||
const node =
|
||||
row && (row.kind === "doc" || row.kind === "index")
|
||||
? row.node
|
||||
: payload.documentId
|
||||
? nodeById.get(payload.documentId) ?? null
|
||||
: null;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x: payload.x,
|
||||
y: payload.y,
|
||||
});
|
||||
},
|
||||
[assetById, nodeById, resourceRowById],
|
||||
);
|
||||
|
||||
const handleFileTreeShellSelectionChange = useCallback(
|
||||
(payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => {
|
||||
const visibleRowIds = resourceRows.map((row) => row.rowId);
|
||||
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
||||
{
|
||||
selectedRowIds: new Set(
|
||||
payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
|
||||
),
|
||||
anchorRowId:
|
||||
payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
|
||||
? payload.anchorRowId
|
||||
: null,
|
||||
focusedRowId:
|
||||
payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
|
||||
? payload.focusedRowId
|
||||
: null,
|
||||
},
|
||||
visibleRowIds,
|
||||
);
|
||||
setResourceSelection(normalized);
|
||||
},
|
||||
[resourceRowById, resourceRows],
|
||||
);
|
||||
|
||||
const handleFileTreeShellAssetOpen = useCallback(
|
||||
(payload: { assetId: string; documentId: string | null }) => {
|
||||
const asset = assetById.get(payload.assetId);
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
handleOpenAsset(asset);
|
||||
},
|
||||
[assetById, handleOpenAsset],
|
||||
);
|
||||
|
||||
const handleTreeShellMutation = useCallback(() => {
|
||||
void refreshTree();
|
||||
}, [refreshTree]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (event: KeyboardEvent) => {
|
||||
const isCopy =
|
||||
@@ -2593,6 +2717,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
treeShellEnabled={filter.trim().length === 0}
|
||||
className="h-full"
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
@@ -2601,6 +2728,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
onNavigate={handlePageTreeShellNavigate}
|
||||
onPageContextMenu={handlePageTreeShellContextMenu}
|
||||
onTreeMutation={handleTreeShellMutation}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2618,6 +2748,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
>
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
treeShellEnabled={filter.trim().length === 0}
|
||||
className="h-full"
|
||||
rows={resourceRows}
|
||||
activeId={activeId}
|
||||
@@ -2635,6 +2768,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
onBlankMouseDown={handleResourcePaneBlankMouseDown}
|
||||
onDropFiles={handleResourcePaneDropFiles}
|
||||
onInternalDrop={handleResourcePaneInternalDrop}
|
||||
onNavigate={handleFileTreeShellNavigate}
|
||||
onFileTreeContextMenu={handleFileTreeShellContextMenu}
|
||||
onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
|
||||
onAssetOpen={handleFileTreeShellAssetOpen}
|
||||
onTreeMutation={handleTreeShellMutation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
TreeShellIframeHost,
|
||||
type TreeShellPickerItem,
|
||||
} from "@/components/sidebar/tree-shell-iframe-host";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TreeRendererFamily = "react" | "rust_family";
|
||||
|
||||
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
||||
|
||||
type TreeShellHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
className?: string;
|
||||
treeShellEnabled?: boolean;
|
||||
workspaceId?: string | null;
|
||||
rootNodeId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
pickerItems?: TreeShellPickerItem[];
|
||||
fileTreeRows?: FileTreeRow[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onPick?: (targetId: string | null) => void;
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onFileTreeContextMenu?: (payload: {
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
rowId: string | null;
|
||||
rowKind: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => void;
|
||||
onFileTreeSelectionChange?: (payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function TreeShellHost({
|
||||
mode,
|
||||
surfaceTestId,
|
||||
rendererFamily = "react",
|
||||
className,
|
||||
treeShellEnabled = true,
|
||||
workspaceId = null,
|
||||
rootNodeId = null,
|
||||
activeDocumentId = null,
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
pickerItems = [],
|
||||
fileTreeRows = [],
|
||||
channel,
|
||||
host,
|
||||
onNavigate,
|
||||
onPick,
|
||||
onPageContextMenu,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onInternalDrop,
|
||||
onDropFiles,
|
||||
onAssetOpen,
|
||||
onTreeMutation,
|
||||
children,
|
||||
}: TreeShellHostProps) {
|
||||
const useRustHost = rendererFamily === "rust_family";
|
||||
const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim());
|
||||
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
||||
const implementation = useIframeHost
|
||||
? "mnote_web_iframe_proxy"
|
||||
: rendererFamily === "rust_family"
|
||||
? "react_fallback"
|
||||
: "react_primary";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={surfaceTestId}
|
||||
data-shell-mode={mode}
|
||||
data-renderer-family={rendererFamily}
|
||||
data-tree-host-kind={hostKind}
|
||||
data-tree-host-implementation={implementation}
|
||||
className={cn(className)}
|
||||
>
|
||||
{useRustHost ? (
|
||||
<div
|
||||
data-testid={`${surfaceTestId}-rust-host`}
|
||||
data-tree-host-mode={mode}
|
||||
data-tree-host-kind="rust_family"
|
||||
data-tree-host-implementation={implementation}
|
||||
className="contents"
|
||||
>
|
||||
{useIframeHost && workspaceId ? (
|
||||
<TreeShellIframeHost
|
||||
mode={mode}
|
||||
surfaceTestId={surfaceTestId}
|
||||
workspaceId={workspaceId}
|
||||
rootNodeId={rootNodeId}
|
||||
activeDocumentId={activeDocumentId}
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
pickerItems={pickerItems}
|
||||
fileTreeRows={fileTreeRows}
|
||||
channel={channel}
|
||||
host={host}
|
||||
onNavigate={onNavigate}
|
||||
onPick={onPick}
|
||||
onPageContextMenu={onPageContextMenu}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
onInternalDrop={onInternalDrop}
|
||||
onDropFiles={onDropFiles}
|
||||
onAssetOpen={onAssetOpen}
|
||||
onTreeMutation={onTreeMutation}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TreeShellIframeHost,
|
||||
buildTreeShellIframeSrc,
|
||||
buildTreeShellInlinePickerItems,
|
||||
injectTreeShellInlineOverrides,
|
||||
} from "./tree-shell-iframe-host";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("tree-shell-iframe-host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("应构造同源 tree shell iframe 地址,并透传 picker 所需 query", () => {
|
||||
const src = buildTreeShellIframeSrc({
|
||||
mode: "picker",
|
||||
workspaceId: "ws_picker",
|
||||
activeDocumentId: "doc_active",
|
||||
allowRootPick: true,
|
||||
excludeIds: ["doc_hidden", "doc_other"],
|
||||
channel: "tree-picker-surface",
|
||||
host: "tree-picker-surface",
|
||||
});
|
||||
const url = new URL(src, "http://127.0.0.1:3000");
|
||||
|
||||
expect(url.pathname).toBe("/api/tree/shell");
|
||||
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
|
||||
expect(url.searchParams.get("mode")).toBe("picker");
|
||||
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
|
||||
expect(url.searchParams.get("allowRootPick")).toBe("1");
|
||||
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
|
||||
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
|
||||
expect(url.searchParams.get("host")).toBe("tree-picker-surface");
|
||||
});
|
||||
|
||||
it("应能把 picker 搜索结果转换为 inline shell items 并注入到 HTML", () => {
|
||||
const pickerItems = buildTreeShellInlinePickerItems([
|
||||
{ kind: "doc", id: "doc_target", title: "目标页面", depth: 0 },
|
||||
{ kind: "doc", id: "doc_recent", title: "最近</script>打开", depth: 0 },
|
||||
]);
|
||||
|
||||
expect(pickerItems).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: "doc_target",
|
||||
parentNodeId: null,
|
||||
title: "目标页面",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeId: "doc_recent",
|
||||
parentNodeId: null,
|
||||
title: "最近</script>打开",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
const html = injectTreeShellInlineOverrides(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ items: pickerItems },
|
||||
);
|
||||
|
||||
expect(html).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||
expect(html).toContain("\\u003c/script\\u003e");
|
||||
expect(html.indexOf("__MNOTE_TREE_SHELL_OVERRIDE__")).toBeGreaterThan(-1);
|
||||
expect(html.indexOf("__MNOTE_TREE_SHELL_OVERRIDE__")).toBeLessThan(
|
||||
html.indexOf('id="tree-shell-state"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const onPageContextMenu = vi.fn();
|
||||
const onPick = vi.fn();
|
||||
const onFileTreeContextMenu = vi.fn();
|
||||
const onFileTreeSelectionChange = vi.fn();
|
||||
const onAssetOpen = vi.fn();
|
||||
const onTreeMutation = vi.fn();
|
||||
const onInternalDrop = vi.fn();
|
||||
const onDropFiles = vi.fn();
|
||||
const targetRow = {
|
||||
kind: "doc",
|
||||
rowId: "doc:doc_target",
|
||||
depth: 0,
|
||||
docId: "doc_target",
|
||||
parentDocId: null,
|
||||
node: {
|
||||
_id: "doc_target",
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
},
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="filetree"
|
||||
surfaceTestId="sidebar-file-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_1"
|
||||
channel="sidebar-file-tree-shell"
|
||||
host="sidebar-file-tree-shell"
|
||||
onNavigate={onNavigate}
|
||||
onPageContextMenu={onPageContextMenu}
|
||||
onPick={onPick}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
onAssetOpen={onAssetOpen}
|
||||
onTreeMutation={onTreeMutation}
|
||||
onInternalDrop={onInternalDrop}
|
||||
onDropFiles={onDropFiles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).not.toBeNull();
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
value: window,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "wrong-channel",
|
||||
type: "tree.navigate",
|
||||
documentId: "doc_wrong",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.navigate",
|
||||
documentId: "doc_2",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.page.context-menu",
|
||||
documentId: "doc_2",
|
||||
x: 12,
|
||||
y: 34,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.pick.root",
|
||||
documentId: null,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.context-menu",
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_2",
|
||||
rowId: "asset:asset_2",
|
||||
rowKind: "asset",
|
||||
x: 56,
|
||||
y: 78,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.selection.changed",
|
||||
selectedRowIds: ["doc:doc_2", "index:doc_2"],
|
||||
anchorRowId: "doc:doc_2",
|
||||
focusedRowId: "index:doc_2",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.asset.open",
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_2",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.node.renamed",
|
||||
documentId: "doc_2",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith("doc_2");
|
||||
expect(onPageContextMenu).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
x: 12,
|
||||
y: 34,
|
||||
});
|
||||
expect(onPick).toHaveBeenCalledWith(null);
|
||||
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_2",
|
||||
rowId: "asset:asset_2",
|
||||
rowKind: "asset",
|
||||
x: 56,
|
||||
y: 78,
|
||||
});
|
||||
expect(onFileTreeSelectionChange).toHaveBeenCalledWith({
|
||||
selectedRowIds: ["doc:doc_2", "index:doc_2"],
|
||||
anchorRowId: "doc:doc_2",
|
||||
focusedRowId: "index:doc_2",
|
||||
});
|
||||
expect(onAssetOpen).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_2",
|
||||
});
|
||||
expect(onTreeMutation).toHaveBeenCalledWith({
|
||||
type: "tree.node.renamed",
|
||||
documentId: "doc_2",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.internal-drop",
|
||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||
copy: true,
|
||||
targetRow,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.drop-files",
|
||||
documentId: "doc_target",
|
||||
targetRow,
|
||||
files: [droppedFile],
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||
targetRow,
|
||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||
copy: true,
|
||||
});
|
||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TreeShellHostMode } from "@/components/sidebar/tree-shell-host";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
|
||||
type TreeShellDocRow = Extract<FileTreeRow, { kind: "doc" }>;
|
||||
type TreeShellIndexRow = Extract<FileTreeRow, { kind: "index" }>;
|
||||
type TreeShellAssetFolderRow = Extract<FileTreeRow, { kind: "asset-folder" }>;
|
||||
type TreeShellAssetRow = Extract<FileTreeRow, { kind: "asset" }>;
|
||||
|
||||
type TreeShellBridgeContextMenuPayload = {
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
rowId: string | null;
|
||||
rowKind: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type TreeShellBridgeMutationPayload = {
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
};
|
||||
|
||||
type TreeShellBridgeSelectionPayload = {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
};
|
||||
|
||||
export type TreeShellPickerItem =
|
||||
| { kind: "root"; id: null; title: string; subtitle?: string; depth?: number }
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
|
||||
|
||||
type TreeShellInlineProjectionItem = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
title: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
position: number;
|
||||
expandedByDefault: boolean;
|
||||
};
|
||||
|
||||
export type TreeShellIframeHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
pickerItems?: TreeShellPickerItem[];
|
||||
fileTreeRows?: FileTreeRow[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onPick?: (targetId: string | null) => void;
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
|
||||
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellBridgeMutationPayload) => void;
|
||||
};
|
||||
|
||||
type TreeShellBridgeMessage = {
|
||||
channel: string;
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
rowId: string | null;
|
||||
rowKind: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
rowIds: string[];
|
||||
copy: boolean;
|
||||
targetRow: FileTreeRow | null;
|
||||
files: File[];
|
||||
};
|
||||
|
||||
const INLINE_TREE_SHELL_LOADING_HTML = [
|
||||
"<!doctype html>",
|
||||
'<html lang="zh-CN">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
" <title>Tree Shell Loading</title>",
|
||||
" <style>",
|
||||
" body { margin: 0; font-family: \"Noto Sans CJK SC\", \"Source Han Sans SC\", sans-serif; background: #f8fafc; color: #475569; }",
|
||||
" main { min-height: 100vh; display: grid; place-items: center; }",
|
||||
" p { margin: 0; font-size: 13px; }",
|
||||
" </style>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <main><p>正在加载树结果…</p></main>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("");
|
||||
|
||||
const normalizeString = (value: unknown, fallback = "") => {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const normalized = value.trim();
|
||||
return normalized || fallback;
|
||||
};
|
||||
|
||||
const readNullableString = (value: unknown) => {
|
||||
const normalized = normalizeString(value);
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const readNumber = (value: unknown) => {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const readBoolean = (value: unknown) => value === true;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return Boolean(value) && typeof value === "object";
|
||||
};
|
||||
|
||||
const readStringArray = (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => normalizeString(item))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const readFiles = (value: unknown) => {
|
||||
if (typeof FileList !== "undefined" && value instanceof FileList) {
|
||||
return Array.from(value);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is File => typeof File !== "undefined" && item instanceof File);
|
||||
};
|
||||
|
||||
const readFileTreeRow = (value: unknown): FileTreeRow | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = normalizeString(value.kind);
|
||||
const rowId = normalizeString(value.rowId);
|
||||
const docId = normalizeString(value.docId);
|
||||
const depth = Math.max(0, readNumber(value.depth));
|
||||
|
||||
if (!kind || !rowId || !docId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "doc":
|
||||
if (!isRecord(value.node)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "doc",
|
||||
rowId: rowId as FileTreeRow["rowId"],
|
||||
depth,
|
||||
docId,
|
||||
parentDocId: readNullableString(value.parentDocId),
|
||||
node: value.node as TreeShellDocRow["node"],
|
||||
hasChildren: readBoolean(value.hasChildren),
|
||||
isExpanded: readBoolean(value.isExpanded),
|
||||
};
|
||||
case "index":
|
||||
if (!isRecord(value.node)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "index",
|
||||
rowId: rowId as FileTreeRow["rowId"],
|
||||
depth,
|
||||
docId,
|
||||
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||
node: value.node as TreeShellIndexRow["node"],
|
||||
};
|
||||
case "asset-folder":
|
||||
if (!isRecord(value.asset)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "asset-folder",
|
||||
rowId: rowId as FileTreeRow["rowId"],
|
||||
depth,
|
||||
docId,
|
||||
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||
asset: value.asset as TreeShellAssetFolderRow["asset"],
|
||||
hasChildren: readBoolean(value.hasChildren),
|
||||
isExpanded: readBoolean(value.isExpanded),
|
||||
};
|
||||
case "asset":
|
||||
if (!isRecord(value.asset)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "asset",
|
||||
rowId: rowId as FileTreeRow["rowId"],
|
||||
depth,
|
||||
docId,
|
||||
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||
asset: value.asset as TreeShellAssetRow["asset"],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function parseTreeShellBridgeMessage(
|
||||
value: unknown,
|
||||
expectedChannel: string,
|
||||
): TreeShellBridgeMessage | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const payload = value as Record<string, unknown>;
|
||||
const channel = normalizeString(payload.channel);
|
||||
const type = normalizeString(payload.type);
|
||||
if (!channel || !type || channel !== expectedChannel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
type,
|
||||
documentId: readNullableString(payload.documentId ?? payload.docId),
|
||||
assetId: readNullableString(payload.assetId),
|
||||
rowId: readNullableString(payload.rowId ?? payload.targetRowId),
|
||||
rowKind: readNullableString(payload.rowKind ?? payload.targetRowKind),
|
||||
x: readNumber(payload.x),
|
||||
y: readNumber(payload.y),
|
||||
selectedRowIds: readStringArray(payload.selectedRowIds),
|
||||
anchorRowId: readNullableString(payload.anchorRowId),
|
||||
focusedRowId: readNullableString(payload.focusedRowId),
|
||||
rowIds: readStringArray(payload.rowIds),
|
||||
copy: readBoolean(payload.copy),
|
||||
targetRow: readFileTreeRow(payload.targetRow),
|
||||
files: readFiles(payload.files),
|
||||
};
|
||||
}
|
||||
|
||||
const escapeInlineScriptJson = (input: string) => {
|
||||
return input
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e");
|
||||
};
|
||||
|
||||
export function buildTreeShellInlinePickerItems(
|
||||
items: TreeShellPickerItem[],
|
||||
): TreeShellInlineProjectionItem[] {
|
||||
return items
|
||||
.filter(
|
||||
(item): item is Extract<TreeShellPickerItem, { kind: "doc"; id: string }> =>
|
||||
item.kind === "doc" && Boolean(normalizeString(item.id)),
|
||||
)
|
||||
.map((item, index) => ({
|
||||
nodeId: normalizeString(item.id),
|
||||
parentNodeId: null,
|
||||
title: normalizeString(item.title, "无标题"),
|
||||
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
|
||||
childCount: 0,
|
||||
position: index,
|
||||
expandedByDefault: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function injectTreeShellInlineOverrides(
|
||||
html: string,
|
||||
overrides: { items?: TreeShellInlineProjectionItem[] },
|
||||
) {
|
||||
const payloadJson = escapeInlineScriptJson(JSON.stringify(overrides));
|
||||
const scriptTag = `<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ = ${payloadJson};</script>`;
|
||||
const anchor = '<script id="tree-shell-state"';
|
||||
if (html.includes(anchor)) {
|
||||
return html.replace(anchor, `${scriptTag}${anchor}`);
|
||||
}
|
||||
return `${scriptTag}${html}`;
|
||||
}
|
||||
|
||||
export function buildTreeShellIframeSrc(input: {
|
||||
mode: TreeShellHostMode;
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
channel: string;
|
||||
host: string;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("workspaceId", input.workspaceId);
|
||||
params.set("mode", input.mode);
|
||||
params.set("channel", input.channel);
|
||||
params.set("host", input.host);
|
||||
|
||||
const rootNodeId = normalizeString(input.rootNodeId);
|
||||
if (rootNodeId) {
|
||||
params.set("rootNodeId", rootNodeId);
|
||||
}
|
||||
|
||||
const activeDocumentId = normalizeString(input.activeDocumentId);
|
||||
if (activeDocumentId) {
|
||||
params.set("activeDocumentId", activeDocumentId);
|
||||
}
|
||||
|
||||
if (input.allowRootPick) {
|
||||
params.set("allowRootPick", "1");
|
||||
}
|
||||
|
||||
const excludeIds = (input.excludeIds ?? [])
|
||||
.map((item) => normalizeString(item))
|
||||
.filter(Boolean);
|
||||
if (excludeIds.length > 0) {
|
||||
params.set("excludeIds", excludeIds.join(","));
|
||||
}
|
||||
|
||||
return `/api/tree/shell?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function TreeShellIframeHost({
|
||||
mode,
|
||||
surfaceTestId,
|
||||
workspaceId,
|
||||
rootNodeId = null,
|
||||
activeDocumentId = null,
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
pickerItems = [],
|
||||
fileTreeRows = [],
|
||||
channel,
|
||||
host,
|
||||
onNavigate,
|
||||
onPick,
|
||||
onPageContextMenu,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onInternalDrop,
|
||||
onDropFiles,
|
||||
onAssetOpen,
|
||||
onTreeMutation,
|
||||
}: TreeShellIframeHostProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [inlineSrcDoc, setInlineSrcDoc] = useState<string | null>(null);
|
||||
const [inlineLoadFailed, setInlineLoadFailed] = useState(false);
|
||||
const resolvedChannel = channel?.trim() || surfaceTestId;
|
||||
const resolvedHost = host?.trim() || surfaceTestId;
|
||||
const fileTreeRowById = useMemo(
|
||||
() => new Map(fileTreeRows.map((row) => [row.rowId, row])),
|
||||
[fileTreeRows],
|
||||
);
|
||||
const inlinePickerItems = useMemo(
|
||||
() => (mode === "picker" ? buildTreeShellInlinePickerItems(pickerItems) : []),
|
||||
[mode, pickerItems],
|
||||
);
|
||||
const useInlinePickerOverride = inlinePickerItems.length > 0;
|
||||
const src = useMemo(
|
||||
() =>
|
||||
buildTreeShellIframeSrc({
|
||||
mode,
|
||||
workspaceId,
|
||||
rootNodeId,
|
||||
activeDocumentId,
|
||||
allowRootPick,
|
||||
excludeIds,
|
||||
channel: resolvedChannel,
|
||||
host: resolvedHost,
|
||||
}),
|
||||
[
|
||||
activeDocumentId,
|
||||
allowRootPick,
|
||||
excludeIds,
|
||||
mode,
|
||||
resolvedChannel,
|
||||
resolvedHost,
|
||||
rootNodeId,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useInlinePickerOverride) {
|
||||
setInlineSrcDoc(null);
|
||||
setInlineLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setInlineLoadFailed(false);
|
||||
setInlineSrcDoc(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(src, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`tree shell inline override fetch failed: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setInlineSrcDoc(
|
||||
injectTreeShellInlineOverrides(html, {
|
||||
items: inlinePickerItems,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setInlineLoadFailed(true);
|
||||
setInlineSrcDoc(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [inlinePickerItems, src, useInlinePickerOverride]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseTreeShellBridgeMessage(event.data, resolvedChannel);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "tree.navigate":
|
||||
if (message.documentId) {
|
||||
onNavigate?.(message.documentId);
|
||||
}
|
||||
return;
|
||||
case "tree.pick":
|
||||
onPick?.(message.documentId);
|
||||
return;
|
||||
case "tree.pick.root":
|
||||
onPick?.(null);
|
||||
return;
|
||||
case "tree.page.context-menu":
|
||||
if (message.documentId) {
|
||||
onPageContextMenu?.({
|
||||
documentId: message.documentId,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "tree.filetree.context-menu":
|
||||
onFileTreeContextMenu?.({
|
||||
documentId: message.documentId,
|
||||
assetId: message.assetId,
|
||||
rowId: message.rowId,
|
||||
rowKind: message.rowKind,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
});
|
||||
return;
|
||||
case "tree.filetree.selection.changed":
|
||||
onFileTreeSelectionChange?.({
|
||||
selectedRowIds: message.selectedRowIds,
|
||||
anchorRowId: message.anchorRowId,
|
||||
focusedRowId: message.focusedRowId,
|
||||
});
|
||||
return;
|
||||
case "tree.filetree.internal-drop":
|
||||
if (message.rowIds.length > 0) {
|
||||
const targetRow =
|
||||
message.targetRow ??
|
||||
(message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null);
|
||||
if (!targetRow) {
|
||||
return;
|
||||
}
|
||||
onInternalDrop?.({
|
||||
targetRow,
|
||||
rowIds: message.rowIds,
|
||||
copy: message.copy,
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "tree.filetree.external-drop":
|
||||
case "tree.filetree.drop-files":
|
||||
if (message.files.length > 0) {
|
||||
const targetRow =
|
||||
message.targetRow ??
|
||||
(message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null);
|
||||
const targetDocId = message.documentId ?? targetRow?.docId ?? null;
|
||||
if (!targetDocId) {
|
||||
return;
|
||||
}
|
||||
onDropFiles?.(targetDocId, message.files, targetRow ?? undefined);
|
||||
}
|
||||
return;
|
||||
case "tree.asset.open":
|
||||
if (message.assetId) {
|
||||
onAssetOpen?.({
|
||||
assetId: message.assetId,
|
||||
documentId: message.documentId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "tree.node.created":
|
||||
case "tree.node.renamed":
|
||||
case "tree.subtree.moved":
|
||||
onTreeMutation?.({
|
||||
type: message.type,
|
||||
documentId: message.documentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
};
|
||||
}, [
|
||||
onAssetOpen,
|
||||
fileTreeRowById,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onInternalDrop,
|
||||
onDropFiles,
|
||||
onNavigate,
|
||||
onPageContextMenu,
|
||||
onPick,
|
||||
onTreeMutation,
|
||||
resolvedChannel,
|
||||
]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={useInlinePickerOverride && !inlineLoadFailed ? undefined : src}
|
||||
srcDoc={useInlinePickerOverride && !inlineLoadFailed ? inlineSrcDoc ?? INLINE_TREE_SHELL_LOADING_HTML : undefined}
|
||||
title={`tree-shell-${mode}`}
|
||||
data-testid={`${surfaceTestId}-rust-iframe`}
|
||||
data-tree-shell-mode={mode}
|
||||
data-tree-shell-channel={resolvedChannel}
|
||||
data-tree-shell-inline={useInlinePickerOverride && !inlineLoadFailed ? "1" : "0"}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("@/components/sidebar/private-tree", () => ({
|
||||
PrivateTree: () => <div data-testid="private-tree-fallback" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/file-tree", () => ({
|
||||
FileTree: () => <div data-testid="file-tree-fallback" />,
|
||||
}));
|
||||
|
||||
describe("tree-shell-surface", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function renderPageSurface(rendererFamily: TreeRendererFamily) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
rendererFamily={rendererFamily}
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
rows={[]}
|
||||
expanded={new Set<string>()}
|
||||
activeId=""
|
||||
onToggleExpand={() => undefined}
|
||||
onMove={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("page tree surface 在 rust_family 下可切到同源 iframe host", () => {
|
||||
renderPageSurface("rust_family");
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||
const rustHost = container.querySelector(
|
||||
'[data-testid="sidebar-page-tree-shell-rust-host"]',
|
||||
);
|
||||
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||
|
||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("page tree 在禁用 tree shell 时应安全回退到 React fallback", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled={false}
|
||||
rows={[]}
|
||||
expanded={new Set<string>()}
|
||||
activeId=""
|
||||
onToggleExpand={() => undefined}
|
||||
onMove={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
rows={[]}
|
||||
activeId=""
|
||||
selectedRowIds={new Set<string>()}
|
||||
onRowClick={() => undefined}
|
||||
onRowDoubleClick={() => undefined}
|
||||
onRowContextMenu={() => undefined}
|
||||
onToggleExpand={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||
const rustHost = container.querySelector(
|
||||
'[data-testid="sidebar-file-tree-shell-rust-host"]',
|
||||
);
|
||||
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
||||
|
||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-tree-fallback"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
||||
const onInternalDrop = vi.fn();
|
||||
const onDropFiles = vi.fn();
|
||||
const targetRow = {
|
||||
kind: "doc",
|
||||
rowId: "doc:doc_target",
|
||||
depth: 0,
|
||||
docId: "doc_target",
|
||||
parentDocId: null,
|
||||
node: {
|
||||
_id: "doc_target",
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
},
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
rows={[targetRow]}
|
||||
activeId=""
|
||||
selectedRowIds={new Set<string>()}
|
||||
onRowClick={() => undefined}
|
||||
onRowDoubleClick={() => undefined}
|
||||
onRowContextMenu={() => undefined}
|
||||
onToggleExpand={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
onInternalDrop={onInternalDrop}
|
||||
onDropFiles={onDropFiles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const iframe = container.querySelector(
|
||||
'[data-testid="sidebar-file-tree-shell-rust-iframe"]',
|
||||
);
|
||||
expect(iframe).not.toBeNull();
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
value: window,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.internal-drop",
|
||||
rowIds: ["doc:doc_source"],
|
||||
copy: false,
|
||||
targetRow,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.external-drop",
|
||||
documentId: "doc_target",
|
||||
targetRow,
|
||||
files: [droppedFile],
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||
targetRow,
|
||||
rowIds: ["doc:doc_source"],
|
||||
copy: false,
|
||||
});
|
||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||
});
|
||||
|
||||
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
||||
const onPick = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreePickerSurface
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
|
||||
highlighted={0}
|
||||
onHighlight={() => undefined}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
|
||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("picker 在 rust_family 但 tree shell 不可用时仍应保留 React fallback", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TreePickerSurface
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled={false}
|
||||
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
|
||||
highlighted={0}
|
||||
emptyText="没有匹配结果"
|
||||
onHighlight={() => undefined}
|
||||
onPick={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||
const row = container.querySelector('[data-testid="tree-picker-row"]');
|
||||
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(row).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,19 @@
|
||||
import type { DragEvent, MouseEvent } from "react";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { TreeShellHost, type TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type { TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
||||
|
||||
type SidebarPageTreeSurfaceProps = {
|
||||
mode: "page";
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
rows: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
@@ -18,10 +24,16 @@ type SidebarPageTreeSurfaceProps = {
|
||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
};
|
||||
|
||||
type SidebarFileTreeSurfaceProps = {
|
||||
mode: "filetree";
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
rows: FileTreeRow[];
|
||||
activeId: string;
|
||||
selectedRowIds: Set<string>;
|
||||
@@ -34,8 +46,24 @@ type SidebarFileTreeSurfaceProps = {
|
||||
onToggleAssetFolderExpand?: (assetId: string) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onBlankMouseDown?: (event: MouseEvent) => void;
|
||||
onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
|
||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onFileTreeContextMenu?: (payload: {
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
rowId: string | null;
|
||||
rowKind: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => void;
|
||||
onFileTreeSelectionChange?: (payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
};
|
||||
|
||||
export type SidebarTreeSurfaceProps =
|
||||
@@ -47,6 +75,13 @@ export type TreePickerSurfaceItem =
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
|
||||
|
||||
type TreePickerSurfaceProps = {
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
activeDocumentId?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
treeShellItems?: TreePickerSurfaceItem[];
|
||||
items: TreePickerSurfaceItem[];
|
||||
highlighted: number;
|
||||
className?: string;
|
||||
@@ -60,48 +95,71 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||
props.mode === "page"
|
||||
? "sidebar-page-tree-shell"
|
||||
: "sidebar-file-tree-shell";
|
||||
const rendererFamily = props.rendererFamily ?? "react";
|
||||
const fallbackContent =
|
||||
props.mode === "page" ? (
|
||||
<PrivateTree
|
||||
rows={props.rows}
|
||||
expanded={props.expanded}
|
||||
activeId={props.activeId}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onMove={props.onMove}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onContextMenu={props.onContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<FileTree
|
||||
rows={props.rows}
|
||||
activeId={props.activeId}
|
||||
selectedRowIds={props.selectedRowIds}
|
||||
onRowClick={props.onRowClick}
|
||||
onRowDoubleClick={props.onRowDoubleClick}
|
||||
onRowContextMenu={props.onRowContextMenu}
|
||||
onRowDragStart={props.onRowDragStart}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onBlankMouseDown={props.onBlankMouseDown}
|
||||
onDropFiles={props.onDropFiles}
|
||||
onInternalDrop={props.onInternalDrop}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={surfaceTestId}
|
||||
data-shell-mode={props.mode}
|
||||
<TreeShellHost
|
||||
mode={props.mode}
|
||||
surfaceTestId={surfaceTestId}
|
||||
rendererFamily={rendererFamily}
|
||||
treeShellEnabled={props.treeShellEnabled}
|
||||
workspaceId={props.workspaceId}
|
||||
activeDocumentId={props.activeId}
|
||||
fileTreeRows={props.mode === "filetree" ? props.rows : undefined}
|
||||
onNavigate={props.onNavigate}
|
||||
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
|
||||
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
|
||||
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
|
||||
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
||||
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
|
||||
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
|
||||
onTreeMutation={props.onTreeMutation}
|
||||
className={cn(
|
||||
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.mode === "page" ? (
|
||||
<PrivateTree
|
||||
rows={props.rows}
|
||||
expanded={props.expanded}
|
||||
activeId={props.activeId}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onMove={props.onMove}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onContextMenu={props.onContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<FileTree
|
||||
rows={props.rows}
|
||||
activeId={props.activeId}
|
||||
selectedRowIds={props.selectedRowIds}
|
||||
onRowClick={props.onRowClick}
|
||||
onRowDoubleClick={props.onRowDoubleClick}
|
||||
onRowContextMenu={props.onRowContextMenu}
|
||||
onRowDragStart={props.onRowDragStart}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onBlankMouseDown={props.onBlankMouseDown}
|
||||
onDropFiles={props.onDropFiles}
|
||||
onInternalDrop={props.onInternalDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{fallbackContent}
|
||||
</TreeShellHost>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreePickerSurface({
|
||||
rendererFamily = "react",
|
||||
workspaceId,
|
||||
treeShellEnabled = true,
|
||||
activeDocumentId = null,
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
treeShellItems,
|
||||
items,
|
||||
highlighted,
|
||||
className,
|
||||
@@ -109,42 +167,53 @@ export function TreePickerSurface({
|
||||
onHighlight,
|
||||
onPick,
|
||||
}: TreePickerSurfaceProps) {
|
||||
if (items.length === 0) {
|
||||
return <div className="p-4 text-sm text-gray-400">{emptyText}</div>;
|
||||
}
|
||||
const hasItems = items.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="tree-picker-surface"
|
||||
className={cn("py-2", className)}
|
||||
<TreeShellHost
|
||||
mode="picker"
|
||||
surfaceTestId="tree-picker-surface"
|
||||
rendererFamily={rendererFamily}
|
||||
treeShellEnabled={treeShellEnabled}
|
||||
workspaceId={workspaceId}
|
||||
activeDocumentId={activeDocumentId}
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
pickerItems={treeShellItems}
|
||||
onPick={onPick}
|
||||
className={cn(hasItems ? "py-2" : null, className)}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isRoot = item.kind === "root";
|
||||
return (
|
||||
<button
|
||||
key={isRoot ? "root" : item.id}
|
||||
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
|
||||
data-node-id={isRoot ? "" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
index === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => onHighlight(index)}
|
||||
onClick={() => onPick(item.id)}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
{!hasItems ? (
|
||||
<div className="p-4 text-sm text-gray-400">{emptyText}</div>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const isRoot = item.kind === "root";
|
||||
return (
|
||||
<button
|
||||
key={isRoot ? "root" : item.id}
|
||||
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
|
||||
data-node-id={isRoot ? "" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
index === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => onHighlight(index)}
|
||||
onClick={() => onPick(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle ? (
|
||||
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle ? (
|
||||
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TreeShellHost>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
@@ -19,6 +20,7 @@ export interface SidebarInitialData {
|
||||
documents: DocumentRecord[];
|
||||
kernelSidebarProjection: KernelSidebarProjection;
|
||||
kernelSidebarTree: SidebarTreeNode[];
|
||||
kernelFileTreeProjection: KernelFileTreeProjection;
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
|
||||
@@ -39,6 +39,7 @@ function Harness(props: {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQueryData: SidebarInitialData;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
||||
}) {
|
||||
const state = usePreferredSidebarSnapshot(props);
|
||||
@@ -71,7 +72,55 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("tree stream 落后于 query refetch 时应优先使用更新后的 query 快照", async () => {
|
||||
it("tree stream live 时即使 query 更新也应继续优先使用 stream 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={initialData}
|
||||
treeStreamData={staleTreeStream}
|
||||
treeStreamStatus="live"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={staleTreeStream}
|
||||
treeStreamStatus="live"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
@@ -87,30 +136,13 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={initialData}
|
||||
treeStreamData={staleTreeStream}
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={staleTreeStream}
|
||||
treeStreamStatus="fallback"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
@@ -129,6 +161,7 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={caughtUpTreeStream}
|
||||
treeStreamStatus="live"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import {
|
||||
buildSidebarDataSyncKey,
|
||||
getSidebarDataFreshness,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||
|
||||
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
||||
|
||||
@@ -11,6 +8,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
}) {
|
||||
const querySyncKey = useMemo(
|
||||
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
||||
@@ -21,23 +19,10 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||
const queryFreshness = useMemo(
|
||||
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : Number.NEGATIVE_INFINITY),
|
||||
[input.sidebarQueryData],
|
||||
);
|
||||
const treeStreamFreshness = useMemo(
|
||||
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : Number.NEGATIVE_INFINITY),
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const streamIsPreferred = input.treeStreamStatus !== "fallback";
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (input.treeStreamData && input.sidebarQueryData) {
|
||||
if (treeStreamSyncKey === querySyncKey) {
|
||||
return "tree_stream";
|
||||
}
|
||||
return queryFreshness > treeStreamFreshness ? "query" : "tree_stream";
|
||||
}
|
||||
if (input.treeStreamData) {
|
||||
if (input.treeStreamData && streamIsPreferred) {
|
||||
return "tree_stream";
|
||||
}
|
||||
if (input.sidebarQueryData) {
|
||||
@@ -47,10 +32,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
}, [
|
||||
input.sidebarQueryData,
|
||||
input.treeStreamData,
|
||||
queryFreshness,
|
||||
querySyncKey,
|
||||
treeStreamFreshness,
|
||||
treeStreamSyncKey,
|
||||
streamIsPreferred,
|
||||
]);
|
||||
|
||||
const data =
|
||||
@@ -61,7 +43,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
|
||||
const syncKey =
|
||||
source === "tree_stream"
|
||||
? treeStreamSyncKey ?? initialSyncKey
|
||||
? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey
|
||||
: source === "query"
|
||||
? querySyncKey ?? initialSyncKey
|
||||
: initialSyncKey;
|
||||
|
||||
Reference in New Issue
Block a user