4-10 树域 Rust 家族化

This commit is contained in:
lix-2026
2026-04-24 06:10:18 +08:00
parent 41e958769e
commit 94631f3636
49 changed files with 5751 additions and 557 deletions
@@ -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;