feat(sidebar): navigate the connection tree with arrow keys
This commit is contained in:
1 parent
a196a68d38
commit
0b8c7d700a
8 files changed
+573
No files matched your search
@@ -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"
|
||||
|
||||
+35
@@ -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;");
|
||||
});
|
||||
});
|
||||
Vendored
+314
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user