feat(grid): add go-to-column shortcut mapping

Closes #4108
This commit is contained in:
t8y2 committed 2026-08-24 20:41:29 +00:00
1 parent 5c75c2391c
commit cd18b7a3db
14 files changed
+313 -3

No files matched your search

@@ -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) {
@@ -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<typeof import("@/composables/useDataGridColumnResize")>();
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<unknown[]>,
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<QueryResult>({
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<HTMLElement>("[data-grid-root]");
if (!root) throw new Error("Data grid root not found");
return root;
}
function goToColumnButton(host: HTMLElement): HTMLButtonElement {
const button = [...host.querySelectorAll<HTMLButtonElement>("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(/<div\b(?=[^>]*\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(/<Popover v-model:open="goToColumnOpen">/g)).toHaveLength(1);
expect(dataGridSource).toContain('>{{ t("grid.goToColumn") }}</span');
expect(dataGridSource).toContain('v-model="goToColumnSearch"');
expect(dataGridSource).toContain("filterDataGridColumnLookupItems(goToColumnItems.value, goToColumnSearch.value)");
expect(selectColumn).toContain('goToColumnSearch.value = ""');
expect(selectColumn).toContain("scrollToColumnIndex(columnIndex)");
expect(escape).toMatch(/event\.key === "Escape"[\s\S]*?goToColumnOpen\.value = false;[\s\S]*?goToColumnSearch\.value = ""/);
expect(scroll).toContain("if (hiddenColumnIndexes.value.has(columnIndex))");
expect(scroll).toContain("showColumn(columnIndex)");
expect(scroll).toContain("highlightedColumnIndex.value = columnIndex");
expect(scroll).toContain("scroller.scrollLeft = targetLeft");
expect(scroll).toContain("updateGridHorizontalViewport(scroller)");
});
});
+1
View File
@@ -6518,6 +6518,7 @@ export default {
shortcutExtendSelection: "Extend selection",
shortcutCopyCurrentRow: "Copy current data row",
shortcutDeleteCurrentRow: "Delete current data row",
shortcutGoToColumn: "Go to column",
shortcutNewQuery: "New query",
shortcutOpenSettings: "Open settings",
shortcutCloseTab: "Close tab",
+1
View File
@@ -6189,6 +6189,7 @@ export default withEnglishFallback({
shortcutExtendSelection: "Ampliar selección",
shortcutCopyCurrentRow: "Copiar fila de datos actual",
shortcutDeleteCurrentRow: "Eliminar fila de datos actual",
shortcutGoToColumn: "Ir a la columna",
shortcutNewQuery: "Nueva consulta",
shortcutOpenSettings: "Abrir configuración",
shortcutCloseTab: "Cerrar pestaña",
+1
View File
@@ -6189,6 +6189,7 @@ export default withEnglishFallback({
shortcutExtendSelection: "Estendi selezione",
shortcutCopyCurrentRow: "Copia riga dati corrente",
shortcutDeleteCurrentRow: "Elimina riga dati corrente",
shortcutGoToColumn: "Vai alla colonna",
shortcutNewQuery: "Nuova query",
shortcutOpenSettings: "Apri impostazioni",
shortcutCloseTab: "Chiudi scheda",
+1
View File
@@ -6203,6 +6203,7 @@ export default withEnglishFallback({
shortcutExtendSelection: "選択範囲を拡張",
shortcutCopyCurrentRow: "現在のデータ行をコピー",
shortcutDeleteCurrentRow: "現在のデータ行を削除",
shortcutGoToColumn: "列へ移動",
shortcutNewQuery: "新しいクエリ",
shortcutOpenSettings: "設定を開く",
shortcutCloseTab: "タブを閉じる",
+1
View File
@@ -5900,6 +5900,7 @@ export default withEnglishFallback({
shortcutSelectAll: "모두 선택",
shortcutCopyCurrentRow: "현재 데이터 행 복사",
shortcutDeleteCurrentRow: "현재 데이터 행 삭제",
shortcutGoToColumn: "컬럼으로 이동",
shortcutNewQuery: "새 쿼리",
shortcutOpenSettings: "설정 열기",
shortcutCloseTab: "탭 닫기",
+1
View File
@@ -6191,6 +6191,7 @@ export default withEnglishFallback({
shortcutExtendSelection: "Expandir seleção",
shortcutCopyCurrentRow: "Copiar linha de dados atual",
shortcutDeleteCurrentRow: "Excluir linha de dados atual",
shortcutGoToColumn: "Ir para a coluna",
shortcutNewQuery: "Nova consulta",
shortcutOpenSettings: "Abrir configurações",
shortcutCloseTab: "Fechar aba",
+1
View File
@@ -6504,6 +6504,7 @@ export default withEnglishFallback({
shortcutExPasteSqlInCondition: "ExPaste:粘贴为 IN 条件",
shortcutCopyCurrentRow: "复制当前数据行",
shortcutDeleteCurrentRow: "删除当前数据行",
shortcutGoToColumn: "跳转列",
shortcutNewQuery: "新建查询",
shortcutOpenSettings: "打开设置",
shortcutCloseTab: "关闭标签页",
+1
View File
@@ -5496,6 +5496,7 @@ export default withEnglishFallback({
shortcutExtendSelection: "擴展選取範圍",
shortcutCopyCurrentRow: "複製目前資料列",
shortcutDeleteCurrentRow: "刪除目前資料列",
shortcutGoToColumn: "跳轉欄",
shortcutNewQuery: "建立查詢",
shortcutOpenSettings: "開啟設定",
shortcutCloseTab: "關閉分頁",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut } from "@/lib/editor/keyboardShortcuts";
import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, isGoToColumnShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut } from "@/lib/editor/keyboardShortcuts";
import { formatShortcutDisplay, isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay";
describe("keyboard shortcut matching", () => {
@@ -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", () => {
@@ -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");
@@ -207,6 +207,10 @@ export function isDeleteCurrentRowShortcut(event: ShortcutLikeEvent, shortcuts?:
return matchesShortcut(event, actionShortcut("deleteCurrentRow", shortcuts));
}
export function isGoToColumnShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("goToColumn", shortcuts));
}
export function isCancelSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("cancelSearch", shortcuts));
}
@@ -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",