checkpoint before gfm ast parser design
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user