From 0b8c7d700a0934836236398ab6ce20dc5cb9eeee Mon Sep 17 00:00:00 2001 From: zhangyaxi Date: Sat, 5 Sep 2026 13:25:03 +0800 Subject: [PATCH] feat(sidebar): navigate the connection tree with arrow keys --- .../src/components/sidebar/ConnectionTree.vue | 22 ++ .../sidebar/SidebarTreeRuntimeHost.vue | 27 ++ .../src/components/sidebar/TreeItem.vue | 1 + .../ConnectionTree.arrowFocus.spec.ts | 35 ++ ...barTreeRuntimeHost.arrowNavigation.spec.ts | 314 ++++++++++++++++++ .../sidebarTreeArrowNavigation.spec.ts | 112 +++++++ .../lib/sidebar/sidebarTreeArrowNavigation.ts | 56 ++++ .../src/lib/sidebar/sidebarTreeContext.ts | 6 + 8 files changed, 573 insertions(+) create mode 100644 apps/desktop/src/components/sidebar/__tests__/ConnectionTree.arrowFocus.spec.ts create mode 100644 apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.arrowNavigation.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/__tests__/sidebarTreeArrowNavigation.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/sidebarTreeArrowNavigation.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 6c54e7f121..bc82be1f88 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -1399,6 +1399,10 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry(); provide(sidebarTreeContextKey, { getVisibleNodes: () => selectableVisibleNodes.value, getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1, + getVisibleFlatNodes: () => flatNodes.value.filter((item) => !isSidebarTableSearchControlNode(item.node)), + focusTreeNode: (nodeId: string) => { + void focusSidebarTreeNode(nodeId); + }, getProjectedConnectionIds: () => projectedConnectionIds.value, // Cover both sides of the input debounce: the immediate query prevents a // collapse while a projection is about to start, and the deferred query @@ -1490,6 +1494,24 @@ async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNo } } +// Arrow-key navigation moves the selection first; only after the row re-renders +// as the tabbable one (tabindex follows selection) can focus follow it. +async function focusSidebarTreeNode(nodeId: string) { + await nextTick(); + // Scroll before querying the row: the virtualized tree only keeps rows in + // the materialized window in the DOM, so querying first would never find an + // out-of-window row and focus would stall at the window edge. Scrolling is + // index-driven (no DOM lookup) and a no-op when the row is already visible; + // one render frame lets RecycleScroller materialize the target row. + await scrollToSidebarNode(nodeId); + const root = rootRef.value; + if (!root) return; + await waitForSidebarRenderFrame(); + const row = root.querySelector(`[data-node-id="${CSS.escape(nodeId)}"]`); + if (!row) return; + row.focus({ preventScroll: true }); +} + function clearSidebarSelection() { // Clicking the blank area of the tree clears the current selection. Row // clicks call event.stopPropagation(), so this only fires for blank clicks diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 28eb4102e8..37dba18a83 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -175,6 +175,7 @@ import { connectionSupportsProcessList } from "@/lib/database/processListDrivers import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus"; import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboard } from "@/lib/database/postgresServerStatus"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; +import { sidebarTreeArrowAction } from "@/lib/sidebar/sidebarTreeArrowNavigation"; import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; @@ -1142,6 +1143,11 @@ function onKeydown(event: KeyboardEvent) { event.stopPropagation(); return; } + if (isSidebarTreeArrowKey(event) && handleSidebarTreeArrowKey(event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "F2") { if (!requestRenameSelectedNode()) return; event.preventDefault(); @@ -1180,6 +1186,27 @@ function isPasteTreeClipboardShortcut(event: KeyboardEvent): boolean { return isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts); } +function isSidebarTreeArrowKey(event: KeyboardEvent): boolean { + return !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "ArrowLeft" || event.key === "ArrowRight"); +} + +function handleSidebarTreeArrowKey(event: KeyboardEvent): boolean { + const rows = sidebarTreeContext?.getVisibleFlatNodes?.(); + if (!rows?.length) return false; + const action = sidebarTreeArrowAction(rows, activeNode.value.id, event.key, { databaseType: currentDatabaseType() }); + if (action.kind === "none") return false; + if (action.kind === "toggle") { + toggleNode(activeNode.value); + return true; + } + connectionStore.connectionMultiSelectActive = false; + connectionStore.selectedTreeNodeId = action.nodeId; + connectionStore.selectedTreeNodeIds = [action.nodeId]; + connectionStore.treeSelectionAnchorId = action.nodeId; + sidebarTreeContext?.focusTreeNode?.(action.nodeId); + return true; +} + function isEditConnectionShortcut(event: KeyboardEvent): boolean { return isEditSidebarConnectionShortcut(event, settingsStore.editorSettings.shortcuts); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 6f8774e5ce..abab5c941e 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -1512,6 +1512,7 @@ function onKeydown(event: KeyboardEvent) { }, ]" :tabindex="selectionVisual.selected || selectionVisual.multiSelected ? 0 : -1" + :data-node-id="node.id" :style="rowStyle" @click="onClick" @dblclick="onDoubleClick" diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.arrowFocus.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.arrowFocus.spec.ts new file mode 100644 index 0000000000..d21e3ffadd --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.arrowFocus.spec.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8"); + +function focusSidebarTreeNodeBody(): string { + const match = connectionTreeSource.match(/^async function focusSidebarTreeNode[\s\S]*?^}/m); + expect(match).not.toBeNull(); + return match![0]!; +} + +describe("ConnectionTree arrow focus", () => { + it("scrolls before querying the row so out-of-window virtual rows materialize first", () => { + const body = focusSidebarTreeNodeBody(); + const scroll = body.indexOf("await scrollToSidebarNode(nodeId);"); + const renderWait = body.indexOf("await waitForSidebarRenderFrame();"); + const query = body.indexOf('root.querySelector(`[data-node-id="${CSS.escape(nodeId)}"]`)'); + const missingRowGuard = body.indexOf("if (!row) return;"); + const focus = body.indexOf("row.focus({ preventScroll: true });"); + + // RecycleScroller only keeps the materialized window in the DOM, so the + // index-driven scroll must run (no-op when already visible) and one render + // frame must pass before the row can be queried and focused. + for (const position of [scroll, renderWait, query, missingRowGuard, focus]) expect(position).toBeGreaterThan(-1); + expect(scroll).toBeLessThan(renderWait); + expect(renderWait).toBeLessThan(query); + expect(query).toBeLessThan(missingRowGuard); + expect(missingRowGuard).toBeLessThan(focus); + }); + + it("keeps focus a no-op when the row is still not rendered after the scroll", () => { + const body = focusSidebarTreeNodeBody(); + expect(body).toContain("if (!row) return;"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.arrowNavigation.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.arrowNavigation.spec.ts new file mode 100644 index 0000000000..bf3c8518f3 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.arrowNavigation.spec.ts @@ -0,0 +1,314 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, provide, ref, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { TreeNode } from "@/types/database"; +import { flattenTree } from "@/composables/useFlatTree"; +import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; +import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue"; + +const connectionStore = { + treeNodes: [] as TreeNode[], + sidebarSearchQuery: "", + activeConnectionId: null as string | null, + connectedIds: new Set(), + selectedTreeNodeId: null as string | null, + selectedTreeNodeIds: [] as string[], + treeSelectionAnchorId: null as string | null, + connectionMultiSelectActive: false, + get selectedTreeNodeIdsSet(): Set { + return new Set(this.selectedTreeNodeIds); + }, + canUseLoadedTreeNodeToggle: vi.fn(() => true), + releaseCollapsedTreeNodeChildren: vi.fn(), + getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })), + getEtcdAccessCapabilities: vi.fn(() => ({ admin: true, writable: true, writePermissions: null })), + ensureConnected: vi.fn(async () => undefined), + loadPackageMembers: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), + loadObjectGroupChildren: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), + loadXuguTablespaces: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), +}; + +const queryStore = { + createTab: vi.fn(), + openNacosAdmin: vi.fn(), +}; + +vi.mock("@/stores/connectionStore", () => ({ + CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "connection attempt cancelled", + useConnectionStore: () => connectionStore, +})); + +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => queryStore })); +vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { sidebarActivation: "single" } }) })); +vi.mock("@/stores/savedSqlStore", () => ({ useSavedSqlStore: () => ({}) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: vi.fn() }) })); +vi.mock("@/composables/useSidebarDataOpenRuntime", () => ({ useSidebarDataOpenRuntime: () => ({ openData: vi.fn() }) })); +vi.mock("@/composables/useDatabaseOptions", () => ({ useDatabaseOptions: () => ({ getDatabaseOptions: vi.fn() }) })); +vi.mock("@/composables/useSidebarConnectionMutationRuntime", () => ({ useSidebarConnectionMutationRuntime: () => ({}) })); +vi.mock("@/composables/useSidebarDatabaseSpecificMutationRuntime", () => ({ useSidebarDatabaseSpecificMutationRuntime: () => ({}) })); +vi.mock("@/composables/useSidebarTableMutationRuntime", () => ({ useSidebarTableMutationRuntime: () => ({}) })); +vi.mock("@/composables/useSidebarTreeExportRuntime", () => ({ useSidebarTreeExportRuntime: () => ({}) })); +vi.mock("@/composables/useSidebarTreeToolRuntime", () => ({ useSidebarTreeToolRuntime: () => ({}) })); + +function buildTree(): TreeNode[] { + return [ + { + id: "conn", + label: "conn", + type: "connection", + connectionId: "mysql", + isExpanded: true, + children: [ + { + id: "tables", + label: "tree.tables", + type: "group-tables", + connectionId: "mysql", + database: "basic", + isExpanded: true, + children: [ + { id: "t1", label: "t1", type: "table", connectionId: "mysql", database: "basic" }, + { id: "t2", label: "t2", type: "table", connectionId: "mysql", database: "basic" }, + ], + }, + ], + }, + ]; +} + +function arrowEvent(key: string): KeyboardEvent { + return new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); +} + +function selectNode(nodeId: string): void { + connectionStore.selectedTreeNodeId = nodeId; + connectionStore.selectedTreeNodeIds = [nodeId]; + connectionStore.treeSelectionAnchorId = nodeId; +} + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + connectionStore.treeNodes = []; + connectionStore.sidebarSearchQuery = ""; + connectionStore.activeConnectionId = null; + connectionStore.connectedIds.clear(); + connectionStore.selectedTreeNodeId = null; + connectionStore.selectedTreeNodeIds = []; + connectionStore.treeSelectionAnchorId = null; + connectionStore.connectionMultiSelectActive = false; + vi.clearAllMocks(); + connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true); + connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" }); + connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: true, writable: true, writePermissions: null }); +}); + +describe("SidebarTreeRuntimeHost arrow navigation", () => { + it("ArrowDown moves the selection to the next row and focuses it", async () => { + const tree = buildTree(); + connectionStore.treeNodes = tree; + connectionStore.connectedIds.add("mysql"); + selectNode("t1"); + const focusTreeNode = vi.fn(); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => flattenTree(tree).map((row) => row.node), + getVisibleNodeIndex: () => 0, + getVisibleFlatNodes: () => flattenTree(tree), + focusTreeNode, + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: tree[0]!.children![0]!.children![0]!, depth: 3 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + const event = arrowEvent("ArrowDown"); + host.value?.handleRowKeydown(tree[0]!.children![0]!.children![0]!, event); + await nextTick(); + + expect(event.defaultPrevented).toBe(true); + expect(connectionStore.selectedTreeNodeId).toBe("t2"); + expect(connectionStore.selectedTreeNodeIds).toEqual(["t2"]); + expect(connectionStore.treeSelectionAnchorId).toBe("t2"); + expect(connectionStore.connectionMultiSelectActive).toBe(false); + expect(focusTreeNode).toHaveBeenCalledWith("t2"); + }); + + it("ArrowRight expands a collapsed cached group without moving the selection", async () => { + const tree = buildTree(); + const group = tree[0]!.children![0]!; + group.isExpanded = false; + connectionStore.treeNodes = tree; + connectionStore.connectedIds.add("mysql"); + connectionStore.activeConnectionId = "mysql"; + selectNode("tables"); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => [group], + getVisibleNodeIndex: () => 0, + getVisibleFlatNodes: () => flattenTree(tree), + focusTreeNode: vi.fn(), + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 2 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + const event = arrowEvent("ArrowRight"); + host.value?.handleRowKeydown(group, event); + await nextTick(); + + expect(event.defaultPrevented).toBe(true); + expect(group.isExpanded).toBe(true); + expect(connectionStore.selectedTreeNodeId).toBe("tables"); + expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled(); + }); + + it("arrow keys do nothing without a context providing visible flat nodes", async () => { + const tree = buildTree(); + connectionStore.treeNodes = tree; + selectNode("t1"); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => flattenTree(tree).map((row) => row.node), + getVisibleNodeIndex: () => 0, + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: tree[0]!.children![0]!.children![0]!, depth: 3 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + const event = arrowEvent("ArrowDown"); + host.value?.handleRowKeydown(tree[0]!.children![0]!.children![0]!, event); + await nextTick(); + + expect(event.defaultPrevented).toBe(false); + expect(connectionStore.selectedTreeNodeId).toBe("t1"); + }); + + it("ArrowRight does not expand a PostgreSQL custom type without members", async () => { + connectionStore.getConfig.mockReturnValue({ db_type: "postgres", name: "connection" }); + const typeNode: TreeNode = { + id: "status", + label: "status", + type: "type", + connectionId: "postgres", + database: "public", + hasMembers: false, + isExpanded: false, + children: [{ id: "status-attr", label: "status", type: "type-attributes", connectionId: "postgres", database: "public" }], + }; + connectionStore.treeNodes = [typeNode]; + selectNode("status"); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => [typeNode], + getVisibleNodeIndex: () => 0, + getVisibleFlatNodes: () => flattenTree([typeNode]), + focusTreeNode: vi.fn(), + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: typeNode, depth: 3 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + const event = arrowEvent("ArrowRight"); + host.value?.handleRowKeydown(typeNode, event); + await nextTick(); + + expect(event.defaultPrevented).toBe(false); + expect(typeNode.isExpanded).toBe(false); + expect(connectionStore.selectedTreeNodeId).toBe("status"); + }); + + it("ArrowRight expands a PostgreSQL custom type that has members", async () => { + connectionStore.getConfig.mockReturnValue({ db_type: "postgres", name: "connection" }); + const typeNode: TreeNode = { + id: "address", + label: "address", + type: "type", + connectionId: "postgres", + database: "public", + hasMembers: true, + isExpanded: false, + children: [{ id: "address-attr", label: "address", type: "type-attributes", connectionId: "postgres", database: "public" }], + }; + connectionStore.treeNodes = [typeNode]; + selectNode("address"); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => [typeNode], + getVisibleNodeIndex: () => 0, + getVisibleFlatNodes: () => flattenTree([typeNode]), + focusTreeNode: vi.fn(), + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: typeNode, depth: 3 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + const event = arrowEvent("ArrowRight"); + host.value?.handleRowKeydown(typeNode, event); + await nextTick(); + + expect(event.defaultPrevented).toBe(true); + expect(typeNode.isExpanded).toBe(true); + expect(connectionStore.selectedTreeNodeId).toBe("address"); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/__tests__/sidebarTreeArrowNavigation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/sidebarTreeArrowNavigation.spec.ts new file mode 100644 index 0000000000..b36422d006 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/sidebarTreeArrowNavigation.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { flattenTree } from "@/composables/useFlatTree"; +import type { TreeNode, TreeNodeType } from "@/types/database"; +import { sidebarTreeArrowAction } from "@/lib/sidebar/sidebarTreeArrowNavigation"; + +function node(id: string, type: TreeNodeType, children: TreeNode[] = [], isExpanded = false): TreeNode { + return { id, label: id, type, children, isExpanded }; +} + +function buildRows(): ReturnType { + const tree = [node("conn1", "connection"), node("conn2", "connection", [node("db1", "database", [node("group-tables", "group-tables", [node("t1", "table"), node("t2", "table")], true), node("group-views", "group-views")], true), node("db2", "database")], true), node("conn3", "connection")]; + return flattenTree(tree); +} + +// Visible order: conn1, conn2, db1, group-tables, t1, t2, group-views, db2, conn3. +describe("sidebar tree arrow navigation", () => { + it("ArrowDown selects the next visible row", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "conn1", "ArrowDown")).toEqual({ kind: "select", nodeId: "conn2" }); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowDown")).toEqual({ kind: "select", nodeId: "t2" }); + }); + + it("ArrowUp selects the previous visible row", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "t2", "ArrowUp")).toEqual({ kind: "select", nodeId: "t1" }); + expect(sidebarTreeArrowAction(rows, "group-views", "ArrowUp")).toEqual({ kind: "select", nodeId: "t2" }); + }); + + it("ArrowUp on the first row and ArrowDown on the last row do nothing", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "conn1", "ArrowUp")).toEqual({ kind: "none" }); + expect(sidebarTreeArrowAction(rows, "conn3", "ArrowDown")).toEqual({ kind: "none" }); + }); + + it("ArrowRight expands a collapsed expandable node", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "conn1", "ArrowRight")).toEqual({ kind: "toggle", nodeId: "conn1", expanded: true }); + }); + + it("ArrowRight expands a collapsed container whose children are not loaded yet", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "db2", "ArrowRight")).toEqual({ kind: "toggle", nodeId: "db2", expanded: true }); + }); + + it("ArrowRight on an expanded node selects its first child", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "group-tables", "ArrowRight")).toEqual({ kind: "select", nodeId: "t1" }); + }); + + it("ArrowRight on a collapsed table expands it (column/index groups load on demand)", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowRight")).toEqual({ kind: "toggle", nodeId: "t1", expanded: true }); + }); + + it("ArrowRight on a leaf does nothing", () => { + const rows = flattenTree([node("tbl", "table", [node("col1", "column")], true)]); + expect(sidebarTreeArrowAction(rows, "col1", "ArrowRight")).toEqual({ kind: "none" }); + }); + + it("ArrowLeft collapses an expanded node", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "group-tables", "ArrowLeft")).toEqual({ kind: "toggle", nodeId: "group-tables", expanded: false }); + expect(sidebarTreeArrowAction(rows, "conn2", "ArrowLeft")).toEqual({ kind: "toggle", nodeId: "conn2", expanded: false }); + }); + + it("ArrowLeft on a leaf selects its parent", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowLeft")).toEqual({ kind: "select", nodeId: "group-tables" }); + }); + + it("ArrowLeft on a collapsed container selects its parent", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "group-views", "ArrowLeft")).toEqual({ kind: "select", nodeId: "db1" }); + }); + + it("ArrowLeft on a collapsed root does nothing", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "conn1", "ArrowLeft")).toEqual({ kind: "none" }); + }); + + it("ignores unknown keys and unknown node ids", () => { + const rows = buildRows(); + expect(sidebarTreeArrowAction(rows, "t1", "PageDown")).toEqual({ kind: "none" }); + expect(sidebarTreeArrowAction(rows, "missing", "ArrowDown")).toEqual({ kind: "none" }); + }); + + it("ArrowRight does not expand a PostgreSQL-family custom type without members", () => { + const typeNode: TreeNode = { ...node("t1", "type", [node("m1", "type-attributes")]), hasMembers: false }; + const rows = flattenTree([typeNode]); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowRight", { databaseType: "postgres" })).toEqual({ kind: "none" }); + }); + + it("ArrowRight expands a custom type that has members", () => { + const typeNode: TreeNode = { ...node("t1", "type", [node("m1", "type-attributes")]), hasMembers: true }; + const rows = flattenTree([typeNode]); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowRight", { databaseType: "postgres" })).toEqual({ kind: "toggle", nodeId: "t1", expanded: true }); + }); + + it("the member gate only applies to custom-type databases", () => { + const typeNode: TreeNode = { ...node("t1", "type", [node("m1", "type-attributes")]), hasMembers: false }; + const rows = flattenTree([typeNode]); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowRight", { databaseType: "mysql" })).toEqual({ kind: "toggle", nodeId: "t1", expanded: true }); + const plainRows = buildRows(); + expect(sidebarTreeArrowAction(plainRows, "conn1", "ArrowRight", { databaseType: "postgres" })).toEqual({ kind: "toggle", nodeId: "conn1", expanded: true }); + }); + + it("ArrowLeft still collapses an expanded custom type without members", () => { + const typeNode: TreeNode = { ...node("t1", "type", [node("m1", "type-attributes")], true), hasMembers: false }; + const rows = flattenTree([typeNode]); + expect(sidebarTreeArrowAction(rows, "t1", "ArrowLeft", { databaseType: "postgres" })).toEqual({ kind: "toggle", nodeId: "t1", expanded: false }); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeArrowNavigation.ts b/apps/desktop/src/lib/sidebar/sidebarTreeArrowNavigation.ts new file mode 100644 index 0000000000..86af513839 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarTreeArrowNavigation.ts @@ -0,0 +1,56 @@ +import type { FlatTreeNode } from "@/composables/useFlatTree"; +import type { DatabaseType, TreeNode } from "@/types/database"; +import { canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout"; +import { customTypeCapabilities } from "@/lib/database/databaseObjectCapabilities"; + +export type SidebarTreeArrowAction = { kind: "select"; nodeId: string } | { kind: "toggle"; nodeId: string; expanded: boolean } | { kind: "none" }; + +/** + * Resolves the standard tree-navigation action (VS Code / file explorer + * semantics) for an arrow key pressed on the row identified by + * `currentNodeId`. Callers pass the visible rows they render, excluding + * non-selectable pseudo rows (e.g. per-database table search controls), so + * vertical movement naturally skips them. `databaseType` is the connection's + * dialect of the active row and mirrors the chevron expandability gates. + */ +export function sidebarTreeArrowAction(rows: readonly FlatTreeNode[], currentNodeId: string, key: string, options?: { databaseType?: DatabaseType }): SidebarTreeArrowAction { + const currentIndex = rows.findIndex((row) => row.id === currentNodeId); + if (currentIndex < 0) return { kind: "none" }; + + const current = rows[currentIndex]!; + const node = current.node; + + if (key === "ArrowUp") { + return currentIndex > 0 ? { kind: "select", nodeId: rows[currentIndex - 1]!.id } : { kind: "none" }; + } + if (key === "ArrowDown") { + return currentIndex < rows.length - 1 ? { kind: "select", nodeId: rows[currentIndex + 1]!.id } : { kind: "none" }; + } + if (key === "ArrowRight") { + const next = rows[currentIndex + 1]; + if (node.isExpanded && next && next.depth === current.depth + 1) return { kind: "select", nodeId: next.id }; + if (!node.isExpanded && isArrowExpandable(node, options?.databaseType)) return { kind: "toggle", nodeId: node.id, expanded: true }; + return { kind: "none" }; + } + if (key === "ArrowLeft") { + if (node.isExpanded && node.children?.length) return { kind: "toggle", nodeId: node.id, expanded: false }; + for (let index = currentIndex - 1; index >= 0; index -= 1) { + if (rows[index]!.depth < current.depth) return { kind: "select", nodeId: rows[index]!.id }; + } + return { kind: "none" }; + } + return { kind: "none" }; +} + +function isArrowExpandable(node: TreeNode, databaseType?: DatabaseType): boolean { + // Match the chevron gate (TreeItem/SidebarTreeRuntimeHost canExpand): + // PostgreSQL-family custom types only expand when the catalog marked them + // with loadable members; member-less kinds (enum/domain/range/base) stay + // collapsed even when child metadata rows exist. + if (node.type === "type" && customTypeCapabilities(databaseType).details) return node.hasMembers === true; + return canTreeNodeShowExpander({ + type: node.type, + childCount: node.children?.length ?? 0, + explicitContainer: (node.type === "package" && node.children !== undefined) || node.xuguTypeMembersExpandable === true, + }); +} diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts index 8de6acb818..0a543ae1ce 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts @@ -1,4 +1,5 @@ import type { InjectionKey } from "vue"; +import type { FlatTreeNode } from "@/composables/useFlatTree"; import type { TreeNode } from "@/types/database"; export interface SidebarTreeContext { @@ -10,6 +11,11 @@ export interface SidebarTreeContext { setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void; refreshTableSearchIndex?: (parentNodeId: string) => void; registerPasteHandler?: (nodeId: string, callback: () => void) => () => void; + /** Selectable visible rows (pseudo rows like table search controls are already + * filtered out) driving keyboard arrow navigation. */ + getVisibleFlatNodes?: () => readonly FlatTreeNode[]; + /** Moves DOM focus to a rendered row after keyboard navigation changed the selection. */ + focusTreeNode?: (nodeId: string) => void; } export const sidebarTreeContextKey: InjectionKey = Symbol("sidebar-tree-context");