feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -2,10 +2,14 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import React, { type ReactNode } from "react";
import type { Mock } from "vitest";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type SidebarFixtureNode = SidebarTreeNode;
const sidebarData = {
activeWorkspaceId: "ws_test",
workspaces: [],
@@ -17,15 +21,15 @@ const sidebarData = {
items: [],
edges: [],
},
kernelSidebarTree: [],
kernelSidebarTree: [] as SidebarFixtureNode[],
trashedDocuments: [],
};
function buildSidebarNode(input: {
id: string;
title: string;
children?: Array<ReturnType<typeof buildSidebarNode>>;
}) {
children?: SidebarFixtureNode[];
}): SidebarFixtureNode {
return {
access_scope: "private",
id: input.id,
@@ -66,12 +70,89 @@ vi.mock("@tanstack/react-query", () => ({
},
}));
const mockUseDocumentSearch = vi.fn(() => ({
const mockUseDocumentSearch: Mock = vi.fn(() => ({
data: null,
isLoading: false,
error: null,
}));
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reducePickerRuntimeForTest(request: RuntimeRequest) {
const action = request.action ?? {};
const env = request.environment ?? {};
const items = Array.isArray(env.items) ? env.items as Array<Record<string, unknown>> : [];
const state = request.state ?? {};
const pickable = items.filter((item) => item.pickable !== false);
const activeItemKey = typeof state.activeItemKey === "string" ? state.activeItemKey : null;
const activeIndex = Math.max(0, pickable.findIndex((item) => item.itemKey === activeItemKey));
let nextItemKey = activeItemKey;
if (action.kind === "focus") {
nextItemKey = typeof action.itemKey === "string" ? action.itemKey : null;
} else if (action.kind === "next") {
nextItemKey = String(pickable[Math.min(pickable.length - 1, activeIndex + 1)]?.itemKey ?? "");
} else if (action.kind === "previous") {
nextItemKey = String(pickable[Math.max(0, activeIndex - 1)]?.itemKey ?? "");
} else if (action.kind === "home") {
nextItemKey = String(pickable[0]?.itemKey ?? "");
} else if (action.kind === "end") {
nextItemKey = String(pickable[pickable.length - 1]?.itemKey ?? "");
}
const nextState = { activeItemKey: nextItemKey || null };
const hostEvents =
action.kind === "pick"
? activeItemKey === "__root__"
? [{ kind: "pickerPickRoot" }]
: activeItemKey
? [{ kind: "pickerPickDocument", documentId: activeItemKey }]
: []
: [];
return {
requestId: request.requestId,
mode: "picker",
state: { mode: "picker", state: nextState },
domPatches: [{ kind: "pickerState", activeItemKey: nextState.activeItemKey, focusDom: false }],
hostEvents,
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reducePickerRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
vi.mock("@/hooks/use-document-search", () => ({
useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args),
}));
@@ -142,6 +223,7 @@ describe("MoveEmbedPickerDialog", () => {
error: null,
});
sidebarData.kernelSidebarTree = [];
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
delete window.__MNOTE_RUNTIME_CONFIG__;
});
@@ -438,6 +520,7 @@ describe("MoveEmbedPickerDialog", () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
mockTreeRuntimeReducer();
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
@@ -458,13 +541,15 @@ describe("MoveEmbedPickerDialog", () => {
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("rust_inline_compat_host");
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
container.querySelector('[data-testid="tree-picker-surface-dom-host"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
mockUseDocumentSearch.mockReturnValue({
data: {
@@ -494,19 +579,22 @@ describe("MoveEmbedPickerDialog", () => {
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("rust_inline_compat_host");
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
container.querySelector('[data-testid="tree-picker-surface-dom-host"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_target"]')).not.toBeNull();
});
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => {
it("rust_family 配置下,输入框键盘命令应驱动 DOM shell 高亮并选中当前项", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
const runtimeReducerMock = mockTreeRuntimeReducer();
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
@@ -525,23 +613,19 @@ describe("MoveEmbedPickerDialog", () => {
isLoading: false,
error: null,
});
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={vi.fn()}
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={vi.fn(async () => undefined)}
onPick={onPick}
/>,
);
});
@@ -558,80 +642,41 @@ describe("MoveEmbedPickerDialog", () => {
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(iframe).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined);
postMessageMock.mockClear();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-dom-host"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_first"]')?.getAttribute("data-focused"),
).toBe("true");
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await Promise.resolve();
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(postMessageMock).toHaveBeenCalledWith(
expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "next",
mode: "picker",
action: expect.objectContaining({ kind: "next" }),
}),
"*",
);
postMessageMock.mockClear();
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "tree-picker-surface",
type: "tree.picker.focus.changed",
documentId: "doc_second",
itemKey: "doc_second",
},
source: window,
}),
);
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.shell.state.patch",
activeDocumentId: "doc_second",
activePickerItemKey: "doc_second",
}),
"*",
);
postMessageMock.mockClear();
expect(
container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_second"]')?.getAttribute("data-focused"),
).toBe("true");
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "pick",
}),
"*",
);
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => {
it("rust_family 配置下,无根目录且无结果时也应保持 DOM shell 空态", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
mockTreeRuntimeReducer();
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
@@ -655,12 +700,14 @@ describe("MoveEmbedPickerDialog", () => {
});
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.textContent).toContain("没有匹配结果");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
"use client";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
export type TreeShellPickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string; depth?: number }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
export type TreeShellDomProjectionItem = {
rowId?: string;
nodeId: string;
parentNodeId: string | null;
title: string;
depth: number;
childCount: number;
position: number;
expandedByDefault: boolean;
rowKind?: string;
iconHint?: string;
capabilities?: string[];
resourceMeta?: {
resourceKind?: string;
documentId?: string;
assetId?: string;
workspaceId?: string;
assetKind?: string;
iconHint?: string;
};
};
const normalizeString = (value: unknown, fallback = "") => {
if (typeof value !== "string") {
return fallback;
}
const trimmed = value.trim();
return trimmed || fallback;
};
export function buildTreeShellDomPickerItems(
items: TreeShellPickerItem[],
): TreeShellDomProjectionItem[] {
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 buildTreeShellDomPageItems(
items: PageTreeProjectionItem[],
expanded: ReadonlySet<string> = new Set(),
): TreeShellDomProjectionItem[] {
return items
.map((item) => ({
rowId: normalizeString(item.rowId),
nodeId: normalizeString(item.nodeId),
parentNodeId: item.parentNodeId ?? null,
title: normalizeString(item.title, "无标题"),
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
childCount:
typeof item.childCount === "number" && Number.isFinite(item.childCount)
? Math.max(0, item.childCount)
: 0,
position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0,
expandedByDefault: item.childCount > 0 ? expanded.has(item.nodeId) : false,
rowKind: "document",
iconHint: normalizeString(item.iconHint, "page"),
capabilities: Array.isArray(item.capabilities)
? item.capabilities.map((capability) => normalizeString(capability)).filter(Boolean)
: [],
resourceMeta: {
resourceKind: normalizeString(item.resourceMeta?.resourceKind, "document"),
documentId: normalizeString(item.resourceMeta?.documentId ?? item.nodeId),
workspaceId: normalizeString(item.resourceMeta?.workspaceId),
iconHint: normalizeString(item.resourceMeta?.iconHint, "page"),
},
}))
.filter((item) => Boolean(item.nodeId));
}
export function buildTreeShellDomKernelFileTreeItems(
items: KernelFileTreeProjectionItem[],
): TreeShellDomProjectionItem[] {
return items
.map((item) => ({
rowId: normalizeString(item.rowId),
nodeId: normalizeString(item.nodeId),
parentNodeId: item.parentNodeId ?? null,
title: normalizeString(item.title, "无标题"),
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
childCount:
typeof item.childCount === "number" && Number.isFinite(item.childCount)
? Math.max(0, item.childCount)
: 0,
position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0,
expandedByDefault: item.expandedByDefault === true,
rowKind: normalizeString(item.rowKind, "document"),
iconHint: normalizeString(item.iconHint, "page"),
capabilities: Array.isArray(item.capabilities)
? item.capabilities.map((capability) => normalizeString(capability)).filter(Boolean)
: [],
resourceMeta: {
resourceKind: normalizeString(item.resourceMeta?.resourceKind),
documentId: normalizeString(item.resourceMeta?.documentId),
assetId: normalizeString(item.resourceMeta?.assetId),
workspaceId: normalizeString(item.resourceMeta?.workspaceId),
assetKind: normalizeString(item.resourceMeta?.assetKind),
iconHint: normalizeString(item.resourceMeta?.iconHint, normalizeString(item.iconHint, "page")),
},
}))
.filter((item) => Boolean(item.nodeId));
}
export function buildTreeShellDomChildrenByParent(items: TreeShellDomProjectionItem[]) {
const ids = new Set(items.map((item) => item.nodeId));
const childrenByParent = new Map<string, TreeShellDomProjectionItem[]>();
for (const item of items) {
const parentId = item.parentNodeId && ids.has(item.parentNodeId) ? item.parentNodeId : "";
const bucket = childrenByParent.get(parentId) ?? [];
bucket.push(item);
childrenByParent.set(parentId, bucket);
}
for (const bucket of childrenByParent.values()) {
bucket.sort((left, right) => {
const byPosition = left.position - right.position;
if (byPosition !== 0) return byPosition;
return left.title.localeCompare(right.title, "zh-CN");
});
}
return childrenByParent;
}
export function buildTreeShellDomFiletreeSelection(activeDocumentId?: string | null) {
const documentId = normalizeString(activeDocumentId);
if (!documentId) {
return {
selectedRowIds: [] as string[],
anchorRowId: null as string | null,
focusedRowId: null as string | null,
};
}
const documentRowId = `doc:${documentId}`;
return {
selectedRowIds: [documentRowId, `index:${documentId}`],
anchorRowId: documentRowId,
focusedRowId: documentRowId,
};
}
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TreeShellHost } from "./tree-shell-host";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("tree-shell-host", () => {
let container: HTMLDivElement;
let root: Root;
let previousLegacyFlag: string | undefined;
beforeEach(() => {
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
if (previousLegacyFlag === undefined) {
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
} else {
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = previousLegacyFlag;
}
});
function renderHost() {
act(() => {
root.render(
<TreeShellHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
rendererFamily="rust_family"
workspaceId="ws_1"
>
<div data-testid="legacy-react-fallback"> React fallback</div>
</TreeShellHost>,
);
});
}
it("rust_family 默认选择 Rust/WASM DOM shell host,不能进入 iframe_srcdoc", () => {
renderHost();
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const rustHost = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-host"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="legacy-react-fallback"]')).toBeNull();
});
it("只有显式 legacy flag 才允许进入旧 TreeShellIframeHost", async () => {
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = "1";
await act(async () => {
renderHost();
await Promise.resolve();
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_runtime_artifact_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
});
});
@@ -1,9 +1,10 @@
"use client";
import type { ReactNode } from "react";
import { TreeShellRustDomShellHost } from "@/components/sidebar/tree-shell-dom-host";
import type { TreeShellPickerItem } from "@/components/sidebar/tree-shell-dom-model";
import {
TreeShellIframeHost,
type TreeShellPickerItem,
} from "@/components/sidebar/tree-shell-iframe-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
@@ -14,6 +15,8 @@ export type TreeRendererFamily = "react" | "rust_family";
export type TreeShellHostMode = "page" | "filetree" | "picker";
const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1";
const RUST_WASM_DOM_SHELL_HOST = "rust_wasm_dom_shell_host";
const RUST_LEGACY_IFRAME_HOST = "rust_runtime_artifact_host";
export type TreeShellPickerCommand = {
kind: "next" | "previous" | "home" | "end" | "pick";
@@ -118,11 +121,17 @@ export function TreeShellHost({
children,
}: TreeShellHostProps) {
const useRustHost = rendererFamily === "rust_family";
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
const useDomHost = useRustHost && Boolean(workspaceId?.trim());
const useLegacyIframeHost =
useDomHost &&
typeof process !== "undefined" &&
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST === "1";
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined;
const implementation = useIframeHost
? "rust_inline_compat_host"
const implementation = useLegacyIframeHost
? RUST_LEGACY_IFRAME_HOST
: useDomHost
? RUST_WASM_DOM_SHELL_HOST
: fallbackImplementation ??
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
@@ -146,36 +155,70 @@ export function TreeShellHost({
data-tree-renderer-contract={rendererContract}
className="contents"
>
{useIframeHost && workspaceId ? (
<TreeShellIframeHost
mode={mode}
surfaceTestId={surfaceTestId}
workspaceId={workspaceId}
rootNodeId={rootNodeId}
activeDocumentId={activeDocumentId}
focusedDocumentId={focusedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={pickerItems}
pageTreeItems={pageTreeItems}
inlineFileTreeItems={inlineFileTreeItems}
channel={channel}
host={host}
onNavigate={onNavigate}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
onPageContextMenu={onPageContextMenu}
onPageExpandChange={onPageExpandChange}
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
onTreeMutation={onTreeMutation}
/>
{useDomHost && workspaceId ? (
useLegacyIframeHost ? (
<TreeShellIframeHost
mode={mode}
surfaceTestId={surfaceTestId}
workspaceId={workspaceId}
rootNodeId={rootNodeId}
activeDocumentId={activeDocumentId}
focusedDocumentId={focusedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={pickerItems}
pageTreeItems={pageTreeItems}
inlineFileTreeItems={inlineFileTreeItems}
channel={channel}
host={host}
onNavigate={onNavigate}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
onPageContextMenu={onPageContextMenu}
onPageExpandChange={onPageExpandChange}
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
onTreeMutation={onTreeMutation}
/>
) : (
<TreeShellRustDomShellHost
mode={mode}
surfaceTestId={surfaceTestId}
workspaceId={workspaceId}
rootNodeId={rootNodeId}
activeDocumentId={activeDocumentId}
focusedDocumentId={focusedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={pickerItems}
pageTreeItems={pageTreeItems}
inlineFileTreeItems={inlineFileTreeItems}
channel={channel}
host={host}
onNavigate={onNavigate}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
onPageContextMenu={onPageContextMenu}
onPageExpandChange={onPageExpandChange}
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
onTreeMutation={onTreeMutation}
>
{children}
</TreeShellRustDomShellHost>
)
) : (
children
)}
@@ -286,11 +286,52 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeExpansionDom(nodeId)");
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain('"inputFields":["rendererInput","projectionItems","expandedIds","selectedRowIds","activePickerItem","focusedId"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"outputChannels":["domPatch","intentEvent","commandDispatchEvent"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reducePageActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "page"');
expect(iframe?.getAttribute("srcdoc")).toContain("buildPageRuntimeEnvironment");
expect(iframe?.getAttribute("srcdoc")).toContain("rows: normalizedItems.map((entry) => ({nodeId: entry.nodeId,parentNodeId: normalizeText(entry.parentNodeId) || null,position: normalizeNumber(entry.position, 0),}))");
expect(iframe?.getAttribute("srcdoc")).toContain("moveNext");
expect(iframe?.getAttribute("srcdoc")).toContain("openFocused");
expect(iframe?.getAttribute("srcdoc")).toContain("contextMenuFocused");
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropFeedbackForTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchMove");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchMoveToTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("commandEvent.sortOrder");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeRequired: true");
expect(iframe?.getAttribute("srcdoc")).not.toContain("canAcceptPageDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchCreate");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchRename");
expect(iframe?.getAttribute("srcdoc")).toContain("pageOpen");
expect(iframe?.getAttribute("srcdoc")).toContain("replayPageRuntimeHostEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("replayPageRuntimeCommandEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("commandEvents");
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "createNode"');
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "renameNode"');
expect(iframe?.getAttribute("srcdoc")).toContain("dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("pagePatch?.dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("stateSnapshot?.dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("data-drop-feedback");
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "moveSubtree"');
expect(iframe?.getAttribute("srcdoc")).toContain('postToHost("tree.subtree.moved"');
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();syncPageDropFeedbackDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
expect.objectContaining({ nodeId: "doc_parent" }),
expect.objectContaining({ nodeId: "doc_child", parentNodeId: "doc_parent" }),
@@ -465,7 +506,7 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialFileTree");
expect(iframe?.getAttribute("srcdoc")).toContain("const usedRustInitialRenderer = hydrateInitialRenderer();");
expect(iframe?.getAttribute("srcdoc")).toContain("patchFileTreeActiveDom();");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();return;}');
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();syncFileTreeDropTargetDom();return;}');
expect(iframe?.getAttribute("srcdoc")).toContain('"rendererInput"');
expect(iframe?.getAttribute("srcdoc")).toContain('"mode":"fileTree"');
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
@@ -474,11 +515,45 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceFileTreeSelectionActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "fileTree"');
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeArtifact.runtimeApi.reduceEndpoint");
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult(action)");
expect(iframe?.getAttribute("srcdoc")).toContain('kind: "update_drop_target"');
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchInternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchExternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchFileTreeDropWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("fileTreeInternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("fileTreeExternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("const fallbackRowId = firstItem?.rowId || firstDoc || \"\";");
expect(iframe?.getAttribute("srcdoc")).toContain("replayFileTreeRuntimeHostEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeResult && fallbackHostEvent.runtimeRequired === true");
expect(iframe?.getAttribute("srcdoc")).toContain("syncFileTreeDropTargetDom");
expect(iframe?.getAttribute("srcdoc")).toContain("row.dataset.dropTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("commitFileTreeRuntimeState");
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult");
expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>");
const fileTreeFallbackRenderBody =
iframe?.getAttribute("srcdoc")?.match(/const renderFileTree = \(\) => \{(?<body>[\s\S]*?)const renderNode =/)?.groups
?.body ?? "";
expect(fileTreeFallbackRenderBody).toContain(
'toggleButton.addEventListener("click", (event) => {event.stopPropagation();toggleExpand(item.nodeId);});',
);
expect(fileTreeFallbackRenderBody).not.toContain(
'applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);',
);
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
const state = readTreeShellState(iframe?.getAttribute("srcdoc"));
expect(state.items).toEqual([
@@ -532,20 +607,45 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_picker_state_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reducePickerStateActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "picker"');
expect(iframe?.getAttribute("srcdoc")).toContain("buildPickerRuntimeEnvironment");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeArtifact.runtimeApi.reduceEndpoint");
expect(iframe?.getAttribute("srcdoc")).toContain("pickerPickDocument");
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
expect(iframe?.getAttribute("srcdoc")).toContain("getPickablePickerEntries");
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost");
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"');
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"');
expect(iframe?.getAttribute("srcdoc")).toContain('applyPickerFocusByItemKey("__root__", { focusDom: true })');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchPickerPickByItemKeyWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain('dispatchPickerPickByItemKeyWithRuntime("__root__", { focusDom: true })');
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchPickerPickByItemKeyWithRuntime(item.nodeId, { focusDom: true })");
expect(iframe?.getAttribute("srcdoc")).not.toContain('applyPickerFocusByItemKey("__root__", { focusDom: true });applyPickerStateAction({ kind: "pick" })');
expect(iframe?.getAttribute("srcdoc")).not.toContain('applyPickerFocusByItemKey(item.nodeId, { focusDom: true });applyPickerStateAction({ kind: "pick" })');
expect(iframe?.getAttribute("srcdoc")).toContain("const shouldFocusDom = options.focusDom === true");
expect(iframe?.getAttribute("srcdoc")).toContain("if (shouldFocusDom) focusPickerRowElement");
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost(runtimeResult);");
expect(iframe?.getAttribute("srcdoc")).toContain("if (runtimeResult) {reconcilePickerRuntimeResult(runtimeResult, fallbackResult);return;}commitPickerFocusResult(fallbackResult");
expect(iframe?.getAttribute("srcdoc")).toContain("commitPickerFocusResult(focusResult, { focusDom: focusAction.focusDom });");
const bindPickerRootEventsBody =
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRootEvents = \(row\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
?.body ?? "";
expect(bindPickerRootEventsBody).not.toContain("postPickerPickResultToHost(");
const bindPickerRowEventsBody =
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
?.body ?? "";
expect(bindPickerRowEventsBody).not.toContain("handleNavigate(item.nodeId)");
expect(bindPickerRowEventsBody).not.toContain("postPickerPickResultToHost(");
expect(iframe?.getAttribute("srcdoc")).not.toContain("postPickerPickResultToHost(applyPickerStateAction");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
const postMessage = vi.fn();
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,97 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reduceTreeRuntimeForTest(request: RuntimeRequest) {
if (request.mode === "fileTree") {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
const target = {
kind: targetRow.rowKind ?? "doc",
documentId: targetRow.documentId ?? null,
assetId: targetRow.assetId ?? null,
};
const hostEvents =
action.kind === "dispatchInternalDrop"
? [{
kind: "fileTreeInternalDrop",
targetRowId: rowId,
target,
rowIds: Array.isArray(action.rowIds) ? action.rowIds : [],
copy: action.copy === true,
}]
: action.kind === "dispatchExternalDrop"
? [{
kind: "fileTreeExternalDrop",
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
};
}
return {
requestId: request.requestId,
mode: request.mode,
state: { mode: request.mode, state: request.state ?? {} },
domPatches: [],
hostEvents: [],
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reduceTreeRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
describe("tree-shell-surface", () => {
let container: HTMLDivElement;
let root: Root;
@@ -20,6 +107,7 @@ describe("tree-shell-surface", () => {
root.unmount();
});
container.remove();
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
});
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
@@ -43,41 +131,41 @@ describe("tree-shell-surface", () => {
});
}
it("page tree surface 在 rust_family 下可切到同源 iframe host", () => {
it("page tree surface 在 rust_family 下默认切到 Rust/WASM DOM shell 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"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
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("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => {
it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => {
renderPageSurface("rust_family", "doc_focus");
const iframe = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
) as HTMLIFrameElement | null;
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
expect(iframe?.getAttribute("src")).toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"');
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
});
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
@@ -97,8 +185,9 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
@@ -126,7 +215,7 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
it("file tree surface 在 rust_family 下也应走默认 Rust/WASM DOM shell host", () => {
act(() => {
root.render(
<SidebarTreeSurface
@@ -149,21 +238,23 @@ describe("tree-shell-surface", () => {
const rustHost = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-host"]',
);
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
const domHost = container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
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("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
@@ -183,8 +274,9 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
@@ -212,10 +304,50 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
it("file tree surface 应把 DOM shell 内部拖放与外部文件拖放桥接回宿主回调", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const onInternalDrop = vi.fn();
const onDropFiles = vi.fn();
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
const dataTransfer = {
files: [] as File[],
types: ["application/x-mnote-file-tree", "text/plain"],
dropEffect: "move",
getData: (type: string) =>
type === "application/x-mnote-file-tree" || type === "text/plain"
? JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["doc:doc_source"] })
: "",
setData: vi.fn(),
} as unknown as DataTransfer;
const externalDataTransfer = {
files: [droppedFile],
types: ["Files"],
dropEffect: "copy",
getData: () => "",
setData: vi.fn(),
} as unknown as DataTransfer;
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => {
root.render(
@@ -225,6 +357,7 @@ describe("tree-shell-surface", () => {
workspaceId="ws_1"
treeShellEnabled
rows={[]}
treeShellItems={[targetProjectionItem]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
@@ -237,45 +370,33 @@ describe("tree-shell-surface", () => {
);
});
const iframe = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-iframe"]',
const targetRow = container.querySelector(
'[data-testid="filetree-doc-row"][data-document-id="doc_target"]',
);
expect(iframe).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
expect(targetRow).not.toBeNull();
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,
rowId: "doc:doc_target",
rowKind: "doc",
documentId: "doc_target",
},
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.external-drop",
documentId: "doc_target",
rowId: "doc:doc_target",
rowKind: "doc",
files: [droppedFile],
},
source: window,
}),
);
const internalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(internalDropEvent, "dataTransfer", {
configurable: true,
value: dataTransfer,
});
targetRow?.dispatchEvent(internalDropEvent);
const externalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(externalDropEvent, "dataTransfer", {
configurable: true,
value: externalDataTransfer,
});
targetRow?.dispatchEvent(externalDropEvent);
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "fileTree",
action: expect.objectContaining({ kind: "dispatchInternalDrop" }),
}),
);
expect(onInternalDrop).toHaveBeenCalledWith({
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
@@ -293,7 +414,7 @@ describe("tree-shell-surface", () => {
});
});
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn();
await act(async () => {
@@ -312,19 +433,24 @@ describe("tree-shell-surface", () => {
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"]');
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
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("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_1"]')).not.toBeNull();
expect(onPick).not.toHaveBeenCalled();
});
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<TreePickerSurface
@@ -346,11 +472,12 @@ describe("tree-shell-surface", () => {
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"]');
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(domHost).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
});
});