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:
lix-2026
2026-04-27 10:27:15 +08:00
parent e564dfde02
commit 4ab36a9386
30 changed files with 2502 additions and 432 deletions
@@ -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();
});