checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -33,6 +33,7 @@ type PageRuntimeState = {
};
type FileTreeRuntimeState = {
activeRowId: string | null;
selection: {
selectedRowIds: string[];
anchorRowId: string | null;
@@ -283,6 +284,7 @@ export function TreeShellRustDomShellHost({
const [fileTreeState, setFileTreeState] = useState<FileTreeRuntimeState>(() => {
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
return {
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -560,6 +562,10 @@ export function TreeShellRustDomShellHost({
}
continue;
}
if (event.kind === "fileTreeKeyboardCommand") {
window.dispatchEvent(new CustomEvent("tree.filetree.keyboard-command", { detail: event }));
continue;
}
if (event.kind === "fileTreeContextMenu") {
onFileTreeContextMenu?.({
documentId,
@@ -640,6 +646,7 @@ export function TreeShellRustDomShellHost({
if (mode !== "filetree") return;
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
setFileTreeState({
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -858,6 +865,45 @@ export function TreeShellRustDomShellHost({
[reduceFileTreeAction],
);
const handleFileTreeKeyDown = useCallback(
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
ArrowDown: { kind: "focusNext" },
ArrowUp: { kind: "focusPrevious" },
Home: { kind: "focusFirst" },
End: { kind: "focusLast" },
Enter: { kind: "openFocused" },
F2: { kind: "beginRenameFocused" },
Delete: { kind: "deleteSelection" },
Backspace: { kind: "deleteSelection" },
Escape: { kind: "escape" },
};
const shortcut = event.ctrlKey || event.metaKey;
const action = shortcut && event.key.toLowerCase() === "c"
? { kind: "copySelection" }
: shortcut && event.key.toLowerCase() === "x"
? { kind: "cutSelection" }
: shortcut && event.key.toLowerCase() === "v"
? { kind: "pasteIntoFocused" }
: actionByKey[event.key];
if (!action) return;
event.preventDefault();
const rowId = toRowId(item);
const { result } = await reduceFileTreeAction(action, {
...fileTreeState,
selection: {
...fileTreeState.selection,
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
},
});
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
async (item: TreeShellDomProjectionItem, event: DragEvent<HTMLElement>) => {
event.preventDefault();
@@ -998,6 +1044,7 @@ export function TreeShellRustDomShellHost({
const documentId = toDocumentId(item);
const assetId = toAssetId(item);
const selected = fileTreeState.selection.selectedRowIds.includes(rowId);
const active = fileTreeState.activeRowId === rowId || activeDocumentId === documentId;
return (
<div
key={rowId}
@@ -1013,7 +1060,7 @@ export function TreeShellRustDomShellHost({
data-asset-id={assetId ?? ""}
data-shell-mode="filetree"
data-selected={selected}
data-active={activeDocumentId === documentId}
data-active={active}
className={cn(
"tree-row group flex min-h-[27px] w-full cursor-pointer items-center gap-2 rounded-[3px] px-3 py-[2px] text-[14px] font-medium text-wolai-text-primary transition-colors hover:bg-wolai-bg-hover",
selected && "bg-wolai-bg-active text-[#2563eb]",
@@ -1023,6 +1070,7 @@ export function TreeShellRustDomShellHost({
draggable
onClick={(event) => void handleFileTreeSelect(item, event)}
onDoubleClick={() => void handleFileTreeOpen(item)}
onKeyDown={(event) => void handleFileTreeKeyDown(item, event)}
onContextMenu={(event) => void handleFileTreeContextMenu(item, event)}
onDragStart={(event) => {
const rowIds = fileTreeState.selection.selectedRowIds.includes(rowId) ? fileTreeState.selection.selectedRowIds : [rowId];
@@ -1103,7 +1151,15 @@ export function TreeShellRustDomShellHost({
{renderPageRows("")}
</div>
) : mode === "filetree" ? (
<div className="tree-root" role="tree" data-rust-filetree-renderer="dom_v1">
<div
className="tree-root"
role="tree"
data-rust-filetree-renderer="dom_v1"
onMouseDown={(event) => {
if (event.target !== event.currentTarget) return;
void reduceFileTreeAction({ kind: "clearSelection" });
}}
>
{renderFileTreeRows()}
</div>
) : (
@@ -296,7 +296,7 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","fileTreeKeyboardCommand","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
@@ -257,6 +257,7 @@ const TREE_SHELL_RUNTIME_ARTIFACT = {
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"fileTreeKeyboardCommand",
"pickerPickRoot",
"pickerPickDocument",
],
@@ -26,6 +26,7 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const selection = (state.selection ?? {}) as Record<string, unknown>;
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
@@ -49,12 +50,28 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
}]
: [];
const selectedRowId = typeof action.rowId === "string" ? action.rowId : null;
const nextSelection =
action.kind === "selectRow" && selectedRowId
? { selectedRowIds: [selectedRowId], anchorRowId: selectedRowId, focusedRowId: selectedRowId }
: action.kind === "clearSelection"
? { selectedRowIds: [], anchorRowId: null, focusedRowId: null }
: selection;
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
state: {
mode: "fileTree",
state: {
activeRowId: (state as Record<string, unknown>).activeRowId ?? null,
selection: nextSelection,
dragRowIds: (state as Record<string, unknown>).dragRowIds ?? [],
dragEffect: (state as Record<string, unknown>).dragEffect ?? null,
dropTargetRowId: (state as Record<string, unknown>).dropTargetRowId ?? null,
},
},
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
@@ -414,6 +431,69 @@ describe("tree-shell-surface", () => {
});
});
it("file tree surface 应支持空白区清空选择", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
rows={[]}
treeShellItems={[targetProjectionItem]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const targetRow = container.querySelector('[data-testid="filetree-doc-row"][data-row-id="doc:doc_target"]') as HTMLElement | null;
const treeRoot = container.querySelector('[data-rust-filetree-renderer="dom_v1"]') as HTMLElement | null;
expect(targetRow).not.toBeNull();
expect(treeRoot).not.toBeNull();
await act(async () => {
targetRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(targetRow?.getAttribute("data-selected")).toBe("true");
await act(async () => {
treeRoot?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
expect(targetRow?.getAttribute("data-selected")).toBe("false");
});
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn();