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
@@ -189,19 +189,6 @@ export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMov
};
}
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveOrderPlan(expected);
if (!normalizedExpected) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
return normalizedExpected;
}
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
if (!normalizedExpected) {
+2 -9
View File
@@ -5,7 +5,6 @@ import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
import {
assertDocumentMoveOrderPlanMatches,
assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "./_utils/documentMoveOrder";
@@ -1400,8 +1399,7 @@ export const move = mutation({
id: v.string(),
parentId: v.union(v.string(), v.null()),
sortOrder: v.number(),
normalizedMove: v.optional(v.any()),
treeWriteOperation: v.optional(v.any()),
treeWriteOperation: v.any(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -1444,12 +1442,7 @@ export const move = mutation({
parentId: toParentId,
sortOrder: args.sortOrder,
});
const rustMoveOrderPlan =
args.treeWriteOperation != null
? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan)
: args.normalizedMove != null
? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan)
: null;
const rustMoveOrderPlan = assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan);
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
@@ -0,0 +1,122 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs/promises");
const path = require("path");
const { spawnSync } = require("child_process");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const RUST_ROOT = path.join(REPO_ROOT, "rust");
const CRATE_ROOT = path.join(RUST_ROOT, "crates", "tree-shell-runtime-wasm");
const GENERATED_ROOT = path.join(CRATE_ROOT, "generated");
const TOOLCHAIN = "1.89.0-x86_64-unknown-linux-gnu";
const TARGET = "wasm32-unknown-unknown";
const OUT_NAME = "mnote-tree-shell-runtime";
const MANIFEST_PATH = path.join(CRATE_ROOT, "Cargo.toml");
const WASM_PATH = path.join(
RUST_ROOT,
"target",
TARGET,
"debug",
"tree_shell_runtime_wasm.wasm",
);
const ENTRY_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}.js`);
const OUTPUT_WASM_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}_bg.wasm`);
const GITIGNORE_PATH = path.join(GENERATED_ROOT, ".gitignore");
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? REPO_ROOT,
env: process.env,
encoding: "utf8",
stdio: "pipe",
});
if (result.status === 0) {
return result;
}
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
const details = [stdout, stderr].filter(Boolean).join("\n");
throw new Error(
[`命令失败:${command} ${args.join(" ")}`, details].filter(Boolean).join("\n"),
);
}
async function ensureGeneratedDir() {
await fs.rm(GENERATED_ROOT, { recursive: true, force: true });
await fs.mkdir(GENERATED_ROOT, { recursive: true });
await fs.writeFile(GITIGNORE_PATH, "*\n!.gitignore\n", "utf8");
}
async function verifyOutput() {
const entrySource = await fs.readFile(ENTRY_PATH, "utf8");
await fs.access(OUTPUT_WASM_PATH);
if (!entrySource.includes("export function reduceTreeShellRuntime")) {
throw new Error("tree shell runtime js glue 缺少 reduceTreeShellRuntime 导出");
}
}
async function buildTreeShellRuntime() {
// 说明:tree shell iframe 主链直接加载固定 wasm/js 产物,因此在 Next 启动前先生成正式 artifact。
run("rustup", ["target", "add", TARGET, "--toolchain", TOOLCHAIN], { cwd: RUST_ROOT });
run(
"cargo",
[
`+${TOOLCHAIN}`,
"build",
"--manifest-path",
MANIFEST_PATH,
"--package",
"tree-shell-runtime-wasm",
"--lib",
"--target",
TARGET,
"--locked",
],
{ cwd: RUST_ROOT },
);
await ensureGeneratedDir();
run(
"wasm-bindgen",
[
WASM_PATH,
"--target",
"web",
"--out-dir",
GENERATED_ROOT,
"--out-name",
OUT_NAME,
],
{ cwd: RUST_ROOT },
);
await verifyOutput();
return {
generatedRoot: GENERATED_ROOT,
entryPath: ENTRY_PATH,
wasmPath: OUTPUT_WASM_PATH,
};
}
module.exports = {
buildTreeShellRuntime,
GENERATED_ROOT,
OUT_NAME,
};
if (require.main === module) {
buildTreeShellRuntime()
.then(({ generatedRoot }) => {
process.stdout.write(`tree shell runtime 已生成:${generatedRoot}\n`);
})
.catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});
}
+4
View File
@@ -17,6 +17,7 @@ const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const { buildTreeShellRuntime } = require("./build-tree-shell-runtime");
const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
@@ -390,6 +391,9 @@ async function main() {
const hostname = resolveHostname();
const dev = true;
// 说明:tree shell iframe 通过 3000 同源 js glue + wasm 直接调用 reducer,因此 dev 启动前先生成正式 artifact。
await buildTreeShellRuntime();
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
await buildLeptosTiptapIsland();
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
let generatedRoot = "";
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
async function writeFixtureFile(relativePath: string, content: string | Uint8Array) {
const absolutePath = path.join(generatedRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content);
}
describe("/api/tree-shell-runtime/[...asset] route", () => {
beforeEach(async () => {
generatedRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mnote-tree-shell-runtime-"));
process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT = generatedRoot;
});
afterEach(async () => {
delete process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT;
await fs.rm(generatedRoot, { recursive: true, force: true });
generatedRoot = "";
});
it("返回固定资源名 manifest,并暴露同源 js/wasm 资源路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export function reduceTreeShellRuntime() {}\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/tree-shell-runtime/manifest.json"), {
params: Promise.resolve({ asset: ["manifest.json"] }),
});
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
jsGlueAssetPath: `/api/tree-shell-runtime/${JS_ASSET_NAME}`,
wasmAssetPath: `/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
assetPaths: [
`/api/tree-shell-runtime/${JS_ASSET_NAME}`,
`/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
],
generatedRootPath: generatedRoot.split(path.sep).join("/"),
});
});
it("返回具体 wasm/js 资源内容,并拒绝越界路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export const runtimeVersion = 1;\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const jsResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${JS_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [JS_ASSET_NAME] }) },
);
expect(jsResponse.status).toBe(200);
expect(jsResponse.headers.get("content-type")).toContain("application/javascript");
expect(await jsResponse.text()).toContain("runtimeVersion");
const wasmResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${WASM_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [WASM_ASSET_NAME] }) },
);
expect(wasmResponse.status).toBe(200);
expect(wasmResponse.headers.get("content-type")).toBe("application/wasm");
expect(new Uint8Array(await wasmResponse.arrayBuffer())).toEqual(
new Uint8Array([0x00, 0x61, 0x73, 0x6d]),
);
const invalidResponse = await GET(
new Request("http://127.0.0.1:3000/api/tree-shell-runtime/../../secret.txt"),
{ params: Promise.resolve({ asset: ["..", "..", "secret.txt"] }) },
);
expect(invalidResponse.status).toBe(400);
expect(await invalidResponse.json()).toEqual({
error: "非法 tree shell runtime 资源路径",
});
});
});
@@ -0,0 +1,153 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_GENERATED_ROOT = path.resolve(
process.cwd(),
"..",
"rust",
"crates",
"tree-shell-runtime-wasm",
"generated",
);
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
type TreeShellRuntimeManifest = {
jsGlueAssetPath: string;
wasmAssetPath: string;
assetPaths: string[];
generatedRootPath: string;
};
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function resolveGeneratedRoot(): string {
return path.resolve(process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT || DEFAULT_GENERATED_ROOT);
}
function toAssetRoutePath(fileName: string): string {
return `/api/tree-shell-runtime/${fileName}`;
}
function sanitizeRelativePath(asset: string[]): string | null {
if (!Array.isArray(asset) || asset.length === 0) {
return null;
}
const decoded = asset.map((segment) => decodeURIComponent(segment));
const joined = decoded.join("/");
if (!joined || joined.includes("\0")) {
return null;
}
const normalized = path.posix.normalize(joined);
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
return null;
}
return normalized;
}
async function fileExists(absolutePath: string): Promise<boolean> {
try {
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
async function buildManifest(): Promise<TreeShellRuntimeManifest> {
const generatedRoot = resolveGeneratedRoot();
const assetCandidates = [JS_ASSET_NAME, WASM_ASSET_NAME];
const existingAssets: string[] = [];
for (const assetName of assetCandidates) {
const absolutePath = path.join(generatedRoot, assetName);
if (await fileExists(absolutePath)) {
existingAssets.push(toAssetRoutePath(assetName));
}
}
return {
jsGlueAssetPath: toAssetRoutePath(JS_ASSET_NAME),
wasmAssetPath: toAssetRoutePath(WASM_ASSET_NAME),
assetPaths: existingAssets,
generatedRootPath: toPosixPath(generatedRoot),
};
}
function guessContentType(absolutePath: string): string {
const extension = path.extname(absolutePath).toLowerCase();
if (extension === ".js" || extension === ".mjs") {
return "application/javascript; charset=utf-8";
}
if (extension === ".wasm") {
return "application/wasm";
}
if (extension === ".json") {
return "application/json; charset=utf-8";
}
return "application/octet-stream";
}
export async function GET(
_request: Request,
context: { params: Promise<{ asset?: string[] }> },
) {
const params = await context.params;
const asset = params.asset ?? [];
if (asset.length === 1 && asset[0] === "manifest.json") {
try {
const manifest = await buildManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 tree shell runtime 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 tree shell runtime 资源路径" }, { status: 400 });
}
const generatedRoot = resolveGeneratedRoot();
const absolutePath = path.resolve(generatedRoot, relativePath);
if (!absolutePath.startsWith(generatedRoot + path.sep)) {
return NextResponse.json({ error: "越界访问 tree shell runtime 资源被拒绝" }, { status: 403 });
}
try {
const fileContent = await fs.readFile(absolutePath);
return new NextResponse(fileContent, {
status: 200,
headers: {
"Content-Type": guessContentType(absolutePath),
"Cache-Control": "no-store",
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "tree shell runtime 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 tree shell runtime 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
@@ -1233,7 +1233,7 @@ describe("/api/tree/commands route", () => {
);
});
it("embed action 走 tree.node.embed,并生成 pageReference 保存 payload", async () => {
it("embed action 走 tree.node.embed,并 pageReference 组装交给 Rust Page Aggregate preflight", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async (name: string, args: { id: string }) => {
@@ -1342,18 +1342,19 @@ describe("/api/tree/commands route", () => {
targetDocumentId: "doc_target",
revision: 7,
conflictDetectionKey: "doc_target:7",
content: [
{ id: "anchor_1", type: "paragraph" },
{
id: expect.any(String),
type: "pageReference",
props: {
pageId: "doc_source",
title: "来源页面",
},
},
],
}),
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: "doc_source",
sourceTitle: "来源页面",
targetDocumentId: "doc_target",
targetContent: [
{ id: "anchor_1", type: "paragraph" },
],
anchorBlockId: "anchor_1",
blockId: expect.any(String),
},
},
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
@@ -1,10 +1,8 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
assertDocumentId,
assertTitle,
@@ -19,7 +17,6 @@ import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
@@ -591,32 +588,9 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull(
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
);
const anchorIndex = anchorId
? currentBlocks.findIndex(
(block) =>
typeof block === "object" &&
block !== null &&
String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId =
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
@@ -629,24 +603,30 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
workspaceId,
commandName: "tree.node.embed",
payload: {
...buildDocumentSavePayload({
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: nextContent,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
sourceDocumentId: sourceId,
targetDocumentId: targetId,
anchorBlockId: anchorId,
},
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: sourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: targetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: randomUUID(),
},
},
pageId: targetId,
client,
});
@@ -98,6 +98,23 @@ describe("/api/tree/projections/file route", () => {
},
],
edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 2,
visibleEdges: 0,
},
},
},
},
});
mockDocumentBridgeErrorResponse.mockClear();
});
@@ -130,6 +147,15 @@ describe("/api/tree/projections/file route", () => {
result: {
projection: "file_tree",
rootNodeId: "page_root",
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
},
},
},
},
});
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/runtime/reduce route", () => {
beforeEach(() => {
vi.resetModules();
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源 POST 代理到 mnote-web runtime reduce,并原样转发 JSON body 与来源 header", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true, revision: 7 }), {
status: 200,
statusText: "OK",
headers: {
"content-type": "application/json; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"transfer-encoding": "chunked",
"x-upstream": "mnote-web-runtime",
},
}),
);
const { POST } = await import("./route");
const body = JSON.stringify({
workspaceId: "ws_body",
op: "tree.node.rename",
payload: { documentId: "doc_1", title: "新标题" },
});
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce?workspaceId=ws_query", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: "session=abc",
},
body,
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce?workspaceId=ws_query",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body,
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("content-type")).toBe("application/json");
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_runtime_reduce_proxy");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("wolai-frontend");
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_query");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("transfer-encoding")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-runtime");
expect(await response.json()).toEqual({ ok: true, revision: 7 });
});
it("没有 workspaceId query 时使用 x-mnote-workspace-id header fallback", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
"x-mnote-workspace-id": "ws_header",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: {
"content-type": "application/json",
},
}),
);
const { POST } = await import("./route");
await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-workspace-id": "ws_header",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce",
expect.objectContaining({
method: "POST",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_header");
});
it("上游调用异常时返回 502 JSON", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(new Headers());
mockFetch.mockRejectedValue(new Error("runtime reduce unavailable"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
error: "runtime reduce unavailable",
});
});
});
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function POST(request: Request) {
try {
const requestUrl = new URL(request.url);
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/api/tree/runtime/reduce", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("content-type", "application/json");
headers.set("accept", "application/json");
headers.set("x-mnote-source-channel", "next_tree_runtime_reduce_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "POST",
headers,
body: await request.text(),
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree runtime reduce 代理失败";
return NextResponse.json(
{
error: message,
},
{
status: 502,
headers: {
"cache-control": "no-store",
},
},
);
}
}
@@ -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();
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder";
describe("documentMoveOrder Convex helper", () => {
it("assertDocumentMoveWriteOperationMatches 应只接受 Rust tree write operation,并返回内部 move plan", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
],
documentId: "doc_b",
parentId: null,
sortOrder: 0,
});
const normalized = assertDocumentMoveWriteOperationMatches(
{
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: "ws_1",
documentId: "doc_b",
fromParentId: "source",
toParentId: null,
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: null,
sortOrder: 0,
moved: true,
},
],
},
actual,
);
expect(normalized).toEqual({
documentId: "doc_b",
fromParentId: "source",
toParentId: null,
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: null,
sortOrder: 0,
moved: true,
},
],
});
expect(() => assertDocumentMoveWriteOperationMatches({ ...actual, operation: "documents.move" }, actual)).toThrow(
"Rust move write operation 与 Convex 当前排序状态不一致",
);
});
it("assertDocumentMoveWriteOperationMatches 应拒绝缺少正式 write operation 外壳的旧 move plan", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
],
documentId: "doc_b",
parentId: null,
sortOrder: 0,
});
expect(() => assertDocumentMoveWriteOperationMatches(actual, actual)).toThrow(
"Rust move write operation 与 Convex 当前排序状态不一致",
);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
assertDocumentMoveOrderPlanMatches,
assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder";
@@ -61,7 +61,7 @@ describe("documentMoveOrder", () => {
});
});
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
it("treeWriteOperation 与当前排序状态不一致时拒绝执行", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
@@ -89,8 +89,13 @@ describe("documentMoveOrder", () => {
});
expect(() =>
assertDocumentMoveOrderPlanMatches(
assertDocumentMoveWriteOperationMatches(
{
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: "ws_1",
...actual,
patches: actual.patches.map((patch) =>
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
@@ -98,6 +103,6 @@ describe("documentMoveOrder", () => {
},
actual,
),
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
).toThrow("Rust move write operation 与 Convex 当前排序状态不一致");
});
});
@@ -15,13 +15,10 @@ import {
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type DocumentCreatePayload = {
documentId: string;
@@ -82,10 +79,7 @@ export type DocumentEmbedPayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
content: Json;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
sourceDocumentId: string;
targetDocumentId: string;
anchorBlockId: string | null;
@@ -275,47 +269,20 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
}
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
const anchorIndex =
anchorId
? currentBlocks.findIndex(
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: safeRandomId(),
type: "pageReference",
props: {
pageId: normalizedSourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const context = await buildRuntimeContext(request, workspaceId);
const embedPayload: DocumentEmbedPayload = {
...buildDocumentSavePayload({
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: payload,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
sourceDocumentId: normalizedSourceId,
targetDocumentId: normalizedTargetId,
anchorBlockId: anchorId,
@@ -324,6 +291,16 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
name: "documents.embed",
payload: embedPayload,
context,
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: normalizedSourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: normalizedTargetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: safeRandomId(),
},
},
target: {
workspaceId,
pageId: normalizedTargetId,
@@ -373,14 +373,14 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
const requestClone = request.clone();
let normalizedMove: NormalizedDocumentMovePayload | null = null;
let movePayload: NormalizedDocumentMovePayload | null = null;
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
let failureAuthUserId: string | null = null;
let failureSourceDocument: MovePreflightDocument | null = null;
try {
const payload = (await request.json()) as MovePayload;
normalizedMove = normalizeDocumentMovePayload(payload);
const documentId = normalizedMove.documentId;
movePayload = normalizeDocumentMovePayload(payload);
const documentId = movePayload.documentId;
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
@@ -402,14 +402,14 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
const movePreflight = await buildMovePreflight({
client,
sourceDocument: failureSourceDocument,
targetParentId: normalizedMove.parentId,
targetParentId: movePayload.parentId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId,
parentId: normalizedMove.parentId,
sortOrder: normalizedMove.sortOrder,
parentId: movePayload.parentId,
sortOrder: movePayload.sortOrder,
movePreflight,
},
preflightData: movePreflight,
@@ -438,7 +438,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
return NextResponse.json({ ok: true });
} catch (error) {
try {
const fallbackMove = normalizedMove
const fallbackMove = movePayload
?? normalizeDocumentMovePayload(
(await requestClone.json().catch(() => ({}))) as MovePayload,
);
@@ -5,6 +5,7 @@ import {
executeRustBridgeMutationTransport,
materializeRustTreeStreamDelta,
readRustTreeDomainEventPlan,
readRustTreeDomainEventPlans,
readRustTreeDomainEventType,
type RustBridgeCommandPlan,
} from "./rust-runtime";
@@ -83,7 +84,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
describe("executeRustBridgeMutationTransport", () => {
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
const normalizedMove = {
const movePlan = {
documentId: "doc_b",
fromParentId: "source",
toParentId: "target",
@@ -104,7 +105,7 @@ describe("executeRustBridgeMutationTransport", () => {
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: "ws_1",
...normalizedMove,
...movePlan,
};
const mutation = vi.fn().mockResolvedValue({ ok: true });
const plan: RustBridgeCommandPlan = {
@@ -122,7 +123,6 @@ describe("executeRustBridgeMutationTransport", () => {
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
treeWriteOperation,
},
};
@@ -137,7 +137,6 @@ describe("executeRustBridgeMutationTransport", () => {
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
treeWriteOperation,
});
});
@@ -645,7 +644,7 @@ describe("readRustTreeDomainEventType", () => {
});
});
it("应保留 Rust formal domainEventPlan payload schema", () => {
it("应保留 Rust formal domainEventPlan payload schema,并拆出 snapshot 独立事件", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "page.body.save",
@@ -668,11 +667,6 @@ describe("readRustTreeDomainEventType", () => {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: {
ids: ["block_1"],
count: 1,
@@ -687,6 +681,57 @@ describe("readRustTreeDomainEventType", () => {
},
},
},
domainEventPlans: [
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "page.body.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
blocks: {
ids: ["block_1"],
count: 1,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
],
},
};
@@ -700,11 +745,6 @@ describe("readRustTreeDomainEventType", () => {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: {
ids: ["block_1"],
count: 1,
@@ -719,6 +759,22 @@ describe("readRustTreeDomainEventType", () => {
},
},
});
expect(readRustTreeDomainEventPlans(plan).map((eventPlan) => eventPlan.eventType)).toEqual([
"page.body.saved",
"document.snapshot.saved",
]);
expect(readRustTreeDomainEventPlans(plan)[0]?.payload).not.toHaveProperty("snapshot");
expect(readRustTreeDomainEventPlans(plan)[1]?.payload).toMatchObject({
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
});
});
});
@@ -796,11 +852,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: {
ids: ["block_1"],
count: 1,
@@ -815,6 +866,57 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
},
},
},
domainEventPlans: [
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "page.body.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
blocks: {
ids: ["block_1"],
count: 1,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
],
},
},
result: {
@@ -842,6 +944,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
});
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
expect(artifactPlan?.domainEvents?.map((event) => event.eventType)).toEqual([
"page.body.saved",
"document.snapshot.saved",
]);
expect(artifactPlan?.domainEvent).toMatchObject({
id: "evt_cmd_artifact_1",
eventType: "page.body.saved",
@@ -854,11 +960,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: {
ids: ["block_1"],
count: 1,
@@ -870,5 +971,30 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
},
},
});
expect(artifactPlan?.domainEvent?.payload).not.toHaveProperty("snapshot");
expect(artifactPlan?.domainEvents?.[1]).toMatchObject({
id: "evt_cmd_artifact_1_02_document_snapshot_saved",
eventType: "document.snapshot.saved",
payload: {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
command_id: "cmd_artifact_1",
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
streamDelta: {
op: "resync_required",
reason: "page_body_saved",
pageId: "doc_1",
},
},
});
}, 30_000);
});
@@ -161,6 +161,7 @@ export type RustBridgeDomainEventArtifactPlan = {
export type RustBridgeCommandArtifactPlan = {
commandLog: RustBridgeCommandLogArtifactPlan;
domainEvent: RustBridgeDomainEventArtifactPlan | null;
domainEvents?: RustBridgeDomainEventArtifactPlan[];
};
export type RustBridgeToolPlanStep = {
@@ -555,6 +556,14 @@ function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string)
return isRecord(value) ? value : null;
}
function readRequiredRecordArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (isRecord(value)) {
return value;
}
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
const value = source[field];
return typeof value === "boolean" ? value : undefined;
@@ -670,6 +679,24 @@ export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
const eventPlan = plan.argsJson.domainEventPlan;
return normalizeRustTreeDomainEventPlan(eventPlan);
}
export function readRustTreeDomainEventPlans(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan[] {
const eventPlans = plan.argsJson.domainEventPlans;
if (Array.isArray(eventPlans)) {
const normalized = eventPlans
.map((eventPlan) => normalizeRustTreeDomainEventPlan(eventPlan))
.filter((eventPlan): eventPlan is RustTreeDomainEventPlan => Boolean(eventPlan));
if (normalized.length > 0) {
return normalized;
}
}
const single = readRustTreeDomainEventPlan(plan);
return single ? [single] : [];
}
function normalizeRustTreeDomainEventPlan(eventPlan: unknown): RustTreeDomainEventPlan | null {
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
return null;
}
@@ -715,6 +742,24 @@ export function materializeRustTreeDomainEventPlan(input: {
};
}
export function materializeRustTreeDomainEventPlans(input: {
plan: RustBridgeCommandPlan;
result: unknown;
streamDelta?: RustTreeStreamDelta | null;
}): RustTreeDomainEventPlan[] {
const eventPlans = readRustTreeDomainEventPlans(input.plan);
const streamDelta =
input.streamDelta ??
materializeRustTreeStreamDelta({
plan: input.plan,
result: input.result,
});
return eventPlans.map((eventPlan) => ({
...eventPlan,
...(streamDelta ? { streamDelta } : {}),
}));
}
export function materializeRustTreeStreamDelta(input: {
plan: RustBridgeCommandPlan;
result: unknown;
@@ -882,8 +927,14 @@ export async function persistRustBridgeCommandArtifacts(input: {
) => Promise<unknown>;
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
if (artifacts.domainEvent) {
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>);
const domainEvents =
artifacts.domainEvents && artifacts.domainEvents.length > 0
? artifacts.domainEvents
: artifacts.domainEvent
? [artifacts.domainEvent]
: [];
for (const domainEvent of domainEvents) {
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, domainEvent as unknown as Record<string, unknown>);
}
}
@@ -1151,12 +1202,7 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
id: assertStringArg(input.plan.argsJson, "id"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
...("normalizedMove" in input.plan.argsJson
? { normalizedMove: input.plan.argsJson.normalizedMove }
: {}),
...("treeWriteOperation" in input.plan.argsJson
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
: {}),
treeWriteOperation: readRequiredRecordArg(input.plan.argsJson, "treeWriteOperation"),
});
case "documents:softDelete":
return mutation(api.documents.softDelete, {
@@ -21,6 +21,23 @@ describe("fetchKernelFileTreeProjection", () => {
rootNodeId: "page_root",
items: [{ rowId: "asset:table_1" }],
edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 1,
visibleEdges: 0,
},
},
},
},
},
}),
{ status: 200 },
@@ -44,6 +61,11 @@ describe("fetchKernelFileTreeProjection", () => {
}),
);
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
expect(result.meta?.search?.indexingVisibility).toMatchObject({
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
});
});
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
@@ -29,12 +29,36 @@ export type KernelFileTreeProjectionEdge = {
toNodeId: string;
};
export type KernelFileTreeIndexingVisibility = {
schema: "mnote.file_tree.indexing_visibility";
schemaVersion: 1;
source: "kernel.project_view";
status: "visible" | "stale" | "refreshing" | "unknown";
requestKey: string | null;
indexedResourceKinds: string[];
visibleResourceKinds: string[];
metrics: {
visibleRows: number;
visibleEdges: number;
};
};
export type KernelFileTreeProjection = {
projectionId: string;
projection: "file_tree";
rootNodeId: string | null;
items: KernelFileTreeProjectionItem[];
edges: KernelFileTreeProjectionEdge[];
meta?: {
search?: {
query?: string | null;
maxResults?: number | null;
maxResultsRule?: "matches_only_before_ancestor_completion";
ancestorCompletion?: "include_all_ancestors_after_match_truncation";
ordering?: "kernel_file_tree_preorder";
indexingVisibility?: KernelFileTreeIndexingVisibility;
};
};
};
type BuildKernelFileTreeProjectionInput = {
@@ -574,5 +598,27 @@ export function buildKernelFileTreeProjection(
rootNodeId,
items,
edges,
meta: {
search: {
query: null,
maxResults: null,
maxResultsRule: "matches_only_before_ancestor_completion",
ancestorCompletion: "include_all_ancestors_after_match_truncation",
ordering: "kernel_file_tree_preorder",
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: null,
indexedResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
visibleResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
metrics: {
visibleRows: items.length,
visibleEdges: edges.length,
},
},
},
},
};
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";
describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
it("固定 3000 route 的 thin proxy 与 compat pending 边界", () => {
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1,
publicEntry: "3000",
});
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "tree.commands",
role: "next-thin-proxy",
route: "/api/tree/commands",
}),
expect.objectContaining({
id: "tree.stream",
role: "next-thin-proxy",
route: "/api/tree/stream",
}),
expect.objectContaining({
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
}),
]),
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
"Rust command result 回包整形",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
"新页面 scaffold 文件创建",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
});
});
@@ -0,0 +1,77 @@
export type TreeRouteBoundaryRole =
| "rust-owned"
| "next-thin-proxy"
| "browser-substrate"
| "compat-pending";
export type TreeRouteBoundaryItem = {
id: string;
role: TreeRouteBoundaryRole;
route: string;
owner: "rust-runtime" | "next-3000" | "convex-substrate";
description: string;
};
export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1,
publicEntry: "3000",
routes: [
{
id: "tree.commands",
role: "next-thin-proxy",
route: "/api/tree/commands",
owner: "next-3000",
description:
"浏览器公开树命令入口,只负责认证、payload envelope、Rust command transport、artifact writer 调用与必要副作用调度。",
},
{
id: "tree.stream",
role: "next-thin-proxy",
route: "/api/tree/stream",
owner: "next-3000",
description:
"浏览器 SSE/polling 入口,只转发 bridge log/domain event cursor 与 Rust 产出的 streamDelta,缺少稳定 delta 时保守 resync。",
},
{
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
owner: "next-3000",
description:
"仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。",
},
] satisfies TreeRouteBoundaryItem[],
rustOwnedSemantics: [
"tree.node.create",
"tree.node.rename",
"tree.subtree.move",
"tree.node.archive",
"tree.node.restore",
"tree.node.purge",
"tree.subtree.copy",
"tree.node.embed",
],
nextThinProxyDuties: [
"cookie/auth 读取与 Convex client 获取",
"workspace bootstrap",
"CommandEnvelope 构造与 Rust runtime transport",
"Rust command result 回包整形",
"Rust artifact writer 调用",
"tree.subtree.move 的 sidebar snapshot preflight 数据采集",
],
browserSubstrateDuties: [
"新页面 scaffold 文件创建",
"复制页面后的 mindmap 文件复制",
"页面嵌入前读取目标内容、源页面标题与 anchor block,作为 Rust Page Aggregate embed plan 的 preflight substrate",
"文件字节读取、upload URL、cookie/auth 等浏览器入口能力",
],
compatPending: [] satisfies TreeRouteBoundaryItem[],
forbiddenNextSemantics: [
"树合法性判断",
"树排序 canonical plan",
"长期 streamDelta 主语义拼装",
"tree shell renderer runtime",
"tree.node.embed 的 pageReference block 结构与插入位置语义",
],
} as const;