diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a4f35ce99..0945d4bd6 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -80,7 +80,7 @@ import { type SnippetSyncConfig, type WebDavConfig, } from "@/lib/backend/api"; -import { eventToShortcut } from "@/lib/editor/keyboardShortcuts"; +import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts"; import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; @@ -1202,7 +1202,8 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) { editingShortcutId.value = null; return; } - const shortcut = eventToShortcut(event); + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + const shortcut = definition?.inputKind === "modifier-only" ? eventToModifierOnlyShortcut(event) : eventToShortcut(event); if (!shortcut) return; onShortcutChange(actionId, shortcut); editingShortcutId.value = null; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index d22df9bd4..12601de58 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -101,6 +101,7 @@ import { usesTreeSchemaMode, } from "@/lib/database/databaseCapabilities"; import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; +import { dataTabOpenModeFromTreeClick, findExistingDataTabCandidate, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy"; import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts"; import { formatSqlInsert } from "@/lib/export/exportFormats"; import { joinExportedDdls } from "@/lib/export/ddlExport"; @@ -975,6 +976,15 @@ function onClick(event: MouseEvent) { // Row clicks must not bubble to the tree container, whose click handler // clears the selection when the blank area is clicked (issue #681). event.stopPropagation(); + const dataTabOpenMode = dataTabOpenModeFromTreeClick(props.node.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab); + if (dataTabOpenMode === "new-tab") { + event.preventDefault(); + if (event.detail > 1) return; + selectSingleTreeNode(props.node); + rowRef.value?.focus({ preventScroll: true }); + openDataInNewTabImmediately(props.node); + return; + } if (event.shiftKey) { selectTreeNodeRange(props.node); rowRef.value?.focus({ preventScroll: true }); @@ -1178,7 +1188,8 @@ function requestDeleteSelectedNode(): boolean { return false; } -function onDoubleClick() { +function onDoubleClick(event: MouseEvent) { + if (dataTabOpenModeFromTreeClick(props.node.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return; const action = treeNodeRowDoubleClickAction(props.node.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value); if (action === "open-object-browser") { void openObjectBrowser(); @@ -1309,7 +1320,11 @@ function openDataImmediately(node: TreeNode = props.node) { emit("open-data", node, false, openData); } -async function openData(node: TreeNode, request?: SidebarDataOpenRequest) { +function openDataInNewTabImmediately(node: TreeNode = props.node) { + emit("open-data", node, false, (target, request) => openData(target, request, "new-tab")); +} + +async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") { if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return; const config = connectionStore.getConfig(node.connectionId); const traceId = uuid().slice(0, 8); @@ -1341,9 +1356,18 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) { const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? ""); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const metadataDatabaseType = effectiveDbType || config?.db_type || ""; - const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) => - tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database && (tab.tableMeta?.catalog || "") === (node.catalog || "") && (tab.schema || "") === (tableSchema || "") && (tab.tableMeta?.tableName || tab.title) === node.label; - const existingSameTableTab = queryStore.tabs.find(isSameDataTableTab); + const existingDataTabCandidate = findExistingDataTabCandidate( + queryStore.tabs, + { + connectionId: node.connectionId, + database: node.database, + schema: tableSchema, + catalog: node.catalog, + tableName: node.label, + }, + { openMode, reuseDataTab: settingsStore.editorSettings.reuseDataTab }, + ); + const existingSameTableTab = existingDataTabCandidate?.match === "same-table" ? existingDataTabCandidate.tab : undefined; const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => { tab.title = node.label; tab.schema = tableSchema; @@ -1374,18 +1398,10 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) { } const tabId = (() => { - if (existingSameTableTab) { - queryStore.switchTab(existingSameTableTab.id); - resetReusedDataTabState(existingSameTableTab); - return existingSameTableTab.id; - } - if (settingsStore.editorSettings.reuseDataTab) { - const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database); - if (existing) { - queryStore.switchTab(existing.id); - resetReusedDataTabState(existing); - return existing.id; - } + if (existingDataTabCandidate) { + queryStore.switchTab(existingDataTabCandidate.tab.id); + resetReusedDataTabState(existingDataTabCandidate.tab); + return existingDataTabCandidate.tab.id; } return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema); })(); @@ -4947,6 +4963,7 @@ onBeforeUnmount(() => { }); const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection); +const shortcutOpenDataInNewTab = computed(() => settingsStore.editorSettings.shortcuts.openDataInNewTab); const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection); const shortcutRename = "F2"; const shortcutRefresh = "F5"; @@ -5393,6 +5410,12 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties }); + items.push({ + label: t("contextMenu.openInNewDataTab"), + action: openDataInNewTabImmediately, + icon: CopyPlus, + shortcut: shortcutOpenDataInNewTab.value, + }); if (node.type === "table") { items.push({ label: t("contextMenu.viewDdl"), diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 66599f915..ee60351e5 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1423,6 +1423,7 @@ export default { noSqlHistory: "No related SQL", openObjectBrowser: "Browse Objects", viewData: "View Data", + openInNewDataTab: "View in New Tab", editStructure: "Edit Structure", editColumn: "Edit Column", editIndex: "Edit Index", @@ -3520,6 +3521,7 @@ export default { shortcutCopySidebarSelection: "Copy sidebar selection", shortcutPasteSidebarSelection: "Paste into sidebar", shortcutEditSidebarConnection: "Edit sidebar connection", + shortcutOpenDataInNewTab: "Open data in new tab (mouse click)", shortcutSendSelectionToAi: "Send selection to AI", shortcutScopeGlobal: "Global", shortcutScopeEditor: "SQL editor", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8016ebeab..4aee7e4ce 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1365,6 +1365,7 @@ export default withEnglishFallback({ noSqlHistory: "Sin SQL relacionado", openObjectBrowser: "Explorar objetos", viewData: "Ver datos", + openInNewDataTab: "Ver en nueva pestaña", editStructure: "Editar estructura", editColumn: "Editar columna", editIndex: "Editar índice", @@ -3302,6 +3303,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "Copiar selección de la barra lateral", shortcutPasteSidebarSelection: "Pegar en la barra lateral", shortcutEditSidebarConnection: "Editar conexión de la barra lateral", + shortcutOpenDataInNewTab: "Abrir datos en una pestaña nueva (clic del ratón)", shortcutSendSelectionToAi: "Enviar selección a IA", shortcutScopeGlobal: "Global", shortcutScopeEditor: "Editor SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9bd146992..dc42c4ee8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1363,6 +1363,7 @@ export default withEnglishFallback({ noSqlHistory: "Nessun SQL correlato", openObjectBrowser: "Esplora Oggetti", viewData: "Visualizza Dati", + openInNewDataTab: "Visualizza in nuova scheda", editStructure: "Modifica Struttura", editColumn: "Modifica Colonna", editIndex: "Modifica Indice", @@ -3300,6 +3301,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "Copia selezione barra laterale", shortcutPasteSidebarSelection: "Incolla nella barra laterale", shortcutEditSidebarConnection: "Modifica connessione barra laterale", + shortcutOpenDataInNewTab: "Apri dati in una nuova scheda (clic del mouse)", shortcutSendSelectionToAi: "Invia selezione ad AI", shortcutScopeGlobal: "Globale", shortcutScopeEditor: "Editor SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 63fa79c17..4cb6bcaea 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1362,6 +1362,7 @@ export default withEnglishFallback({ noSqlHistory: "関連するSQLはありません", openObjectBrowser: "オブジェクトを参照", viewData: "データを表示", + openInNewDataTab: "新しいタブで表示", editStructure: "構造を編集", editColumn: "列を編集", editIndex: "インデックスを編集", @@ -3283,6 +3284,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "サイドバーの選択をコピー", shortcutPasteSidebarSelection: "サイドバーに貼り付け", shortcutEditSidebarConnection: "サイドバー接続を編集", + shortcutOpenDataInNewTab: "新しいデータタブで開く(マウスクリック)", shortcutSendSelectionToAi: "選択範囲をAIに送信", shortcutScopeGlobal: "グローバル", shortcutScopeEditor: "SQLエディタ", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b2c7b6db3..df17eddf0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1365,6 +1365,7 @@ export default withEnglishFallback({ noSqlHistory: "Nenhum SQL relacionado", openObjectBrowser: "Explorar Objetos", viewData: "Ver Dados", + openInNewDataTab: "Ver em nova aba", editStructure: "Editar Estrutura", editColumn: "Editar Coluna", editIndex: "Editar Índice", @@ -3302,6 +3303,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "Copiar seleção da barra lateral", shortcutPasteSidebarSelection: "Colar na barra lateral", shortcutEditSidebarConnection: "Editar conexão da barra lateral", + shortcutOpenDataInNewTab: "Abrir dados em nova aba (clique do mouse)", shortcutSendSelectionToAi: "Enviar seleção para IA", shortcutScopeGlobal: "Global", shortcutScopeEditor: "Editor SQL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a58c23f26..fd6ad4916 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1425,6 +1425,7 @@ export default withEnglishFallback({ noSqlHistory: "暂无关联 SQL", openObjectBrowser: "浏览对象", viewData: "查看数据", + openInNewDataTab: "新标签页查看", editStructure: "编辑表结构", editColumn: "编辑字段", editIndex: "编辑索引", @@ -3515,6 +3516,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "复制侧边栏选中项", shortcutPasteSidebarSelection: "粘贴到侧边栏", shortcutEditSidebarConnection: "编辑侧边栏连接", + shortcutOpenDataInNewTab: "在新数据标签页中打开(鼠标点击)", shortcutSendSelectionToAi: "发送选中代码到 AI", shortcutScopeGlobal: "全局", shortcutScopeEditor: "SQL 编辑器", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d657c0642..0e33e6ea2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1365,6 +1365,7 @@ export default withEnglishFallback({ noSqlHistory: "暫無關聯 SQL", openObjectBrowser: "瀏覽物件", viewData: "檢視資料", + openInNewDataTab: "新分頁檢視", editStructure: "編輯資料表結構", editColumn: "編輯欄位", editIndex: "編輯索引", @@ -3142,6 +3143,7 @@ export default withEnglishFallback({ shortcutCopySidebarSelection: "複製側邊欄選取項", shortcutPasteSidebarSelection: "貼到側邊欄", shortcutEditSidebarConnection: "編輯側邊欄連線", + shortcutOpenDataInNewTab: "在新資料分頁中開啟(滑鼠點擊)", shortcutSendSelectionToAi: "傳送選取程式碼至 AI", shortcutScopeGlobal: "全域", shortcutScopeEditor: "SQL 編輯器", diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index 0e6ea6155..dbb4cb2d6 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -1,7 +1,27 @@ import { describe, expect, it } from "vitest"; -import { eventToShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts"; +import { eventToModifierOnlyShortcut, eventToShortcut, matchesModifierOnlyShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts"; describe("keyboard shortcut matching", () => { + it("records modifier-only mouse shortcut settings", () => { + expect(eventToModifierOnlyShortcut({ key: "Alt", altKey: true })).toBe("Alt"); + expect(eventToModifierOnlyShortcut({ key: "Shift", shiftKey: true })).toBe("Shift"); + expect(eventToModifierOnlyShortcut({ key: "Control", ctrlKey: true }, "Win32")).toBe("Mod"); + expect(eventToModifierOnlyShortcut({ key: "Meta", metaKey: true }, "Win32")).toBe("Meta"); + expect(eventToModifierOnlyShortcut({ key: "Meta", metaKey: true }, "MacIntel")).toBe("Mod"); + expect(eventToModifierOnlyShortcut({ key: "Control", ctrlKey: true }, "MacIntel")).toBe("Ctrl"); + expect(eventToModifierOnlyShortcut({ key: "A", altKey: true })).toBeNull(); + }); + + it("matches a configured mouse modifier exactly", () => { + expect(matchesModifierOnlyShortcut({ altKey: true }, "Alt")).toBe(true); + expect(matchesModifierOnlyShortcut({ ctrlKey: true }, "Mod")).toBe(true); + expect(matchesModifierOnlyShortcut({ metaKey: true }, "Mod")).toBe(true); + expect(matchesModifierOnlyShortcut({ ctrlKey: true }, "Ctrl")).toBe(true); + expect(matchesModifierOnlyShortcut({ metaKey: true }, "Meta")).toBe(true); + expect(matchesModifierOnlyShortcut({ altKey: true, shiftKey: true }, "Alt")).toBe(false); + expect(matchesModifierOnlyShortcut({ shiftKey: true }, "")).toBe(false); + }); + it("records the plus key without losing it to the separator", () => { expect(eventToShortcut({ key: "+", ctrlKey: true })).toBe("Mod+Plus"); expect(eventToShortcut({ key: "+", ctrlKey: true, shiftKey: true })).toBe("Shift+Mod+Plus"); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts index e6e6de30d..05986339b 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { formatShortcutDisplay, shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay"; describe("shortcut display", () => { + it("shows the default mouse modifier as Option on macOS and Alt elsewhere", () => { + expect(formatShortcutDisplay("Alt", "MacIntel")).toBe("⌥"); + expect(formatShortcutDisplay("Alt", "Win32")).toBe("Alt"); + }); + it("uses readable modifier labels on Windows", () => { expect(shortcutDisplayKeys("Shift+Alt+U", "Win32")).toEqual(["Shift", "Alt", "U"]); }); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index edf2db04c..76a26a41d 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; +import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeModifierOnlyShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; describe("shortcutRegistry editor actions", () => { const formatterEditorActionIds: ShortcutActionId[] = [ @@ -22,6 +22,21 @@ describe("shortcutRegistry editor actions", () => { ]; const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"]; + it("registers the new-data-tab mouse modifier as a configurable sidebar shortcut", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "openDataInNewTab"); + + expect(definition).toMatchObject({ scope: "sidebar", defaultShortcut: "Alt", inputKind: "modifier-only" }); + expect(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab).toBe("Alt"); + expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab, "MacIntel")).toBe("Alt"); + }); + + it("normalizes custom, cleared, and invalid modifier-only shortcuts", () => { + expect(normalizeShortcutSettings({ openDataInNewTab: "Shift" }).openDataInNewTab).toBe("Shift"); + expect(normalizeShortcutSettings({ openDataInNewTab: "" }).openDataInNewTab).toBe(""); + expect(normalizeShortcutSettings({ openDataInNewTab: "Mod+Enter" }).openDataInNewTab).toBe("Alt"); + expect(normalizeModifierOnlyShortcut("Control")).toBe("Ctrl"); + }); + it("registers formatter editor shortcuts in the generic editor scope", () => { for (const actionId of formatterEditorActionIds) { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); diff --git a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts new file mode 100644 index 000000000..4ec23b1cc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { dataTabOpenModeFromTreeClick, findExistingDataTabCandidate } from "@/lib/sidebar/dataTabOpenPolicy"; +import type { QueryTab } from "@/types/database"; + +function click(modifiers: Partial> = {}) { + return { + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: modifiers.altKey ?? false, + shiftKey: modifiers.shiftKey ?? false, + }; +} + +function dataTab(id: string, title: string, schema = "public"): QueryTab { + return { + id, + title, + connectionId: "conn", + database: "app", + schema, + sql: "", + isExecuting: false, + mode: "data", + }; +} + +const usersTarget = { connectionId: "conn", database: "app", schema: "public", tableName: "users" }; + +describe("dataTabOpenPolicy", () => { + it("maps the default Alt/Option modifier to explicit new-tab mode for data nodes only", () => { + expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true }), "Alt")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("view", click({ altKey: true }), "Alt")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("materialized_view", click({ altKey: true }), "Alt")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("database", click({ altKey: true }), "Alt")).toBe("default"); + }); + + it("honors customized modifier-only shortcuts and a cleared shortcut", () => { + expect(dataTabOpenModeFromTreeClick("table", click({ ctrlKey: true }), "Mod")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("table", click({ metaKey: true }), "Mod")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("table", click({ shiftKey: true }), "Shift")).toBe("new-tab"); + expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true }), "")).toBe("default"); + }); + + it("does not consume tree selection modifier combinations under the default Alt shortcut", () => { + expect(dataTabOpenModeFromTreeClick("table", click({ metaKey: true }), "Alt")).toBe("default"); + expect(dataTabOpenModeFromTreeClick("table", click({ ctrlKey: true }), "Alt")).toBe("default"); + expect(dataTabOpenModeFromTreeClick("table", click({ shiftKey: true }), "Alt")).toBe("default"); + expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true, shiftKey: true }), "Alt")).toBe("default"); + }); + + it("never returns an existing tab in explicit new-tab mode", () => { + const existing = dataTab("users", "users"); + existing.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; + + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: true })).toBeUndefined(); + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: false })).toBeUndefined(); + }); + + it("preserves same-table activation and configured database-tab reuse for ordinary opens", () => { + const sameTable = dataTab("users", "users"); + sameTable.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; + const otherTable = dataTab("orders", "orders"); + + expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseDataTab: false })).toEqual({ tab: sameTable, match: "same-table" }); + expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: true })).toEqual({ tab: otherTable, match: "database" }); + expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: false })).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 3bc394543..0ae8b1833 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { parseShortcutParts } from "@/lib/editor/shortcutDisplay"; +import { isMacShortcutPlatform, parseShortcutParts } from "@/lib/editor/shortcutDisplay"; import { normalizeShortcutSettings, type ShortcutActionId, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; export interface ShortcutLikeEvent { @@ -41,6 +41,29 @@ export function eventToShortcut(event: ShortcutLikeEvent): string | null { return parts.join("+"); } +export function eventToModifierOnlyShortcut(event: ShortcutLikeEvent, platform = globalThis.navigator?.platform || ""): string | null { + if (event.isComposing) return null; + if (event.key === "Alt") return "Alt"; + if (event.key === "Shift") return "Shift"; + if (event.key === "Meta") return isMacShortcutPlatform(platform) ? "Mod" : "Meta"; + if (event.key === "Control") return isMacShortcutPlatform(platform) ? "Ctrl" : "Mod"; + return null; +} + +export function matchesModifierOnlyShortcut(event: Omit, shortcut: string): boolean { + if (event.isComposing || !shortcut) return false; + const meta = !!event.metaKey; + const ctrl = !!event.ctrlKey; + const alt = !!event.altKey; + const shift = !!event.shiftKey; + if (shortcut === "Mod") return meta !== ctrl && !alt && !shift; + if (shortcut === "Meta") return meta && !ctrl && !alt && !shift; + if (shortcut === "Ctrl") return ctrl && !meta && !alt && !shift; + if (shortcut === "Alt") return alt && !meta && !ctrl && !shift; + if (shortcut === "Shift") return shift && !meta && !ctrl && !alt; + return false; +} + export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean { if (event.isComposing || !shortcut) return false; const parts = parseShortcutParts(shortcut); diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 71b8486a7..de78c2b66 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -50,6 +50,7 @@ export type ShortcutActionId = | "copySidebarSelection" | "pasteSidebarSelection" | "editSidebarConnection" + | "openDataInNewTab" | "sendSelectionToAi"; export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar"; @@ -59,6 +60,7 @@ export interface ShortcutDefinition { labelKey: string; scope: ShortcutScope; defaultShortcut: string; + inputKind?: "keyboard" | "modifier-only"; } export type ShortcutSettings = Record; @@ -358,6 +360,13 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "sidebar", defaultShortcut: "Mod+E", }, + { + id: "openDataInNewTab", + labelKey: "settings.shortcutOpenDataInNewTab", + scope: "sidebar", + defaultShortcut: "Alt", + inputKind: "modifier-only", + }, { id: "sendSelectionToAi", labelKey: "settings.shortcutSendSelectionToAi", @@ -368,8 +377,23 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings; +const modifierOnlyShortcuts = new Set(["Alt", "Shift", "Mod", "Ctrl", "Meta"]); + +export function normalizeModifierOnlyShortcut(shortcut: string, fallback = ""): string { + const normalized = shortcut.trim() === "Control" ? "Ctrl" : shortcut.trim(); + if (normalized === "") return ""; + return modifierOnlyShortcuts.has(normalized) ? normalized : fallback; +} + export function normalizeShortcutSettings(settings?: Partial): ShortcutSettings { - return Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, typeof settings?.[definition.id] === "string" ? settings[definition.id] : definition.defaultShortcut])) as ShortcutSettings; + return Object.fromEntries( + SHORTCUT_DEFINITIONS.map((definition) => { + const configuredValue = settings?.[definition.id]; + const configured = typeof configuredValue === "string" ? configuredValue : definition.defaultShortcut; + const normalized = definition.inputKind === "modifier-only" ? normalizeModifierOnlyShortcut(configured, definition.defaultShortcut) : configured; + return [definition.id, normalized]; + }), + ) as ShortcutSettings; } export function shortcutToCodeMirrorKey(shortcut: string): string { diff --git a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts new file mode 100644 index 000000000..493309438 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts @@ -0,0 +1,49 @@ +import { matchesModifierOnlyShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; +import type { QueryTab, TreeNodeType } from "@/types/database"; + +export type DataTabOpenMode = "default" | "new-tab"; + +type DataTabLike = Pick; + +export interface DataTabTarget { + connectionId: string; + database: string; + schema?: string; + catalog?: string; + tableName: string; +} + +export type ExistingDataTabCandidate = { + tab: T; + match: "same-table" | "database"; +}; + +const dataNodeTypes = new Set(["table", "view", "materialized_view"]); + +export function isDataTreeNodeType(type: TreeNodeType): boolean { + return dataNodeTypes.has(type); +} + +export function dataTabOpenModeFromTreeClick(type: TreeNodeType, event: Omit, shortcut: string): DataTabOpenMode { + if (!isDataTreeNodeType(type)) return "default"; + return matchesModifierOnlyShortcut(event, shortcut) ? "new-tab" : "default"; +} + +function isSameDatabase(tab: DataTabLike, target: Pick): boolean { + return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database; +} + +function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean { + return isSameDatabase(tab, target) && (tab.tableMeta?.catalog || "") === (target.catalog || "") && (tab.schema || "") === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName; +} + +export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseDataTab: boolean }): ExistingDataTabCandidate | undefined { + if (options.openMode === "new-tab") return undefined; + + const sameTable = tabs.find((tab) => isSameTable(tab, target)); + if (sameTable) return { tab: sameTable, match: "same-table" }; + if (!options.reuseDataTab) return undefined; + + const sameDatabase = tabs.find((tab) => isSameDatabase(tab, target)); + return sameDatabase ? { tab: sameDatabase, match: "database" } : undefined; +}