feat(sidebar): navigate the connection tree with arrow keys

This commit is contained in:
zhangyaxi
2026-09-05 13:25:03 +08:00
committed by GitHub
parent a196a68d38
commit 0b8c7d700a
8 changed files with 573 additions and 0 deletions
@@ -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<HTMLElement>(`[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
@@ -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);
}
@@ -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"
@@ -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<HTMLElement>(`[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;");
});
});
@@ -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<string>(),
selectedTreeNodeId: null as string | null,
selectedTreeNodeIds: [] as string[],
treeSelectionAnchorId: null as string | null,
connectionMultiSelectActive: false,
get selectedTreeNodeIdsSet(): Set<string> {
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<InstanceType<typeof SidebarTreeRuntimeHost> | 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<InstanceType<typeof SidebarTreeRuntimeHost> | 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<InstanceType<typeof SidebarTreeRuntimeHost> | 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<InstanceType<typeof SidebarTreeRuntimeHost> | 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<InstanceType<typeof SidebarTreeRuntimeHost> | 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");
});
});
@@ -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<typeof flattenTree> {
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 });
});
});
@@ -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,
});
}
@@ -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<SidebarTreeContext> = Symbol("sidebar-tree-context");