4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
@@ -1,23 +1,5 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCopyTreePayload,
} from "@/lib/documents/page-command-adapter";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
export const dynamic = "force-dynamic";
type CopyTreeItem = {
documentId: string;
@@ -25,102 +7,88 @@ type CopyTreeItem = {
};
type CopyTreePayload = {
items: CopyTreeItem[];
targetParentId: string | null;
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
type TreeCopyResponse = {
requestId?: string;
traceId?: string;
result?: {
items?: Array<{
oldId: string;
newId: string;
}>;
} | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { client } = await getAuthedConvexClient();
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
let workspaceId: string | null = null;
if (normalizedTargetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id?.trim() || null;
} else {
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = firstDoc.workspace_id?.trim() || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCopyTreePayload,
{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
workspaceId,
targetParentId: normalizedTargetParentId,
items: normalizedItems,
},
context: bridgeContext,
target: {
workspaceId,
pageId: normalizedTargetParentId,
},
}),
});
await Promise.all(
result.result.items.map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
return NextResponse.json({
items: result.result.items.map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: item.documentId.trim(),
recursive: Boolean(item.recursive),
}))
.filter((item) => item.documentId.length > 0);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "copy",
targetParentId:
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
items: normalizedItems,
}),
});
const result = (await response.json().catch(() => null)) as
| TreeCopyResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
result && typeof result === "object" && "error" in result && typeof result.error === "string"
? result.error
: "复制页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
items: result?.result?.items ?? [],
meta: {
requestId: result?.requestId,
traceId: result?.traceId,
commandName: "tree.subtree.copy",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentDeletePayload,
} from "@/lib/documents/page-command-adapter";
type TreeArchiveResponse = {
requestId?: string;
traceId?: string;
};
type DeletePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.delete",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, workspaceId }: DeletePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "archive",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeArchiveResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.archive",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,11 +1,74 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreeEmbedResponse = {
requestId?: string;
traceId?: string;
};
type EmbedPayload = {
sourceId?: string | null;
targetId?: string | null;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentEmbedBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { sourceId, targetId }: EmbedPayload = await request.json();
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
if (!normalizedSourceId || !normalizedTargetId) {
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "embed",
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeEmbedResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "嵌入失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.embed",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,13 +1,77 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreePurgeResponse = {
requestId?: string;
traceId?: string;
result?: {
purged?: boolean;
} | null;
};
type PurgePayload = {
documentId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentPurgeBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId }: PurgePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "purge",
documentId: normalizedDocumentId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreePurgeResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "彻底删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
purged: payload?.result?.purged ?? true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.purge",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentRestorePayload,
} from "@/lib/documents/page-command-adapter";
type TreeRestoreResponse = {
requestId?: string;
traceId?: string;
};
type RestorePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.restore",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, workspaceId }: RestorePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "restore",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeRestoreResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "恢复失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.restore",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -72,22 +72,23 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postDelete } from "@/app/api/documents/delete/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postRestore } from "@/app/api/documents/restore/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
import { POST as postCreate } from "@/app/api/documents/create/route";
import { POST as postMove } from "@/app/api/documents/move/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
@@ -246,12 +247,115 @@ describe("documents route adapters", () => {
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
});
it("embed route delegates to unified adapter", async () => {
await postEmbed(new Request("http://localhost/api/documents/embed", {
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_archive_1",
traceId: "trace_tree_archive_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "archive",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.archive");
});
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_restore_1",
traceId: "trace_tree_restore_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "restore",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.restore");
});
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_embed_1",
traceId: "trace_tree_embed_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
}));
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "embed",
sourceId: "doc_1",
targetId: "doc_2",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.embed");
});
it("template route delegates to unified adapter", async () => {
@@ -270,12 +374,85 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("purge route delegates to unified adapter", async () => {
await postPurge(new Request("http://localhost/api/documents/purge", {
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_purge_1",
traceId: "trace_tree_purge_1",
result: {
purged: true,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1" }),
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
const payload = await response.json() as {
success: boolean;
purged: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "purge",
documentId: "doc_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.purged).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.purge");
});
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_copy_1",
traceId: "trace_tree_copy_1",
result: {
items: [{ oldId: "doc_1", newId: "doc_2" }],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
}));
const payload = await response.json() as {
items: Array<{ oldId: string; newId: string }>;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "copy",
targetParentId: null,
items: [{ documentId: "doc_1", recursive: true }],
}),
}),
);
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
expect(payload.meta.commandName).toBe("tree.subtree.copy");
});
it("page route delegates to unified aggregate loader", async () => {
@@ -1,201 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentBridgeContextWithActor = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQuery = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockResolveKernelFileTreeProjection = vi.fn();
const mockAttachKernelFileTreeProjection = vi.fn((input: { dataset: unknown; projection: unknown }) => ({
...(input.dataset as Record<string, unknown>),
kernel_file_tree_projection: input.projection,
const mockStreamTreeFrames = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {},
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
mockBuildDocumentBridgeContextWithActor(...args),
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
executeRustBridgeQueryTransport: (...args: unknown[]) => mockExecuteRustBridgeQueryTransport(...args),
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
}));
vi.mock("@/lib/server/kernel-file-tree", () => ({
resolveKernelFileTreeProjection: (...args: unknown[]) => mockResolveKernelFileTreeProjection(...args),
attachKernelFileTreeProjection: (...args: unknown[]) => mockAttachKernelFileTreeProjection(...args),
vi.mock("@/lib/server/kernel-file-tree", async () => {
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
"@/lib/server/kernel-file-tree",
);
return {
...actual,
resolveKernelFileTreeProjection: (...args: unknown[]) =>
mockResolveKernelFileTreeProjection(...args),
};
});
vi.mock("@/lib/tree-stream/server", () => ({
streamTreeFrames: (...args: unknown[]) => mockStreamTreeFrames(...args),
}));
async function* makeFrames() {
yield {
event: "snapshot",
payload: {
kind: "snapshot",
stream: "workspace",
workspaceId: "ws_1",
rootNodeId: null,
cursor: "cursor_1",
projection: "sidebar_tree",
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] } },
overview: { command_logs: [], domain_events: [] },
},
};
}
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentQueryEnvelope.mockReset();
mockExecuteRustBridgeQueryTransport.mockReset();
mockResolveRustBridgeQueryPlan.mockReset();
mockResolveKernelFileTreeProjection.mockReset();
mockAttachKernelFileTreeProjection.mockClear();
mockDocumentBridgeErrorResponse.mockClear();
});
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: { query: vi.fn() },
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
auth: { userId: "user_1" },
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_1",
traceId: "trace_stream_1",
workspaceId: "ws_1",
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
requestId: "req_1",
traceId: "trace_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
});
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title: "工作区首页",
parent_id: null,
sort_order: 0,
is_starred: true,
is_template: false,
created_at: "2026-04-22T00:00:00Z",
updated_at: "2026-04-22T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: null,
has_more: false,
generated_at: "2026-04-22T00:00:00Z",
});
mockResolveKernelFileTreeProjection.mockResolvedValue({
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
argsJson: { workspaceId: "ws_1" },
functionName: "bridgeLogs:listWorkspaceOverview",
});
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
method: "GET",
headers: { cookie: "a=1" },
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
const text = await response.text();
expect(text).toContain("event: snapshot");
expect(text).toContain('"kind":"snapshot"');
expect(text).toContain('"projection":"sidebar_tree"');
expect(text).toContain('"workspaceId":"ws_1"');
expect(text).toContain('"activeWorkspaceId":"ws_1"');
expect(text).toContain('"kernelFileTreeProjection"');
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledTimes(1);
});
it("应把请求 cursor 继续透传到 overview query 和 snapshot envelope", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: { query: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_2",
traceId: "trace_stream_2",
workspaceId: "ws_1",
});
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: "evt_9",
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({
kind: "query",
functionName: "bridgeLogs:listWorkspaceOverview",
argsJson: { workspaceId: "ws_1", cursor: "evt_9" },
});
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
mockExecuteRustBridgeQuery.mockReset();
mockExecuteRustBridgeQueryTransport.mockImplementation(async ({ plan }) => {
if (plan?.functionName === "bridgeLogs:listWorkspaceOverview") {
return {
command_logs: [],
domain_events: [],
};
}
return {
active_workspace_id: "ws_1",
workspaces: [],
documents: [],
@@ -208,43 +107,59 @@ describe("/api/mnote-web/stream route", () => {
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: "evt_10",
has_more: true,
generated_at: "2026-04-22T00:00:00Z",
});
mockResolveKernelFileTreeProjection.mockResolvedValue({
projectionId: "kernel_projection:file_tree:root",
};
});
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
projectionId: "kernel_projection:file_tree:workspace_root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
});
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
});
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9", {
new Request(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
{ method: "GET" },
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toContain("event: snapshot");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
expect(mockStreamTreeFrames).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
initialCursor: "evt_9",
pollMs: 500,
maxPolls: 0,
}),
);
});
it("Convex 未启用时应返回 501,而不是探测 3104", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
method: "GET",
}),
);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain('"cursor":"evt_9"');
expect(mockBuildDocumentQueryEnvelope).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
payload: expect.objectContaining({
workspaceId: "ws_1",
cursor: "evt_9",
}),
}),
);
expect(response.status).toBe(501);
expect(fetchSpy).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
});
});
@@ -1,76 +1,72 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
import {
attachKernelFileTreeProjection,
resolveKernelFileTreeProjection,
} from "@/lib/server/kernel-file-tree";
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function toSseFrame(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
if (!workspaceId) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const requestUrl = new URL(request.url);
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
},
});
const sidebarEnvelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const sidebarPlan = await resolveRustBridgeQueryPlan({
context,
envelope: sidebarEnvelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport({
client,
plan: sidebarPlan,
});
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
dataset: sidebarDataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
dataset: sidebarDataset,
}),
});
const overviewEnvelope = buildDocumentQueryEnvelope({
const loadOverview = async (): Promise<TreeStreamOverview> => {
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 20,
cursor,
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
@@ -79,43 +75,88 @@ export async function GET(request: Request) {
aggregateId: null,
},
});
const overviewPlan = await resolveRustBridgeQueryPlan({
const plan = await resolveRustBridgeQueryPlan({
context,
envelope: overviewEnvelope,
envelope,
});
const overview = await executeRustBridgeQueryTransport({
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
plan: overviewPlan,
plan,
});
};
const payload = {
kind: "snapshot",
stream: "workspace",
projection: "sidebar_tree",
workspaceId,
rootNodeId: null,
cursor,
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
data: datasetWithFileTree,
snapshot: {
dataset: sidebarDatasetWithFileTree,
tree:
sidebarDatasetWithFileTree.kernel_sidebar_projection ??
sidebarDatasetWithFileTree.kernelSidebarProjection ??
null,
dataset: datasetWithFileTree,
},
overview,
};
};
return new Response(toSseFrame("snapshot", payload), {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
cancel() {
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream",
},
});
}
File diff suppressed because it is too large Load Diff
+616 -69
View File
@@ -1,8 +1,10 @@
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,
@@ -10,21 +12,49 @@ import {
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
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,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type TreeCommandAction =
| "create"
| "rename"
| "move"
| "archive"
| "restore"
| "purge"
| "embed"
| "copy";
type TreeCopyItem = {
documentId: string;
recursive: boolean;
};
type TreeCommandPayload = {
action?: "create" | "move" | "rename";
action?: TreeCommandAction;
workspaceId?: string | null;
documentId?: string | null;
parentId?: string | null;
targetParentId?: string | null;
title?: string | null;
accessScope?: "private" | "shared" | "public" | null;
content?: unknown;
sortOrder?: number | null;
sourceId?: string | null;
targetId?: string | null;
items?: TreeCopyItem[] | null;
};
function trimOrNull(value: unknown) {
@@ -42,6 +72,150 @@ function normalizeSortOrder(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
if (!streamDelta) {
return commandPayload;
}
if (isRecord(commandPayload)) {
return {
...commandPayload,
streamDelta,
};
}
return {
payload: commandPayload,
streamDelta,
};
}
async function recordTreeCommandSuccess(args: {
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
commandPayload?: unknown;
}) {
try {
await recordBridgeCommandArtifacts({
context: args.context,
envelope: args.envelope,
client: args.client,
commandPayload: args.commandPayload,
});
} catch (error) {
console.warn("[tree.commands] bridge success artifacts skipped:", error);
}
}
async function loadTreeCommandSidebarSnapshot(args: {
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
auth: Awaited<ReturnType<typeof getAuthedConvexClient>>["auth"];
workspaceId: string | null;
}) {
if (!args.workspaceId) {
return null;
}
try {
const result = await loadSidebarDataFromConvex({
client: args.client,
auth: {
userId: args.auth.userId,
email: args.auth.email,
name: args.auth.name,
},
fallbackName: args.auth.email ?? args.auth.name ?? "我的空间",
requestedWorkspaceId: args.workspaceId,
});
return result.sidebarInitialData ?? null;
} catch (error) {
console.warn("[tree.commands] sidebar snapshot skipped:", error);
return null;
}
}
function buildTreeCommandSnapshotDelta(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
if (!isRecord(sidebarSnapshot)) {
return null;
}
if (Array.isArray(sidebarSnapshot.documents)) {
return {
op: "replace_documents",
documents: sidebarSnapshot.documents,
};
}
return {
op: "replace_sidebar",
sidebar: sidebarSnapshot,
};
}
function buildTreeMovePreflightDataFromSidebarSnapshot(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
if (!isRecord(sidebarSnapshot) || !Array.isArray(sidebarSnapshot.documents)) {
return null;
}
return {
documents: sidebarSnapshot.documents,
};
}
async function resolveTreeMutationResult<TResult>(args: {
request: Request;
workspaceId: string | null;
commandName: string;
payload: unknown;
preflightData?: Record<string, unknown> | null;
pageId?: string | null;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
}) {
const context = await buildDocumentBridgeContext({
request: args.request,
workspaceId: args.workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: args.commandName,
payload: args.payload,
context,
preflightData: args.preflightData ?? null,
target: {
workspaceId: args.workspaceId,
pageId: args.pageId ?? undefined,
},
reason: `tree-route ${args.commandName}`,
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
try {
const result = await executeRustBridgeMutationTransport<TResult>({
client: args.client,
plan,
});
return {
context,
envelope,
result,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context,
envelope,
client: args.client,
error,
});
throw error;
}
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -51,11 +225,21 @@ export async function POST(request: Request) {
const payload = (await request.json()) as TreeCommandPayload;
switch (payload.action) {
case "create":
return handleCreate(request, payload);
return await handleCreate(request, payload);
case "move":
return handleMove(request, payload);
return await handleMove(request, payload);
case "rename":
return handleRename(request, payload);
return await handleRename(request, payload);
case "archive":
return await handleArchive(request, payload);
case "restore":
return await handleRestore(request, payload);
case "purge":
return await handlePurge(request, payload);
case "embed":
return await handleEmbed(request, payload);
case "copy":
return await handleCopy(request, payload);
default:
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
}
@@ -91,33 +275,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
const title = normalizeTitle(payload.title);
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.node.create",
payload: {
documentId,
workspaceId,
parentId,
title,
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
content: Array.isArray(payload.content) ? payload.content : [],
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route create",
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{
const { context, envelope, result } = await resolveTreeMutationResult<{
id: string;
title: string | null;
parent_id: string | null;
@@ -128,11 +286,42 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
created_at: string;
updated_at: string;
}>({
request,
workspaceId,
commandName: "tree.node.create",
payload: {
documentId,
workspaceId,
parentId,
title,
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
content: Array.isArray(payload.content) ? payload.content : [],
},
pageId: documentId,
client,
plan,
});
await ensureDocumentScaffold(result.id, result.title ?? title);
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: result.id,
workspace_id: result.workspace_id,
title: result.title ?? title,
parent_id: result.parent_id ?? parentId,
sort_order: result.sort_order ?? 0,
access_scope: result.access_scope,
is_starred: false,
is_template: result.is_template,
created_at: result.created_at,
updated_at: result.updated_at,
},
}),
});
return NextResponse.json({
requestId: context.requestId,
@@ -151,7 +340,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
}
async function handleMove(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
@@ -161,32 +350,44 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const parentId = trimOrNull(payload.parentId);
const sortOrder = normalizeSortOrder(payload.sortOrder);
const context = await buildDocumentBridgeContext({
request,
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.subtree.move",
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string | null;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.subtree.move",
payload: {
documentId,
parentId,
sortOrder,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route move",
refs: ["next-tree-route"],
preflightData: movePreflightData,
pageId: documentId,
client,
});
const plan = await resolveRustBridgeCommandPlan({
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
),
});
return NextResponse.json({
@@ -194,12 +395,23 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
traceId: context.traceId,
result: {
action: "move",
workspaceId,
workspaceId: trimOrNull(result?.workspace_id) ?? workspaceId,
documentId,
parentId,
sortOrder,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
parentId: trimOrNull(result?.parent_id) ?? parentId,
sortOrder:
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
? result.sort_order
: sortOrder,
updatedAt: trimOrNull(result?.updated_at) ?? null,
execution: {
...(result ?? null),
parent_id: trimOrNull(result?.parent_id) ?? parentId,
sort_order:
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
? result.sort_order
: sortOrder,
workspace_id: trimOrNull(result?.workspace_id) ?? workspaceId,
},
},
});
}
@@ -214,32 +426,33 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const title = assertTitle(payload.title ?? null);
const context = await buildDocumentBridgeContext({
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.node.rename",
commandName: "tree.node.rename",
payload: {
documentId,
workspaceId,
title,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route rename",
refs: ["next-tree-route"],
pageId: documentId,
client,
});
const plan = await resolveRustBridgeCommandPlan({
await recordTreeCommandSuccess({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: documentId,
title,
updated_at: result?.updated_at ?? null,
},
}),
});
return NextResponse.json({
@@ -256,4 +469,338 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
});
}
async function handleArchive(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.archive",
payload: {
documentId,
workspaceId,
},
pageId: documentId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "archive",
workspaceId,
documentId,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
async function handleRestore(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.restore",
payload: {
documentId,
workspaceId,
},
pageId: documentId,
client,
});
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "restore",
workspaceId,
documentId,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
async function handlePurge(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.purge",
payload: {
documentId,
},
pageId: documentId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "purge",
workspaceId,
documentId,
purged: result?.purged ?? true,
updatedAt: result?.purged_at ?? null,
execution: result ?? null,
},
});
}
async function handleEmbed(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const sourceId = assertDocumentId(payload.sourceId ?? null);
const targetId = assertDocumentId(payload.targetId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const targetContent = await client.query(api.documents.getContent, { id: targetId });
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
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);
const { context, envelope, result } = await resolveTreeMutationResult<{
revision?: number | null;
conflict_detection_key?: string | null;
}>({
request,
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,
}),
sourceDocumentId: sourceId,
targetDocumentId: targetId,
anchorBlockId: anchorId,
},
pageId: targetId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "noop",
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "embed",
workspaceId,
documentId: targetId,
sourceDocumentId: sourceId,
targetDocumentId: targetId,
execution: result ?? null,
},
});
}
async function handleCopy(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = trimOrNull(payload.targetParentId);
let workspaceId: string | null = null;
if (targetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = trimOrNull(targetDoc.workspace_id);
} else {
const firstDoc = await client.query(api.documents.getMeta, {
id: normalizedItems[0]?.documentId ?? "",
});
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = trimOrNull(firstDoc.workspace_id);
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const { context, envelope, result } = await resolveTreeMutationResult<{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}>({
request,
workspaceId,
commandName: "tree.subtree.copy",
payload: {
workspaceId,
targetParentId,
items: normalizedItems,
},
pageId: targetParentId,
client,
});
await Promise.all(
(result.items ?? []).map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "copy",
workspaceId,
targetParentId,
items: (result.items ?? []).map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
execution: result ?? null,
},
});
}
export const runtime = "nodejs";
@@ -1,7 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import React, { type ReactNode } from "react";
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -21,6 +21,33 @@ const sidebarData = {
trashedDocuments: [],
};
function buildSidebarNode(input: {
id: string;
title: string;
children?: Array<ReturnType<typeof buildSidebarNode>>;
}) {
return {
access_scope: "private",
id: input.id,
workspace_id: "ws_test",
title: input.title,
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
children: input.children ?? [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: 0,
childCount: input.children?.length ?? 0,
expandedByDefault: true,
},
};
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
@@ -60,7 +87,9 @@ vi.mock("@/components/ui/dialog", () => ({
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => (
<input ref={ref} {...props} />
)),
}));
vi.mock("@/components/ui/tabs", () => ({
@@ -94,6 +123,9 @@ describe("MoveEmbedPickerDialog", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "react",
};
});
afterEach(() => {
@@ -101,6 +133,7 @@ describe("MoveEmbedPickerDialog", () => {
root.unmount();
});
container.remove();
vi.restoreAllMocks();
vi.clearAllMocks();
mockUseDocumentSearch.mockReset();
mockUseDocumentSearch.mockReturnValue({
@@ -108,6 +141,7 @@ describe("MoveEmbedPickerDialog", () => {
isLoading: false,
error: null,
});
sidebarData.kernelSidebarTree = [];
delete window.__MNOTE_RUNTIME_CONFIG__;
});
@@ -141,6 +175,73 @@ describe("MoveEmbedPickerDialog", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("空查询时应保留根节点并过滤 excludeIds", async () => {
sidebarData.kernelSidebarTree = [
buildSidebarNode({ id: "doc_hidden", title: "隐藏页面" }),
buildSidebarNode({ id: "doc_visible", title: "保留页面" }),
];
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={vi.fn()}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={["doc_hidden"]}
onPick={vi.fn(async () => undefined)}
/>,
);
});
expect(container.querySelector('[data-testid="tree-picker-root"]')).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_hidden"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_visible"]')).not.toBeNull();
});
it("空查询态应支持键盘高亮切换并按当前高亮项选中", async () => {
sidebarData.kernelSidebarTree = [
buildSidebarNode({ id: "doc_first", title: "第一页" }),
buildSidebarNode({ id: "doc_second", title: "第二页" }),
];
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(input).not.toBeNull();
expect(pickerRows).toHaveLength(2);
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
expect(pickerRows[0]?.className ?? "").toContain("bg-[#e3ecff]");
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_first");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果也应继续复用统一 picker surface", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
@@ -198,6 +299,141 @@ describe("MoveEmbedPickerDialog", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果态应支持高亮切换并按当前高亮项选中", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(pickerRows).toHaveLength(2);
await act(async () => {
pickerRows[1]?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
expect(pickerRows[1]?.className ?? "").toContain("bg-[#e3ecff]");
await act(async () => {
pickerRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果态不应再注入根节点,且仍应支持多次 ArrowDown 后选中后续结果", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(container.querySelector('[data-testid="tree-picker-root"]')).toBeNull();
expect(pickerRows).toHaveLength(2);
await act(async () => {
input?.focus();
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
@@ -266,4 +502,165 @@ describe("MoveEmbedPickerDialog", () => {
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
).not.toBeNull();
});
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
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" } },
),
);
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={vi.fn()}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={vi.fn(async () => undefined)}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const 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();
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "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();
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",
}),
"*",
);
});
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", 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" } },
),
);
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot={false}
excludeIds={[]}
onPick={onPick}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
});
});
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface";
import type { TreeShellPickerCommand } from "@/components/sidebar/tree-shell-host";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -14,6 +15,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
export type MoveEmbedMode = "move" | "embed";
const PICKER_ROOT_ITEM_KEY = "__root__";
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: true,
@@ -106,7 +108,11 @@ function MoveEmbedPickerDialogBody({
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
const [pickerCommand, setPickerCommand] = useState<TreeShellPickerCommand | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const keyboardHighlightPendingRef = useRef(false);
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
const canDelegatePickerKeyboardToShell = treeRendererFamily === "rust_family" && Boolean(workspaceId);
const handleModeChange = useCallback((value: string) => {
setMode(value as MoveEmbedMode);
@@ -119,6 +125,13 @@ function MoveEmbedPickerDialogBody({
setHighlighted(0);
}, []);
const queuePickerCommand = useCallback((kind: TreeShellPickerCommand["kind"]) => {
setPickerCommand((prev) => ({
kind,
seq: (prev?.seq ?? 0) + 1,
}));
}, []);
const payload = useMemo(() => {
if (!workspaceId) return null;
return {
@@ -152,11 +165,11 @@ function MoveEmbedPickerDialogBody({
const result: PickerItem[] = [];
if (allowRoot && mode === "move") {
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
}
if (isEmptyQuery) {
if (allowRoot && mode === "move") {
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
}
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
const flattened = buildPickerTreeItems(
buildPageTreeProjectionItems(tree),
@@ -191,6 +204,14 @@ function MoveEmbedPickerDialogBody({
return result;
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
const pickerItemIndexByKey = useMemo(() => {
const result = new Map<string, number>();
items.forEach((item, index) => {
result.set(item.kind === "root" ? PICKER_ROOT_ITEM_KEY : item.id, index);
});
return result;
}, [items]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
const handlePick = useCallback(
async (targetId: string | null) => {
@@ -199,26 +220,161 @@ function MoveEmbedPickerDialogBody({
},
[mode, onOpenChange, onPick],
);
useEffect(() => {
if (items.length === 0) {
if (highlighted !== 0) {
setHighlighted(0);
}
return;
}
if (highlighted >= items.length) {
setHighlighted(items.length - 1);
}
}, [highlighted, items.length]);
const handleShellPickerFocusChange = useCallback(
(payload: { itemKey: string | null; documentId: string | null }) => {
const nextKey = payload.itemKey ?? payload.documentId;
if (!nextKey) {
return;
}
const nextIndex = pickerItemIndexByKey.get(nextKey);
if (typeof nextIndex === "number") {
setHighlighted(nextIndex);
}
},
[pickerItemIndexByKey],
);
const handlePickerKeyDown = useCallback(
(event: KeyboardEvent) => {
if (items.length === 0) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
keyboardHighlightPendingRef.current = true;
if (canDelegatePickerKeyboardToShell) {
queuePickerCommand("next");
return;
}
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
keyboardHighlightPendingRef.current = true;
if (canDelegatePickerKeyboardToShell) {
queuePickerCommand("previous");
return;
}
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Home") {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
keyboardHighlightPendingRef.current = true;
if (canDelegatePickerKeyboardToShell) {
queuePickerCommand("home");
return;
}
setHighlighted(0);
} else if (event.key === "End") {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
keyboardHighlightPendingRef.current = true;
if (canDelegatePickerKeyboardToShell) {
queuePickerCommand("end");
return;
}
setHighlighted(items.length - 1);
} else if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (canDelegatePickerKeyboardToShell) {
queuePickerCommand("pick");
return;
}
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
},
[canDelegatePickerKeyboardToShell, handlePick, highlighted, items, queuePickerCommand],
);
useEffect(() => {
const input = searchInputRef.current;
if (!input) {
return undefined;
}
const onKeyDown = (event: KeyboardEvent) => {
handlePickerKeyDown(event);
};
input.addEventListener("keydown", onKeyDown, true);
return () => {
input.removeEventListener("keydown", onKeyDown, true);
};
}, [handlePickerKeyDown]);
useEffect(() => {
if (!keyboardHighlightPendingRef.current) {
return;
}
keyboardHighlightPendingRef.current = false;
if (isEmptyQuery || treeRendererFamily !== "rust_family") {
return;
}
const input = searchInputRef.current;
if (!input) {
return;
}
window.requestAnimationFrame(() => {
const current = searchInputRef.current;
if (!current) {
return;
}
// 说明:same-origin picker shell 在搜索态会随高亮更新重新同步 iframe。
// 这里把焦点稳回搜索框,避免第二次 ArrowDown 丢到输入框外。
current.focus({ preventScroll: true });
const end = current.value.length;
try {
current.setSelectionRange(end, end);
} catch {
// 说明:部分输入实现不支持 selection range,这里静默忽略即可。
}
});
}, [highlighted, isEmptyQuery, treeRendererFamily]);
const highlightedItem = items[highlighted] ?? null;
const highlightedDocumentId = highlightedItem?.kind === "doc" ? highlightedItem.id : null;
const activePickerItemKey =
highlightedItem?.kind === "root"
? PICKER_ROOT_ITEM_KEY
: highlightedItem?.kind === "doc"
? highlightedItem.id
: null;
const pickerFallback = (
sidebarQuery.isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : sidebarQuery.error ? (
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<TreePickerSurface
rendererFamily={treeRendererFamily}
workspaceId={workspaceId}
treeShellEnabled={isEmptyQuery}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRoot && mode === "move"}
excludeIds={excludeIds}
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
treeShellItems={items}
items={items}
highlighted={highlighted}
onHighlight={setHighlighted}
onPick={(targetId) => {
void handlePick(targetId);
}}
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
/>
)
);
@@ -243,6 +399,7 @@ function MoveEmbedPickerDialogBody({
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={searchInputRef}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={placeholder}
@@ -253,26 +410,7 @@ function MoveEmbedPickerDialogBody({
</Tabs>
</div>
<div
className="flex-1 overflow-y-auto"
onKeyDown={(event) => {
if (isEmptyQuery) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
}}
>
<div className="flex-1 overflow-y-auto">
{!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : isEmptyQuery ? (
@@ -281,15 +419,16 @@ function MoveEmbedPickerDialogBody({
<div className="p-4 text-sm text-gray-400">...</div>
) : error ? (
<div className="p-4 text-sm text-red-600">{String(error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<TreePickerSurface
rendererFamily={treeRendererFamily}
workspaceId={workspaceId}
treeShellEnabled={Boolean(workspaceId)}
activeDocumentId={highlightedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={false}
excludeIds={excludeIds}
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
treeShellItems={items}
items={items}
highlighted={highlighted}
@@ -298,6 +437,7 @@ function MoveEmbedPickerDialogBody({
onPick={(targetId) => {
void handlePick(targetId);
}}
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
/>
)}
</div>
@@ -1,6 +1,6 @@
"use client";
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { FileTreeRow } from "@/lib/file-tree/types";
@@ -26,6 +26,47 @@ interface FileTreeProps {
const INDENT = 16;
function resolveAssetIconKind(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase();
if (assetType === "mindmap") return "mindmap";
if (assetType === "luckysheet") return "table";
const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase();
const fileName = String(row.asset.file_name ?? "").trim().toLowerCase();
const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
if (ext === "epub" || mimeType.includes("epub")) return "book";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio";
return "file";
}
function renderAssetIcon(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
const iconKind = resolveAssetIconKind(row);
const baseClass = "w-4 h-4 shrink-0";
switch (iconKind) {
case "mindmap":
return <Folder className={`${baseClass} text-[#7c3aed]`} />;
case "table":
return <FileText className={`${baseClass} text-[#b45309]`} />;
case "pdf":
return <FileText className={`${baseClass} text-[#dc2626]`} />;
case "book":
return <BookOpen className={`${baseClass} text-[#0f766e]`} />;
case "image":
return <FileImage className={`${baseClass} text-[#0891b2]`} />;
case "video":
return <FileVideo className={`${baseClass} text-[#ea580c]`} />;
case "audio":
return <AudioLines className={`${baseClass} text-[#16a34a]`} />;
default:
return <Paperclip className={`${baseClass} text-wolai-text-secondary`} />;
}
}
export function FileTree({
rows,
activeId,
@@ -300,13 +341,13 @@ export function FileTree({
) : (
<span className="w-5 h-5 shrink-0" />
)}
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
{renderAssetIcon(row)}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
) : (
<>
<span className="w-4 h-4 shrink-0" />
<Paperclip className="w-4 h-4 text-wolai-text-secondary shrink-0" />
{renderAssetIcon(row)}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
)}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
describe("sidebar-navigation", () => {
it("页面树普通打开应留在当前窗口", () => {
expect(buildSidebarDocumentOpenTarget("doc_1", "main", "http://127.0.0.1:3000")).toEqual({
kind: "same-window",
path: "/documents/doc_1",
});
});
it("显式侧栏预览打开才应使用新窗口 URL", () => {
expect(buildSidebarDocumentOpenTarget("doc_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
kind: "new-window",
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
});
});
});
@@ -0,0 +1,19 @@
export type SidebarDocumentOpenMode = "main" | "sidebar";
export type SidebarDocumentOpenTarget =
| { kind: "same-window"; path: string }
| { kind: "new-window"; url: string };
export function buildSidebarDocumentOpenTarget(
documentId: string,
mode: SidebarDocumentOpenMode,
origin?: string | null,
): SidebarDocumentOpenTarget {
const path = `/documents/${documentId}`;
if (mode === "main") {
return { kind: "same-window", path };
}
const base = origin ? `${origin}${path}` : path;
return { kind: "new-window", url: `${base}?preview=sidebar` };
}
+301 -233
View File
@@ -54,9 +54,18 @@ import {
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows";
import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import {
computeFileTreeShellDeleteTargets,
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
type FileTreeShellRow,
inferFileTreeShellTargetDocumentId,
getOrderedFileTreeShellRows,
resolveFileTreeShellMindmapTargetId,
} from "@/lib/file-tree/shell";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
computeTreePaneDeleteTargets,
@@ -69,6 +78,10 @@ import {
type TreePaneSelectionState,
writeTreePaneClipboardPayload,
} from "@/components/sidebar/tree-pane-bindings";
import {
buildSidebarDocumentOpenTarget,
type SidebarDocumentOpenMode,
} from "@/components/sidebar/sidebar-navigation";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
@@ -105,8 +118,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
@@ -122,27 +133,6 @@ const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | nul
return null;
};
const extractMindmapIdFromStoragePath = (
storagePath: string | null | undefined,
): string | null => {
if (!storagePath) return null;
const normalized = normalizeStoragePath(storagePath);
const prefix = "mindmaps/";
if (normalized.startsWith(prefix)) {
const rest = normalized.slice(prefix.length);
const id = rest.split("/")[0];
return id ? id : null;
}
const marker = "/mindmaps/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
const rest = normalized.slice(idx + marker.length);
const id = rest.split("/")[0];
return id ? id : null;
};
interface SidebarProps {
initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
@@ -212,7 +202,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const { signOut } = useAuthActions();
const activeId = segments?.[1] ?? "";
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
const isRustFamilyTreeRenderer = treeRendererFamily === "rust_family";
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
const [filter, setFilter] = useState("");
@@ -282,6 +273,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
@@ -301,6 +293,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
});
}, [sidebarData.kernelSidebarTree]);
useEffect(() => {
pageTreeFocusedDocumentIdRef.current = activeId || null;
}, [activeId]);
useEffect(() => {
const nextAssets = sidebarData.mediaAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
@@ -588,80 +584,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
const mindmapChildrenSnapshot = useMemo(() => {
const mapping = sidebarData.mindmapAssetChildren ?? {};
const mindmapDocById = new Map<string, string>(
(mindmapAssets ?? [])
.filter((asset) => asset.asset_type === "mindmap")
.map((asset) => [asset.id, asset.document_id]),
);
const mindmapIds = new Set(mindmapDocById.keys());
const mediaById = new Map<string, MediaAsset>(
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
);
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
const childIds = new Set<string>();
const assigned = new Set<string>();
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
(mediaAssets ?? []).forEach((asset) => {
const sp = asset.storage_path;
if (!sp || typeof sp !== "string") return;
const mindmapId = extractMindmapIdFromStoragePath(sp);
if (!mindmapId) return;
if (!mindmapIds.has(mindmapId)) return;
const docId = mindmapDocById.get(mindmapId);
if (docId && asset.document_id !== docId) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
childAssetsByMindmapId[mindmapId].push(asset);
});
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
(mindmapAssets ?? []).forEach((mindmapAsset) => {
const ids = mapping[mindmapAsset.id] ?? [];
if (!Array.isArray(ids) || ids.length === 0) return;
ids.forEach((id) => {
const asset = mediaById.get(id);
if (!asset) return;
if (asset.document_id !== mindmapAsset.document_id) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
childAssetsByMindmapId[mindmapAsset.id].push(asset);
});
});
return { childAssetsByMindmapId, childIds };
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = [
...((mediaAssets ?? []).filter(
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
)),
...(mindmapAssets ?? []),
...(tableAssets ?? []),
];
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
if (!exists) {
map[asset.document_id].push(asset);
}
});
return map;
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
const assetById = useMemo(() => {
const map = new Map<string, MediaAsset>();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
@@ -672,40 +594,81 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const resourceRows = useMemo(
const resourceTreeShellItems = useMemo(
() =>
buildVisibleRows({
fileTreeItems:
filter.trim().length === 0
? sidebarData.kernelFileTreeProjection.items
: undefined,
pageRows: visibleFilteredPrivatePageRows,
expanded,
assetsByDoc,
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
expandedAssetFolderIds: expandedAssetFolders,
nodeById,
assetById,
}),
filter.trim().length === 0
? undefined
: filterKernelFileTreeProjectionItems({
fileTreeItems: sidebarData.kernelFileTreeProjection.items,
visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)),
expandedDocumentIds: expanded,
expandedAssetFolderIds: expandedAssetFolders,
}),
[
assetById,
assetsByDoc,
expanded,
expandedAssetFolders,
mindmapChildrenSnapshot.childAssetsByMindmapId,
nodeById,
sidebarData.kernelFileTreeProjection.items,
visibleFilteredPrivatePageRows,
filter,
],
);
const effectivePageTreeShellRows = useMemo(
() => (filter.trim().length > 0 ? filteredPrivatePageRows : privatePageRows),
[filter, filteredPrivatePageRows, privatePageRows],
);
const effectiveResourceTreeShellItems = useMemo(
() => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items,
[resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items],
);
const resourceShellVisibleRowIds = useMemo(
() => buildFileTreeShellVisibleRowIds(effectiveResourceTreeShellItems),
[effectiveResourceTreeShellItems],
);
const resourceShellRowById = useMemo(
() =>
buildFileTreeShellRowById({
fileTreeItems: effectiveResourceTreeShellItems,
nodeById,
assetById,
}),
[assetById, effectiveResourceTreeShellItems, nodeById],
);
const resourceRows = useMemo<TreePaneRow[]>(() => {
if (isRustFamilyTreeRenderer) {
return [];
}
return buildVisibleRows({
fileTreeItems: effectiveResourceTreeShellItems,
expanded,
expandedAssetFolderIds: expandedAssetFolders,
nodeById,
assetById,
});
}, [
assetById,
effectiveResourceTreeShellItems,
expanded,
expandedAssetFolders,
isRustFamilyTreeRenderer,
nodeById,
]);
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
? resourceShellVisibleRowIds
: resourceVisibleRowIds;
useEffect(() => {
setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
}, [resourceVisibleRowIds]);
setResourceSelection((prev) =>
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
);
}, [resourceSelectionVisibleRowIds]);
const docParentById = useMemo(
() =>
@@ -730,18 +693,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
sidebarData.workspaces[0];
const pageTreeFocusedDocumentId = pageTreeFocusedDocumentIdRef.current ?? (activeId || null);
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
(documentId: string, mode: SidebarDocumentOpenMode) => {
const target = buildSidebarDocumentOpenTarget(
documentId,
mode,
typeof window !== "undefined" ? window.location.origin : null,
);
if (target.kind === "same-window") {
router.push(target.path);
setOpen(false);
return;
}
if (typeof window !== "undefined") {
const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
window.open(sidebarUrl, "_blank", "noopener,noreferrer");
window.open(target.url, "_blank", "noopener,noreferrer");
}
},
[router, setOpen],
@@ -981,7 +948,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const handlePageTreeShellNavigate = useCallback(
(documentId: string) => {
handleOpenDocument(documentId, "sidebar");
pageTreeFocusedDocumentIdRef.current = documentId;
handleOpenDocument(documentId, "main");
},
[handleOpenDocument],
);
@@ -1008,6 +976,31 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[nodeById],
);
const handlePageTreeShellExpandChange = useCallback(
(payload: { documentId: string | null; expanded: boolean }) => {
if (!payload.documentId) {
return;
}
setExpanded((prev) => {
const next = new Set(prev);
if (payload.expanded) {
next.add(payload.documentId!);
} else {
next.delete(payload.documentId!);
}
return next;
});
},
[],
);
const handlePageTreeShellFocusChange = useCallback(
(payload: { documentId: string | null }) => {
pageTreeFocusedDocumentIdRef.current = payload.documentId;
},
[],
);
const handleFileTreeShellContextMenu = useCallback(
(payload: {
documentId: string | null;
@@ -1029,9 +1022,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}
}
const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
const row = payload.rowId ? resourceShellRowById.get(payload.rowId) : null;
const node =
row && (row.kind === "doc" || row.kind === "index")
row && (row.rowKind === "doc" || row.rowKind === "index")
? row.node
: payload.documentId
? nodeById.get(payload.documentId) ?? null
@@ -1045,7 +1038,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
y: payload.y,
});
},
[assetById, nodeById, resourceRowById],
[assetById, nodeById, resourceShellRowById],
);
const handleFileTreeShellSelectionChange = useCallback(
@@ -1054,26 +1047,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
const visibleRowIds = resourceRows.map((row) => row.rowId);
const normalized = normalizeTreePaneSelectionForVisibleRows(
{
selectedRowIds: new Set(
payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)),
),
anchorRowId:
payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId)
? payload.anchorRowId
: null,
focusedRowId:
payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId)
? payload.focusedRowId
: null,
},
visibleRowIds,
resourceShellVisibleRowIds,
);
setResourceSelection(normalized);
},
[resourceRowById, resourceRows],
[resourceShellRowById, resourceShellVisibleRowIds],
);
const handleFileTreeShellAssetOpen = useCallback(
@@ -1121,9 +1113,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
event.preventDefault();
const orderedRowIds = resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.map((row) => row.rowId);
const orderedRowIds = resourceSelectionVisibleRowIds.filter((rowId) =>
resourceSelection.selectedRowIds.has(rowId),
);
await writeTreePaneClipboardPayload({
type: "mnote-file-tree",
version: 1,
@@ -1140,30 +1132,68 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
const targetDocId = inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
const targetDocId = isRustFamilyTreeRenderer
? inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceShellRowById,
activeDocId: activeId || null,
})
: inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
return;
}
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
const docItemsMap = new Map<string, boolean>();
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
const copyableAssetIds: string[] = [];
if (isRustFamilyTreeRenderer) {
const rows = getOrderedFileTreeShellRows({
rowIds: payload.rowIds,
visibleRowIds: resourceShellVisibleRowIds,
rowById: resourceShellRowById,
});
rows.forEach((row) => {
if (row.rowKind === "doc") {
docItemsMap.set(row.documentId, true);
return;
}
}
});
if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) {
docItemsMap.set(row.documentId, false);
return;
}
if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) {
copyableAssetIds.push(row.asset.id);
}
});
} else {
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
}
});
rows
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.forEach((asset) => {
copyableAssetIds.push(asset.id);
});
}
if (docItemsMap.size > 0) {
try {
@@ -1183,9 +1213,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
emitDocumentsChanged(targetDocId);
}
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
@@ -1213,10 +1240,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return () => window.removeEventListener("keydown", handler);
}, [
activeId,
isRustFamilyTreeRenderer,
resourceSelectionVisibleRowIds,
resourceShellRowById,
resourceRowById,
resourceRows,
resourceSelection.focusedRowId,
resourceSelection.selectedRowIds,
resourceShellVisibleRowIds,
sidebarQuery,
]);
@@ -1474,11 +1504,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const handleDeleteResourceSelection = useCallback(async () => {
const { docIds, assetIds } = computeTreePaneDeleteTargets({
visibleRows: resourceRows,
selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById,
});
const shellDeleteTargets = isRustFamilyTreeRenderer
? computeFileTreeShellDeleteTargets({
visibleRowIds: resourceShellVisibleRowIds,
rowById: resourceShellRowById,
selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById,
})
: null;
const legacyDeleteTargets = !isRustFamilyTreeRenderer
? computeTreePaneDeleteTargets({
visibleRows: resourceRows,
selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById,
})
: null;
const docIds = shellDeleteTargets?.docIds ?? legacyDeleteTargets?.docIds ?? [];
const assetIds = shellDeleteTargets?.assetIds ?? legacyDeleteTargets?.assetIds ?? [];
if (docIds.length === 0 && assetIds.length === 0) {
return;
@@ -1493,14 +1535,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from(
new Map(
resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
);
const selectedAssetHints =
shellDeleteTargets?.assetHints ??
Array.from(
new Map(
resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
);
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
@@ -1558,12 +1602,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}, [
activeId,
docParentById,
isRustFamilyTreeRenderer,
resourceRows,
resourceShellRowById,
resourceShellVisibleRowIds,
resourceSelection.selectedRowIds,
handleDeleteAssets,
mediaAssets,
mindmapAssets,
tableAssets,
refreshTree,
router,
]);
@@ -1710,31 +1754,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const handleResourcePaneDropFiles = useCallback(
(docId: string, files: FileList, targetRow?: TreePaneRow) => {
(payload: {
targetDocumentId: string | null;
targetRowId: string | null;
targetRowKind: string | null;
targetAssetId: string | null;
files: FileList | File[];
}) => {
void (async () => {
const droppedFiles = Array.from(files ?? []);
const droppedFiles = Array.from(payload.files ?? []);
if (droppedFiles.length === 0) return;
const targetRow =
payload.targetRowId
? (resourceShellRowById.get(payload.targetRowId) ?? null)
: null;
const targetMindmapId = (() => {
if (!targetRow) return null;
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
return targetRow.asset.id;
}
if (targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
}
return null;
})();
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const inferredTargetDocId =
docId ||
inferPasteTargetDocId({
payload.targetDocumentId ||
inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
rowById: resourceShellRowById,
activeDocId: activeId || null,
}) ||
"";
@@ -1799,7 +1844,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[
activeId,
editorBridge,
resourceRowById,
resourceShellRowById,
resourceSelection.focusedRowId,
sidebarData.activeWorkspaceId,
sidebarData.documents,
@@ -1808,23 +1853,33 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const handleResourcePaneInternalDrop = useCallback(
(args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
(payload: {
targetDocumentId: string | null;
targetRowId: string | null;
targetRowKind: string | null;
targetAssetId: string | null;
rowIds: string[];
copy: boolean;
}) => {
void (async () => {
const targetDocId = inferDropTargetDocId(args.targetRow);
const targetRow =
payload.targetRowId
? (resourceShellRowById.get(payload.targetRowId) ?? null)
: null;
const targetDocId =
payload.targetDocumentId ??
targetRow?.documentId ??
inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceShellRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
return;
}
const targetMindmapId = (() => {
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
return args.targetRow.asset.id;
}
if (args.targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
}
return null;
})();
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
@@ -1834,26 +1889,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const uniqueRowIds: string[] = [];
const seen = new Set<string>();
args.rowIds.forEach((id) => {
payload.rowIds.forEach((id) => {
if (!id || seen.has(id)) return;
seen.add(id);
uniqueRowIds.push(id);
});
const rows = uniqueRowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
.map((rowId) => resourceShellRowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row));
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId);
const assetRows = rows.filter(
(row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } =>
row.rowKind === "asset" && Boolean(row.asset),
);
const copyableAssetIds = assetRows
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.map((asset) => asset.id);
if (docIds.length === 0 && copyableAssetIds.length === 0) {
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
return;
}
if (args.copy) {
if (payload.copy) {
if (docIds.length > 0) {
try {
await copyTreeCommand({
@@ -1894,17 +1955,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
if (topLevelDocIds.length > 0) {
if (
isInvalidDocDrop({
sourceDocIds: topLevelDocIds,
targetParentId: targetDocId,
parentById: docParentById,
})
) {
setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
return;
}
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
setTree((prev) => {
let next = prev;
@@ -1915,12 +1965,19 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
});
setExpanded((prev) => new Set(prev).add(targetDocId));
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await moveDocumentCommand({
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
});
try {
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await moveDocumentCommand({
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
});
}
} catch (error) {
await refreshTree();
const message = error instanceof Error ? error.message : "移动页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await refreshTree();
emitDocumentsChanged(targetDocId);
@@ -1943,7 +2000,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
await sidebarQuery.refetch();
const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
const sourceDocIds = new Set(
assetRows
.map((row) => row.asset?.document_id ?? null)
.filter((documentId): documentId is string => Boolean(documentId)),
);
sourceDocIds.forEach((id) => emitAssetsChanged(id));
emitAssetsChanged(targetDocId);
}
@@ -1952,7 +2013,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[
childrenCountByParentId,
docParentById,
resourceRowById,
resourceSelection.focusedRowId,
activeId,
resourceShellRowById,
moveLocalNode,
refreshTree,
sidebarQuery,
@@ -2719,17 +2782,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
mode="page"
rendererFamily={treeRendererFamily}
workspaceId={sidebarData.activeWorkspaceId ?? null}
treeShellEnabled={filter.trim().length === 0}
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
className="h-full"
rows={visibleFilteredPrivatePageRows}
rows={isRustFamilyTreeRenderer ? undefined : visibleFilteredPrivatePageRows}
treeShellRows={effectivePageTreeShellRows}
expanded={expanded}
activeId={activeId}
focusedDocumentId={pageTreeFocusedDocumentId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
onNavigate={handlePageTreeShellNavigate}
onPageContextMenu={handlePageTreeShellContextMenu}
onPageExpandChange={handlePageTreeShellExpandChange}
onPageFocusChange={handlePageTreeShellFocusChange}
onTreeMutation={handleTreeShellMutation}
/>
</div>
@@ -2750,9 +2817,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
mode="filetree"
rendererFamily={treeRendererFamily}
workspaceId={sidebarData.activeWorkspaceId ?? null}
treeShellEnabled={filter.trim().length === 0}
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
className="h-full"
rows={resourceRows}
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
treeShellItems={effectiveResourceTreeShellItems}
activeId={activeId}
selectedRowIds={resourceSelection.selectedRowIds}
onRowClick={handleResourceRowClick}
@@ -5,31 +5,62 @@ import {
TreeShellIframeHost,
type TreeShellPickerItem,
} from "@/components/sidebar/tree-shell-iframe-host";
import type { FileTreeRow } from "@/lib/file-tree/types";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import { cn } from "@/lib/utils";
export type TreeRendererFamily = "react" | "rust_family";
export type TreeShellHostMode = "page" | "filetree" | "picker";
export type TreeShellPickerCommand = {
kind: "next" | "previous" | "home" | "end" | "pick";
seq: number;
};
export type FileTreeShellInternalDropPayload = {
targetDocumentId: string | null;
targetRowId: string | null;
targetRowKind: string | null;
targetAssetId: string | null;
rowIds: string[];
copy: boolean;
};
export type FileTreeShellExternalDropPayload = {
targetDocumentId: string | null;
targetRowId: string | null;
targetRowKind: string | null;
targetAssetId: string | null;
files: FileList | File[];
};
type TreeShellHostProps = {
mode: TreeShellHostMode;
surfaceTestId: string;
rendererFamily?: TreeRendererFamily;
className?: string;
treeShellEnabled?: boolean;
fallbackImplementation?: string;
workspaceId?: string | null;
rootNodeId?: string | null;
activeDocumentId?: string | null;
focusedDocumentId?: string | null;
activePickerItemKey?: string | null;
allowRootPick?: boolean;
excludeIds?: string[];
pickerCommand?: TreeShellPickerCommand | null;
pickerItems?: TreeShellPickerItem[];
fileTreeRows?: FileTreeRow[];
pageTreeItems?: PageTreeProjectionItem[];
inlineFileTreeItems?: KernelFileTreeProjectionItem[];
channel?: string;
host?: string;
onNavigate?: (documentId: string) => void;
onPick?: (targetId: string | null) => void;
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onFileTreeContextMenu?: (payload: {
documentId: string | null;
assetId: string | null;
@@ -43,8 +74,8 @@ type TreeShellHostProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
children: ReactNode;
@@ -56,18 +87,26 @@ export function TreeShellHost({
rendererFamily = "react",
className,
treeShellEnabled = true,
fallbackImplementation,
workspaceId = null,
rootNodeId = null,
activeDocumentId = null,
focusedDocumentId = null,
activePickerItemKey = null,
allowRootPick = false,
excludeIds = [],
pickerItems = [],
fileTreeRows = [],
pickerCommand = null,
pickerItems,
pageTreeItems,
inlineFileTreeItems,
channel,
host,
onNavigate,
onPick,
onPickerFocusChange,
onPageContextMenu,
onPageExpandChange,
onPageFocusChange,
onFileTreeContextMenu,
onFileTreeSelectionChange,
onInternalDrop,
@@ -77,13 +116,12 @@ export function TreeShellHost({
children,
}: TreeShellHostProps) {
const useRustHost = rendererFamily === "rust_family";
const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim());
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
const implementation = useIframeHost
? "mnote_web_iframe_proxy"
: rendererFamily === "rust_family"
? "react_fallback"
: "react_primary";
: fallbackImplementation ??
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
return (
<div
@@ -92,6 +130,7 @@ export function TreeShellHost({
data-renderer-family={rendererFamily}
data-tree-host-kind={hostKind}
data-tree-host-implementation={implementation}
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
className={cn(className)}
>
{useRustHost ? (
@@ -109,15 +148,22 @@ export function TreeShellHost({
workspaceId={workspaceId}
rootNodeId={rootNodeId}
activeDocumentId={activeDocumentId}
focusedDocumentId={focusedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={pickerItems}
fileTreeRows={fileTreeRows}
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}
@@ -1,8 +1,15 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import {
TreeShellIframeHost,
type TreeShellPickerItem,
buildTreeShellInlineKernelFileTreeItems,
buildTreeShellInlinePageItems,
buildTreeShellIframeSrc,
buildTreeShellInlinePickerItems,
injectTreeShellInlineOverrides,
@@ -24,6 +31,7 @@ describe("tree-shell-iframe-host", () => {
act(() => {
root.unmount();
});
vi.restoreAllMocks();
container.remove();
});
@@ -32,6 +40,8 @@ describe("tree-shell-iframe-host", () => {
mode: "picker",
workspaceId: "ws_picker",
activeDocumentId: "doc_active",
focusedDocumentId: "doc_focus",
activePickerItemKey: "__root__",
allowRootPick: true,
excludeIds: ["doc_hidden", "doc_other"],
channel: "tree-picker-surface",
@@ -43,6 +53,8 @@ describe("tree-shell-iframe-host", () => {
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
expect(url.searchParams.get("mode")).toBe("picker");
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
expect(url.searchParams.get("focusedDocumentId")).toBe("doc_focus");
expect(url.searchParams.get("activePickerItemKey")).toBe("__root__");
expect(url.searchParams.get("allowRootPick")).toBe("1");
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
@@ -85,9 +97,409 @@ describe("tree-shell-iframe-host", () => {
);
});
it("page tree 提供 inline items 时应直接生成 srcDoc,不再 fetch /api/tree/shell", async () => {
const fetchMock = 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 pageItems: PageTreeProjectionItem[] = [
{
rowId: "page:doc_parent",
nodeId: "doc_parent",
parentNodeId: null,
nodeType: "page",
projectionKind: "page_tree",
depth: 0,
position: 0,
title: "父页面",
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_parent",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
node: {
id: "doc_parent",
title: "父页面",
workspace_id: "ws_1",
parent_id: null,
sort_order: 0,
is_archived: false,
is_deleted: false,
is_published: false,
is_starred: false,
access_scope: "private",
created_at: "2026-04-24T00:00:00.000Z",
updated_at: "2026-04-24T00:00:00.000Z",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: 0,
childCount: 1,
expandedByDefault: false,
},
} as unknown as SidebarTreeNode,
},
];
expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([
expect.objectContaining({
nodeId: "doc_parent",
parentNodeId: null,
title: "父页面",
childCount: 1,
expandedByDefault: true,
}),
]);
await act(async () => {
root.render(
<TreeShellIframeHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
workspaceId="ws_1"
activeDocumentId="doc_parent"
pageTreeItems={pageItems}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
});
it("SSR 时 inline tree shell 应先输出稳定 loading srcDoc,避免大模板属性水合不一致", () => {
const pageItems: PageTreeProjectionItem[] = [
{
rowId: "page:doc_parent",
nodeId: "doc_parent",
parentNodeId: null,
nodeType: "page",
projectionKind: "page_tree",
depth: 0,
position: 0,
title: "父页面",
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_parent",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
node: {} as SidebarTreeNode,
},
];
const html = renderToString(
<TreeShellIframeHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
workspaceId="ws_1"
activeDocumentId="doc_parent"
pageTreeItems={pageItems}
/>,
);
expect(html).toContain("Tree Shell Loading");
expect(html).not.toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(html).not.toContain("父页面");
});
it("page tree 提供空 inline items 时也应直接生成 srcDoc", async () => {
const fetchMock = 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" } },
),
);
await act(async () => {
root.render(
<TreeShellIframeHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
workspaceId="ws_1"
activeDocumentId={null}
pageTreeItems={[]}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
});
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
const fetchMock = 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 fileTreeItems: KernelFileTreeProjectionItem[] = [
{
rowId: "doc:doc_a",
rowKind: "document",
nodeId: "doc_a",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "文档 A",
depth: 0,
position: 0,
childCount: 2,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_a",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "asset:asset_pdf",
rowKind: "asset",
nodeId: "asset:asset_pdf",
parentNodeId: "doc_a",
nodeType: "pdf",
projectionKind: "file_tree",
title: "guide.pdf",
depth: 1,
position: 1,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "pdf",
documentId: "doc_a",
assetId: "asset_pdf",
workspaceId: "ws_1",
assetKind: "pdf",
iconHint: "pdf",
},
iconHint: "pdf",
},
];
expect(buildTreeShellInlineKernelFileTreeItems(fileTreeItems)).toEqual([
expect.objectContaining({
nodeId: "doc_a",
rowKind: "document",
iconHint: "page",
}),
expect.objectContaining({
nodeId: "asset:asset_pdf",
rowKind: "asset",
iconHint: "pdf",
}),
]);
await act(async () => {
root.render(
<TreeShellIframeHost
mode="filetree"
surfaceTestId="sidebar-file-tree-shell"
workspaceId="ws_1"
activeDocumentId="doc_a"
inlineFileTreeItems={fileTreeItems}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
});
it("picker inline override 在高亮变化时应复用 bootstrap 文档,并通过 postMessage 同步状态", async () => {
const fetchMock = 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 pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }];
await act(async () => {
root.render(
<TreeShellIframeHost
mode="picker"
surfaceTestId="tree-picker-surface"
workspaceId="ws_1"
activeDocumentId="doc_1"
activePickerItemKey="doc_1"
pickerItems={pickerItems}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
const postMessage = vi.fn();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: { postMessage },
});
await act(async () => {
root.render(
<TreeShellIframeHost
mode="picker"
surfaceTestId="tree-picker-surface"
workspaceId="ws_1"
activeDocumentId="doc_2"
activePickerItemKey="doc_2"
pickerItems={pickerItems}
/>,
);
});
expect(fetchMock).not.toHaveBeenCalled();
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.shell.state.patch",
activeDocumentId: "doc_2",
activePickerItemKey: "doc_2",
}),
"*",
);
});
it("picker 宿主应向 iframe 下发键盘命令,并接回焦点变化事件", async () => {
const fetchMock = 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 onPickerFocusChange = vi.fn();
const pickerItems: TreeShellPickerItem[] = [
{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 },
{ kind: "doc", id: "doc_2", title: "页面 2", depth: 0 },
];
await act(async () => {
root.render(
<TreeShellIframeHost
mode="picker"
surfaceTestId="tree-picker-surface"
workspaceId="ws_1"
activeDocumentId="doc_1"
activePickerItemKey="doc_1"
pickerItems={pickerItems}
onPickerFocusChange={onPickerFocusChange}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
const postMessage = vi.fn();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
vi.spyOn(window, "postMessage").mockImplementation(postMessage);
await act(async () => {
root.render(
<TreeShellIframeHost
mode="picker"
surfaceTestId="tree-picker-surface"
workspaceId="ws_1"
activeDocumentId="doc_1"
activePickerItemKey="doc_1"
pickerItems={pickerItems}
pickerCommand={{ kind: "next", seq: 1 }}
onPickerFocusChange={onPickerFocusChange}
/>,
);
});
expect(fetchMock).not.toHaveBeenCalled();
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "next",
}),
"*",
);
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "tree-picker-surface",
type: "tree.picker.focus.changed",
documentId: "doc_2",
itemKey: "doc_2",
},
source: window,
}),
);
});
expect(onPickerFocusChange).toHaveBeenCalledWith({
documentId: "doc_2",
itemKey: "doc_2",
});
});
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
const onNavigate = vi.fn();
const onPageContextMenu = vi.fn();
const onPageExpandChange = vi.fn();
const onPageFocusChange = vi.fn();
const onPick = vi.fn();
const onFileTreeContextMenu = vi.fn();
const onFileTreeSelectionChange = vi.fn();
@@ -95,20 +507,6 @@ describe("tree-shell-iframe-host", () => {
const onTreeMutation = vi.fn();
const onInternalDrop = vi.fn();
const onDropFiles = vi.fn();
const targetRow = {
kind: "doc",
rowId: "doc:doc_target",
depth: 0,
docId: "doc_target",
parentDocId: null,
node: {
_id: "doc_target",
id: "doc_target",
title: "目标页面",
},
hasChildren: false,
isExpanded: false,
};
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
await act(async () => {
@@ -122,6 +520,8 @@ describe("tree-shell-iframe-host", () => {
host="sidebar-file-tree-shell"
onNavigate={onNavigate}
onPageContextMenu={onPageContextMenu}
onPageExpandChange={onPageExpandChange}
onPageFocusChange={onPageFocusChange}
onPick={onPick}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
@@ -178,6 +578,27 @@ describe("tree-shell-iframe-host", () => {
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.page.expand.changed",
documentId: "doc_2",
expanded: true,
},
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.page.focus.changed",
documentId: "doc_3",
},
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
@@ -244,6 +665,13 @@ describe("tree-shell-iframe-host", () => {
x: 12,
y: 34,
});
expect(onPageExpandChange).toHaveBeenCalledWith({
documentId: "doc_2",
expanded: true,
});
expect(onPageFocusChange).toHaveBeenCalledWith({
documentId: "doc_3",
});
expect(onPick).toHaveBeenCalledWith(null);
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
documentId: "doc_2",
@@ -275,7 +703,9 @@ describe("tree-shell-iframe-host", () => {
type: "tree.filetree.internal-drop",
rowIds: ["doc:doc_source", "asset:asset_source"],
copy: true,
targetRow,
rowId: "doc:doc_target",
rowKind: "doc",
documentId: "doc_target",
},
source: window,
}),
@@ -286,7 +716,8 @@ describe("tree-shell-iframe-host", () => {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.drop-files",
documentId: "doc_target",
targetRow,
rowId: "doc:doc_target",
rowKind: "doc",
files: [droppedFile],
},
source: window,
@@ -295,10 +726,19 @@ describe("tree-shell-iframe-host", () => {
});
expect(onInternalDrop).toHaveBeenCalledWith({
targetRow,
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
rowIds: ["doc:doc_source", "asset:asset_source"],
copy: true,
});
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
expect(onDropFiles).toHaveBeenCalledWith({
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
files: [droppedFile],
});
});
});
File diff suppressed because it is too large Load Diff
@@ -5,14 +5,6 @@ import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/components/sidebar/private-tree", () => ({
PrivateTree: () => <div data-testid="private-tree-fallback" />,
}));
vi.mock("@/components/sidebar/file-tree", () => ({
FileTree: () => <div data-testid="file-tree-fallback" />,
}));
describe("tree-shell-surface", () => {
let container: HTMLDivElement;
let root: Root;
@@ -30,7 +22,7 @@ describe("tree-shell-surface", () => {
container.remove();
});
function renderPageSurface(rendererFamily: TreeRendererFamily) {
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
act(() => {
root.render(
<SidebarTreeSurface
@@ -41,6 +33,7 @@ describe("tree-shell-surface", () => {
rows={[]}
expanded={new Set<string>()}
activeId=""
focusedDocumentId={focusedDocumentId}
onToggleExpand={() => undefined}
onMove={() => undefined}
onCreateChild={() => undefined}
@@ -64,11 +57,21 @@ describe("tree-shell-surface", () => {
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(container.querySelector('[data-testid="private-tree-fallback"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree 在禁用 tree shell 时应安全回退到 React fallback", () => {
act(() => {
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
renderPageSurface("rust_family", "doc_focus");
const iframe = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
) as HTMLIFrameElement | null;
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
});
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
mode="page"
@@ -87,8 +90,33 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
expect(container.querySelector('[data-testid="private-tree-fallback"]')).not.toBeNull();
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
act(() => {
root.render(
<SidebarTreeSurface
mode="page"
rendererFamily="rust_family"
workspaceId={null}
treeShellEnabled
rows={[]}
expanded={new Set<string>()}
activeId=""
onToggleExpand={() => undefined}
onMove={() => undefined}
onCreateChild={() => undefined}
onContextMenu={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("page_tree_renderer_removed");
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
@@ -122,26 +150,63 @@ describe("tree-shell-surface", () => {
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(container.querySelector('[data-testid="file-tree-fallback"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled={false}
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
it("file tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
act(() => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId={null}
treeShellEnabled
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("filetree_renderer_removed");
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
const onInternalDrop = vi.fn();
const onDropFiles = vi.fn();
const targetRow = {
kind: "doc",
rowId: "doc:doc_target",
depth: 0,
docId: "doc_target",
parentDocId: null,
node: {
_id: "doc_target",
id: "doc_target",
title: "目标页面",
},
hasChildren: false,
isExpanded: false,
};
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
await act(async () => {
@@ -151,7 +216,7 @@ describe("tree-shell-surface", () => {
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
rows={[targetRow]}
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
@@ -182,7 +247,9 @@ describe("tree-shell-surface", () => {
type: "tree.filetree.internal-drop",
rowIds: ["doc:doc_source"],
copy: false,
targetRow,
rowId: "doc:doc_target",
rowKind: "doc",
documentId: "doc_target",
},
source: window,
}),
@@ -193,7 +260,8 @@ describe("tree-shell-surface", () => {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.external-drop",
documentId: "doc_target",
targetRow,
rowId: "doc:doc_target",
rowKind: "doc",
files: [droppedFile],
},
source: window,
@@ -202,11 +270,20 @@ describe("tree-shell-surface", () => {
});
expect(onInternalDrop).toHaveBeenCalledWith({
targetRow,
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
rowIds: ["doc:doc_source"],
copy: false,
});
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
expect(onDropFiles).toHaveBeenCalledWith({
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
files: [droppedFile],
});
});
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
@@ -238,8 +315,8 @@ describe("tree-shell-surface", () => {
expect(onPick).not.toHaveBeenCalled();
});
it("picker 在 rust_family tree shell 不可用时仍应保留 React fallback", () => {
act(() => {
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
await act(async () => {
root.render(
<TreePickerSurface
rendererFamily="rust_family"
@@ -254,13 +331,17 @@ describe("tree-shell-surface", () => {
);
});
await act(async () => {
await Promise.resolve();
});
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const row = container.querySelector('[data-testid="tree-picker-row"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull();
expect(row).not.toBeNull();
expect(iframe).not.toBeNull();
});
});
@@ -1,9 +1,14 @@
"use client";
import type { DragEvent, MouseEvent } from "react";
import { FileTree } from "@/components/sidebar/file-tree";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { TreeShellHost, type TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
import {
TreeShellHost,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellPickerCommand,
type TreeRendererFamily,
} from "@/components/sidebar/tree-shell-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { FileTreeRow } from "@/lib/file-tree/types";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
@@ -16,9 +21,11 @@ type SidebarPageTreeSurfaceProps = {
rendererFamily?: TreeRendererFamily;
workspaceId: string | null;
treeShellEnabled?: boolean;
rows: PageTreeProjectionItem[];
rows?: PageTreeProjectionItem[];
treeShellRows?: PageTreeProjectionItem[];
expanded: Set<string>;
activeId: string;
focusedDocumentId?: string | null;
className?: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
@@ -26,6 +33,8 @@ type SidebarPageTreeSurfaceProps = {
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
onNavigate?: (documentId: string) => void;
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
};
@@ -34,7 +43,8 @@ type SidebarFileTreeSurfaceProps = {
rendererFamily?: TreeRendererFamily;
workspaceId: string | null;
treeShellEnabled?: boolean;
rows: FileTreeRow[];
rows?: FileTreeRow[];
treeShellItems?: KernelFileTreeProjectionItem[];
activeId: string;
selectedRowIds: Set<string>;
className?: string;
@@ -46,8 +56,8 @@ type SidebarFileTreeSurfaceProps = {
onToggleAssetFolderExpand?: (assetId: string) => void;
onCreateChild: (parentId: string | null) => void;
onBlankMouseDown?: (event: MouseEvent) => void;
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onNavigate?: (documentId: string) => void;
onFileTreeContextMenu?: (payload: {
documentId: string | null;
@@ -79,8 +89,10 @@ type TreePickerSurfaceProps = {
workspaceId: string | null;
treeShellEnabled?: boolean;
activeDocumentId?: string | null;
activePickerItemKey?: string | null;
allowRootPick?: boolean;
excludeIds?: string[];
pickerCommand?: TreeShellPickerCommand | null;
treeShellItems?: TreePickerSurfaceItem[];
items: TreePickerSurfaceItem[];
highlighted: number;
@@ -88,6 +100,7 @@ type TreePickerSurfaceProps = {
emptyText?: string;
onHighlight: (index: number) => void;
onPick: (targetId: string | null) => void;
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
};
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
@@ -96,33 +109,27 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
? "sidebar-page-tree-shell"
: "sidebar-file-tree-shell";
const rendererFamily = props.rendererFamily ?? "react";
const pageTreeFallback = (
<div
data-testid="page-tree-renderer-removed"
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
>
React renderer 退使 Rust tree shell 宿
</div>
);
const fileTreeFallback = (
<div
data-testid="file-tree-renderer-removed"
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
>
React renderer 退使 Rust tree shell 宿
</div>
);
const fallbackContent =
props.mode === "page" ? (
<PrivateTree
rows={props.rows}
expanded={props.expanded}
activeId={props.activeId}
onToggleExpand={props.onToggleExpand}
onMove={props.onMove}
onCreateChild={props.onCreateChild}
onContextMenu={props.onContextMenu}
/>
pageTreeFallback
) : (
<FileTree
rows={props.rows}
activeId={props.activeId}
selectedRowIds={props.selectedRowIds}
onRowClick={props.onRowClick}
onRowDoubleClick={props.onRowDoubleClick}
onRowContextMenu={props.onRowContextMenu}
onRowDragStart={props.onRowDragStart}
onToggleExpand={props.onToggleExpand}
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
onCreateChild={props.onCreateChild}
onBlankMouseDown={props.onBlankMouseDown}
onDropFiles={props.onDropFiles}
onInternalDrop={props.onInternalDrop}
/>
fileTreeFallback
);
return (
@@ -131,11 +138,20 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
surfaceTestId={surfaceTestId}
rendererFamily={rendererFamily}
treeShellEnabled={props.treeShellEnabled}
fallbackImplementation={
props.mode === "page"
? "page_tree_renderer_removed"
: "filetree_renderer_removed"
}
workspaceId={props.workspaceId}
activeDocumentId={props.activeId}
fileTreeRows={props.mode === "filetree" ? props.rows : undefined}
focusedDocumentId={props.mode === "page" ? (props.focusedDocumentId ?? null) : undefined}
pageTreeItems={props.mode === "page" ? props.treeShellRows : undefined}
inlineFileTreeItems={props.mode === "filetree" ? props.treeShellItems : undefined}
onNavigate={props.onNavigate}
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
onPageExpandChange={props.mode === "page" ? props.onPageExpandChange : undefined}
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
@@ -157,8 +173,10 @@ export function TreePickerSurface({
workspaceId,
treeShellEnabled = true,
activeDocumentId = null,
activePickerItemKey = null,
allowRootPick = false,
excludeIds = [],
pickerCommand = null,
treeShellItems,
items,
highlighted,
@@ -166,8 +184,11 @@ export function TreePickerSurface({
emptyText = "没有匹配结果",
onHighlight,
onPick,
onPickerFocusChange,
}: TreePickerSurfaceProps) {
const hasItems = items.length > 0;
const effectiveTreeShellItems =
rendererFamily === "rust_family" ? (treeShellItems ?? items) : treeShellItems;
return (
<TreeShellHost
@@ -177,10 +198,13 @@ export function TreePickerSurface({
treeShellEnabled={treeShellEnabled}
workspaceId={workspaceId}
activeDocumentId={activeDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerItems={treeShellItems}
pickerCommand={pickerCommand}
pickerItems={effectiveTreeShellItems}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
className={cn(hasItems ? "py-2" : null, className)}
>
{!hasItems ? (
+20 -15
View File
@@ -2,20 +2,25 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
@@ -23,6 +23,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client?: ConvexHttpClient;
commandPayload?: unknown;
status?: BridgeCommandLogStatus;
eventStatus?: BridgeDomainEventStatus;
error?: string | null;
@@ -35,7 +36,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
const commandLogId = `clog_${input.envelope.commandId}`;
const eventId = `evt_${input.envelope.commandId}`;
const now = input.now ?? new Date().toISOString();
const payload = input.envelope.payload as Record<string, unknown>;
const payload = input.commandPayload ?? input.envelope.payload;
const status = input.status ?? "succeeded";
const eventStatus =
input.eventStatus ??
@@ -70,6 +70,7 @@ export type CommandEnvelope<T> = {
source: BridgeSource;
target: BridgeTarget | null;
payload: T;
preflightData?: Record<string, unknown> | null;
reason: string | null;
refs: string[];
dryRun: boolean;
@@ -275,6 +276,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
payload: T;
context: BridgeContext;
target?: BridgeTarget | null;
preflightData?: Record<string, unknown> | null;
reason?: string | null;
refs?: string[];
}): CommandEnvelope<T> {
@@ -286,6 +288,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
source: input.context.source,
target: input.target ?? null,
payload: input.payload,
preflightData: input.preflightData ?? null,
reason: input.reason ?? null,
refs: input.refs ?? [],
dryRun: input.context.dryRun,
@@ -3,6 +3,7 @@ import {
compareDocumentCanonicalOrder,
getCanonicalDocumentByBusinessId,
getCanonicalParentDocumentId,
pickCanonicalDocumentRecordsByBusinessId,
pickCanonicalDocumentRecord,
} from "../../../convex/_utils/documentRecord";
@@ -176,3 +177,37 @@ describe("canonical document helper", () => {
expect(parentId).toBe("parent_alive");
});
});
describe("pickCanonicalDocumentRecordsByBusinessId", () => {
it("同一 workspace 扫描结果里 business id 重复时应先折叠成 canonical 记录,供树命令写链复用", () => {
const records = pickCanonicalDocumentRecordsByBusinessId([
{
_id: "doc_old",
id: "doc_1",
parent_id: "parent_old",
deleted_at: null,
created_at: "2026-04-14T00:00:00.000Z",
updated_at: "2026-04-14T00:00:01.000Z",
},
{
_id: "doc_new",
id: "doc_1",
parent_id: "parent_new",
deleted_at: null,
created_at: "2026-04-14T00:00:02.000Z",
updated_at: "2026-04-14T00:00:03.000Z",
},
{
_id: "doc_2",
id: "doc_2",
parent_id: null,
deleted_at: null,
created_at: "2026-04-14T00:00:04.000Z",
updated_at: "2026-04-14T00:00:05.000Z",
},
]);
expect(records).toHaveLength(2);
expect(records.map((record) => record._id)).toEqual(["doc_new", "doc_2"]);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildParentById, isAncestorOf } from "../../../convex/_utils/documentTree";
describe("document tree helper", () => {
it("构建父链映射时保留页面与父页面关系,供树命令 legality 复用", () => {
const parentById = buildParentById([
{ id: "root", parent_id: null },
{ id: "child", parent_id: "root" },
{ id: "leaf", parent_id: "child" },
]);
expect(parentById.get("root")).toBeNull();
expect(parentById.get("child")).toBe("root");
expect(parentById.get("leaf")).toBe("child");
});
it("祖先判断应能识别多级后代,避免把页面移动到自己的子树下面", () => {
const parentById = buildParentById([
{ id: "root", parent_id: null },
{ id: "child", parent_id: "root" },
{ id: "leaf", parent_id: "child" },
]);
expect(isAncestorOf("root", "leaf", parentById)).toBe(true);
expect(isAncestorOf("child", "leaf", parentById)).toBe(true);
expect(isAncestorOf("leaf", "root", parentById)).toBe(false);
expect(isAncestorOf("missing", "leaf", parentById)).toBe(false);
});
});
@@ -1,5 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { buildParentById } from "@/lib/file-tree/dnd";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
vi.mock("next/server", () => ({
NextResponse: {
@@ -42,6 +55,7 @@ vi.mock("@/lib/server/local-paths", () => ({
}));
const {
handleDocumentMoveRequest,
normalizeDocumentCopyTreePayload,
normalizeDocumentMovePayload,
resolveSubtreeMoveLegality,
@@ -128,4 +142,244 @@ describe("page-lifecycle-command-adapter", () => {
isInvalid: false,
});
});
it("documents.move 应把 movePreflight 透传给 Rust plan", async () => {
const client = {
query: vi.fn(async (name: string, args: { id: string }) => {
if (name !== "documents:getMeta") {
return null;
}
if (args.id === "doc_1") {
return {
id: "doc_1",
workspace_id: "ws_1",
parent_id: null,
};
}
if (args.id === "child_1") {
return {
id: "child_1",
workspace_id: "ws_1",
parent_id: "doc_1",
};
}
return null;
}),
mutation: vi.fn(),
};
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: client as never,
});
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_move_1",
traceId: "trace_move_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.move",
commandId: "cmd_move_1",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_move_1",
traceId: "trace_move_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
parentId: "child_1",
sortOrder: 0,
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true } as never);
const response = await handleDocumentMoveRequest(
new Request("http://127.0.0.1:3000/api/documents/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
documentId: "doc_1",
parentId: "child_1",
position: 0,
}),
}),
);
expect(response.status).toBe(200);
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: expect.objectContaining({
requestId: "req_move_1",
}),
envelope: expect.objectContaining({
name: "documents.move",
preflightData: {
sourceDocument: {
id: "doc_1",
workspaceId: "ws_1",
parentId: null,
},
targetParentDocument: {
id: "child_1",
workspaceId: "ws_1",
parentId: "doc_1",
},
targetAncestorIds: ["doc_1"],
},
payload: {
documentId: "doc_1",
parentId: "child_1",
sortOrder: 0,
movePreflight: {
sourceDocument: {
id: "doc_1",
workspaceId: "ws_1",
parentId: null,
},
targetParentDocument: {
id: "child_1",
workspaceId: "ws_1",
parentId: "doc_1",
},
targetAncestorIds: ["doc_1"],
},
},
}),
});
});
it("documents.move 失败时记录的 failure artifact 仍应保持 move envelope", async () => {
const client = {
query: vi.fn(async (name: string, args: { id: string }) => {
if (name !== "documents:getMeta") {
return null;
}
if (args.id === "doc_1") {
return {
id: "doc_1",
workspace_id: "ws_1",
parent_id: null,
};
}
if (args.id === "child_1") {
return {
id: "child_1",
workspace_id: "ws_1",
parent_id: "doc_1",
};
}
return null;
}),
mutation: vi.fn(),
};
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: client as never,
});
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_move_2",
traceId: "trace_move_2",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
vi.mocked(resolveRustBridgeCommandPlan).mockRejectedValue(new Error("move failed"));
vi.mocked(documentBridgeErrorResponse).mockImplementation((error: unknown) => ({
body: { error: error instanceof Error ? error.message : String(error) },
status: 500,
}) as never);
const response = await handleDocumentMoveRequest(
new Request("http://127.0.0.1:3000/api/documents/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
documentId: "doc_1",
parentId: "child_1",
position: 0,
}),
}),
);
expect(response.status).toBe(500);
expect(recordBridgeCommandFailureArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "documents.move",
preflightData: {
sourceDocument: {
id: "doc_1",
workspaceId: "ws_1",
parentId: null,
},
targetParentDocument: {
id: "child_1",
workspaceId: "ws_1",
parentId: "doc_1",
},
targetAncestorIds: ["doc_1"],
},
payload: {
documentId: "doc_1",
parentId: "child_1",
sortOrder: 0,
movePreflight: {
sourceDocument: {
id: "doc_1",
workspaceId: "ws_1",
parentId: null,
},
targetParentDocument: {
id: "child_1",
workspaceId: "ws_1",
parentId: "doc_1",
},
targetAncestorIds: ["doc_1"],
},
},
}),
}),
);
});
});
@@ -29,6 +29,18 @@ type MovePayload = {
position?: number | null;
};
type MovePreflightDocument = {
id: string;
workspaceId: string | null;
parentId: string | null;
};
type MovePreflightPayload = {
sourceDocument: MovePreflightDocument;
targetParentDocument: MovePreflightDocument | null;
targetAncestorIds: string[];
};
type DeletePayload = {
documentId?: string | null;
};
@@ -136,6 +148,41 @@ export function resolveSubtreeMoveLegality(input: {
};
}
async function buildMovePreflight(args: {
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
sourceDocument: MovePreflightDocument;
targetParentId: string | null;
}) : Promise<MovePreflightPayload> {
let targetParentDocument: MovePreflightDocument | null = null;
const targetAncestorIds: string[] = [];
if (args.targetParentId) {
const targetParentDoc = await args.client.query(api.documents.getMeta, { id: args.targetParentId });
if (!targetParentDoc) {
throw new Error("目标父页面不存在或无权限");
}
targetParentDocument = {
id: targetParentDoc.id,
workspaceId: trimOrNull(targetParentDoc.workspace_id),
parentId: trimOrNull(targetParentDoc.parent_id),
};
let cursor = trimOrNull(targetParentDoc.parent_id);
let depth = 0;
while (cursor && depth < 256) {
targetAncestorIds.push(cursor);
const parentDoc = await args.client.query(api.documents.getMeta, { id: cursor });
if (!parentDoc) break;
cursor = trimOrNull(parentDoc.parent_id);
depth += 1;
}
}
return {
sourceDocument: args.sourceDocument,
targetParentDocument,
targetAncestorIds,
};
}
function safeRandomId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
@@ -328,28 +375,47 @@ 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 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;
const normalizedMove = normalizeDocumentMovePayload(payload);
normalizedMove = normalizeDocumentMovePayload(payload);
const documentId = normalizedMove.documentId;
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
failureClient = client;
failureAuthUserId = auth.userId;
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
failureSourceDocument = {
id: sourceDoc.id,
workspaceId: trimOrNull(sourceDoc.workspace_id),
parentId: trimOrNull(sourceDoc.parent_id),
};
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
const movePreflight = await buildMovePreflight({
client,
sourceDocument: failureSourceDocument,
targetParentId: normalizedMove.parentId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId,
parentId: normalizedMove.parentId,
sortOrder: normalizedMove.sortOrder,
movePreflight,
},
preflightData: movePreflight,
context,
target: {
pageId: documentId,
@@ -373,18 +439,49 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
return NextResponse.json({ ok: true });
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
const documentId = trimOrNull(payload.documentId);
const fallbackMove = normalizedMove
?? normalizeDocumentMovePayload(
(await requestClone.json().catch(() => ({}))) as MovePayload,
);
const documentId = fallbackMove.documentId;
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
let client = failureClient;
let authUserId = failureAuthUserId;
if (!client || !authUserId) {
const authedClient = await getAuthedConvexClient();
client = authedClient.client;
authUserId = authedClient.auth.userId;
}
let sourceDocument = failureSourceDocument;
if (!sourceDocument) {
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
throw error;
}
sourceDocument = {
id: sourceDoc.id,
workspaceId: trimOrNull(sourceDoc.workspace_id),
parentId: trimOrNull(sourceDoc.parent_id),
};
}
const context = await buildBridgeContext(request, sourceDocument.workspaceId ?? null, authUserId);
const movePreflight = await buildMovePreflight({
client,
sourceDocument,
targetParentId: fallbackMove.parentId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.delete",
payload: { documentId },
name: "documents.move",
payload: {
documentId,
parentId: fallbackMove.parentId,
sortOrder: fallbackMove.sortOrder,
movePreflight,
},
preflightData: movePreflight,
context,
target: {
workspaceId: sourceDoc?.workspace_id ?? null,
workspaceId: sourceDocument.workspaceId ?? null,
pageId: documentId,
},
});
@@ -69,6 +69,7 @@ const mockContext: BridgeContext = {
describe("page-write-command-adapter", () => {
it("标题命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -94,7 +95,10 @@ describe("page-write-command-adapter", () => {
title: "新标题",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-24T00:00:00.000Z",
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
@@ -116,6 +120,25 @@ describe("page-write-command-adapter", () => {
name: "page.head.updateTitle",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.head.updateTitle",
}),
commandPayload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
streamDelta: {
op: "upsert_document",
document: {
id: "doc_1",
title: "新标题",
updated_at: "2026-04-24T00:00:00.000Z",
},
},
},
});
expect(result.commandName).toBe("page.head.updateTitle");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
@@ -40,6 +40,26 @@ export type PageWriteCommandExecutionResult = {
conflictDetectionKey: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
if (!streamDelta) {
return commandPayload;
}
if (isRecord(commandPayload)) {
return {
...commandPayload,
streamDelta,
};
}
return {
payload: commandPayload,
streamDelta,
};
}
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
@@ -98,6 +118,33 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
};
}
function normalizeUpdatedAt(result: unknown): string | null {
const record = isRecord(result) ? result : null;
const updatedAt = record?.updated_at;
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
}
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
envelope: CommandEnvelope<TPayload>;
transportResult?: unknown;
}) {
if (input.envelope.name !== "page.head.updateTitle") {
return input.envelope.payload;
}
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
const updatedAt = normalizeUpdatedAt(input.transportResult);
return attachStreamDelta(payload, {
op: "upsert_document",
document: {
id: payload.documentId,
title: payload.title,
...(updatedAt ? { updated_at: updatedAt } : {}),
},
});
}
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
@@ -120,6 +167,10 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
commandPayload: buildPageWriteCommandPayload({
envelope: input.envelope,
transportResult,
}),
});
return {
@@ -479,7 +479,10 @@ export async function resolveRustBridgeCommandPlan<TPayload>(input: {
const response = await runRustRuntime({
kind: "command",
context: input.context,
command: input.envelope,
command: {
...input.envelope,
preflightData: input.envelope.preflightData ?? null,
},
});
if (!("plan" in response) || response.plan.kind !== "command") {
@@ -44,11 +44,11 @@ describe("tree-command-client", () => {
"/api/tree/commands",
"/api/tree/commands",
"/api/tree/commands",
"/api/documents/delete",
"/api/documents/restore",
"/api/documents/purge",
"/api/documents/embed",
"/api/documents/copy-tree",
"/api/tree/commands",
"/api/tree/commands",
"/api/tree/commands",
"/api/tree/commands",
"/api/tree/commands",
"/api/documents/title",
"/api/documents/options",
]);
@@ -70,6 +70,30 @@ describe("tree-command-client", () => {
sortOrder: 0,
workspaceId: null,
});
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({
action: "archive",
documentId: "doc_1",
workspaceId: null,
});
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
action: "restore",
documentId: "doc_1",
workspaceId: null,
});
expect(JSON.parse(String(fetchMock.mock.calls[5]?.[1]?.body))).toEqual({
action: "purge",
documentId: "doc_1",
});
expect(JSON.parse(String(fetchMock.mock.calls[6]?.[1]?.body))).toEqual({
action: "embed",
sourceId: "doc_1",
targetId: "doc_2",
});
expect(JSON.parse(String(fetchMock.mock.calls[7]?.[1]?.body))).toEqual({
action: "copy",
targetParentId: null,
items: [{ documentId: "doc_1", recursive: true }],
});
});
it("在后端返回错误时抛出统一异常", async () => {
@@ -96,7 +96,15 @@ type MoveDocumentInput = {
workspaceId?: string | null;
};
type TreeCommandAction = "create" | "rename" | "move";
type TreeCommandAction =
| "create"
| "rename"
| "move"
| "archive"
| "restore"
| "purge"
| "embed"
| "copy";
type DeleteDocumentInput = {
documentId: string;
@@ -161,12 +169,16 @@ type TreeCommandResponse = {
title?: string | null;
sortOrder?: number | null;
updatedAt?: string | null;
execution?: {
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} | null;
items?: Array<{ oldId: string; newId: string }>;
execution?:
| ({
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
purged?: boolean;
} & Record<string, unknown>)
| null;
} | null;
};
@@ -275,52 +287,92 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o
export async function deleteDocumentCommand(
input: DeleteDocumentInput,
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
"/api/documents/delete",
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "archive",
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
},
"删除失败,请稍后再试",
);
return {
success: true,
meta: {
requestId: response.requestId,
traceId: response.traceId,
commandName: TREE_COMMAND_PROTOCOL.archive.preferredCommandName,
},
};
}
export async function restoreDocumentCommand(
input: RestoreDocumentInput,
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
"/api/documents/restore",
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "restore",
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
},
"恢复失败,请稍后再试",
);
return {
success: true,
meta: {
requestId: response.requestId,
traceId: response.traceId,
commandName: TREE_COMMAND_PROTOCOL.restore.preferredCommandName,
},
};
}
export async function purgeDocumentCommand(
input: PurgeDocumentInput,
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
"/api/documents/purge",
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "purge",
documentId: input.documentId,
},
"彻底删除失败,请稍后再试",
);
return {
success: true,
purged:
typeof response.result?.execution?.purged === "boolean"
? response.result.execution.purged
: undefined,
meta: {
requestId: response.requestId,
traceId: response.traceId,
commandName: TREE_COMMAND_PROTOCOL.purge.preferredCommandName,
},
};
}
export async function embedDocumentCommand(
input: EmbedDocumentInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/embed",
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "embed",
sourceId: input.sourceId,
targetId: input.targetId,
},
"嵌入失败,请稍后再试",
);
return {
ok: true,
meta: {
requestId: response.requestId,
traceId: response.traceId,
commandName: TREE_COMMAND_PROTOCOL.embed.preferredCommandName,
},
};
}
export async function copyTreeCommand(
@@ -329,15 +381,21 @@ export async function copyTreeCommand(
items: Array<{ oldId: string; newId: string }>;
meta?: DocumentCommandMeta;
}> {
return postDocumentCommand<{
items: Array<{ oldId: string; newId: string }>;
meta?: DocumentCommandMeta;
}>(
"/api/documents/copy-tree",
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "copy",
targetParentId: input.targetParentId,
items: input.items,
},
"复制页面失败,请稍后再试",
);
return {
items: response.result?.items ?? [],
meta: {
requestId: response.requestId,
traceId: response.traceId,
commandName: TREE_COMMAND_PROTOCOL.copy.preferredCommandName,
},
};
}
+171 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
import { buildVisibleRows } from "./rows";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
import { parseFileTreeRowId } from "./types";
describe("buildVisibleRows", () => {
@@ -283,6 +283,176 @@ describe("buildVisibleRows", () => {
},
});
});
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
const fileTreeItems = [
{
rowId: "doc:page_root",
rowKind: "document",
nodeId: "page_root",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "根页面",
depth: 0,
position: 0,
childCount: 3,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_root",
rowKind: "index",
nodeId: "index:page_root",
parentNodeId: "page_root",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 1,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset_folder",
nodeId: "asset-folder:mind_1",
parentNodeId: "page_root",
nodeType: "mindmap",
projectionKind: "file_tree",
title: "头脑风暴",
depth: 1,
position: 1,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open-asset", "select"],
resourceMeta: {
resourceKind: "mindmap",
documentId: "page_root",
assetId: "mind_1",
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
},
iconHint: "mindmap",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
nodeId: "asset:asset_child_1",
parentNodeId: "asset-folder:mind_1",
nodeType: "asset",
projectionKind: "file_tree",
title: "节点图片.png",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "asset",
documentId: "page_root",
assetId: "asset_child_1",
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
},
iconHint: "image",
},
{
rowId: "doc:page_child",
rowKind: "document",
nodeId: "page_child",
parentNodeId: "page_root",
nodeType: "page",
projectionKind: "file_tree",
title: "子页面",
depth: 1,
position: 2,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_child",
rowKind: "index",
nodeId: "index:page_child",
parentNodeId: "page_child",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
] as const;
const filteredItems = filterKernelFileTreeProjectionItems({
fileTreeItems: [...fileTreeItems],
visibleDocumentIds: new Set(["page_root", "page_child"]),
expandedDocumentIds: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(filteredItems.map((item) => item.rowId)).toEqual([
"doc:page_root",
"index:page_root",
"asset-folder:mind_1",
"asset:asset_child_1",
"doc:page_child",
]);
const rows = buildVisibleRows({
fileTreeItems: filteredItems,
expanded: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
"doc:doc:page_root",
"index:index:page_root",
"asset-folder:asset-folder:mind_1",
"asset:asset:asset_child_1",
"doc:doc:page_child",
]);
});
});
describe("parseFileTreeRowId", () => {
+34
View File
@@ -12,6 +12,40 @@ import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function filterKernelFileTreeProjectionItems(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
visibleDocumentIds: ReadonlySet<string>;
expandedDocumentIds: ReadonlySet<string>;
expandedAssetFolderIds?: ReadonlySet<string>;
}): KernelFileTreeProjectionItem[] {
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
return input.fileTreeItems.filter((item) => {
const docId = getDocIdFromFileTreeItem(item);
if (!input.visibleDocumentIds.has(docId)) {
return false;
}
switch (item.rowKind) {
case "document":
return true;
case "index":
case "asset_folder":
return input.expandedDocumentIds.has(docId);
case "asset": {
if (!input.expandedDocumentIds.has(docId)) {
return false;
}
const parentNodeId = String(item.parentNodeId ?? "").trim();
if (parentNodeId.startsWith("asset-folder:")) {
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
}
return true;
}
}
});
}
function buildRowsFromKernelFileTreeProjection(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
expanded: Set<string>;
@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import {
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
computeFileTreeShellDeleteTargets,
getOrderedFileTreeShellRows,
inferFileTreeShellTargetDocumentId,
resolveFileTreeShellMindmapTargetId,
} from "./shell";
describe("file-tree shell helpers", () => {
const nodeById = new Map<string, SidebarTreeNode>([
[
"doc_root",
{
id: "doc_root",
title: "根页面",
} as SidebarTreeNode,
],
]);
const assetById = new Map<string, MediaAsset>([
[
"mind_1",
{
id: "mind_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "mindmap",
file_name: "mindmap.json",
storage_path: "mindmaps/mind_1/mindmap.json",
} as MediaAsset,
],
[
"asset_child_1",
{
id: "asset_child_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "file",
file_name: "node.png",
storage_path: "mindmaps/mind_1/assets/node.png",
} as MediaAsset,
],
[
"pdf_1",
{
id: "pdf_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "file",
file_name: "guide.pdf",
storage_path: "uploads/guide.pdf",
} as MediaAsset,
],
]);
const fileTreeItems: KernelFileTreeProjectionItem[] = [
{
rowId: "doc:doc_root",
rowKind: "document",
nodeId: "doc_root",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "根页面",
depth: 0,
position: 0,
childCount: 3,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:doc_root",
rowKind: "index",
nodeId: "index:doc_root",
parentNodeId: "doc_root",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 1,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "doc_root",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset_folder",
nodeId: "asset-folder:mind_1",
parentNodeId: "doc_root",
nodeType: "mindmap",
projectionKind: "file_tree",
title: "mindmap",
depth: 1,
position: 1,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open-asset", "select"],
resourceMeta: {
resourceKind: "mindmap",
documentId: "doc_root",
assetId: "mind_1",
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
},
iconHint: "mindmap",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
nodeId: "asset:asset_child_1",
parentNodeId: "asset-folder:mind_1",
nodeType: "asset",
projectionKind: "file_tree",
title: "node.png",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
},
iconHint: "image",
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
nodeId: "asset:pdf_1",
parentNodeId: "doc_root",
nodeType: "pdf",
projectionKind: "file_tree",
title: "guide.pdf",
depth: 1,
position: 2,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "pdf",
documentId: "doc_root",
assetId: "pdf_1",
workspaceId: "ws_1",
assetKind: "pdf",
iconHint: "pdf",
},
iconHint: "pdf",
},
];
it("应直接从 kernel file_tree items 构造宿主 row map", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(buildFileTreeShellVisibleRowIds([...fileTreeItems])).toEqual([
"doc:doc_root",
"index:doc_root",
"asset-folder:mind_1",
"asset:asset_child_1",
"asset:pdf_1",
]);
expect(rowById.get("doc:doc_root")).toMatchObject({
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
node: expect.objectContaining({
id: "doc_root",
}),
});
expect(rowById.get("asset-folder:mind_1")).toMatchObject({
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
asset: expect.objectContaining({
id: "mind_1",
}),
});
});
it("应正确解析 file tree shell 的导图投放目标", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset-folder:mind_1") ?? null)).toBe(
"mind_1",
);
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:asset_child_1") ?? null)).toBe(
"mind_1",
);
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:pdf_1") ?? null)).toBeNull();
});
it("应能仅凭 focusedRowId 从 shell row map 推回目标页面", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: "asset-folder:mind_1",
rowById,
activeDocId: null,
}),
).toBe("doc_root");
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: "asset:pdf_1",
rowById,
activeDocId: null,
}),
).toBe("doc_root");
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: null,
rowById,
activeDocId: "doc_root",
}),
).toBe("doc_root");
});
it("应按当前可见顺序返回选中的 shell rows", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
getOrderedFileTreeShellRows({
rowIds: ["asset:pdf_1", "doc:doc_root", "missing"],
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
rowById,
}).map((row) => row.rowId),
).toEqual(["doc:doc_root", "asset:pdf_1"]);
});
it("删除目标计算应跳过被父页面覆盖的附件", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = computeFileTreeShellDeleteTargets({
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
rowById,
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
parentById: new Map([["doc_root", null]]),
});
expect(result.docIds).toEqual(["doc_root"]);
expect(result.assetIds).toEqual([]);
expect(result.assetHints).toEqual([]);
});
});
+207
View File
@@ -0,0 +1,207 @@
"use client";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import {
getDocIdFromFileTreeItem,
resolveFileTreeRowAsset,
resolveFileTreeRowNode,
} from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import { filterTopLevelDocIds } from "./dnd";
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
export type FileTreeShellRow = {
rowId: string;
rowKind: FileTreeShellRowKind;
documentId: string;
assetId: string | null;
node: SidebarTreeNode | null;
asset: MediaAsset | null;
};
export type FileTreeShellDeleteTargets = {
docIds: string[];
assetIds: string[];
assetHints: MediaAsset[];
};
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
switch (item.rowKind) {
case "document":
return "doc";
case "asset_folder":
return "asset-folder";
default:
return item.rowKind;
}
}
export function buildFileTreeShellVisibleRowIds(
fileTreeItems: readonly KernelFileTreeProjectionItem[],
): string[] {
return fileTreeItems
.map((item) => item.rowId)
.filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
}
export function buildFileTreeShellRowById(input: {
fileTreeItems: readonly KernelFileTreeProjectionItem[];
nodeById?: Map<string, SidebarTreeNode>;
assetById?: Map<string, MediaAsset>;
}): Map<string, FileTreeShellRow> {
const rowById = new Map<string, FileTreeShellRow>();
input.fileTreeItems.forEach((item) => {
const rowId = typeof item.rowId === "string" ? item.rowId.trim() : "";
if (!rowId || rowById.has(rowId)) {
return;
}
const rowKind = toShellRowKind(item);
const documentId = getDocIdFromFileTreeItem(item);
const isDocumentRow = rowKind === "doc" || rowKind === "index";
rowById.set(rowId, {
rowId,
rowKind,
documentId,
assetId: isDocumentRow ? null : item.resourceMeta.assetId ?? null,
node: isDocumentRow ? resolveFileTreeRowNode(item, input.nodeById) : null,
asset: isDocumentRow ? null : resolveFileTreeRowAsset(item, input.assetById),
});
});
return rowById;
}
export function getOrderedFileTreeShellRows(input: {
rowIds: Iterable<string>;
visibleRowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellRow[] {
const selectedRowIds = new Set<string>();
for (const rowId of input.rowIds) {
if (typeof rowId !== "string" || rowId.trim().length === 0) {
continue;
}
if (!input.rowById.has(rowId)) {
continue;
}
selectedRowIds.add(rowId);
}
return input.visibleRowIds
.map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
.filter((row): row is FileTreeShellRow => Boolean(row));
}
export function extractMindmapAssetIdFromStoragePath(
storagePath: string | null | undefined,
): string | null {
if (!storagePath) return null;
const normalized = storagePath.replaceAll("\\", "/");
const prefix = "mindmaps/";
if (normalized.startsWith(prefix)) {
const rest = normalized.slice(prefix.length);
const id = rest.split("/")[0];
return id ? id : null;
}
const marker = "/mindmaps/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
const rest = normalized.slice(idx + marker.length);
const id = rest.split("/")[0];
return id ? id : null;
}
export function resolveFileTreeShellMindmapTargetId(
row: FileTreeShellRow | null,
): string | null {
if (!row?.asset) {
return null;
}
if (row.rowKind === "asset-folder" && row.asset.asset_type === "mindmap") {
return row.asset.id;
}
if (row.rowKind === "asset") {
return extractMindmapAssetIdFromStoragePath(row.asset.storage_path);
}
return null;
}
export function computeFileTreeShellDeleteTargets(input: {
visibleRowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
selectedRowIds: ReadonlySet<string>;
parentById: Map<string, string | null>;
}): FileTreeShellDeleteTargets {
const rows = getOrderedFileTreeShellRows({
rowIds: input.selectedRowIds,
visibleRowIds: input.visibleRowIds,
rowById: input.rowById,
});
const docCandidates: string[] = [];
const assetCandidates: string[] = [];
const assetDocIdByAssetId = new Map<string, string>();
const assetHintById = new Map<string, MediaAsset>();
rows.forEach((row) => {
if (row.rowKind === "doc" || row.rowKind === "index") {
docCandidates.push(row.documentId);
return;
}
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
assetCandidates.push(row.assetId);
assetDocIdByAssetId.set(row.assetId, row.documentId);
if (row.asset) {
assetHintById.set(row.assetId, row.asset);
}
}
});
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
const docIdSet = new Set(docIds);
const seenAssets = new Set<string>();
const assetIds: string[] = [];
const assetHints: MediaAsset[] = [];
assetCandidates.forEach((assetId) => {
if (!assetId || seenAssets.has(assetId)) {
return;
}
seenAssets.add(assetId);
const ownerDocId = assetDocIdByAssetId.get(assetId);
if (ownerDocId && docIdSet.has(ownerDocId)) {
return;
}
assetIds.push(assetId);
const assetHint = assetHintById.get(assetId);
if (assetHint) {
assetHints.push(assetHint);
}
});
return { docIds, assetIds, assetHints };
}
export function inferFileTreeShellTargetDocumentId(input: {
focusedRowId: string | null;
rowById: Map<string, FileTreeShellRow>;
activeDocId: string | null;
}): string | null {
if (input.focusedRowId) {
const row = input.rowById.get(input.focusedRowId) ?? null;
if (row?.documentId) {
return row.documentId;
}
}
return input.activeDocId || null;
}
@@ -31,10 +31,10 @@ describe("runtime-config public projection", () => {
expect(runtime.treeRendererFamily).toBe("rust_family");
});
it("树 renderer family 缺省时应回落到 react", () => {
it("树 renderer family 缺省时应回落到 rust_family", () => {
const runtime = getMnoteRuntimeConfig();
expect(runtime.treeRendererFamily).toBe("react");
expect(runtime.treeRendererFamily).toBe("rust_family");
});
afterEach(() => {
+2 -2
View File
@@ -37,7 +37,7 @@ export type MnoteRuntimeConfig = {
documentEditorBlocknoteKillSwitch?: boolean;
/**
* 树域 renderer family 选择。
* 说明:默认仍为 react`rust_family` 只作为渐进切流开关,不代表已完全切主路径
* 说明:默认主路径已切到 rust_familyReact fallback 仍作为过渡兜底保留
*/
treeRendererFamily?: "react" | "rust_family";
/**
@@ -292,7 +292,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
const treeRendererFamily =
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "react";
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";
return {
...cfg,
@@ -0,0 +1,499 @@
import { describe, expect, it, vi } from "vitest";
import { streamTreeFrames } from "./server";
import type { TreeStreamCommandLogCursorRow } from "./server";
function buildOverview(rows: TreeStreamCommandLogCursorRow[]) {
return {
command_logs: rows,
domain_events: [],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:00Z",
};
}
async function collectFrames<T>(generator: AsyncGenerator<T>) {
const frames: T[] = [];
for await (const frame of generator) {
frames.push(frame);
}
return frames;
}
describe("tree-stream/server", () => {
it("workspace scope 首帧应发 snapshot,并固定 sidebar_tree + cursor", async () => {
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 0,
loadOverview: vi.fn().mockResolvedValue(
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
),
loadSnapshot: vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
}),
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({
event: "snapshot",
payload: {
kind: "snapshot",
stream: "workspace",
workspaceId: "ws_1",
rootNodeId: null,
projection: "sidebar_tree",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:01Z",
id: "clog_2",
}),
},
});
});
it("subtree scope 首帧应切到 subtree + page_tree", async () => {
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
rootNodeId: "page_root",
pollMs: 1,
maxPolls: 0,
loadOverview: vi.fn().mockResolvedValue(
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
),
loadSnapshot: vi.fn().mockResolvedValue({
requestId: "req_subtree_1",
traceId: "trace_subtree_1",
data: { nodes: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { nodes: [] } },
}),
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({
event: "snapshot",
payload: {
kind: "snapshot",
stream: "subtree",
workspaceId: "ws_1",
rootNodeId: "page_root",
projection: "page_tree",
},
});
});
it("检测到 cursor 之后出现新命令时,应发 resync 而不是静默结束", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{ id: "clog_2", created_at: "2026-04-24T00:00:02Z" },
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi
.fn()
.mockResolvedValueOnce({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
})
.mockResolvedValueOnce({
requestId: "req_2",
traceId: "trace_2",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
snapshot: {
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
tree: { items: [] },
},
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[0]).toMatchObject({ event: "snapshot" });
expect(frames[1]).toMatchObject({
event: "resync",
payload: {
kind: "resync",
stream: "workspace",
workspaceId: "ws_1",
projection: "sidebar_tree",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "clog_2",
}),
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("检测到带 streamDelta 的单条新命令时,应直接发 delta", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{
id: "clog_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.node.archive",
payload: {
documentId: "page_2",
streamDelta: {
op: "remove_document",
documentId: "page_2",
},
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "clog_2",
}),
data: {
op: "remove_document",
documentId: "page_2",
},
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("只有 domain event 推进时,也应刷新 cursor 并触发 resync", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce({
command_logs: [{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }],
domain_events: [
{
event_id: "evt_2",
created_at: "2026-04-24T00:00:02Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi
.fn()
.mockResolvedValueOnce({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
})
.mockResolvedValueOnce({
requestId: "req_2",
traceId: "trace_2",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "resync",
payload: {
kind: "resync",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "domain_event:evt_2",
}),
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{
id: "clog_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.subtree.move",
payload: {
documentId: "page_2",
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi
.fn()
.mockResolvedValueOnce({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
})
.mockResolvedValueOnce({
requestId: "req_2",
traceId: "trace_2",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
snapshot: {
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
tree: { items: [] },
},
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "resync",
payload: {
kind: "resync",
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("正文保存这类无树结构影响的命令应降级为 noop delta,而不是触发 resync", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{
id: "clog_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "page.body.save",
payload: {
documentId: "page_1",
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
data: {
op: "noop",
},
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
const sidebarSnapshot = {
activeWorkspaceId: "ws_1",
workspaces: [],
documents: [
{
id: "page_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:01:00Z",
},
],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelFileTreeProjection: {
projectionId: "kernel_projection:file_tree:workspace_root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelSidebarTree: [],
trashedDocuments: [],
mediaAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
tableAssets: [],
};
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{
id: "clog_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.subtree.move",
payload: {
documentId: "page_1",
streamDelta: {
op: "replace_documents",
documents: sidebarSnapshot.documents,
},
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
data: {
op: "replace_documents",
documents: expect.arrayContaining([
expect.objectContaining({
id: "page_1",
}),
]),
},
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("轮询期间没有新 cursor 时,不应额外发 resync", async () => {
const loadOverview = vi
.fn()
.mockResolvedValue(buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]));
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot: vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
}),
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({ event: "snapshot" });
});
});
@@ -0,0 +1,392 @@
import type { TreeStreamDeltaEvent } from "./tree-delta";
export type TreeStreamScope = "workspace" | "subtree";
export type TreeStreamProjection = "sidebar_tree" | "page_tree";
export type TreeStreamEventName = "snapshot" | "delta" | "resync";
export interface TreeStreamCommandLogCursorRow {
id?: string | null;
created_at?: string | null;
command_name?: string | null;
commandName?: string | null;
payload?: unknown;
}
export interface TreeStreamOverview {
command_logs?: TreeStreamCommandLogCursorRow[] | null;
domain_events?: unknown[] | null;
next_cursor?: string | null;
has_more?: boolean | null;
generated_at?: string | null;
}
export interface TreeStreamSnapshotPayload {
requestId: string;
traceId: string;
data: unknown;
snapshot: unknown;
}
export interface TreeStreamEnvelope {
kind: TreeStreamEventName;
stream: TreeStreamScope;
workspaceId: string;
rootNodeId: string | null;
cursor: string | null;
projection: TreeStreamProjection;
requestId: string;
traceId: string;
data: unknown;
snapshot: unknown;
overview: TreeStreamOverview;
}
export interface TreeStreamFrame {
event: TreeStreamEventName;
payload: TreeStreamEnvelope;
}
export interface StreamTreeFramesInput {
workspaceId: string;
rootNodeId?: string | null;
initialCursor?: string | null;
pollMs?: number;
maxPolls?: number | null;
loadOverview: () => Promise<TreeStreamOverview>;
loadSnapshot: () => Promise<TreeStreamSnapshotPayload>;
sleep?: (ms: number) => Promise<void>;
}
type DecodedTreeStreamCursor = {
createdAt: string;
id: string;
};
type TreeStreamDomainEventCursorRow = {
id?: string | null;
event_id?: string | null;
created_at?: string | null;
createdAt?: string | null;
};
const TREE_STREAM_NOOP_COMMANDS = new Set([
"page.body.save",
"page.layout.updateOptions",
"documents.stats.update",
"blocks.patch",
"blocks.move",
"blocks.embed",
]);
function normalizeNodeId(value: string | null | undefined) {
const normalized = typeof value === "string" ? value.trim() : "";
return normalized || null;
}
export function resolveTreeStreamContract(input: {
rootNodeId?: string | null;
}): {
stream: TreeStreamScope;
projection: TreeStreamProjection;
rootNodeId: string | null;
} {
const rootNodeId = normalizeNodeId(input.rootNodeId);
if (rootNodeId) {
return {
stream: "subtree",
projection: "page_tree",
rootNodeId,
};
}
return {
stream: "workspace",
projection: "sidebar_tree",
rootNodeId: null,
};
}
export function encodeTreeStreamCursor(row: TreeStreamCommandLogCursorRow | null | undefined) {
const id = typeof row?.id === "string" ? row.id.trim() : "";
const createdAt = typeof row?.created_at === "string" ? row.created_at.trim() : "";
if (!id || !createdAt) {
return null;
}
return JSON.stringify({
createdAt,
id,
});
}
function encodeTreeStreamDomainEventCursor(
row: TreeStreamDomainEventCursorRow | null | undefined,
) {
const rawId =
typeof row?.event_id === "string"
? row.event_id.trim()
: typeof row?.id === "string"
? row.id.trim()
: "";
const createdAt =
typeof row?.created_at === "string"
? row.created_at.trim()
: typeof row?.createdAt === "string"
? row.createdAt.trim()
: "";
if (!rawId || !createdAt) {
return null;
}
return JSON.stringify({
createdAt,
id: `domain_event:${rawId}`,
});
}
function decodeTreeStreamCursor(raw: string | null | undefined): DecodedTreeStreamCursor | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
createdAt?: string | null;
id?: string | null;
};
const createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt.trim() : "";
const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
return createdAt && id ? { createdAt, id } : null;
} catch {
return null;
}
}
export function resolveOverviewCursor(
overview: TreeStreamOverview,
fallback?: string | null,
) {
const commandRows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
const eventRows = Array.isArray(overview.domain_events)
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
: [];
const commandCursor = encodeTreeStreamCursor(commandRows[0] ?? null);
const domainEventCursor = encodeTreeStreamDomainEventCursor(eventRows[0] ?? null);
if (!commandCursor) {
return domainEventCursor ?? fallback ?? null;
}
if (!domainEventCursor) {
return commandCursor ?? fallback ?? null;
}
const decodedCommandCursor = decodeTreeStreamCursor(commandCursor);
const decodedDomainEventCursor = decodeTreeStreamCursor(domainEventCursor);
if (!decodedCommandCursor) {
return domainEventCursor;
}
if (!decodedDomainEventCursor) {
return commandCursor;
}
return decodedDomainEventCursor.createdAt > decodedCommandCursor.createdAt
? domainEventCursor
: commandCursor;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStreamDeltaEvent | null {
const commandName =
typeof row.command_name === "string"
? row.command_name.trim()
: typeof row.commandName === "string"
? row.commandName.trim()
: "";
if (commandName && TREE_STREAM_NOOP_COMMANDS.has(commandName)) {
return {
op: "noop",
};
}
if (!isRecord(row.payload) || !("streamDelta" in row.payload)) {
return null;
}
const candidate = row.payload.streamDelta;
if (!isRecord(candidate) || typeof candidate.op !== "string") {
return null;
}
return {
op: candidate.op as TreeStreamDeltaEvent["op"],
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
};
}
function collectNewCommandLogs(input: {
rows: TreeStreamCommandLogCursorRow[];
previousCursor: string | null;
}) {
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
if (!previousCursor) {
return {
rows: input.rows,
drifted: false,
};
}
const previousIndex = input.rows.findIndex((row) => {
const id = typeof row.id === "string" ? row.id.trim() : "";
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
return id === previousCursor.id && createdAt === previousCursor.createdAt;
});
if (previousIndex >= 0) {
return {
rows: input.rows.slice(0, previousIndex),
drifted: false,
};
}
return {
rows: input.rows,
drifted: input.rows.length > 0,
};
}
function buildTreeStreamEnvelope(input: {
kind: TreeStreamEventName;
workspaceId: string;
rootNodeId: string | null;
projection: TreeStreamProjection;
cursor: string | null;
overview: TreeStreamOverview;
snapshot: TreeStreamSnapshotPayload;
}): TreeStreamEnvelope {
return {
kind: input.kind,
stream: input.rootNodeId ? "subtree" : "workspace",
workspaceId: input.workspaceId,
rootNodeId: input.rootNodeId,
cursor: input.cursor,
projection: input.projection,
requestId: input.snapshot.requestId,
traceId: input.snapshot.traceId,
data: input.snapshot.data,
snapshot: input.snapshot.snapshot,
overview: input.overview,
};
}
function buildTreeStreamDeltaEnvelope(input: {
workspaceId: string;
rootNodeId: string | null;
projection: TreeStreamProjection;
cursor: string | null;
overview: TreeStreamOverview;
snapshot: TreeStreamSnapshotPayload;
delta: TreeStreamDeltaEvent;
}): TreeStreamEnvelope {
return {
kind: "delta",
stream: input.rootNodeId ? "subtree" : "workspace",
workspaceId: input.workspaceId,
rootNodeId: input.rootNodeId,
cursor: input.cursor,
projection: input.projection,
requestId: input.snapshot.requestId,
traceId: input.snapshot.traceId,
data: input.delta,
snapshot: null,
overview: input.overview,
};
}
async function defaultSleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export async function* streamTreeFrames(
input: StreamTreeFramesInput,
): AsyncGenerator<TreeStreamFrame> {
const contract = resolveTreeStreamContract({
rootNodeId: input.rootNodeId,
});
const pollMs = Math.max(250, Math.floor(input.pollMs ?? 2000));
const maxPolls =
typeof input.maxPolls === "number" && Number.isFinite(input.maxPolls)
? Math.max(0, Math.floor(input.maxPolls))
: null;
const sleep = input.sleep ?? defaultSleep;
let snapshot = await input.loadSnapshot();
let overview = await input.loadOverview();
let cursor = resolveOverviewCursor(overview, input.initialCursor ?? null);
yield {
event: "snapshot",
payload: buildTreeStreamEnvelope({
kind: "snapshot",
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
}),
};
let polls = 0;
while (maxPolls === null || polls < maxPolls) {
polls += 1;
await sleep(pollMs);
overview = await input.loadOverview();
const nextCursor = resolveOverviewCursor(overview, cursor);
if (nextCursor === cursor) {
continue;
}
const rows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
const newRows = collectNewCommandLogs({
rows,
previousCursor: cursor,
});
if (!newRows.drifted && newRows.rows.length === 1) {
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
if (delta) {
cursor = nextCursor;
yield {
event: "delta",
payload: buildTreeStreamDeltaEnvelope({
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
delta,
}),
};
continue;
}
}
snapshot = await input.loadSnapshot();
cursor = nextCursor;
yield {
event: "resync",
payload: buildTreeStreamEnvelope({
kind: "resync",
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
}),
};
}
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { applyTreeStreamDelta } from "./tree-delta";
import {
applyTreeStreamDelta,
applyTreeStreamDeltaToProjectionState,
} from "./tree-delta";
const baseSidebarData: SidebarInitialData = {
activeWorkspaceId: "ws_1",
@@ -174,6 +177,119 @@ const baseSidebarData: SidebarInitialData = {
mediaAssets: [],
};
const fileTreeProjectionBase: SidebarInitialData = {
...baseSidebarData,
mediaAssets: [
{
id: "asset_pdf",
workspace_id: "ws_1",
document_id: "root",
asset_type: "file",
file_url: "/manual.pdf",
thumbnail_url: null,
bucket: null,
storage_path: "documents/root/manual.pdf",
file_name: "manual.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-18T00:00:00Z",
},
{
id: "asset_book",
workspace_id: "ws_1",
document_id: "root",
asset_type: "file",
file_url: "/novel.epub",
thumbnail_url: null,
bucket: null,
storage_path: "documents/root/novel.epub",
file_name: "novel.epub",
file_size: 2048,
mime_type: "application/epub+zip",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-18T00:00:00Z",
},
{
id: "asset_mindmap_child",
workspace_id: "ws_1",
document_id: "root",
asset_type: "image",
file_url: "/mindmap/concept.png",
thumbnail_url: null,
bucket: null,
storage_path: "documents/root/mindmaps/asset_mindmap/concept.png",
file_name: "concept.png",
file_size: 512,
mime_type: "image/png",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-18T00:00:00Z",
},
],
tableAssets: [
{
id: "asset_table",
workspace_id: "ws_1",
document_id: "root",
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: "budget.luckysheet",
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-18T00:00:00Z",
},
],
mindmapAssets: [
{
id: "asset_mindmap",
workspace_id: "ws_1",
document_id: "root",
asset_type: "mindmap",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: "mindmap.json",
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-18T00:00:00Z",
},
],
mindmapAssetChildren: {
asset_mindmap: ["asset_mindmap_child"],
},
};
describe("tree-stream/tree-delta", () => {
it("支持 upsert_document 重建 sidebar projection", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
@@ -210,6 +326,26 @@ describe("tree-stream/tree-delta", () => {
expect(next.kernelSidebarProjection.items).toEqual([]);
});
it("支持对已存在文档做局部 upsert patch,而不丢失原有排序字段", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "upsert_document",
document: {
id: "child",
title: "Child Renamed",
updated_at: "2026-04-18T00:05:00Z",
},
});
expect(next.documents).toHaveLength(2);
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
id: "child",
title: "Child Renamed",
parent_id: "root",
sort_order: 1,
workspace_id: "ws_1",
});
});
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "replace_sidebar",
@@ -247,4 +383,99 @@ describe("tree-stream/tree-delta", () => {
expect(next.documents).toEqual([]);
expect(next.kernelSidebarProjection.items).toEqual([]);
});
it("支持 noop delta 仅推进 cursor,不修改当前 sidebar snapshot", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "noop",
});
expect(next).toEqual(baseSidebarData);
});
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
const next = applyTreeStreamDeltaToProjectionState({
projection: "page_tree",
base: baseSidebarData,
event: {
op: "upsert_document",
document: {
id: "leaf",
workspace_id: "ws_1",
title: "Leaf",
parent_id: "child",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-18T00:00:00Z",
updated_at: null,
},
},
});
expect(next.projection).toBe("page_tree");
expect(next.documents.map((item) => item.id)).toEqual(["root", "child", "leaf"]);
expect(next.pageTreeItems.map((item) => item.nodeId)).toEqual(["root", "child", "leaf"]);
expect(next.pageTreeItems.find((item) => item.nodeId === "leaf")).toMatchObject({
parentNodeId: "child",
depth: 2,
title: "Leaf",
});
});
it("为 file_tree 定义统一 delta 应用边界,并保留 doc/index/asset-folder/asset 行语义", () => {
const next = applyTreeStreamDeltaToProjectionState({
projection: "file_tree",
base: fileTreeProjectionBase,
event: {
op: "replace_documents",
documents: [...fileTreeProjectionBase.documents],
},
});
expect(next.projection).toBe("file_tree");
expect(next.fileTreeItems.map((item) => item.rowKind)).toEqual(
expect.arrayContaining(["document", "index", "asset_folder", "asset"]),
);
expect(
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_mindmap"),
).toMatchObject({
rowKind: "asset_folder",
iconHint: "mindmap",
resourceMeta: expect.objectContaining({
resourceKind: "mindmap",
assetKind: "mindmap",
}),
});
expect(
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
).toMatchObject({
rowKind: "asset",
iconHint: "pdf",
resourceMeta: expect.objectContaining({
resourceKind: "pdf",
assetKind: "pdf",
}),
});
expect(
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_book"),
).toMatchObject({
rowKind: "asset",
iconHint: "book",
resourceMeta: expect.objectContaining({
resourceKind: "book",
assetKind: "book",
}),
});
expect(
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_table"),
).toMatchObject({
rowKind: "asset",
iconHint: "table",
resourceMeta: expect.objectContaining({
resourceKind: "table",
assetKind: "table",
}),
});
});
});
@@ -1,14 +1,28 @@
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import {
buildSidebarDatasetListQueryResult,
mapSidebarDatasetListQueryResultToInitialData,
} from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import {
buildSidebarTreeFromKernelProjection,
type KernelSidebarProjectionItem,
} from "@/lib/kernel-sidebar";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import {
buildPageTreeProjectionItems,
type PageTreeProjectionItem,
} from "@/lib/tree-projection";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
export type TreeStreamDocumentPatch =
Partial<DocumentRecord> & Pick<DocumentRecord, "id">;
export type TreeStreamDeltaOp =
| "noop"
| "upsert_document"
| "remove_document"
| "replace_documents"
@@ -16,13 +30,24 @@ export type TreeStreamDeltaOp =
export type TreeStreamDeltaEvent = {
op: TreeStreamDeltaOp;
node?: DocumentRecord | null;
document?: DocumentRecord | null;
node?: TreeStreamDocumentPatch | null;
document?: TreeStreamDocumentPatch | null;
documentId?: string | null;
documents?: DocumentRecord[] | null;
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
};
export type TreeRendererProjection = "sidebar_tree" | "page_tree" | "file_tree";
export type TreeRendererDeltaState = {
projection: TreeRendererProjection;
sidebar: SidebarInitialData;
documents: DocumentRecord[];
sidebarItems: KernelSidebarProjectionItem[];
pageTreeItems: PageTreeProjectionItem[];
fileTreeItems: KernelFileTreeProjectionItem[];
};
function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
return {
...data,
@@ -85,12 +110,37 @@ function buildSidebarFromDocuments(input: {
})),
});
if (
(input.base.mediaAssets?.length ?? 0) > 0 ||
(input.base.mindmapAssets?.length ?? 0) > 0 ||
(input.base.tableAssets?.length ?? 0) > 0 ||
Object.keys(input.base.mindmapAssetChildren ?? {}).length > 0
) {
// 说明:stream delta 只替换 documents 时,仍要保留已有资源树语义;
// 否则 mindmap 子附件会在 resync 前退化成普通 asset。
const nextFileTreeProjection = buildKernelFileTreeProjection({
documents: input.documents,
mediaAssets: input.base.mediaAssets,
mindmapAssets: input.base.mindmapAssets,
tableAssets: input.base.tableAssets,
mindmapAssetChildren: input.base.mindmapAssetChildren,
});
queryResult.kernel_file_tree_projection = nextFileTreeProjection;
queryResult.kernelFileTreeProjection = nextFileTreeProjection;
queryResult.mindmap_asset_children = { ...(input.base.mindmapAssetChildren ?? {}) };
}
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
}
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): DocumentRecord | null {
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): TreeStreamDocumentPatch | null {
const candidate = event.node ?? event.document ?? null;
return candidate && typeof candidate === "object" ? candidate : null;
if (!candidate || typeof candidate !== "object") {
return null;
}
return typeof candidate.id === "string" && candidate.id.trim()
? ({ ...candidate, id: candidate.id.trim() } as TreeStreamDocumentPatch)
: null;
}
function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
@@ -98,10 +148,50 @@ function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
return candidate || null;
}
function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is DocumentRecord {
return (
typeof value.workspace_id === "string" &&
typeof value.access_scope === "string" &&
typeof value.is_template === "boolean" &&
typeof value.created_at === "string" &&
"parent_id" in value &&
"sort_order" in value &&
"is_starred" in value &&
"updated_at" in value
);
}
export function deriveTreeRendererDeltaState(input: {
projection: TreeRendererProjection;
sidebar: SidebarInitialData;
}): TreeRendererDeltaState {
const sidebar = input.sidebar;
const pageTreeSource =
sidebar.kernelSidebarTree.length > 0
? sidebar.kernelSidebarTree
: buildSidebarTreeFromKernelProjection({
records: sidebar.documents,
projection: sidebar.kernelSidebarProjection,
});
return {
projection: input.projection,
sidebar,
documents: [...sidebar.documents],
sidebarItems: [...sidebar.kernelSidebarProjection.items],
pageTreeItems: buildPageTreeProjectionItems(pageTreeSource),
fileTreeItems: [...sidebar.kernelFileTreeProjection.items],
};
}
export function applyTreeStreamDelta(
base: SidebarInitialData,
event: TreeStreamDeltaEvent,
): SidebarInitialData {
if (event.op === "noop") {
return base;
}
if (event.op === "replace_sidebar" && event.sidebar) {
if ("activeWorkspaceId" in event.sidebar) {
return cloneSidebarData(event.sidebar as SidebarInitialData);
@@ -117,16 +207,22 @@ export function applyTreeStreamDelta(
}
if (event.op === "upsert_document") {
const nextDocument = normalizeUpsertDocument(event);
if (!nextDocument) {
const documentPatch = normalizeUpsertDocument(event);
if (!documentPatch) {
return base;
}
const nextDocuments = [...base.documents];
const existingIndex = nextDocuments.findIndex((item) => item.id === nextDocument.id);
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
if (existingIndex >= 0) {
nextDocuments[existingIndex] = nextDocument;
nextDocuments[existingIndex] = {
...nextDocuments[existingIndex],
...documentPatch,
};
} else {
nextDocuments.push(nextDocument);
if (!isCompleteDocumentRecord(documentPatch)) {
return base;
}
nextDocuments.push(documentPatch);
}
return buildSidebarFromDocuments({
base,
@@ -158,3 +254,14 @@ export function applyTreeStreamDelta(
return base;
}
export function applyTreeStreamDeltaToProjectionState(input: {
projection: TreeRendererProjection;
base: SidebarInitialData;
event: TreeStreamDeltaEvent;
}): TreeRendererDeltaState {
return deriveTreeRendererDeltaState({
projection: input.projection,
sidebar: applyTreeStreamDelta(input.base, input.event),
});
}
@@ -50,11 +50,6 @@ class MockEventSource {
}
}
declare global {
// eslint-disable-next-line no-var
var EventSource: typeof MockEventSource;
}
function flush() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
@@ -74,6 +69,13 @@ function buildInitialData(): SidebarInitialData {
edges: [],
},
kernelSidebarTree: [],
kernelFileTreeProjection: {
projectionId: "kernel_projection:file_tree:workspace_root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
},
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
@@ -86,6 +88,56 @@ function buildInitialData(): SidebarInitialData {
};
}
function buildSnapshotEnvelope(title = "工作区首页") {
return {
stream: "workspace",
workspaceId: "ws_1",
cursor: "evt_2",
projection: "sidebar_tree",
data: {
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title,
parent_id: null,
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
},
],
kernel_file_tree_projection: {
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernel_sidebar_projection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
},
};
}
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
const state = useSidebarTreeStream(buildInitialData());
@@ -112,7 +164,7 @@ describe("useSidebarTreeStream", () => {
},
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -175,4 +227,74 @@ describe("useSidebarTreeStream", () => {
}),
);
});
it("首帧前连接报错时应进入 fallback", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.onerror?.();
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
data: null,
status: "fallback",
cursor: null,
error: expect.any(Error),
}),
);
expect(MockEventSource.instances[0]?.closed).toBe(true);
});
it("收到 snapshot 后连接中断也应切到 fallback,并保留最近一次 stream 数据", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.emit("snapshot", buildSnapshotEnvelope("来自 stream 的标题"));
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "live",
cursor: "evt_2",
data: expect.objectContaining({
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
}),
}),
);
await act(async () => {
MockEventSource.instances[0]?.onerror?.();
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "fallback",
cursor: "evt_2",
error: expect.any(Error),
data: expect.objectContaining({
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
}),
}),
);
expect(MockEventSource.instances[0]?.closed).toBe(true);
});
});
@@ -30,7 +30,7 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null,
documentId: typeof input.documentId === "string" ? input.documentId : null,
documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null,
sidebar: isRecord(input.sidebar) ? input.sidebar : null,
sidebar: isRecord(input.sidebar) ? (input.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
};
}
@@ -120,7 +120,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
const handleError = () => {
setState((previous) => ({
...previous,
status: previous.data ? "live" : "fallback",
status: "fallback",
error: previous.error ?? new Error("tree stream 连接失败"),
}));
eventSource?.close();