diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 71ea299091..05a3c8840d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -109,6 +109,7 @@ import { isBrowserTaskManagerShortcut, isCloseOtherTabsShortcut, isCloseTabShortcut, + isEditTableStructureShortcut, isExecuteSqlInNewResultTabShortcut, isExecuteSqlShortcut, isFocusSearchShortcut, @@ -152,7 +153,7 @@ import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUp import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { webPath } from "@/lib/common/webPath"; -import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard"; +import { eventTargetAllowsNativeClipboard, shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard"; import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts"; import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme"; import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory"; @@ -3833,6 +3834,14 @@ async function handleKeydown(e: KeyboardEvent) { return; } } + if (isEditTableStructureShortcut(e, shortcuts) && !showSettingsPage.value && !showPluginCenter.value && !showDriverStore.value) { + const target = e.target instanceof Element ? e.target : null; + if (!target?.closest('[role="dialog"], [role="alertdialog"]') && !eventTargetAllowsNativeClipboard(e) && contentAreaRef.value?.openTableStructureEditor?.()) { + e.preventDefault(); + e.stopPropagation(); + return; + } + } // Grid-scoped shortcuts normally win inside DataGrid. Keep that precedence // for a data tab even when focus is in a sibling input, where the grid's // local listener intentionally leaves native editing untouched. diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 3a2c3c3211..c99a8d5b98 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -9786,10 +9786,9 @@ async function onGridKeydown(event: KeyboardEvent) { } const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard(event); - if (!targetAllowsNativeClipboard && props.context === "table-data" && canOpenTableStructureEditor.value && isEditTableStructureShortcut(event, settingsStore.editorSettings.shortcuts)) { + if (!targetAllowsNativeClipboard && props.context === "table-data" && isEditTableStructureShortcut(event, settingsStore.editorSettings.shortcuts) && openTableStructureEditor("columns")) { event.preventDefault(); event.stopPropagation(); - openTableStructureEditor("columns"); return; } if (!targetAllowsNativeClipboard && isGoToColumnShortcut(event, settingsStore.editorSettings.shortcuts) && openGoToColumn()) { @@ -11898,9 +11897,10 @@ function copyDdl() { copyText(ddlContent.value); } -function openTableStructureEditor(initialTab: TableInfoTab) { - if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !canOpenTableStructureEditor.value) return; +function openTableStructureEditor(initialTab: TableInfoTab = "columns"): boolean { + if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !canOpenTableStructureEditor.value) return false; queryStore.openTableStructure(props.connectionId, props.database, props.tableMeta.schema, props.tableMeta.tableName, initialTab, undefined, props.tableMeta.catalog, (props.tableMeta.tableType || "").toUpperCase() === "VIEW" ? "view" : "table"); + return true; } function toggleDdlWrap() { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts index e3f9d24eda..717ed4fa9e 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridEditTableStructureShortcut.spec.ts @@ -95,7 +95,7 @@ function mountGrid( }; const host = document.createElement("div"); - const grid = ref<{ canOpenTableStructureEditor: boolean; openTableStructureEditor: (tab: TableInfoTab) => void }>(); + const grid = ref<{ canOpenTableStructureEditor: boolean; openTableStructureEditor: (tab?: TableInfoTab) => boolean }>(); document.body.append(host); const Root = defineComponent({ setup() { @@ -160,10 +160,18 @@ describe("DataGrid edit-table-structure shortcut", () => { await settle(); expect(host.querySelector("[data-edit-table-structure]")).toBeNull(); expect(grid.value?.canOpenTableStructureEditor).toBe(true); - grid.value!.openTableStructureEditor("columns"); + expect(grid.value!.openTableStructureEditor()).toBe(true); expect(openTableStructure).toHaveBeenCalledExactlyOnceWith("connection-1", "app", "public", "users", "columns", undefined, "warehouse", "table"); }); + it("returns false from openTableStructureEditor when table structure editing is not available", async () => { + const { grid, openTableStructure } = mountGrid({ databaseType: "redis" }); + await settle(); + expect(grid.value?.canOpenTableStructureEditor).toBe(false); + expect(grid.value!.openTableStructureEditor()).toBe(false); + expect(openTableStructure).not.toHaveBeenCalled(); + }); + it("opens the existing structure editor route for an eligible table-data grid", async () => { const { host, openTableStructure } = mountGrid(); await settle(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index d2f6a3bfb2..46f0ef0975 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -222,7 +222,7 @@ type DataGridHandle = DataGridColumnLayoutHandle & { showDdl: boolean; toggleDdl: (tab?: TableInfoTab) => void; canOpenTableStructureEditor: boolean; - openTableStructureEditor: (tab: TableInfoTab) => void; + openTableStructureEditor: (tab?: TableInfoTab) => boolean; multiRowTranspose: boolean; setMultiRowTranspose: (value: boolean) => void; exportCsv: () => Promise; @@ -1302,6 +1302,11 @@ function openGoToColumn(): boolean { return dataGridRef.value?.openGoToColumn() ?? false; } +function openTableStructureEditor(initialTab: TableInfoTab = "columns"): boolean { + if (props.activeTab.mode !== "data") return false; + return dataGridRef.value?.openTableStructureEditor?.(initialTab) ?? false; +} + function refreshQueryEditorCompletionCache(): boolean { if (props.activeTab.mode !== "query" || !queryEditorRef.value) return false; queryEditorRef.value.refreshCompletionCache(); @@ -1786,6 +1791,7 @@ defineExpose({ focusSearch, focusWhere, openGoToColumn, + openTableStructureEditor, refreshData, toggleResultsPane, refreshQueryEditorCompletionCache, diff --git a/apps/desktop/src/components/layout/EditorGroup.vue b/apps/desktop/src/components/layout/EditorGroup.vue index 4ca64214d5..4d9afb3a61 100644 --- a/apps/desktop/src/components/layout/EditorGroup.vue +++ b/apps/desktop/src/components/layout/EditorGroup.vue @@ -16,7 +16,7 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport"; import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces"; -import type { QueryTab } from "@/types/database"; +import type { QueryTab, TableInfoTab } from "@/types/database"; import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding"; defineOptions({ inheritAttrs: false }); @@ -73,6 +73,7 @@ defineExpose({ focusSearch: (target: Element | null = null) => activeSurfaceRef.value?.focusSearch(target) ?? false, focusWhere: () => activeSurfaceRef.value?.focusWhere() ?? false, openGoToColumn: () => activeSurfaceRef.value?.openGoToColumn() ?? false, + openTableStructureEditor: (initialTab?: TableInfoTab) => activeSurfaceRef.value?.openTableStructureEditor?.(initialTab) ?? false, refreshData: () => activeSurfaceRef.value?.refreshData() ?? false, toggleResultsPane: () => activeSurfaceRef.value?.toggleResultsPane() ?? false, refreshQueryEditorCompletionCache: () => activeSurfaceRef.value?.refreshQueryEditorCompletionCache() ?? false, diff --git a/apps/desktop/src/components/layout/QueryEditorSurface.vue b/apps/desktop/src/components/layout/QueryEditorSurface.vue index 12f4d51f9c..df958922b6 100644 --- a/apps/desktop/src/components/layout/QueryEditorSurface.vue +++ b/apps/desktop/src/components/layout/QueryEditorSurface.vue @@ -4,6 +4,7 @@ import ContentArea from "./ContentArea.vue"; import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces"; import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding"; +import type { TableInfoTab } from "@/types/database"; const props = defineProps(); const emit = defineEmits(); @@ -26,6 +27,7 @@ defineExpose({ focusSearch: (target: Element | null = null) => contentAreaRef.value?.focusSearch(target) ?? false, focusWhere: () => contentAreaRef.value?.focusWhere() ?? false, openGoToColumn: () => contentAreaRef.value?.openGoToColumn() ?? false, + openTableStructureEditor: (initialTab?: TableInfoTab) => contentAreaRef.value?.openTableStructureEditor?.(initialTab) ?? false, refreshData: () => contentAreaRef.value?.refreshData() ?? false, toggleResultsPane: () => contentAreaRef.value?.toggleResultsPane() ?? false, refreshQueryEditorCompletionCache: () => contentAreaRef.value?.refreshQueryEditorCompletionCache() ?? false, diff --git a/apps/desktop/src/components/layout/SqlEditorWorkspace.vue b/apps/desktop/src/components/layout/SqlEditorWorkspace.vue index d2c42b674e..4c94470f19 100644 --- a/apps/desktop/src/components/layout/SqlEditorWorkspace.vue +++ b/apps/desktop/src/components/layout/SqlEditorWorkspace.vue @@ -15,7 +15,7 @@ import EditorGroup from "./EditorGroup.vue"; import QueryResultSurface from "./QueryResultSurface.vue"; import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, StatementRange } from "./querySurfaces"; -import type { QueryTab } from "@/types/database"; +import type { QueryTab, TableInfoTab } from "@/types/database"; import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding"; defineOptions({ inheritAttrs: false }); @@ -74,6 +74,7 @@ defineExpose({ }, focusWhere: () => activeEditorGroup()?.focusWhere() ?? false, openGoToColumn: () => activeEditorGroup()?.openGoToColumn() ?? false, + openTableStructureEditor: (initialTab?: TableInfoTab) => activeEditorGroup()?.openTableStructureEditor?.(initialTab) ?? false, refreshData: (target: Element | null = null) => { const element = commandTargetElement(target); if (element?.closest("[data-shared-result-surface]")) { diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.dataHeaderNavigation.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.dataHeaderNavigation.spec.ts index 1cf46c90f1..8192145b22 100644 --- a/apps/desktop/src/components/layout/__tests__/ContentArea.dataHeaderNavigation.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.dataHeaderNavigation.spec.ts @@ -1,13 +1,13 @@ // @vitest-environment happy-dom -import { createApp, h, nextTick, reactive, type App } from "vue"; +import { createApp, h, nextTick, reactive, ref, type App } from "vue"; import { createPinia, setActivePinia } from "pinia"; import { createI18n } from "vue-i18n"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ConnectionConfig, QueryTab } from "@/types/database"; vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); -const mocks = vi.hoisted(() => ({ openTableStructureEditor: vi.fn() })); +const mocks = vi.hoisted(() => ({ openTableStructureEditor: vi.fn(() => true) })); vi.mock("@/components/grid/DataGrid.vue", () => ({ __esModule: true, default: { @@ -62,9 +62,11 @@ async function mountDataTab() { store.switchTab(tab.id); const host = document.createElement("div"); document.body.appendChild(host); + const contentAreaRef = ref | null>(null); const app = createApp({ setup: () => () => h(ContentArea, { + ref: contentAreaRef, activeTab: tab, activeConnection: connection, activeOutputView: "result", @@ -81,10 +83,21 @@ async function mountDataTab() { app.mount(host); mounted.push({ app, host }); await nextTick(); - return { host, store, tab }; + return { host, store, tab, contentAreaRef }; } describe("table data header navigation", () => { + it("delegates openTableStructureEditor to the data grid for active table data tabs", async () => { + const { contentAreaRef, tab } = await mountDataTab(); + tab.result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 }; + await vi.waitFor(() => expect(contentAreaRef.value?.openTableStructureEditor()).toBe(true)); + expect(mocks.openTableStructureEditor).toHaveBeenCalledWith("columns"); + + tab.mode = "query"; + await nextTick(); + expect(contentAreaRef.value?.openTableStructureEditor()).toBe(false); + }); + it("opens the columns editor from the header beside the table controls", async () => { const { host, tab } = await mountDataTab(); tab.result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 }; diff --git a/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.mount.spec.ts b/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.mount.spec.ts index cc576485b8..60bd953b89 100644 --- a/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.mount.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.mount.spec.ts @@ -19,6 +19,7 @@ const editorPreviewCalls = vi.hoisted(() => [] as Array<{ tabId: string; range: const editorFocusCalls = vi.hoisted(() => [] as Array<{ tabId: string; range: unknown }>); const groupHandleModRCalls = vi.hoisted(() => [] as Element[]); const groupFocusWhereCalls = vi.hoisted(() => [] as string[]); +const groupOpenTableStructureEditorCalls = vi.hoisted(() => [] as string[]); const groupFocusSearchCalls = vi.hoisted(() => [] as Array); const resultHandleModRCalls = vi.hoisted(() => [] as Element[]); const resultFocusSearchCalls = vi.hoisted(() => [] as boolean[]); @@ -46,6 +47,10 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({ groupFocusWhereCalls.push(this.groupId); return true; }, + openTableStructureEditor(this: { groupId: string }) { + groupOpenTableStructureEditorCalls.push(this.groupId); + return true; + }, focusSearch(target?: Element | null) { groupFocusSearchCalls.push(target ?? null); return true; @@ -163,6 +168,7 @@ describe("SqlEditorWorkspace mount contract", () => { groupHandleModRCalls.length = 0; groupFocusSearchCalls.length = 0; groupFocusWhereCalls.length = 0; + groupOpenTableStructureEditorCalls.length = 0; resultHandleModRCalls.length = 0; resultFocusSearchCalls.length = 0; groupExecutionCalls.capture.length = 0; @@ -744,16 +750,20 @@ describe("SqlEditorWorkspace mount contract", () => { }); app.use(pinia); app.use(i18n); - const vm = app.mount(host) as unknown as { focusWhere: () => boolean }; + const vm = app.mount(host) as unknown as { focusWhere: () => boolean; openTableStructureEditor: () => boolean }; try { await nextTick(); expect(vm.focusWhere()).toBe(true); expect(groupFocusWhereCalls).toEqual(["g1"]); + expect(vm.openTableStructureEditor()).toBe(true); + expect(groupOpenTableStructureEditorCalls).toEqual(["g1"]); store.focusedGroupId = "g2"; store.activeTabId = "data-b"; await nextTick(); expect(vm.focusWhere()).toBe(true); expect(groupFocusWhereCalls).toEqual(["g1", "g2"]); + expect(vm.openTableStructureEditor()).toBe(true); + expect(groupOpenTableStructureEditorCalls).toEqual(["g1", "g2"]); } finally { app.unmount(); host.remove(); diff --git a/apps/desktop/src/components/layout/querySurfaces.ts b/apps/desktop/src/components/layout/querySurfaces.ts index 209267658f..8676f06d8e 100644 --- a/apps/desktop/src/components/layout/querySurfaces.ts +++ b/apps/desktop/src/components/layout/querySurfaces.ts @@ -1,4 +1,4 @@ -import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab, TabOutputView } from "@/types/database"; +import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab, TabOutputView, TableInfoTab } from "@/types/database"; import type { NavigationTarget } from "@/composables/useNavigationTargets"; import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar"; import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; @@ -15,6 +15,7 @@ export interface QueryEditorSurfaceHandle { focusSearch(target?: Element | null): boolean; focusWhere(): boolean; openGoToColumn(): boolean; + openTableStructureEditor?(initialTab?: TableInfoTab): boolean; refreshData(target?: Element | null): boolean; toggleResultsPane(): boolean; refreshQueryEditorCompletionCache(): boolean;