feat(tree): complete rust family runtime checklist
- add tree shell runtime artifact contracts and page/filetree/picker runtime reducers - sink tree.subtree.move write operation through Rust and formalize command event plans - harden file tree search projection contract and route thin-proxy boundaries - record completed harness tasks and move design docs into process/done
This commit is contained in:
+2
@@ -10,6 +10,7 @@
|
||||
|
||||
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
|
||||
import type * as _utils_auth from "../_utils/auth.js";
|
||||
import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js";
|
||||
import type * as _utils_documentRecord from "../_utils/documentRecord.js";
|
||||
import type * as _utils_documentTree from "../_utils/documentTree.js";
|
||||
import type * as _utils_id from "../_utils/id.js";
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"_utils/attachmentExtract": typeof _utils_attachmentExtract;
|
||||
"_utils/auth": typeof _utils_auth;
|
||||
"_utils/documentMoveOrder": typeof _utils_documentMoveOrder;
|
||||
"_utils/documentRecord": typeof _utils_documentRecord;
|
||||
"_utils/documentTree": typeof _utils_documentTree;
|
||||
"_utils/id": typeof _utils_id;
|
||||
|
||||
@@ -21,6 +21,14 @@ export type DocumentMoveOrderPlan = {
|
||||
patches: DocumentMoveOrderPatch[];
|
||||
};
|
||||
|
||||
export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & {
|
||||
family: "tree";
|
||||
schema: "mnote.tree.write_operation";
|
||||
schemaVersion: 1;
|
||||
operation: "tree.subtree.move.write";
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
function normalizeParentId(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
@@ -121,7 +129,7 @@ export function buildDocumentMoveOrderPlanFromDocuments(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
export function normalizeDocumentMoveOrderPlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -152,8 +160,37 @@ function normalizePlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) {
|
||||
const normalizedExpected = normalizePlan(expected);
|
||||
export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Partial<DocumentMoveWriteOperation>;
|
||||
if (
|
||||
record.family !== "tree" ||
|
||||
record.schema !== "mnote.tree.write_operation" ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.operation !== "tree.subtree.move.write" ||
|
||||
typeof record.workspaceId !== "string" ||
|
||||
!record.workspaceId.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPlan = normalizeDocumentMoveOrderPlan(value);
|
||||
if (!normalizedPlan) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: record.workspaceId.trim(),
|
||||
...normalizedPlan,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
|
||||
const normalizedExpected = normalizeDocumentMoveOrderPlan(expected);
|
||||
if (!normalizedExpected) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
@@ -161,4 +198,21 @@ export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: Do
|
||||
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
return normalizedExpected;
|
||||
}
|
||||
|
||||
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
|
||||
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
|
||||
if (!normalizedExpected) {
|
||||
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } =
|
||||
normalizedExpected;
|
||||
if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) {
|
||||
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
return expectedPlan;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nowIso } from "./_utils/time";
|
||||
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
|
||||
import {
|
||||
assertDocumentMoveOrderPlanMatches,
|
||||
assertDocumentMoveWriteOperationMatches,
|
||||
buildDocumentMoveOrderPlanFromDocuments,
|
||||
} from "./_utils/documentMoveOrder";
|
||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||
@@ -1400,6 +1401,7 @@ export const move = mutation({
|
||||
parentId: v.union(v.string(), v.null()),
|
||||
sortOrder: v.number(),
|
||||
normalizedMove: v.optional(v.any()),
|
||||
treeWriteOperation: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -1436,23 +1438,52 @@ export const move = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
if (args.normalizedMove != null) {
|
||||
assertDocumentMoveOrderPlanMatches(
|
||||
args.normalizedMove,
|
||||
buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: canonicalWorkspaceDocs,
|
||||
documentId: doc.id,
|
||||
parentId: toParentId,
|
||||
sortOrder: args.sortOrder,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const currentMoveOrderPlan = buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: canonicalWorkspaceDocs,
|
||||
documentId: doc.id,
|
||||
parentId: toParentId,
|
||||
sortOrder: args.sortOrder,
|
||||
});
|
||||
const rustMoveOrderPlan =
|
||||
args.treeWriteOperation != null
|
||||
? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan)
|
||||
: args.normalizedMove != null
|
||||
? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan)
|
||||
: null;
|
||||
|
||||
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
||||
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
||||
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
||||
const ts = nowIso();
|
||||
|
||||
if (rustMoveOrderPlan != null) {
|
||||
const documentsById = new Map(canonicalWorkspaceDocs.map((item) => [item.id, item]));
|
||||
for (const item of rustMoveOrderPlan.patches) {
|
||||
const target = documentsById.get(item.documentId);
|
||||
if (!target) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ((target.parent_id ?? null) !== item.parentId) patch.parent_id = item.parentId;
|
||||
if ((target.sort_order ?? null) !== item.sortOrder) patch.sort_order = item.sortOrder;
|
||||
// Rust plan 决定被移动节点;Convex 只负责把同一结果持久化。
|
||||
if (item.moved) patch.updated_at = ts;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(target._id, patch);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
parent_id: rustMoveOrderPlan.toParentId,
|
||||
sort_order: rustMoveOrderPlan.normalizedSortOrder,
|
||||
workspace_id: doc.workspace_id,
|
||||
updated_at: ts,
|
||||
};
|
||||
}
|
||||
|
||||
const compareDocOrder = (a: any, b: any) => {
|
||||
const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "@/lib/tree-route-boundary";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
@@ -203,6 +204,19 @@ describe("/api/tree/commands route", () => {
|
||||
});
|
||||
|
||||
it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => {
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "tree.commands",
|
||||
role: "next-thin-proxy",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.node.create");
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
|
||||
"新页面 scaffold 文件创建",
|
||||
);
|
||||
|
||||
const client = {
|
||||
mutation: vi.fn(async () => ({ activeWorkspaceId: "ws_root" })),
|
||||
query: vi.fn(),
|
||||
|
||||
@@ -56,7 +56,10 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import { fetchKernelFileTreeProjection } from "@/lib/file-tree/projection-client";
|
||||
import {
|
||||
FILE_TREE_SEARCH_MAX_RESULTS,
|
||||
fetchKernelFileTreeProjection,
|
||||
} from "@/lib/file-tree/projection-client";
|
||||
import {
|
||||
copyFileTreeResourceAssets,
|
||||
deleteFileTreeResourceAssets,
|
||||
@@ -337,7 +340,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
void fetchKernelFileTreeProjection({
|
||||
workspaceId,
|
||||
query,
|
||||
maxResults: 80,
|
||||
maxResults: FILE_TREE_SEARCH_MAX_RESULTS,
|
||||
})
|
||||
.then((projection) => {
|
||||
if (cancelled) {
|
||||
|
||||
@@ -193,6 +193,49 @@ describe("tree-shell-iframe-host", () => {
|
||||
},
|
||||
} as unknown as SidebarTreeNode,
|
||||
},
|
||||
{
|
||||
rowId: "page:doc_child",
|
||||
nodeId: "doc_child",
|
||||
parentNodeId: "doc_parent",
|
||||
nodeType: "page",
|
||||
projectionKind: "page_tree",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
title: "子页面",
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {
|
||||
id: "doc_child",
|
||||
title: "子页面",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: "doc_parent",
|
||||
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: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandedByDefault: false,
|
||||
},
|
||||
} as unknown as SidebarTreeNode,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([
|
||||
@@ -203,6 +246,13 @@ describe("tree-shell-iframe-host", () => {
|
||||
childCount: 1,
|
||||
expandedByDefault: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeId: "doc_child",
|
||||
parentNodeId: "doc_parent",
|
||||
title: "子页面",
|
||||
childCount: 0,
|
||||
expandedByDefault: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
@@ -227,15 +277,23 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-node-toggle");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-action="toggle"');
|
||||
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")).toContain("patchPageTreeActiveDom();");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeExpansionDom(nodeId)");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"inputFields":["rendererInput","projectionItems","expandedIds","selectedRowIds","activePickerItem","focusedId"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"outputChannels":["domPatch","intentEvent","commandDispatchEvent"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||
expect.objectContaining({ nodeId: "doc_parent" }),
|
||||
expect.objectContaining({ nodeId: "doc_child", parentNodeId: "doc_parent" }),
|
||||
]);
|
||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||
});
|
||||
@@ -415,6 +473,7 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
|
||||
@@ -437,7 +496,10 @@ describe("tree-shell-iframe-host", () => {
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }];
|
||||
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(
|
||||
@@ -463,13 +525,27 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialPickerTree");
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||
expect.objectContaining({ nodeId: "doc_1" }),
|
||||
expect.objectContaining({ nodeId: "doc_2" }),
|
||||
]);
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).rendererInput?.activePickerItem).toBe("doc_1");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"activePickerItem":"doc_1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_picker_state_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("getPickablePickerEntries");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('applyPickerFocusByItemKey("__root__", { focusDom: true })');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("const shouldFocusDom = options.focusDom === true");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("if (shouldFocusDom) focusPickerRowElement");
|
||||
const bindPickerRowEventsBody =
|
||||
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
|
||||
?.body ?? "";
|
||||
expect(bindPickerRowEventsBody).not.toContain("handleNavigate(item.nodeId)");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
|
||||
const postMessage = vi.fn();
|
||||
|
||||
@@ -103,6 +103,12 @@ type TreeShellInlineRendererInput = {
|
||||
channel: string;
|
||||
commandNames: string[];
|
||||
};
|
||||
runtimeArtifact: {
|
||||
contractName: "rust_tree_shell_runtime_artifact_v1";
|
||||
inputFields: string[];
|
||||
outputChannels: string[];
|
||||
eventKinds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type TreeShellIframeHostProps = {
|
||||
@@ -212,6 +218,19 @@ const PICKER_STATE_REDUCER_ACTIONS = [
|
||||
"end",
|
||||
"pick",
|
||||
] as const;
|
||||
const TREE_SHELL_RUNTIME_ARTIFACT = {
|
||||
contractName: "rust_tree_shell_runtime_artifact_v1",
|
||||
inputFields: [
|
||||
"rendererInput",
|
||||
"projectionItems",
|
||||
"expandedIds",
|
||||
"selectedRowIds",
|
||||
"activePickerItem",
|
||||
"focusedId",
|
||||
],
|
||||
outputChannels: ["domPatch", "intentEvent", "commandDispatchEvent"],
|
||||
eventKinds: ["focus", "keyboard", "expandCollapse", "selection", "contextMenu", "dragDrop", "pick"],
|
||||
} as const satisfies TreeShellInlineRendererInput["runtimeArtifact"];
|
||||
|
||||
const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -706,6 +725,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
target: { documentId: nodeId },
|
||||
});
|
||||
}
|
||||
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nodeId);
|
||||
};
|
||||
@@ -735,6 +758,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
focusedNodeId = nextFocusId;
|
||||
postPageFocusChange(nextFocusId);
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeActiveDom();
|
||||
focusRowElement(nextFocusId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nextFocusId);
|
||||
};
|
||||
@@ -773,11 +801,22 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
return;
|
||||
}
|
||||
if (actionKind === "expand") {
|
||||
if (item?.nodeId) toggleExpand(item.nodeId);
|
||||
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
const firstChild = item ? getSiblings(item.nodeId)[0] : null;
|
||||
if (firstChild) focusNode(firstChild.nodeId);
|
||||
return;
|
||||
}
|
||||
if (actionKind === "collapse") {
|
||||
if (item?.nodeId) toggleExpand(item.nodeId);
|
||||
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
if (item?.parentNodeId && itemById.has(item.parentNodeId)) {
|
||||
focusNode(item.parentNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (actionKind === "open") {
|
||||
@@ -1245,7 +1284,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
syncFileTreeSelectionDom();
|
||||
return true;
|
||||
};
|
||||
const getVisiblePickerEntries = () => {
|
||||
const getPickablePickerEntries = () => {
|
||||
const visible = [];
|
||||
if (allowRootPick) {
|
||||
visible.push({ pickerItemKey: "__root__", documentId: null });
|
||||
@@ -1277,7 +1316,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (fromActive) return fromActive;
|
||||
const fromDocument = normalizePickerItemKey(currentActiveDocumentId);
|
||||
if (fromDocument) return fromDocument;
|
||||
return getVisiblePickerEntries()[0]?.pickerItemKey || "";
|
||||
return getPickablePickerEntries()[0]?.pickerItemKey || "";
|
||||
};
|
||||
const computePickerStateActionResult = (action) => {
|
||||
const currentPickerItemKey = resolveCurrentPickerItemKey();
|
||||
@@ -1295,21 +1334,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__",
|
||||
};
|
||||
}
|
||||
const visible = getVisiblePickerEntries();
|
||||
if (visible.length === 0) {
|
||||
const pickable = getPickablePickerEntries();
|
||||
if (pickable.length === 0) {
|
||||
return {
|
||||
nextItemKey: "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
}
|
||||
const currentIndex = visible.findIndex(
|
||||
const currentIndex = pickable.findIndex(
|
||||
(entry) => entry.pickerItemKey === currentPickerItemKey,
|
||||
);
|
||||
const baseIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
if (action.kind === "normalize") {
|
||||
return {
|
||||
nextItemKey: visible[baseIndex]?.pickerItemKey || "",
|
||||
nextItemKey: pickable[baseIndex]?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
@@ -1317,13 +1356,13 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (action.kind === "focus") {
|
||||
const nextItemKey = normalizePickerItemKey(action.itemKey);
|
||||
return {
|
||||
nextItemKey: nextItemKey || visible[0]?.pickerItemKey || "",
|
||||
nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
}
|
||||
if (action.kind === "pick") {
|
||||
const target = visible[baseIndex];
|
||||
const target = pickable[baseIndex];
|
||||
const targetKey = target?.pickerItemKey || "";
|
||||
return {
|
||||
nextItemKey: targetKey,
|
||||
@@ -1338,20 +1377,35 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
: action.kind === "home"
|
||||
? 0
|
||||
: action.kind === "end"
|
||||
? visible.length - 1
|
||||
: Math.min(visible.length - 1, baseIndex + 1);
|
||||
const target = visible[nextIndex];
|
||||
? pickable.length - 1
|
||||
: Math.min(pickable.length - 1, baseIndex + 1);
|
||||
const target = pickable[nextIndex];
|
||||
return {
|
||||
nextItemKey: target?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
};
|
||||
const applyPickerFocusByItemKey = (pickerItemKey) => {
|
||||
const postPickerPickResultToHost = (result) => {
|
||||
if (mode !== "picker" || !result) return;
|
||||
if (result.pickedRoot) {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
postToHost("tree.pick", {
|
||||
documentId: result.pickedDocumentId,
|
||||
itemKey: result.nextItemKey || result.pickedDocumentId,
|
||||
target: { documentId: result.pickedDocumentId },
|
||||
});
|
||||
}
|
||||
};
|
||||
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
|
||||
const result = computePickerStateActionResult({
|
||||
kind: "focus",
|
||||
itemKey: pickerItemKey,
|
||||
});
|
||||
const shouldFocusDom = options.focusDom === true;
|
||||
currentActivePickerItemKey = result.nextItemKey || "";
|
||||
currentActiveDocumentId =
|
||||
result.nextItemKey && result.nextItemKey !== "__root__"
|
||||
@@ -1364,10 +1418,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
});
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
if (shouldFocusDom) focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
if (shouldFocusDom) focusPickerRowElement(currentActivePickerItemKey);
|
||||
};
|
||||
const applyPickerStateAction = (action) => {
|
||||
const result = computePickerStateActionResult(action);
|
||||
@@ -1380,7 +1435,8 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.dataset.focused = String(currentActivePickerItemKey === "__root__");
|
||||
row.addEventListener("click", () => {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
const bindPickerRowEvents = (row, item) => {
|
||||
@@ -1391,7 +1447,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
currentActivePickerItemKey === item.nodeId ||
|
||||
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
|
||||
);
|
||||
row.addEventListener("click", () => handleNavigate(item.nodeId));
|
||||
row.addEventListener("click", () => {
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
const focusPickerRowElement = (pickerItemKey) => {
|
||||
const normalizedItemKey = normalizeText(pickerItemKey);
|
||||
@@ -1481,6 +1540,12 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.addEventListener("click", (event) => {
|
||||
const action = normalizeText(element.dataset.rustAction);
|
||||
if (action === "toggle") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
if (action === "open") {
|
||||
handleNavigate(item.nodeId);
|
||||
return;
|
||||
@@ -1491,6 +1556,32 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
void runTreeCommand({ action: "create", parentId: item.nodeId });
|
||||
return;
|
||||
}
|
||||
if (action === "rename") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextTitle = window.prompt("重命名页面", item.title);
|
||||
const title = normalizeText(nextTitle);
|
||||
if (!title || title === item.title) return;
|
||||
void runTreeCommand({
|
||||
action: "rename",
|
||||
documentId: item.nodeId,
|
||||
title,
|
||||
})
|
||||
.then(() => {
|
||||
item.title = title;
|
||||
const titleElement = row.querySelector(".tree-link-title");
|
||||
if (titleElement) titleElement.textContent = title;
|
||||
setLastAction(\`已重命名为 \${title}\`);
|
||||
postToHost("tree.node.renamed", {
|
||||
documentId: item.nodeId,
|
||||
target: { documentId: item.nodeId },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setLastAction(error instanceof Error ? error.message : "重命名失败", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "menu") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1504,6 +1595,141 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
});
|
||||
});
|
||||
};
|
||||
const renderHydratedPageNode = (item) => {
|
||||
if (!item) return null;
|
||||
const children = getSiblings(item.nodeId);
|
||||
const hasChildren = item.childCount > 0 && children.length > 0;
|
||||
const nodeElement = document.createElement("li");
|
||||
nodeElement.className = "tree-node";
|
||||
nodeElement.dataset.nodeId = item.nodeId;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
row.setAttribute("role", "treeitem");
|
||||
row.setAttribute("aria-level", String(item.depth + 1));
|
||||
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
||||
row.setAttribute("data-testid", "tree-node-open");
|
||||
row.dataset.rustRenderedRow = "page";
|
||||
row.dataset.nodeId = item.nodeId;
|
||||
row.dataset.shellMode = "page";
|
||||
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
|
||||
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
||||
row.dataset.draggable = "true";
|
||||
row.draggable = true;
|
||||
|
||||
if (hasChildren) {
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.className = "tree-toggle";
|
||||
toggleButton.setAttribute("data-testid", "tree-node-toggle");
|
||||
toggleButton.dataset.rustAction = "toggle";
|
||||
toggleButton.dataset.nodeId = item.nodeId;
|
||||
toggleButton.setAttribute("aria-label", \`\${expanded.has(item.nodeId) ? "折叠" : "展开"} \${item.title}\`);
|
||||
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
||||
row.appendChild(toggleButton);
|
||||
} else {
|
||||
const spacer = document.createElement("span");
|
||||
spacer.className = "tree-spacer";
|
||||
spacer.setAttribute("aria-hidden", "true");
|
||||
row.appendChild(spacer);
|
||||
}
|
||||
|
||||
row.appendChild(createKindBadge("page"));
|
||||
|
||||
const linkButton = document.createElement("button");
|
||||
linkButton.type = "button";
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", "tree-node-open");
|
||||
linkButton.dataset.rustAction = "open";
|
||||
linkButton.dataset.nodeId = item.nodeId;
|
||||
const titleElement = document.createElement("span");
|
||||
titleElement.className = "tree-link-title";
|
||||
titleElement.textContent = item.title;
|
||||
linkButton.appendChild(titleElement);
|
||||
row.appendChild(linkButton);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
[
|
||||
["create", "tree-action-create", "+", "新建子页面"],
|
||||
["rename", "tree-action-rename", "✎", "重命名"],
|
||||
["menu", "tree-action-menu", "…", "更多操作"],
|
||||
].forEach(([action, testId, label, ariaLabel]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "tree-action";
|
||||
button.setAttribute("data-testid", testId);
|
||||
button.dataset.rustAction = action;
|
||||
button.dataset.nodeId = item.nodeId;
|
||||
button.setAttribute("aria-label", ariaLabel);
|
||||
button.textContent = label;
|
||||
actions.appendChild(button);
|
||||
});
|
||||
row.appendChild(actions);
|
||||
nodeElement.appendChild(row);
|
||||
bindPageRowEvents(row, item);
|
||||
|
||||
if (hasChildren && expanded.has(item.nodeId)) {
|
||||
const childrenList = document.createElement("ul");
|
||||
childrenList.className = "tree-children";
|
||||
children.forEach((child) => {
|
||||
const childNode = renderHydratedPageNode(child);
|
||||
if (childNode) childrenList.appendChild(childNode);
|
||||
});
|
||||
nodeElement.appendChild(childrenList);
|
||||
}
|
||||
return nodeElement;
|
||||
};
|
||||
const patchPageTreeExpansionDom = (nodeId) => {
|
||||
if (mode !== "page") return false;
|
||||
const normalizedNodeId = normalizeText(nodeId);
|
||||
if (!normalizedNodeId) return false;
|
||||
const item = itemById.get(normalizedNodeId);
|
||||
if (!item) return false;
|
||||
const nodeElement = appElement.querySelector(
|
||||
\`.tree-node[data-node-id="\${CSS.escape(normalizedNodeId)}"]\`,
|
||||
);
|
||||
if (!(nodeElement instanceof HTMLElement)) return false;
|
||||
const row = nodeElement.querySelector(
|
||||
\`:scope > .tree-row[data-node-id="\${CSS.escape(normalizedNodeId)}"]\`,
|
||||
);
|
||||
const children = getSiblings(normalizedNodeId);
|
||||
const hasChildren = item.childCount > 0 && children.length > 0;
|
||||
const isExpanded = hasChildren && expanded.has(normalizedNodeId);
|
||||
if (row instanceof HTMLElement) {
|
||||
row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false");
|
||||
const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]');
|
||||
if (toggleButton instanceof HTMLButtonElement) {
|
||||
toggleButton.textContent = isExpanded ? "▾" : "▸";
|
||||
toggleButton.setAttribute("aria-label", \`\${isExpanded ? "折叠" : "展开"} \${item.title}\`);
|
||||
}
|
||||
}
|
||||
if (!hasChildren) {
|
||||
patchPageTreeActiveDom();
|
||||
return true;
|
||||
}
|
||||
let childrenList = Array.from(nodeElement.children).find(
|
||||
(child) => child instanceof HTMLElement && child.classList.contains("tree-children"),
|
||||
);
|
||||
if (isExpanded) {
|
||||
if (!(childrenList instanceof HTMLElement)) {
|
||||
childrenList = document.createElement("ul");
|
||||
childrenList.className = "tree-children";
|
||||
children.forEach((child) => {
|
||||
const childNode = renderHydratedPageNode(child);
|
||||
if (childNode) childrenList.appendChild(childNode);
|
||||
});
|
||||
nodeElement.appendChild(childrenList);
|
||||
}
|
||||
childrenList.hidden = false;
|
||||
childrenList.style.display = "";
|
||||
} else if (childrenList instanceof HTMLElement) {
|
||||
childrenList.hidden = true;
|
||||
childrenList.style.display = "none";
|
||||
}
|
||||
patchPageTreeActiveDom();
|
||||
return true;
|
||||
};
|
||||
const patchPageTreeActiveDom = () => {
|
||||
if (mode !== "page") return;
|
||||
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
||||
@@ -1744,7 +1970,14 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", mode === "picker" ? "tree-picker-row" : "tree-node-open");
|
||||
linkButton.setAttribute("aria-label", \`打开 \${item.title}\`);
|
||||
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
|
||||
linkButton.addEventListener("click", () => {
|
||||
if (mode === "picker") {
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
return;
|
||||
}
|
||||
handleNavigate(item.nodeId);
|
||||
});
|
||||
const titleElement = document.createElement("span");
|
||||
titleElement.className = "tree-link-title";
|
||||
titleElement.textContent = item.title;
|
||||
@@ -1857,9 +2090,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
rootButton.className = "tree-row";
|
||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||
rootButton.dataset.focused = String(currentActivePickerItemKey === "__root__");
|
||||
rootButton.tabIndex = currentActivePickerItemKey === "__root__" ? 0 : -1;
|
||||
rootButton.textContent = "根目录";
|
||||
rootButton.addEventListener("click", () => {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
root.appendChild(rootButton);
|
||||
}
|
||||
@@ -1881,11 +2116,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
if (normalizedCommand === "pick") {
|
||||
const result = applyPickerStateAction({ kind: "pick" });
|
||||
if (result.pickedRoot) {
|
||||
postToHost("tree.pick.root", { documentId: null });
|
||||
} else if (result.pickedDocumentId) {
|
||||
handleNavigate(result.pickedDocumentId);
|
||||
}
|
||||
postPickerPickResultToHost(result);
|
||||
return;
|
||||
}
|
||||
applyPickerStateAction({ kind: normalizedCommand });
|
||||
@@ -1916,7 +2147,6 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
if (mode === "picker" && usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
@@ -2188,6 +2418,12 @@ function buildInlineRendererInput(input: {
|
||||
channel: normalizeString(input.channel),
|
||||
commandNames: [...TREE_SHELL_COMMAND_NAMES],
|
||||
};
|
||||
const runtimeArtifact = {
|
||||
contractName: TREE_SHELL_RUNTIME_ARTIFACT.contractName,
|
||||
inputFields: [...TREE_SHELL_RUNTIME_ARTIFACT.inputFields],
|
||||
outputChannels: [...TREE_SHELL_RUNTIME_ARTIFACT.outputChannels],
|
||||
eventKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.eventKinds],
|
||||
};
|
||||
if (input.mode === "filetree") {
|
||||
const filetreeSelection = buildInlineFiletreeSelection(input.activeDocumentId);
|
||||
return {
|
||||
@@ -2203,6 +2439,7 @@ function buildInlineRendererInput(input: {
|
||||
activePickerItem: null,
|
||||
excludedPickerIds: [],
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
if (input.mode === "picker") {
|
||||
@@ -2220,6 +2457,7 @@ function buildInlineRendererInput(input: {
|
||||
actions: [...PICKER_STATE_REDUCER_ACTIONS],
|
||||
},
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -2235,6 +2473,7 @@ function buildInlineRendererInput(input: {
|
||||
activePickerItem: null,
|
||||
excludedPickerIds: [],
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2250,11 +2489,14 @@ function buildInlinePageTreeHtml(input: {
|
||||
const children = childrenByParent.get(item.nodeId) ?? [];
|
||||
const expandable = item.childCount > 0 && children.length > 0;
|
||||
const expanded = expandable && item.expandedByDefault;
|
||||
const toggleHtml = expandable
|
||||
? `<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="${expanded ? "折叠" : "展开"} ${escapeInlineHtml(item.title)}">${expanded ? "▾" : "▸"}</button>`
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
const childHtml =
|
||||
expanded && children.length > 0
|
||||
? `<ul class="tree-children">${children.map(renderRow).join("")}</ul>`
|
||||
: "";
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><div class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="page" data-testid="tree-node-open" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="page" data-active="${item.nodeId === activeDocumentId}" data-focused="${item.nodeId === focusedDocumentId}" data-draggable="true" draggable="true" tabindex="${item.nodeId === focusedDocumentId ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="${escapeInlineHtml(item.nodeId)}"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="更多操作">…</button></div></div>${childHtml}</li>`;
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><div class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="page" data-testid="tree-node-open" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="page" data-active="${item.nodeId === activeDocumentId}" data-focused="${item.nodeId === focusedDocumentId}" data-draggable="true" draggable="true" tabindex="${item.nodeId === focusedDocumentId ? "0" : "-1"}">${toggleHtml}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="${escapeInlineHtml(item.nodeId)}"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="更多操作">…</button></div></div>${childHtml}</li>`;
|
||||
};
|
||||
const roots = childrenByParent.get("") ?? [];
|
||||
if (roots.length === 0) {
|
||||
@@ -2304,8 +2546,9 @@ function buildInlinePickerHtml(input: {
|
||||
const childrenByParent = buildInlineChildrenByParent(input.items);
|
||||
const activeDocumentId = normalizeString(input.activeDocumentId);
|
||||
const activePickerItemKey = normalizeString(input.activePickerItemKey);
|
||||
const rootActive = activePickerItemKey === "__root__";
|
||||
const rootHtml = input.allowRootPick
|
||||
? `<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="${activePickerItemKey === "__root__"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>`
|
||||
? `<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="${rootActive}" tabindex="${rootActive ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>`
|
||||
: "";
|
||||
const renderRow = (item: TreeShellInlineProjectionItem): string => {
|
||||
const children = childrenByParent.get(item.nodeId) ?? [];
|
||||
@@ -2318,7 +2561,7 @@ function buildInlinePickerHtml(input: {
|
||||
expanded && children.length > 0
|
||||
? `<ul class="tree-children">${children.map(renderRow).join("")}</ul>`
|
||||
: "";
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><button type="button" class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="picker" data-focused="${active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></span></button>${childHtml}</li>`;
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><button type="button" class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="picker" data-focused="${active}" data-rust-action="pick" tabindex="${active ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></span></button>${childHtml}</li>`;
|
||||
};
|
||||
const roots = childrenByParent.get("") ?? [];
|
||||
return `<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">${rootHtml}${roots.map(renderRow).join("")}</ul>`;
|
||||
|
||||
@@ -59,6 +59,8 @@ describe("tree-shell-surface", () => {
|
||||
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -156,6 +158,8 @@ describe("tree-shell-surface", () => {
|
||||
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
recordRustBridgeCommandArtifacts as recordRustBridgeCommandArtifactsFromRuntime,
|
||||
type RustTreeDomainEventPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
export const recordRustBridgeCommandArtifacts = recordRustBridgeCommandArtifactsFromRuntime;
|
||||
|
||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
});
|
||||
|
||||
describe("executeRustBridgeMutationTransport", () => {
|
||||
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
|
||||
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
|
||||
const normalizedMove = {
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
@@ -98,6 +98,14 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const treeWriteOperation = {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
...normalizedMove,
|
||||
};
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -115,6 +123,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,6 +138,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +301,79 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
});
|
||||
|
||||
describe("materializeRustTreeStreamDelta", () => {
|
||||
it("应把 Rust noop hint 物化为 noop delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "noop",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "noop",
|
||||
});
|
||||
});
|
||||
|
||||
it("应把 Rust resync_required hint 物化为保守 resync delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -561,6 +644,82 @@ describe("readRustTreeDomainEventType", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应保留 Rust formal domainEventPlan payload schema", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(readRustTreeDomainEventPlan(plan)).toEqual({
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
@@ -631,14 +790,28 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -661,26 +834,39 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||
id: "evt_cmd_artifact_1",
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
command_id: "cmd_artifact_1",
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export type RustTreeDomainEventPlan = {
|
||||
schema: "mnote.tree.domain_event";
|
||||
schemaVersion: 1;
|
||||
eventType: string;
|
||||
payload?: Record<string, unknown>;
|
||||
streamDeltaHint?: Record<string, unknown>;
|
||||
streamDelta?: RustTreeStreamDelta;
|
||||
};
|
||||
@@ -86,6 +87,13 @@ export type RustTreeStreamDelta =
|
||||
| {
|
||||
op: "noop";
|
||||
}
|
||||
| {
|
||||
op: "resync_required";
|
||||
reason?: string;
|
||||
pageId?: string;
|
||||
documentId?: string;
|
||||
blockId?: string;
|
||||
}
|
||||
| {
|
||||
op: "upsert_document";
|
||||
document: Record<string, unknown>;
|
||||
@@ -674,11 +682,13 @@ export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTr
|
||||
}
|
||||
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
|
||||
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
|
||||
const payload = readRecordField(eventPlan, "payload") ?? undefined;
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType,
|
||||
...(payload ? { payload } : {}),
|
||||
...(streamDeltaHint ? { streamDeltaHint } : {}),
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
};
|
||||
@@ -718,6 +728,20 @@ export function materializeRustTreeStreamDelta(input: {
|
||||
return { op: "noop" };
|
||||
}
|
||||
|
||||
if (hint.kind === "resync_required") {
|
||||
const reason = readTrimmedStringField(hint.args, "reason");
|
||||
const pageId = readTrimmedStringField(hint.args, "pageId");
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
const blockId = readTrimmedStringField(hint.args, "blockId");
|
||||
return {
|
||||
op: "resync_required",
|
||||
...(reason ? { reason } : {}),
|
||||
...(pageId ? { pageId } : {}),
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(blockId ? { blockId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "remove_document") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
return documentId ? { op: "remove_document", documentId } : null;
|
||||
@@ -1130,6 +1154,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
...("normalizedMove" in input.plan.argsJson
|
||||
? { normalizedMove: input.plan.argsJson.normalizedMove }
|
||||
: {}),
|
||||
...("treeWriteOperation" in input.plan.argsJson
|
||||
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
|
||||
: {}),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchKernelFileTreeProjection } from "./projection-client";
|
||||
import {
|
||||
FILE_TREE_SEARCH_MAX_RESULTS,
|
||||
buildFileTreeProjectionSearchRequestMeta,
|
||||
fetchKernelFileTreeProjection,
|
||||
} from "./projection-client";
|
||||
|
||||
describe("fetchKernelFileTreeProjection", () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,6 +46,59 @@ describe("fetchKernelFileTreeProjection", () => {
|
||||
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||
});
|
||||
|
||||
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
|
||||
expect(FILE_TREE_SEARCH_MAX_RESULTS).toBe(80);
|
||||
expect(
|
||||
buildFileTreeProjectionSearchRequestMeta({
|
||||
workspaceId: "ws_1",
|
||||
query: " rust ",
|
||||
maxResults: 500,
|
||||
}),
|
||||
).toEqual({
|
||||
query: "rust",
|
||||
maxResults: 80,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: "没有匹配结果",
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: "ws_1:rust",
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
});
|
||||
});
|
||||
|
||||
it("搜索请求会把 maxResults 限制在 sidebar 使用的稳定上限内", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchKernelFileTreeProjection({
|
||||
workspaceId: "ws_1",
|
||||
query: "rust",
|
||||
maxResults: 500,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/projections/file?workspaceId=ws_1&query=rust&maxResults=80",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("失败时透出服务端错误消息", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
|
||||
export const FILE_TREE_SEARCH_MAX_RESULTS = 80;
|
||||
export const FILE_TREE_SEARCH_EMPTY_STATE_TEXT = "没有匹配结果";
|
||||
|
||||
export type FileTreeProjectionSearchRequestMeta = {
|
||||
query: string | null;
|
||||
maxResults: number | null;
|
||||
maxResultsRule: "matches_only_before_ancestor_completion";
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation";
|
||||
ordering: "kernel_file_tree_preorder";
|
||||
emptyStateText: typeof FILE_TREE_SEARCH_EMPTY_STATE_TEXT;
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view";
|
||||
requestKey: string | null;
|
||||
};
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"];
|
||||
};
|
||||
|
||||
export type FetchKernelFileTreeProjectionInput = {
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
@@ -8,6 +25,38 @@ export type FetchKernelFileTreeProjectionInput = {
|
||||
maxResults?: number | null;
|
||||
};
|
||||
|
||||
function normalizeQuery(value: string | null | undefined): string | null {
|
||||
const query = value?.trim() ?? "";
|
||||
return query.length > 0 ? query : null;
|
||||
}
|
||||
|
||||
function normalizeMaxResults(value: number | null | undefined): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(FILE_TREE_SEARCH_MAX_RESULTS, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function buildFileTreeProjectionSearchRequestMeta(
|
||||
input: Pick<FetchKernelFileTreeProjectionInput, "workspaceId" | "query" | "maxResults">,
|
||||
): FileTreeProjectionSearchRequestMeta {
|
||||
const query = normalizeQuery(input.query);
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
return {
|
||||
query,
|
||||
maxResults,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: FILE_TREE_SEARCH_EMPTY_STATE_TEXT,
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: query ? `${input.workspaceId}:${query}` : null,
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKernelFileTreeProjection(
|
||||
input: FetchKernelFileTreeProjectionInput,
|
||||
): Promise<KernelFileTreeProjection> {
|
||||
@@ -20,12 +69,13 @@ export async function fetchKernelFileTreeProjection(
|
||||
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
|
||||
params.set("depth", String(input.depth));
|
||||
}
|
||||
const query = input.query?.trim();
|
||||
const query = normalizeQuery(input.query);
|
||||
if (query) {
|
||||
params.set("query", query);
|
||||
}
|
||||
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
|
||||
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
if (maxResults != null) {
|
||||
params.set("maxResults", String(maxResults));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
|
||||
|
||||
@@ -470,6 +470,14 @@ describe("tree-stream/tree-delta", () => {
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("支持 resync_required delta 仅推进 cursor,等待后续 snapshot/resync", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "resync_required",
|
||||
});
|
||||
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "page_tree",
|
||||
@@ -557,6 +565,59 @@ describe("tree-stream/tree-delta", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("file_tree fixture 覆盖搜索 hardening 需要的 index、asset-folder、mindmap child、book 与 pdf 资源类型", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
base: fileTreeProjectionBase,
|
||||
event: {
|
||||
op: "replace_documents",
|
||||
documents: [...fileTreeProjectionBase.documents],
|
||||
},
|
||||
});
|
||||
const rowById = new Map(next.fileTreeItems.map((item) => [item.rowId, item]));
|
||||
|
||||
expect(rowById.get("index:root")).toMatchObject({
|
||||
rowKind: "index",
|
||||
title: "index.md",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "index",
|
||||
documentId: "root",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset-folder:asset_mindmap")).toMatchObject({
|
||||
rowKind: "asset_folder",
|
||||
title: "mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "mindmap",
|
||||
assetKind: "mindmap",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_mindmap_child")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
parentNodeId: "asset-folder:asset_mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "asset",
|
||||
assetKind: "image",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_book")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "book",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "book",
|
||||
assetKind: "book",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_pdf")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "pdf",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "pdf",
|
||||
assetKind: "pdf",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type TreeStreamDocumentPatch =
|
||||
|
||||
export type TreeStreamDeltaOp =
|
||||
| "noop"
|
||||
| "resync_required"
|
||||
| "upsert_document"
|
||||
| "upsert_documents"
|
||||
| "upsert_assets"
|
||||
@@ -236,6 +237,10 @@ export function applyTreeStreamDelta(
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "resync_required") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||
if ("activeWorkspaceId" in event.sidebar) {
|
||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||
|
||||
Reference in New Issue
Block a user