diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 73934808bc..af8fbc20ed 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -182,7 +182,20 @@ 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, isGoToColumnShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; +import { + isCancelSearchShortcut, + isCopyCurrentRowShortcut, + isDeleteCurrentRowShortcut, + isFocusSearchShortcut, + isGoToColumnShortcut, + isGoToFirstPageShortcut, + isGoToLastPageShortcut, + isGoToNextPageShortcut, + isGoToPreviousPageShortcut, + 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"; @@ -3742,6 +3755,21 @@ async function lastPage() { } } +function handleGridPaginationShortcut(event: KeyboardEvent): boolean { + if (!props.paginationEnabled || gridPaginationBusy.value || infiniteScrollEnabled.value) return false; + const shortcuts = settingsStore.editorSettings.shortcuts; + let navigate: (() => void) | undefined; + if (currentPage.value > 1 && isGoToFirstPageShortcut(event, shortcuts)) navigate = firstPage; + else if (currentPage.value > 1 && isGoToPreviousPageShortcut(event, shortcuts)) navigate = prevPage; + else if (canGoNextPage.value && isGoToNextPageShortcut(event, shortcuts)) navigate = nextPage; + else if (canJumpLastPage.value && isGoToLastPageShortcut(event, shortcuts)) navigate = lastPage; + if (!navigate) return false; + event.preventDefault(); + event.stopPropagation(); + void navigate(); + return true; +} + async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string } | undefined> { if (props.countSql) return { sql: props.countSql, schema: props.schema }; if (props.tableMeta) { @@ -8987,6 +9015,7 @@ async function onGridKeydown(event: KeyboardEvent) { goToColumnOpen.value = true; return; } + if (!targetAllowsNativeClipboard && handleGridPaginationShortcut(event)) return; if (isFocusSearchShortcut(event)) { event.preventDefault(); focusSearch(); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts new file mode 100644 index 0000000000..b82b151b63 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts @@ -0,0 +1,279 @@ +// @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"; +import type { ShortcutActionId } from "@/lib/editor/shortcutRegistry"; + +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 shortcutCases = [ + { actionId: "goToFirstPage", key: "F1", offset: 0, functionName: "firstPage" }, + { actionId: "goToPreviousPage", key: "F2", offset: 100, functionName: "prevPage" }, + { actionId: "goToNextPage", key: "F3", offset: 300, functionName: "nextPage" }, + { actionId: "goToLastPage", key: "F4", offset: 400, functionName: "lastPage" }, +] as const; + +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: { + pageOffset?: number; + totalRowCount?: number; + loading?: boolean; + paginationEnabled?: boolean; + infiniteScroll?: boolean; + empty?: boolean; + configurePaginationShortcuts?: boolean; + } = {}, +) { + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ + dataGridRenderMode: "canvas", + infiniteScroll: options.infiniteScroll ?? false, + shortcuts: { + ...settingsStore.editorSettings.shortcuts, + ...(options.configurePaginationShortcuts === false + ? {} + : { + goToFirstPage: "Alt+F1", + goToPreviousPage: "Alt+F2", + goToNextPage: "Alt+F3", + goToLastPage: "Alt+F4", + }), + }, + }); + const result = markRaw({ + columns: ["id"], + rows: options.empty ? [] : [[1]], + affected_rows: 0, + execution_time_ms: 0, + }); + const paginate = vi.fn(); + + 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", + pageLimit: 100, + pageOffset: options.pageOffset ?? 200, + totalRowCount: options.totalRowCount ?? 500, + loading: options.loading ?? false, + paginationEnabled: options.paginationEnabled ?? true, + onPaginate: paginate, + }), + }, + ); + }, + }); + 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 { host, paginate }; +} + +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 dispatchShortcut(target: HTMLElement, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, altKey: true, key }); + target.dispatchEvent(event); + return event; +} + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } +}); + +describe("DataGrid pagination shortcuts", () => { + it.each(shortcutCases)("runs $functionName through the configured $actionId shortcut", async ({ key, offset }) => { + const { host, paginate } = mountGrid(); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + root.focus(); + + const event = dispatchShortcut(root, key); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(bubbled).not.toHaveBeenCalled(); + expect(paginate).toHaveBeenCalledOnce(); + expect(paginate.mock.calls[0]?.slice(0, 2)).toEqual([offset, 100]); + }); + + it.each([ + ["goToFirstPage", "F1"], + ["goToPreviousPage", "F2"], + ] as const)("does not consume %s on the first page", async (_actionId, key) => { + const { host, paginate } = mountGrid({ pageOffset: 0 }); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = dispatchShortcut(root, key); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(paginate).not.toHaveBeenCalled(); + }); + + it.each([ + ["goToNextPage", "F3"], + ["goToLastPage", "F4"], + ] as const)("does not consume %s on the last page", async (_actionId, key) => { + const { host, paginate } = mountGrid({ pageOffset: 400 }); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = dispatchShortcut(root, key); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(paginate).not.toHaveBeenCalled(); + }); + + it.each([ + ["loading", { loading: true }], + ["disabled pagination", { paginationEnabled: false }], + ["infinite scroll", { infiniteScroll: true }], + ["no available pages", { pageOffset: 0, totalRowCount: 0, empty: true }], + ] as const)("does not consume pagination shortcuts during %s", async (_label, options) => { + const { host, paginate } = mountGrid(options); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + for (const { key } of shortcutCases) { + const event = dispatchShortcut(root, key); + expect(event.defaultPrevented).toBe(false); + } + await settle(); + + expect(bubbled).toHaveBeenCalledTimes(shortcutCases.length); + expect(paginate).not.toHaveBeenCalled(); + }); + + it("does not trigger or consume pagination shortcuts from editable targets", async () => { + const { host, paginate } = 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 = dispatchShortcut(target, "F3"); + expect(event.defaultPrevented).toBe(false); + } + await settle(); + + expect(bubbled).toHaveBeenCalledTimes(targets.length); + expect(paginate).not.toHaveBeenCalled(); + }); + + it("routes shortcuts to the existing pagination functions and preserves PageUp/PageDown navigation", () => { + expect(dataGridSource).toMatch(/if \(!targetAllowsNativeClipboard && handleGridPaginationShortcut\(event\)\) return;/); + for (const { actionId, functionName } of shortcutCases) { + expect(dataGridSource).toContain(`is${actionId[0]!.toUpperCase()}${actionId.slice(1)}Shortcut(event, shortcuts)`); + expect(dataGridSource).toContain(`navigate = ${functionName}`); + } + expect(dataGridSource).toContain('event.key === "PageUp" && navigateSelectedCell("pageUp", event.shiftKey)'); + expect(dataGridSource).toContain('event.key === "PageDown" && navigateSelectedCell("pageDown", event.shiftKey)'); + }); + + it("keeps legacy settings without pagination mappings keyboard-neutral", async () => { + const { host, paginate } = mountGrid({ configurePaginationShortcuts: false }); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = dispatchShortcut(root, "F3"); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(paginate).not.toHaveBeenCalled(); + const actionIds: ShortcutActionId[] = shortcutCases.map(({ actionId }) => actionId); + expect(actionIds).toHaveLength(4); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index befc33a609..3f57366bf4 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6519,6 +6519,10 @@ export default { shortcutCopyCurrentRow: "Copy current data row", shortcutDeleteCurrentRow: "Delete current data row", shortcutGoToColumn: "Go to column", + shortcutGoToFirstPage: "First page", + shortcutGoToPreviousPage: "Previous page", + shortcutGoToNextPage: "Next page", + shortcutGoToLastPage: "Last page", shortcutNewQuery: "New query", shortcutOpenSettings: "Open settings", shortcutCloseTab: "Close tab", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a3241861da..d400f3d24d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6190,6 +6190,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "Copiar fila de datos actual", shortcutDeleteCurrentRow: "Eliminar fila de datos actual", shortcutGoToColumn: "Ir a la columna", + shortcutGoToFirstPage: "Primera página", + shortcutGoToPreviousPage: "Página anterior", + shortcutGoToNextPage: "Página siguiente", + shortcutGoToLastPage: "Última página", shortcutNewQuery: "Nueva consulta", shortcutOpenSettings: "Abrir configuración", shortcutCloseTab: "Cerrar pestaña", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ba9e92f83c..c0d6e16859 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6190,6 +6190,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "Copia riga dati corrente", shortcutDeleteCurrentRow: "Elimina riga dati corrente", shortcutGoToColumn: "Vai alla colonna", + shortcutGoToFirstPage: "Prima pagina", + shortcutGoToPreviousPage: "Pagina precedente", + shortcutGoToNextPage: "Pagina successiva", + shortcutGoToLastPage: "Ultima pagina", shortcutNewQuery: "Nuova query", shortcutOpenSettings: "Apri impostazioni", shortcutCloseTab: "Chiudi scheda", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 7179c984a8..70b6769f13 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6204,6 +6204,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "現在のデータ行をコピー", shortcutDeleteCurrentRow: "現在のデータ行を削除", shortcutGoToColumn: "列へ移動", + shortcutGoToFirstPage: "最初のページ", + shortcutGoToPreviousPage: "前のページ", + shortcutGoToNextPage: "次のページ", + shortcutGoToLastPage: "最後のページ", shortcutNewQuery: "新しいクエリ", shortcutOpenSettings: "設定を開く", shortcutCloseTab: "タブを閉じる", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 4057e264b6..5e9af8f985 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5901,6 +5901,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "현재 데이터 행 복사", shortcutDeleteCurrentRow: "현재 데이터 행 삭제", shortcutGoToColumn: "컬럼으로 이동", + shortcutGoToFirstPage: "첫 페이지", + shortcutGoToPreviousPage: "이전 페이지", + shortcutGoToNextPage: "다음 페이지", + shortcutGoToLastPage: "마지막 페이지", shortcutNewQuery: "새 쿼리", shortcutOpenSettings: "설정 열기", shortcutCloseTab: "탭 닫기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d9313c35a8..28a21abccd 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6192,6 +6192,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "Copiar linha de dados atual", shortcutDeleteCurrentRow: "Excluir linha de dados atual", shortcutGoToColumn: "Ir para a coluna", + shortcutGoToFirstPage: "Primeira página", + shortcutGoToPreviousPage: "Página anterior", + shortcutGoToNextPage: "Próxima página", + shortcutGoToLastPage: "Última página", shortcutNewQuery: "Nova consulta", shortcutOpenSettings: "Abrir configurações", shortcutCloseTab: "Fechar aba", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2bcf859403..d8a1056012 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6505,6 +6505,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "复制当前数据行", shortcutDeleteCurrentRow: "删除当前数据行", shortcutGoToColumn: "跳转列", + shortcutGoToFirstPage: "第一页", + shortcutGoToPreviousPage: "上一页", + shortcutGoToNextPage: "下一页", + shortcutGoToLastPage: "末页", shortcutNewQuery: "新建查询", shortcutOpenSettings: "打开设置", shortcutCloseTab: "关闭标签页", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c7a51f2296..6a27a45d3d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5497,6 +5497,10 @@ export default withEnglishFallback({ shortcutCopyCurrentRow: "複製目前資料列", shortcutDeleteCurrentRow: "刪除目前資料列", shortcutGoToColumn: "跳轉欄", + shortcutGoToFirstPage: "第一頁", + shortcutGoToPreviousPage: "上一頁", + shortcutGoToNextPage: "下一頁", + shortcutGoToLastPage: "末頁", shortcutNewQuery: "建立查詢", shortcutOpenSettings: "開啟設定", shortcutCloseTab: "關閉分頁", diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index 8335ffb432..ad983037a2 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, isGoToColumnShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut } from "@/lib/editor/keyboardShortcuts"; +import { + eventToModifierOnlyShortcut, + eventToShortcut, + isExecuteSqlInNewResultTabShortcut, + isGoToColumnShortcut, + isGoToFirstPageShortcut, + isGoToLastPageShortcut, + isGoToNextPageShortcut, + isGoToPreviousPageShortcut, + matchesModifierOnlyShortcut, + matchesShortcut, + tabSwitcherDirectionFromShortcut, +} from "@/lib/editor/keyboardShortcuts"; import { formatShortcutDisplay, isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; describe("keyboard shortcut matching", () => { @@ -94,6 +106,21 @@ describe("keyboard shortcut matching", () => { expect(isGoToColumnShortcut({ key: "g", ctrlKey: true }, { goToColumn: "" })).toBe(false); expect(isGoToColumnShortcut({ key: "g", ctrlKey: true, isComposing: true }, { goToColumn: "Mod+G" })).toBe(false); }); + + it.each([ + ["goToFirstPage", isGoToFirstPageShortcut, "F1"], + ["goToPreviousPage", isGoToPreviousPageShortcut, "F2"], + ["goToNextPage", isGoToNextPageShortcut, "F3"], + ["goToLastPage", isGoToLastPageShortcut, "F4"], + ] as const)("matches only the configured pagination shortcut for %s", (actionId, matcher, key) => { + const shortcuts = { [actionId]: `Alt+${key}` }; + + expect(matcher({ key, altKey: true }, shortcuts)).toBe(true); + expect(matcher({ key }, shortcuts)).toBe(false); + expect(matcher({ key: "F8", altKey: true }, shortcuts)).toBe(false); + expect(matcher({ key, altKey: true, isComposing: true }, shortcuts)).toBe(false); + expect(matcher({ key, altKey: true })).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 cc596fe55d..85ba2e19fa 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -25,6 +25,43 @@ describe("shortcutRegistry editor actions", () => { ]; const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection", "viewTableDdl"]; + it("registers pagination navigation as unassigned grid shortcuts", () => { + const paginationActions = [ + ["goToFirstPage", "settings.shortcutGoToFirstPage"], + ["goToPreviousPage", "settings.shortcutGoToPreviousPage"], + ["goToNextPage", "settings.shortcutGoToNextPage"], + ["goToLastPage", "settings.shortcutGoToLastPage"], + ] as const; + + for (const [id, labelKey] of paginationActions) { + expect(SHORTCUT_DEFINITIONS.find((item) => item.id === id)).toMatchObject({ id, labelKey, scope: "grid", defaultShortcut: "" }); + expect(DEFAULT_SHORTCUT_SETTINGS[id]).toBe(""); + } + }); + + it("normalizes missing, legacy, cleared, and configured pagination shortcuts", () => { + const missing = normalizeShortcutSettings(); + const legacy = normalizeShortcutSettings({ goToColumn: "Mod+G" }); + const configured = normalizeShortcutSettings({ goToFirstPage: "Alt+F1", goToPreviousPage: "Alt+F2", goToNextPage: "Alt+F3", goToLastPage: "Alt+F4" }); + + for (const actionId of ["goToFirstPage", "goToPreviousPage", "goToNextPage", "goToLastPage"] as const) { + expect(missing[actionId]).toBe(""); + expect(legacy[actionId]).toBe(""); + } + expect(configured.goToFirstPage).toBe("Alt+F1"); + expect(configured.goToPreviousPage).toBe("Alt+F2"); + expect(configured.goToNextPage).toBe("Alt+F3"); + expect(configured.goToLastPage).toBe("Alt+F4"); + expect(configured.goToColumn).toBe(""); + }); + + it("detects pagination shortcut conflicts in the grid scope", () => { + const shortcuts = normalizeShortcutSettings({ goToFirstPage: "Alt+F1", goToPreviousPage: "Alt+F1" }); + + expect(findShortcutConflict("goToFirstPage", shortcuts.goToFirstPage, shortcuts)).toBe("goToPreviousPage"); + expect(findShortcutConflict("goToFirstPage", "Mod+F", shortcuts)).toBeNull(); + }); + it("registers go to column as an unassigned grid shortcut", () => { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "goToColumn"); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 19735fba01..5c5152a694 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -211,6 +211,22 @@ export function isGoToColumnShortcut(event: ShortcutLikeEvent, shortcuts?: Parti return matchesShortcut(event, actionShortcut("goToColumn", shortcuts)); } +export function isGoToFirstPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("goToFirstPage", shortcuts)); +} + +export function isGoToPreviousPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("goToPreviousPage", shortcuts)); +} + +export function isGoToNextPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("goToNextPage", shortcuts)); +} + +export function isGoToLastPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("goToLastPage", 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 38a0e2f550..52c8052542 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -31,6 +31,10 @@ export type ShortcutActionId = | "copyCurrentRow" | "deleteCurrentRow" | "goToColumn" + | "goToFirstPage" + | "goToPreviousPage" + | "goToNextPage" + | "goToLastPage" | "newQuery" | "openSettings" | "closeTab" @@ -287,6 +291,30 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "grid", defaultShortcut: "", }, + { + id: "goToFirstPage", + labelKey: "settings.shortcutGoToFirstPage", + scope: "grid", + defaultShortcut: "", + }, + { + id: "goToPreviousPage", + labelKey: "settings.shortcutGoToPreviousPage", + scope: "grid", + defaultShortcut: "", + }, + { + id: "goToNextPage", + labelKey: "settings.shortcutGoToNextPage", + scope: "grid", + defaultShortcut: "", + }, + { + id: "goToLastPage", + labelKey: "settings.shortcutGoToLastPage", + scope: "grid", + defaultShortcut: "", + }, { id: "newQuery", labelKey: "settings.shortcutNewQuery", diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 0b2bec8a91..f5192cc476 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -392,6 +392,10 @@ test("defaults shortcut settings", () => { assert.equal(settings.shortcuts.extendSelection, "Alt+W"); assert.equal(settings.shortcuts.copyCurrentRow, "Mod+D"); assert.equal(settings.shortcuts.deleteCurrentRow, "Delete"); + assert.equal(settings.shortcuts.goToFirstPage, ""); + assert.equal(settings.shortcuts.goToPreviousPage, ""); + assert.equal(settings.shortcuts.goToNextPage, ""); + assert.equal(settings.shortcuts.goToLastPage, ""); assert.equal(settings.shortcuts.newQuery, "Mod+T"); assert.equal(settings.shortcuts.openSettings, "Mod+,"); assert.equal(settings.shortcuts.focusSearch, "Mod+F"); @@ -413,6 +417,7 @@ test("keeps saved shortcut overrides", () => { executeSql: "Shift+Mod+Enter", copyCurrentRow: "Alt+Shift+D", deleteCurrentRow: "Backspace", + goToNextPage: "Alt+F3", newQuery: "Shift+Mod+N", openSettings: "Shift+Mod+P", zoomInUi: "Alt+Mod+=", @@ -423,6 +428,8 @@ test("keeps saved shortcut overrides", () => { assert.equal(settings.shortcuts.executeSql, "Shift+Mod+Enter"); assert.equal(settings.shortcuts.copyCurrentRow, "Alt+Shift+D"); assert.equal(settings.shortcuts.deleteCurrentRow, "Backspace"); + assert.equal(settings.shortcuts.goToNextPage, "Alt+F3"); + assert.equal(settings.shortcuts.goToFirstPage, ""); assert.equal(settings.shortcuts.newQuery, "Shift+Mod+N"); assert.equal(settings.shortcuts.openSettings, "Shift+Mod+P"); assert.equal(settings.shortcuts.zoomInUi, "Alt+Mod+=");