From 44dc2a88f8a242d1e60478c9f3255b91cda46a4e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 25 Aug 2026 00:41:11 +0000 Subject: [PATCH] feat(grid): add edit structure shortcut Closes #4303 --- apps/desktop/src/components/grid/DataGrid.vue | 7 + ...DataGridEditTableStructureShortcut.spec.ts | 264 ++++++++++++++++++ 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 | 24 +- .../src/lib/editor/keyboardShortcuts.ts | 4 + .../src/lib/editor/shortcutRegistry.ts | 15 +- 14 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0870c78e25..7afbcda8cd 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -187,6 +187,7 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, + isEditTableStructureShortcut, isFocusSearchShortcut, isGoToColumnShortcut, isGoToFirstPageShortcut, @@ -9040,6 +9041,12 @@ async function onGridKeydown(event: KeyboardEvent) { if (event.defaultPrevented) return; const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard(event); + if (!targetAllowsNativeClipboard && props.context === "table-data" && canOpenTableStructureEditor.value && isEditTableStructureShortcut(event, settingsStore.editorSettings.shortcuts)) { + event.preventDefault(); + event.stopPropagation(); + openTableStructureEditor(); + return; + } if (!targetAllowsNativeClipboard && displayableColumnIndexes.value.length > 0 && isGoToColumnShortcut(event, settingsStore.editorSettings.shortcuts)) { event.preventDefault(); event.stopPropagation(); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts new file mode 100644 index 0000000000..1986a89dff --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts @@ -0,0 +1,264 @@ +// @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 { ColumnInfo, DatabaseType, 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 { useQueryStore } from "@/stores/queryStore"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const dataGridSource = readFileSync(resolve(import.meta.dirname, "../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 })), + ); + }, +}); + +interface TableMeta { + catalog?: string; + schema?: string; + tableName: string; + columns: ColumnInfo[]; + primaryKeys: string[]; +} + +function mountGrid( + options: { + context?: "results" | "table-data"; + databaseType?: DatabaseType; + connectionId?: string; + database?: string; + tableMeta?: TableMeta | null; + shortcut?: string; + } = {}, +) { + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ + dataGridRenderMode: "canvas", + shortcuts: { + ...settingsStore.editorSettings.shortcuts, + editTableStructure: options.shortcut ?? "Mod+D", + }, + }); + const queryStore = useQueryStore(); + const openTableStructure = vi.spyOn(queryStore, "openTableStructure").mockImplementation(() => "structure-tab"); + const result = markRaw({ + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 0, + }); + const defaultTableMeta: TableMeta = { + catalog: "warehouse", + schema: "public", + tableName: "users", + columns: [], + primaryKeys: [], + }; + + const host = document.createElement("div"); + document.body.append(host); + const Root = defineComponent({ + setup() { + return () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + result, + databaseType: options.databaseType ?? "mysql", + context: options.context ?? "table-data", + connectionId: options.connectionId ?? "connection-1", + database: options.database ?? "app", + tableMeta: options.tableMeta === null ? undefined : (options.tableMeta ?? defaultTableMeta), + }), + }, + ); + }, + }); + const app = createApp(Root); + app.use(pinia); + app.use(i18n); + app.component("RecycleScroller", RecycleScroller); + app.mount(host); + mountedApps.push({ app, host }); + return { host, openTableStructure }; +} + +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 dispatchModShortcut(target: HTMLElement, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key }); + target.dispatchEvent(event); + return event; +} + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } +}); + +describe("DataGrid edit-table-structure shortcut", () => { + it("opens the existing structure editor route for an eligible table-data grid", async () => { + const { host, openTableStructure } = mountGrid(); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + root.focus(); + + const event = dispatchModShortcut(root, "d"); + await settle(); + + expect(document.activeElement).toBe(root); + expect(event.defaultPrevented).toBe(true); + expect(bubbled).not.toHaveBeenCalled(); + expect(openTableStructure).toHaveBeenCalledOnce(); + expect(openTableStructure).toHaveBeenCalledWith("connection-1", "app", "public", "users", "ddl", undefined, "warehouse"); + }); + + it("uses a configured shortcut and leaves the default key untouched", async () => { + const { host, openTableStructure } = mountGrid({ shortcut: "Mod+E" }); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const defaultEvent = dispatchModShortcut(root, "d"); + const customEvent = dispatchModShortcut(root, "e"); + await settle(); + + expect(defaultEvent.defaultPrevented).toBe(false); + expect(customEvent.defaultPrevented).toBe(true); + expect(bubbled).toHaveBeenCalledOnce(); + expect(openTableStructure).toHaveBeenCalledOnce(); + }); + + it.each([ + ["query result", { context: "results" as const }], + ["unsupported database", { databaseType: "redis" as const }], + ["missing connection", { connectionId: "" }], + ["missing database", { database: "" }], + ["missing table metadata", { tableMeta: null }], + ])("does not consume the shortcut for %s", async (_label, options) => { + const { host, openTableStructure } = mountGrid(options); + await settle(); + const root = gridRoot(host); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = dispatchModShortcut(root, "d"); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(openTableStructure).not.toHaveBeenCalled(); + }); + + it("leaves editable text targets untouched", async () => { + const { host, openTableStructure } = 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 = dispatchModShortcut(target, "d"); + expect(event.defaultPrevented).toBe(false); + } + await settle(); + + expect(bubbled).toHaveBeenCalledTimes(targets.length); + expect(openTableStructure).not.toHaveBeenCalled(); + }); + + it("preserves Ctrl+C for a native clipboard selection", async () => { + const { host, openTableStructure } = mountGrid(); + await settle(); + const root = gridRoot(host); + const region = document.createElement("div"); + region.setAttribute("data-native-clipboard", "true"); + region.textContent = "selected table information"; + root.append(region); + const range = document.createRange(); + range.selectNodeContents(region); + window.getSelection()?.addRange(range); + const bubbled = vi.fn(); + host.addEventListener("keydown", bubbled); + + const event = dispatchModShortcut(region, "c"); + await settle(); + + expect(event.defaultPrevented).toBe(false); + expect(bubbled).toHaveBeenCalledOnce(); + expect(openTableStructure).not.toHaveBeenCalled(); + }); + + it("routes through the existing guarded action and keeps the button path", () => { + const start = dataGridSource.indexOf("async function onGridKeydown"); + const end = dataGridSource.indexOf("function copyDetailValue", start); + const keydown = dataGridSource.slice(start, end); + + expect(keydown).toMatch( + /if \(!targetAllowsNativeClipboard && props\.context === "table-data" && canOpenTableStructureEditor\.value && isEditTableStructureShortcut\(event, settingsStore\.editorSettings\.shortcuts\)\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopPropagation\(\);[\s\S]*?openTableStructureEditor\(\);[\s\S]*?return;/, + ); + expect(dataGridSource).toMatch(/