feat: complete tree shell cutover and regression coverage

This commit is contained in:
lix-2026
2026-04-18 09:38:16 +08:00
parent d3876e56eb
commit 111a87d4fd
53 changed files with 5440 additions and 516 deletions
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyTreeCommand,
createDocumentCommand,
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
purgeDocumentCommand,
renameDocumentCommand,
restoreDocumentCommand,
} from "@/lib/documents/tree-command-client";
describe("tree-command-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, success: true, items: [] }),
} as Response);
await createDocumentCommand(null);
await renameDocumentCommand({ documentId: "doc_1", title: "新标题" });
await moveDocumentCommand({ documentId: "doc_1", parentId: null, position: 0 });
await deleteDocumentCommand({ documentId: "doc_1" });
await restoreDocumentCommand({ documentId: "doc_1" });
await purgeDocumentCommand({ documentId: "doc_1" });
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
expect(fetchMock).toHaveBeenCalledTimes(8);
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
"/api/documents/create",
"/api/documents/title",
"/api/documents/move",
"/api/documents/delete",
"/api/documents/restore",
"/api/documents/purge",
"/api/documents/embed",
"/api/documents/copy-tree",
]);
});
it("在后端返回错误时抛出统一异常", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
json: async () => ({ error: "删除失败" }),
} as Response);
await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败");
});
});
@@ -11,6 +11,41 @@ type DocumentCommandErrorPayload = {
error?: string;
};
export const TREE_COMMAND_PROTOCOL = {
create: {
preferredCommandName: "tree.node.create",
compatCommandName: "documents.create",
},
rename: {
preferredCommandName: "tree.node.rename",
compatCommandName: "documents.title.update",
},
move: {
preferredCommandName: "tree.subtree.move",
compatCommandName: "documents.move",
},
archive: {
preferredCommandName: "tree.node.archive",
compatCommandName: "documents.delete",
},
restore: {
preferredCommandName: "tree.node.restore",
compatCommandName: "documents.restore",
},
purge: {
preferredCommandName: "tree.node.purge",
compatCommandName: "documents.purge",
},
embed: {
preferredCommandName: "tree.node.embed",
compatCommandName: "documents.embed",
},
copy: {
preferredCommandName: "tree.subtree.copy",
compatCommandName: "documents.copy_tree",
},
} as const;
export type DocumentCreateCommandResult = {
id: string;
title?: string | null;
+38
View File
@@ -1,4 +1,8 @@
import type { DocumentRecord } from "@/lib/documents";
import type {
TreeProjectionCapability,
TreeProjectionResourceMeta,
} from "@/lib/tree-protocol";
export type KernelSidebarProjectionEdge = {
id: string;
@@ -12,11 +16,16 @@ export type KernelSidebarProjectionItem = {
nodeId: string;
parentNodeId: string | null;
nodeType: "page";
projectionKind: "sidebar_tree";
title: string | null;
depth: number;
position: number | null;
childCount: number;
expandable: boolean;
expandedByDefault: boolean;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
iconHint: string | null;
};
export type KernelSidebarProjection = {
@@ -64,6 +73,25 @@ function dedupeRecords(records: DocumentRecord[]) {
return unique;
}
function buildSidebarCapabilities(childCount: number): TreeProjectionCapability[] {
const capabilities: TreeProjectionCapability[] = [
"open",
"drag",
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
];
if (childCount > 0) {
capabilities.unshift("expand");
}
return capabilities;
}
function buildChildrenByParent(records: DocumentRecord[]) {
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
const recordIds = new Set(records.map((record) => record.id));
@@ -102,11 +130,21 @@ export function buildKernelSidebarProjection(
nodeId: child.id,
parentNodeId: parentId,
nodeType: "page",
projectionKind: "sidebar_tree",
title: child.title ?? "无标题",
depth,
position: child.sort_order ?? null,
childCount: childNodes.length,
expandable: childNodes.length > 0,
expandedByDefault: true,
capabilities: buildSidebarCapabilities(childNodes.length),
resourceMeta: {
resourceKind: "document",
documentId: child.id,
workspaceId: child.workspace_id ?? null,
iconHint: "page",
},
iconHint: "page",
});
if (parentId && recordById.has(parentId)) {
edges.push({
+12
View File
@@ -0,0 +1,12 @@
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
export async function ensureMnoteWebAuthCookie(): Promise<void> {
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error("mnote-web 鉴权 cookie 准备失败");
}
}
+61 -4
View File
@@ -221,11 +221,32 @@ describe("buildSidebarInitialData", () => {
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
projectionKind: "sidebar_tree",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandable: false,
expandedByDefault: true,
capabilities: [
"open",
"drag",
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
],
resourceMeta: {
resourceKind: "document",
documentId: "doc_1",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
],
edges: [],
@@ -276,11 +297,32 @@ describe("buildSidebarInitialData", () => {
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
projectionKind: "sidebar_tree",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandable: false,
expandedByDefault: true,
capabilities: [
"open",
"drag",
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
],
resourceMeta: {
resourceKind: "document",
documentId: "doc_1",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
],
edges: [],
@@ -319,7 +361,7 @@ describe("buildSidebarInitialData", () => {
});
});
it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => {
it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => {
expect(
mapSidebarDatasetListQueryResultToInitialData({
active_workspace_id: "ws_1",
@@ -337,6 +379,18 @@ describe("buildSidebarInitialData", () => {
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
{
id: "doc_2",
workspace_id: "ws_1",
title: "页面 2",
parent_id: "doc_1",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:01:00Z",
updated_at: null,
},
],
trashed_documents: [],
media_assets: [],
@@ -351,13 +405,16 @@ describe("buildSidebarInitialData", () => {
).toMatchObject({
activeWorkspaceId: "ws_1",
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:missing",
items: [],
edges: [],
projectionId: "kernel_projection:sidebar_tree:workspace_root",
},
kernelSidebarTree: [
{
id: "doc_1",
children: [
{
id: "doc_2",
},
],
},
],
});
+6
View File
@@ -96,6 +96,12 @@ function readKernelSidebarProjection(
return camelCaseProjection;
}
if (Array.isArray(result.documents) && result.documents.length > 0) {
// 说明:部分 query transport 只回 documents,没有同步附带 projection。
// 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。
return buildProjectionContract(result.documents);
}
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
describe("tree-projection contract", () => {
it("page_tree projection item 持续输出共享 contract 字段", () => {
const items = buildPageTreeProjectionItems([
{
access_scope: "private",
id: "root",
workspace_id: "ws_1",
title: "Root",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-18T00:00:00Z",
updated_at: null,
children: [],
},
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
rowId: "page:root",
nodeId: "root",
parentNodeId: null,
projectionKind: "page_tree",
expandable: false,
expandedByDefault: true,
iconHint: "page",
resourceMeta: {
resourceKind: "document",
documentId: "root",
iconHint: "page",
},
});
expect(items[0]?.capabilities).toContain("open");
});
});
+15 -27
View File
@@ -1,34 +1,12 @@
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
export type TreeProjectionKind = "page_tree";
export type TreeProjectionNodeType = "page";
export type TreeProjectionCapability =
| "expand"
| "open"
| "drag"
| "drop"
| "select"
| "create-child";
export type TreeProjectionResourceMeta = {
resourceKind: "document";
documentId: string;
};
import type {
TreeProjectionCapability,
TreeProjectionItemBase,
} from "@/lib/tree-protocol";
export type PageTreeProjectionItem = {
rowId: `page:${string}`;
nodeId: string;
parentNodeId: string | null;
nodeType: TreeProjectionNodeType;
projectionKind: TreeProjectionKind;
depth: number;
position: number | null;
title: string;
childCount: number;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
} & TreeProjectionItemBase & {
node: SidebarTreeNode;
};
@@ -45,6 +23,11 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
];
if (childCount > 0) {
capabilities.unshift("expand");
@@ -80,11 +63,16 @@ export function buildPageTreeProjectionItems(
position: node.kernel?.position ?? node.sort_order ?? index,
title: node.title ?? "无标题",
childCount,
expandable: childCount > 0,
expandedByDefault: node.kernel?.expandedByDefault ?? true,
capabilities: buildPageCapabilities(childCount),
resourceMeta: {
resourceKind: "document",
documentId: node.id,
workspaceId: node.workspace_id,
iconHint: "page",
},
iconHint: "page",
node: {
...node,
parent_id: parentNodeId,
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
describe("tree-protocol", () => {
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
const item: TreeProjectionItemBase = {
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "页面 1",
depth: 0,
position: 1,
childCount: 2,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "drag", "drop", "select", "create-child"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_1",
assetKind: "file",
iconHint: "page",
},
iconHint: "page",
};
expect(item.resourceMeta.resourceKind).toBe("document");
expect(item.resourceMeta.assetKind).toBe("file");
expect(item.capabilities).toContain("expand");
expect(item.expandable).toBe(true);
expect(item.iconHint).toBe("page");
});
});
+76
View File
@@ -0,0 +1,76 @@
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
export type TreeProjectionNodeType =
| "workspace"
| "folder"
| "page"
| "section"
| "asset"
| "mindmap"
| "table"
| "book"
| "pdf"
| "index";
export type TreeProjectionCapability =
| "expand"
| "open"
| "drag"
| "drop"
| "select"
| "create-child"
| "rename"
| "archive"
| "restore"
| "context-menu"
| "reorder"
| "open-asset"
| "pick";
export type TreeProjectionResourceKind =
| "workspace"
| "document"
| "index"
| "asset"
| "asset_folder"
| "mindmap"
| "table"
| "book"
| "pdf";
export type TreeProjectionAssetKind =
| "file"
| "mindmap"
| "table"
| "book"
| "pdf"
| "image"
| "video"
| "audio"
| "unknown";
export type TreeProjectionResourceMeta = {
resourceKind: TreeProjectionResourceKind;
documentId?: string | null;
assetId?: string | null;
workspaceId?: string | null;
assetKind?: TreeProjectionAssetKind | null;
iconHint?: string | null;
extra?: Record<string, unknown>;
};
export type TreeProjectionItemBase = {
nodeId: string;
parentNodeId: string | null;
nodeType: TreeProjectionNodeType;
projectionKind: TreeProjectionKind;
title: string;
depth: number;
position: number | null;
childCount: number;
expandable: boolean;
expandedByDefault: boolean;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
iconHint: string | null;
};
@@ -40,21 +40,64 @@ const baseSidebarData: SidebarInitialData = {
nodeId: "root",
parentNodeId: null,
nodeType: "page",
projectionKind: "sidebar_tree",
title: "Root",
depth: 0,
position: 0,
childCount: 1,
expandable: true,
expandedByDefault: true,
capabilities: [
"expand",
"open",
"drag",
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
],
resourceMeta: {
resourceKind: "document",
documentId: "root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
nodeId: "child",
parentNodeId: "root",
nodeType: "page",
projectionKind: "sidebar_tree",
title: "Child",
depth: 1,
position: 1,
childCount: 0,
expandable: false,
expandedByDefault: true,
capabilities: [
"open",
"drag",
"drop",
"select",
"create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
],
resourceMeta: {
resourceKind: "document",
documentId: "child",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
],
edges: [
@@ -9,6 +9,7 @@ import {
parseTreeStreamMessage,
} from "@/lib/tree-stream/protocol";
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
export interface SidebarTreeStreamState {
data: SidebarInitialData | null;
@@ -60,9 +61,8 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
return undefined;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
const eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
let cancelled = false;
let eventSource: EventSource | null = null;
const handleMessage = (event: MessageEvent<string>) => {
const envelope = parseTreeStreamMessage({
@@ -121,21 +121,45 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 连接失败"),
}));
eventSource.close();
eventSourceRef.current = null;
eventSource?.close();
if (eventSourceRef.current === eventSource) {
eventSourceRef.current = null;
}
};
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (cancelled) {
return;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
} catch {
if (cancelled) {
return;
}
setState((previous) => ({
...previous,
status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 鉴权失败"),
}));
}
})();
return () => {
eventSource.removeEventListener("snapshot", handleMessage as EventListener);
eventSource.removeEventListener("delta", handleMessage as EventListener);
eventSource.removeEventListener("resync", handleMessage as EventListener);
eventSource.close();
cancelled = true;
eventSource?.removeEventListener("snapshot", handleMessage as EventListener);
eventSource?.removeEventListener("delta", handleMessage as EventListener);
eventSource?.removeEventListener("resync", handleMessage as EventListener);
eventSource?.close();
if (eventSourceRef.current === eventSource) {
eventSourceRef.current = null;
}