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:
@@ -2242,6 +2242,10 @@ fn build_tree_shell_html(
|
||||
if (nextExpanded) expanded.add(nodeId);
|
||||
else expanded.delete(nodeId);
|
||||
postPageExpandChange(nodeId, nextExpanded);
|
||||
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
};
|
||||
|
||||
@@ -2410,6 +2414,11 @@ fn build_tree_shell_html(
|
||||
}
|
||||
focusedNodeId = nodeId;
|
||||
postPageFocusChange(nodeId);
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeActiveDom();
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nodeId);
|
||||
};
|
||||
@@ -2460,7 +2469,11 @@ fn build_tree_shell_html(
|
||||
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||
expanded.add(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, true);
|
||||
renderTree();
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeExpansionDom(item.nodeId);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
focusRowElement(item.nodeId);
|
||||
return;
|
||||
}
|
||||
@@ -2474,7 +2487,11 @@ fn build_tree_shell_html(
|
||||
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
|
||||
expanded.delete(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, false);
|
||||
renderTree();
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeExpansionDom(item.nodeId);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
focusRowElement(item.nodeId);
|
||||
return;
|
||||
}
|
||||
@@ -2524,12 +2541,13 @@ fn build_tree_shell_html(
|
||||
});
|
||||
};
|
||||
|
||||
const applyPickerFocusByItemKey = (pickerItemKey) => {
|
||||
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
|
||||
if (mode !== "picker") return;
|
||||
const result = computePickerStateActionResult({
|
||||
kind: "focus",
|
||||
itemKey: pickerItemKey,
|
||||
});
|
||||
const shouldFocusDom = options.focusDom === true;
|
||||
const nextPickerItemKey = result.nextItemKey || "";
|
||||
const nextDocumentId =
|
||||
nextPickerItemKey && nextPickerItemKey !== "__root__"
|
||||
@@ -2541,11 +2559,11 @@ fn build_tree_shell_html(
|
||||
focusedNodeId = nextDocumentId || "";
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(nextPickerItemKey);
|
||||
if (shouldFocusDom) focusPickerRowElement(nextPickerItemKey);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
if (nextDocumentId) {
|
||||
if (shouldFocusDom && nextDocumentId) {
|
||||
focusRowElement(nextDocumentId);
|
||||
}
|
||||
postPickerFocusChange(nextPickerItemKey || null);
|
||||
@@ -2567,6 +2585,27 @@ fn build_tree_shell_html(
|
||||
return result;
|
||||
};
|
||||
|
||||
const postPickerPickResultToHost = (result) => {
|
||||
if (mode !== "picker" || !result) return;
|
||||
if (result.pickedRoot) {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
postToHost("tree.pick", {
|
||||
documentId: result.pickedDocumentId,
|
||||
itemKey: result.nextItemKey || result.pickedDocumentId,
|
||||
target: { documentId: result.pickedDocumentId },
|
||||
payload: { documentId: result.pickedDocumentId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickerCommand = (command) => {
|
||||
if (mode !== "picker") return;
|
||||
|
||||
@@ -2576,19 +2615,7 @@ fn build_tree_shell_html(
|
||||
}
|
||||
|
||||
if (normalizedCommand === "pick") {
|
||||
const result = applyPickerStateAction({ kind: "pick" });
|
||||
if (result.pickedRoot) {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
handleNavigate(result.pickedDocumentId);
|
||||
}
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2947,7 +2974,10 @@ fn build_tree_shell_html(
|
||||
element.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const action = normalizeText(element.dataset.rustAction);
|
||||
if (action === "open") {
|
||||
if (action === "toggle") {
|
||||
event.preventDefault();
|
||||
toggleExpand(item.nodeId);
|
||||
} else if (action === "open") {
|
||||
handleNavigate(item.nodeId);
|
||||
} else if (action === "create") {
|
||||
void handleCreate(item.nodeId);
|
||||
@@ -2961,6 +2991,72 @@ fn build_tree_shell_html(
|
||||
});
|
||||
};
|
||||
|
||||
const patchPageTreeActiveDom = () => {
|
||||
if (mode !== "page") return;
|
||||
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
const nodeId = normalizeText(row.dataset.nodeId);
|
||||
const isFocused = nodeId === focusedNodeId;
|
||||
row.dataset.active = String(nodeId === currentActiveDocumentId);
|
||||
row.dataset.focused = String(isFocused);
|
||||
row.tabIndex = isFocused ? 0 : -1;
|
||||
row.dataset.dropFeedback = String(activePageDropNodeId === nodeId);
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
childrenList.appendChild(renderNode(child));
|
||||
});
|
||||
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 hydrateInitialPageTree = () => {
|
||||
if (mode !== "page") return false;
|
||||
const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]');
|
||||
@@ -3210,12 +3306,8 @@ fn build_tree_shell_html(
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.dataset.focused = String(resolvePickerRootFocused());
|
||||
row.addEventListener("click", () => {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3228,7 +3320,8 @@ fn build_tree_shell_html(
|
||||
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
|
||||
);
|
||||
row.addEventListener("click", () => {
|
||||
handleNavigate(item.nodeId);
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3447,7 +3540,14 @@ fn build_tree_shell_html(
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", "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";
|
||||
@@ -3812,13 +3912,10 @@ fn build_tree_shell_html(
|
||||
rootButton.className = "tree-row";
|
||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
||||
rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1;
|
||||
rootButton.addEventListener("click", () => {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
@@ -3903,7 +4000,6 @@ fn build_tree_shell_html(
|
||||
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||
if (mode === "picker" && usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
@@ -4345,8 +4441,12 @@ mod tests {
|
||||
assert!(html.contains("tree.shell.state.patch"));
|
||||
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
|
||||
assert!(html.contains("applyPageKeyboardAction"));
|
||||
assert!(html.contains("patchPageTreeActiveDom"));
|
||||
assert!(html.contains("patchPageTreeExpansionDom"));
|
||||
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(html.contains("hydrateInitialPageTree"));
|
||||
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||
@@ -4382,8 +4482,14 @@ mod tests {
|
||||
assert!(html.contains("tree.picker.focus.changed"));
|
||||
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
||||
assert!(html.contains("applyPickerStateAction"));
|
||||
assert!(html.contains("postPickerPickResultToHost"));
|
||||
assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
||||
assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
||||
assert!(html.contains("const shouldFocusDom = options.focusDom === true"));
|
||||
assert!(html.contains("if (shouldFocusDom) focusPickerRowElement"));
|
||||
assert!(html.contains("patchPickerActiveDom"));
|
||||
assert!(html.contains("hydrateInitialPickerTree"));
|
||||
assert!(html.contains("tabindex=\""));
|
||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||
}
|
||||
@@ -4442,6 +4548,9 @@ mod tests {
|
||||
assert!(filetree_html.contains("\"filetreeSelection\""));
|
||||
assert!(filetree_html.contains("\"selectedRowIds\""));
|
||||
assert!(filetree_html.contains("\"commandDispatcher\""));
|
||||
assert!(filetree_html.contains("\"runtimeArtifact\""));
|
||||
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
||||
assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"));
|
||||
|
||||
let picker_response = app()
|
||||
.oneshot(
|
||||
@@ -4460,6 +4569,8 @@ mod tests {
|
||||
assert!(picker_html.contains("\"mode\":\"picker\""));
|
||||
assert!(picker_html.contains("\"activePickerItem\":\"page_child\""));
|
||||
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
|
||||
assert!(picker_html.contains("\"runtimeArtifact\""));
|
||||
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user