From cd18b7a3dbe04c8b1c7228422071c7e06011d322 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 24 Aug 2026 20:36:46 +0000 Subject: [PATCH] feat(grid): add go-to-column shortcut mapping Closes #4108 --- apps/desktop/src/components/grid/DataGrid.vue | 11 +- .../DataGridGoToColumnShortcut.spec.ts | 247 ++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../editor/keyboardShortcuts.spec.ts | 14 +- .../__tests__/editor/shortcutRegistry.spec.ts | 25 ++ .../src/lib/editor/keyboardShortcuts.ts | 4 + .../src/lib/editor/shortcutRegistry.ts | 7 + 14 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridGoToColumnShortcut.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 1f17b9e933..73934808bc 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -182,7 +182,7 @@ import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/d import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn"; import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata"; import { resolveDataGridNewRowCellPlaceholder } from "@/lib/dataGrid/dataGridDefaultPlaceholder"; -import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; +import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isGoToColumnShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; @@ -8980,6 +8980,13 @@ function openCellDetailSearch(): boolean { async function onGridKeydown(event: KeyboardEvent) { if (event.defaultPrevented) return; + const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard(event); + if (!targetAllowsNativeClipboard && displayableColumnIndexes.value.length > 0 && isGoToColumnShortcut(event, settingsStore.editorSettings.shortcuts)) { + event.preventDefault(); + event.stopPropagation(); + goToColumnOpen.value = true; + return; + } if (isFocusSearchShortcut(event)) { event.preventDefault(); focusSearch(); @@ -8991,7 +8998,7 @@ async function onGridKeydown(event: KeyboardEvent) { await onToolbarRefresh(); return; } - if (eventTargetAllowsNativeClipboard(event)) return; + if (targetAllowsNativeClipboard) return; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { const handled = event.shiftKey ? redoGridChange() : undoGridChange(); if (handled) { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridGoToColumnShortcut.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridGoToColumnShortcut.spec.ts new file mode 100644 index 0000000000..ab85b122d7 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridGoToColumnShortcut.spec.ts @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { QueryResult } from "@/types/database"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = await import("vue"); + return { + ...actual, + useDataGridColumnResize: () => ({ + initColumnWidths: vi.fn(), + onResizeStart: vi.fn(), + autoFitColumn: vi.fn(), + renderedColumnWidths: ref([120]), + totalWidth: ref(120), + columnVars: ref({ "--total-w": "120px" }), + getIsResizing: () => false, + }), + }; +}); + +import DataGrid from "../DataGrid.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8"); +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +const RecycleScroller = defineComponent({ + props: { + items: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props, { attrs, slots }) { + return () => + h( + "div", + attrs, + props.items.map((item) => slots.default?.({ item })), + ); + }, +}); + +function mountGrid(options: { displayableColumns?: boolean } = {}) { + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ + dataGridRenderMode: "canvas", + shortcuts: { + ...settingsStore.editorSettings.shortcuts, + goToColumn: "Mod+G", + }, + }); + const result = markRaw({ + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 0, + hidden_column_indexes: options.displayableColumns === false ? [0] : undefined, + }); + + const host = document.createElement("div"); + document.body.append(host); + const Root = defineComponent({ + setup() { + return () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + result, + databaseType: "mysql", + context: "table-data", + }), + }, + ); + }, + }); + const app = createApp(Root); + app.use(pinia); + app.use(i18n); + app.component("RecycleScroller", RecycleScroller); + app.mount(host); + const mounted = { app, host }; + mountedApps.push(mounted); + return mounted; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function gridRoot(host: HTMLElement): HTMLElement { + const root = host.querySelector("[data-grid-root]"); + if (!root) throw new Error("Data grid root not found"); + return root; +} + +function goToColumnButton(host: HTMLElement): HTMLButtonElement { + const button = [...host.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Go to column"); + if (!button) throw new Error("Go-to-column button not found"); + return button; +} + +function goToColumnEvent(target: HTMLElement): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "g" }); + target.dispatchEvent(event); + return event; +} + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } +}); + +function functionBody(name: string, nextName: string): string { + const start = dataGridSource.indexOf(`function ${name}`); + const end = dataGridSource.indexOf(`function ${nextName}`, start + 1); + return start >= 0 && end > start ? dataGridSource.slice(start, end) : ""; +} + +describe("DataGrid go-to-column shortcut", () => { + it("opens and consumes the configured shortcut when a column is displayable", async () => { + const { host } = mountGrid(); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + root.focus(); + expect(document.activeElement).toBe(root); + + const event = goToColumnEvent(root); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(bubbled).not.toHaveBeenCalled(); + expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("true"); + expect(document.activeElement?.getAttribute("placeholder")).toBe("Search column/comment..."); + }); + + it("does not consume the configured shortcut without a displayable column", async () => { + const { host } = mountGrid({ displayableColumns: false }); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = goToColumnEvent(root); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false"); + }); + + it("does not trigger or consume shortcuts from editable targets", async () => { + const { host } = mountGrid(); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + const targets = [document.createElement("input"), document.createElement("textarea"), document.createElement("div"), document.createElement("div")]; + targets[2]!.setAttribute("contenteditable", "true"); + targets[3]!.setAttribute("role", "textbox"); + + for (const target of targets) { + root.append(target); + const event = goToColumnEvent(target); + expect(event.defaultPrevented).toBe(false); + } + await settle(); + + expect(bubbled).toHaveBeenCalledTimes(targets.length); + expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false"); + }); + + it("leaves an unmatched root event untouched", async () => { + const { host } = mountGrid(); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "j" }); + + root.dispatchEvent(event); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false"); + }); + + it("opens the existing popover only for a handled root shortcut", () => { + const keydown = functionBody("onGridKeydown", "copyDetailValue"); + + expect(dataGridSource).toMatch(/]*\bdata-grid-root)(?=[^>]*\btabindex="0")(?=[^>]*@keydown="onGridKeydown")[^>]*>/); + expect(keydown).toMatch( + /const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard\(event\);[\s\S]*?if \(!targetAllowsNativeClipboard && displayableColumnIndexes\.value\.length > 0 && isGoToColumnShortcut\(event, settingsStore\.editorSettings\.shortcuts\)\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopPropagation\(\);[\s\S]*?goToColumnOpen\.value = true;[\s\S]*?return;[\s\S]*?\}/, + ); + }); + + it("leaves editable targets and unhandled events untouched by the shortcut", () => { + const keydown = functionBody("onGridKeydown", "copyDetailValue"); + const shortcutStart = keydown.indexOf("if (!targetAllowsNativeClipboard && displayableColumnIndexes.value.length > 0 && isGoToColumnShortcut"); + const shortcutEnd = keydown.indexOf("if (isFocusSearchShortcut", shortcutStart); + const shortcutBranch = keydown.slice(shortcutStart, shortcutEnd); + + expect(shortcutStart).toBeGreaterThan(-1); + expect(shortcutBranch).not.toContain("else"); + expect(shortcutBranch.match(/preventDefault/g)).toHaveLength(1); + expect(shortcutBranch.match(/stopPropagation/g)).toHaveLength(1); + }); + + it("keeps toolbar navigation and the existing search/filter/reveal path", () => { + const selectColumn = functionBody("scrollToColumn", "onGoToColumnKeydown"); + const escape = functionBody("onGoToColumnKeydown", "matchesTableInfoColumn"); + const scroll = functionBody("scrollToColumnIndex", "measureColumnHeaderText"); + + expect(dataGridSource.match(//g)).toHaveLength(1); + expect(dataGridSource).toContain('>{{ t("grid.goToColumn") }} { @@ -82,6 +82,18 @@ describe("keyboard shortcut matching", () => { expect(matchesShortcut({ key: "+", ctrlKey: true }, "Mod++", "Win32")).toBe(true); expect(matchesShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Shift+Mod++", "Win32")).toBe(true); }); + + it("matches only the configured go-to-column shortcut", () => { + expect(isGoToColumnShortcut({ key: "g", ctrlKey: true }, { goToColumn: "Mod+G" })).toBe(true); + expect(isGoToColumnShortcut({ key: "g", ctrlKey: true, shiftKey: true }, { goToColumn: "Mod+G" })).toBe(false); + expect(isGoToColumnShortcut({ key: "j", ctrlKey: true }, { goToColumn: "Mod+G" })).toBe(false); + }); + + it("does not match an empty or composing go-to-column shortcut", () => { + expect(isGoToColumnShortcut({ key: "g", ctrlKey: true })).toBe(false); + expect(isGoToColumnShortcut({ key: "g", ctrlKey: true }, { goToColumn: "" })).toBe(false); + expect(isGoToColumnShortcut({ key: "g", ctrlKey: true, isComposing: true }, { goToColumn: "Mod+G" })).toBe(false); + }); }); describe("tabSwitcherDirectionFromShortcut", () => { diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 4f8686e2a5..cc596fe55d 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -25,6 +25,31 @@ describe("shortcutRegistry editor actions", () => { ]; const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection", "viewTableDdl"]; + it("registers go to column as an unassigned grid shortcut", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "goToColumn"); + + expect(definition).toMatchObject({ + labelKey: "settings.shortcutGoToColumn", + scope: "grid", + defaultShortcut: "", + }); + expect(DEFAULT_SHORTCUT_SETTINGS.goToColumn).toBe(""); + }); + + it("normalizes missing, legacy, cleared, and configured go-to-column settings", () => { + expect(normalizeShortcutSettings().goToColumn).toBe(""); + expect(normalizeShortcutSettings({ executeSql: "Mod+Shift+Enter" }).goToColumn).toBe(""); + expect(normalizeShortcutSettings({ goToColumn: "" }).goToColumn).toBe(""); + expect(normalizeShortcutSettings({ goToColumn: "Mod+G" }).goToColumn).toBe("Mod+G"); + }); + + it("detects go-to-column conflicts only within the grid scope", () => { + const shortcuts = normalizeShortcutSettings({ goToColumn: "Mod+D" }); + + expect(findShortcutConflict("goToColumn", shortcuts.goToColumn, shortcuts)).toBe("copyCurrentRow"); + expect(findShortcutConflict("goToColumn", "Mod+F", shortcuts)).toBeNull(); + }); + it("registers the new-data-tab mouse modifier as a configurable sidebar shortcut", () => { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "openDataInNewTab"); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index a83b0e49d6..19735fba01 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -207,6 +207,10 @@ export function isDeleteCurrentRowShortcut(event: ShortcutLikeEvent, shortcuts?: return matchesShortcut(event, actionShortcut("deleteCurrentRow", shortcuts)); } +export function isGoToColumnShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("goToColumn", shortcuts)); +} + export function isCancelSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { return matchesShortcut(event, actionShortcut("cancelSearch", shortcuts)); } diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 45ce89ab20..38a0e2f550 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -30,6 +30,7 @@ export type ShortcutActionId = | "toggleFold" | "copyCurrentRow" | "deleteCurrentRow" + | "goToColumn" | "newQuery" | "openSettings" | "closeTab" @@ -280,6 +281,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "grid", defaultShortcut: "Delete", }, + { + id: "goToColumn", + labelKey: "settings.shortcutGoToColumn", + scope: "grid", + defaultShortcut: "", + }, { id: "newQuery", labelKey: "settings.shortcutNewQuery",