From 4768dbb99a2cc52c11635019bc63301869766db9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 15 Aug 2026 00:52:24 +0000 Subject: [PATCH] feat(tabs): add adjacent data tab opening option Closes #3723 --- .../editor/EditorSettingsDialog.vue | 14 ++++ .../useSidebarDataOpenRuntime.spec.ts | 67 ++++++++++++++++++- .../composables/useSidebarDataOpenRuntime.ts | 10 ++- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/editorSettingsDraft.spec.ts | 20 ++++++ .../openDataTabsNextToActive.spec.ts | 13 ++++ .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + .../__tests__/queryStore.switchTab.spec.ts | 22 ++++++ .../stores/__tests__/settingsStore.spec.ts | 25 +++++++ apps/desktop/src/stores/queryStore.ts | 6 +- apps/desktop/src/stores/settingsStore.ts | 4 ++ 19 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/settings/__tests__/openDataTabsNextToActive.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 939729189f..a6742ba4e1 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -399,6 +399,7 @@ const editSidebarOpenDatabaseOnSingleClick = ref(settingsStore.editorSettings.si const editOpenTabsRestoreMode = ref(settingsStore.editorSettings.openTabsRestoreMode); const editDisconnectTabHandlingMode = ref(settingsStore.editorSettings.disconnectTabHandlingMode); const editDataTabReuseMode = ref(settingsStore.editorSettings.dataTabReuseMode); +const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive); const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect); const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); @@ -520,6 +521,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { openTabsRestoreMode: editOpenTabsRestoreMode.value, disconnectTabHandlingMode: editDisconnectTabHandlingMode.value, dataTabReuseMode: editDataTabReuseMode.value, + openDataTabsNextToActive: editOpenDataTabsNextToActive.value, prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, sidebarObjectInfoMode: editSidebarObjectInfoMode.value, @@ -799,6 +801,7 @@ function syncEditorSettingsDraftFromStore() { editOpenTabsRestoreMode.value = settingsStore.editorSettings.openTabsRestoreMode; editDisconnectTabHandlingMode.value = settingsStore.editorSettings.disconnectTabHandlingMode; editDataTabReuseMode.value = settingsStore.editorSettings.dataTabReuseMode; + editOpenDataTabsNextToActive.value = settingsStore.editorSettings.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect; editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; @@ -1014,6 +1017,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; + editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; @@ -1123,6 +1127,7 @@ function resetAllDefaults() { editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; + editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; @@ -4564,6 +4569,15 @@ onUnmounted(() => { +
+
+ + + {{ t("settings.openDataTabsNextToActiveDescription") }} + +
+ +
diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index bd294a5023..217f7b8754 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ activeTabId: null as string | null, cachedMetadata: undefined as unknown, dataTabReuseMode: "same-table" as DataTabReuseMode, + openDataTabsNextToActive: false, ensureConnected: vi.fn(), executeTabSql: vi.fn(), loadTableMetadata: vi.fn(), @@ -32,7 +33,7 @@ vi.mock("@/stores/queryStore", () => ({ get activeTabId() { return mocks.activeTabId; }, - createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, _initialSql?: string, catalog?: string, options: { forceNew?: boolean } = {}) => { + createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, _initialSql?: string, catalog?: string, options: { forceNew?: boolean; insertAfterActive?: boolean } = {}) => { if (!options.forceNew) { const existing = mocks.tabs.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || "")); if (existing) { @@ -54,7 +55,9 @@ vi.mock("@/stores/queryStore", () => ({ isCancelling: false, isExplaining: false, } as QueryTab; - mocks.tabs.push(tab); + const activeIndex = options.insertAfterActive ? mocks.tabs.findIndex((item) => item.id === mocks.activeTabId) : -1; + if (activeIndex >= 0) mocks.tabs.splice(activeIndex + 1, 0, tab); + else mocks.tabs.push(tab); mocks.activeTabId = tab.id; return tab.id; }, @@ -87,7 +90,7 @@ vi.mock("@/stores/queryStore", () => ({ })); vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: mocks.dataTabReuseMode, pageSize: 100 } }), + useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: mocks.dataTabReuseMode, openDataTabsNextToActive: mocks.openDataTabsNextToActive, pageSize: 100 } }), })); vi.mock("@/lib/database/jdbcDialect", () => ({ @@ -141,6 +144,7 @@ describe("useSidebarDataOpenRuntime", () => { mocks.activeTabId = null; mocks.cachedMetadata = undefined; mocks.dataTabReuseMode = "same-table"; + mocks.openDataTabsNextToActive = false; mocks.ensureConnected.mockResolvedValue(undefined); mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users"); mocks.executeTabSql.mockImplementation(async () => { @@ -174,7 +178,24 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs).toHaveLength(2); }); + it("keeps adjacent placement for every tab created in always-new mode", async () => { + mocks.dataTabReuseMode = "always-new"; + mocks.openDataTabsNextToActive = true; + mocks.tabs.push( + { id: "query-1", connectionId: "connection-1", database: "app", title: "Query 1", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + { id: "query-2", connectionId: "connection-1", database: "app", title: "Query 2", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + ); + mocks.activeTabId = "query-1"; + + await useSidebarDataOpenRuntime().openData(tableNode); + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs.map((tab) => tab.title)).toEqual(["Query 1", "users", "users", "Query 2"]); + }); + it("reuses a sidebar tab for the same table in same-table mode", async () => { + mocks.openDataTabsNextToActive = true; + await useSidebarDataOpenRuntime().openData(tableNode); await useSidebarDataOpenRuntime().openData(tableNode); @@ -191,8 +212,34 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs.map((tab) => tab.title)).toEqual(["users", "orders"]); }); + it("opens a new table data tab next to the active tab when enabled", async () => { + mocks.openDataTabsNextToActive = true; + mocks.tabs.push( + { id: "query-1", connectionId: "connection-1", database: "app", title: "Query 1", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + { id: "query-2", connectionId: "connection-1", database: "app", title: "Query 2", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + ); + mocks.activeTabId = "query-1"; + + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs.map((tab) => tab.title)).toEqual(["Query 1", "users", "Query 2"]); + }); + + it("keeps appending new table data tabs when adjacent opening is disabled", async () => { + mocks.tabs.push( + { id: "query-1", connectionId: "connection-1", database: "app", title: "Query 1", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + { id: "query-2", connectionId: "connection-1", database: "app", title: "Query 2", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + ); + mocks.activeTabId = "query-1"; + + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs.map((tab) => tab.title)).toEqual(["Query 1", "Query 2", "users"]); + }); + it("reuses the active safe data tab for a different table in active-tab mode", async () => { mocks.dataTabReuseMode = "active-tab"; + mocks.openDataTabsNextToActive = true; const ordersNode = { ...tableNode, id: "table-orders", label: "orders" }; mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({ metadata: { @@ -272,6 +319,20 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs).toHaveLength(2); }); + it("opens a new HBase data tab next to the active tab when enabled", async () => { + mocks.databaseType = "hbase"; + mocks.openDataTabsNextToActive = true; + mocks.tabs.push( + { id: "query-1", connectionId: "connection-1", database: "app", title: "Query 1", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + { id: "query-2", connectionId: "connection-1", database: "app", title: "Query 2", mode: "query", sql: "", isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab, + ); + mocks.activeTabId = "query-1"; + + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs.map((tab) => tab.title)).toEqual(["Query 1", "users", "Query 2"]); + }); + it("starts cold-cache OceanBase metadata before the table query", async () => { await useSidebarDataOpenRuntime().openData(tableNode); diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index bceea3f0bf..36cdbd362c 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -34,7 +34,10 @@ export function useSidebarDataOpenRuntime() { const reuseMode = options.reuseMode ?? settingsStore.editorSettings.dataTabReuseMode; if (config?.db_type === "hbase") { await connectionStore.ensureConnected(node.connectionId); - const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" || reuseMode === "always-new" }); + const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { + forceNew: openMode === "new-tab" || reuseMode === "always-new", + insertAfterActive: settingsStore.editorSettings.openDataTabsNextToActive, + }); queryStore.updateSql(tabId, node.label); return; } @@ -174,7 +177,10 @@ export function useSidebarDataOpenRuntime() { resetReusedDataTabState(existingDataTabCandidate.tab); return existingDataTabCandidate.tab.id; } - return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema, undefined, node.catalog, { forceNew: true }); + return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema, undefined, node.catalog, { + forceNew: true, + insertAfterActive: settingsStore.editorSettings.openDataTabsNextToActive, + }); })(); openDataLog("info", "tab-created", { traceId, tabId, elapsed: elapsed() }); logPhase("tab-created", { tabId }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3fb292ce9a..2074126b9a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5812,6 +5812,8 @@ export default { disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Keep related tabs, SQL text, and current results without extra cleanup.", reuseDataTab: "Reuse data tab", reuseDataTabDescription: "Choose how opening a table uses data tabs.", + openDataTabsNextToActive: "Open new data tabs next to the active tab", + openDataTabsNextToActiveDescription: "Place newly created table data tabs immediately after the active tab.", dataTabReuseAlwaysNew: "Always open a new tab", dataTabReuseAlwaysNewDescription: "Create a new data tab every time, even when the table is already open.", dataTabReuseSameTable: "Reuse the same table", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f6fca8601b..fcb32051a1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5556,6 +5556,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Conserva las pestañas relacionadas, el texto SQL y los resultados actuales sin limpieza adicional.", reuseDataTab: "Reutilizar pestaña de datos", reuseDataTabDescription: "Elige cómo se usan las pestañas de datos al abrir una tabla.", + openDataTabsNextToActive: "Abrir nuevas pestañas de datos junto a la activa", + openDataTabsNextToActiveDescription: "Coloca las nuevas pestañas de datos de tablas justo después de la pestaña activa.", dataTabReuseAlwaysNew: "Abrir siempre una pestaña nueva", dataTabReuseAlwaysNewDescription: "Crea una pestaña de datos nueva cada vez, aunque la tabla ya esté abierta.", dataTabReuseSameTable: "Reutilizar solo la misma tabla", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 21d22896e2..6184de8103 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5556,6 +5556,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Mantieni le schede correlate, il testo SQL e i risultati correnti senza ulteriore pulizia.", reuseDataTab: "Riusa scheda dati", reuseDataTabDescription: "Scegli come usare le schede dati quando apri una tabella.", + openDataTabsNextToActive: "Apri le nuove schede dati accanto a quella attiva", + openDataTabsNextToActiveDescription: "Posiziona le nuove schede dati delle tabelle subito dopo la scheda attiva.", dataTabReuseAlwaysNew: "Apri sempre una nuova scheda", dataTabReuseAlwaysNewDescription: "Crea ogni volta una nuova scheda dati, anche se la tabella è già aperta.", dataTabReuseSameTable: "Riusa solo la stessa tabella", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3493bb0b7b..e7b7ec70cd 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5583,6 +5583,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。", reuseDataTab: "データタブを再利用", reuseDataTabDescription: "テーブルを開くときのデータタブの使い方を選択します。", + openDataTabsNextToActive: "新しいデータタブをアクティブタブの隣に開く", + openDataTabsNextToActiveDescription: "新しく作成したテーブルのデータタブをアクティブタブの直後に配置します。", dataTabReuseAlwaysNew: "常に新しいタブを開く", dataTabReuseAlwaysNewDescription: "同じテーブルが開いていても、毎回新しいデータタブを作成します。", dataTabReuseSameTable: "同じテーブルだけ再利用", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f21b22a90c..19314e7b02 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5292,6 +5292,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.", reuseDataTab: "데이터 탭 재사용", reuseDataTabDescription: "테이블을 열 때 데이터 탭을 사용하는 방식을 선택합니다.", + openDataTabsNextToActive: "새 데이터 탭을 활성 탭 옆에 열기", + openDataTabsNextToActiveDescription: "새로 만든 테이블 데이터 탭을 활성 탭 바로 뒤에 배치합니다.", dataTabReuseAlwaysNew: "항상 새 탭 열기", dataTabReuseAlwaysNewDescription: "테이블이 이미 열려 있어도 매번 새 데이터 탭을 만듭니다.", dataTabReuseSameTable: "같은 테이블만 재사용", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index dd829666c8..d7a3a48470 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5558,6 +5558,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Manter abas relacionadas, texto SQL e resultados atuais sem limpeza adicional.", reuseDataTab: "Reutilizar aba de dados", reuseDataTabDescription: "Escolha como as abas de dados são usadas ao abrir uma tabela.", + openDataTabsNextToActive: "Abrir novas abas de dados ao lado da aba ativa", + openDataTabsNextToActiveDescription: "Coloca as novas abas de dados de tabelas logo após a aba ativa.", dataTabReuseAlwaysNew: "Sempre abrir uma nova aba", dataTabReuseAlwaysNewDescription: "Cria uma nova aba de dados todas as vezes, mesmo que a tabela já esteja aberta.", dataTabReuseSameTable: "Reutilizar apenas a mesma tabela", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index de0fe1ec10..5d5b6cf845 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5799,6 +5799,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。", reuseDataTab: "复用数据标签页", reuseDataTabDescription: "选择打开表时如何使用数据标签页。", + openDataTabsNextToActive: "在当前标签页旁打开新数据标签页", + openDataTabsNextToActiveDescription: "将新建的表数据标签页放在当前标签页之后。", dataTabReuseAlwaysNew: "始终新建标签页", dataTabReuseAlwaysNewDescription: "每次打开表都新建标签页,即使该表已经打开。", dataTabReuseSameTable: "仅复用同一张表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a8e9feecd3..aa4d4bc294 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4893,6 +4893,8 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。", reuseDataTab: "重複使用資料分頁", reuseDataTabDescription: "選擇開啟資料表時如何使用資料分頁。", + openDataTabsNextToActive: "在目前分頁旁開啟新的資料分頁", + openDataTabsNextToActiveDescription: "將新建的資料表資料分頁放在目前分頁之後。", dataTabReuseAlwaysNew: "一律開啟新分頁", dataTabReuseAlwaysNewDescription: "每次開啟資料表都建立新分頁,即使該資料表已經開啟。", dataTabReuseSameTable: "僅重複使用同一資料表", diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index 8e07bca54b..10d23c80a0 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -61,6 +61,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataTabReuseMode"); }); + it("includes adjacent data-tab opening", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("openDataTabsNextToActive"); + }); + it("includes data grid type colors", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("colorizeDataGridCellTypes"); }); @@ -208,6 +212,14 @@ describe("editorSettingsDraftChanged", () => { expect(editorSettingsDraftChanged(draft, base)).toBe(true); }); + it("detects adjacent data-tab opening changes", () => { + const settings = makeSettings({ openDataTabsNextToActive: false }); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.openDataTabsNextToActive = true; + expect(editorSettingsDraftChanged(draft, base)).toBe(true); + }); + it("detects completionTriggerMode change", () => { const settings = makeSettings({ completionTriggerMode: "positional" } as Partial); const draft = editorSettingsDraftFromSettings(settings); @@ -266,6 +278,14 @@ describe("editorSettingsPatchFromDraft", () => { expect(editorSettingsPatchFromDraft(draft, base).dataTabReuseMode).toBe("always-new"); }); + it("includes adjacent data-tab opening when changed", () => { + const settings = makeSettings({ openDataTabsNextToActive: false }); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.openDataTabsNextToActive = true; + expect(editorSettingsPatchFromDraft(draft, base).openDataTabsNextToActive).toBe(true); + }); + it("includes completionTriggerMode in patch when changed", () => { const settings = makeSettings({ completionTriggerMode: "positional" } as Partial); const draft = editorSettingsDraftFromSettings(settings); diff --git a/apps/desktop/src/lib/settings/__tests__/openDataTabsNextToActive.spec.ts b/apps/desktop/src/lib/settings/__tests__/openDataTabsNextToActive.spec.ts new file mode 100644 index 0000000000..f761459ca1 --- /dev/null +++ b/apps/desktop/src/lib/settings/__tests__/openDataTabsNextToActive.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8"); + +describe("openDataTabsNextToActive settings control", () => { + it("binds the navigation switch through apply and both reset paths", () => { + expect(settingsDialogSource).toContain("const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive)"); + expect(settingsDialogSource).toContain("openDataTabsNextToActive: editOpenDataTabsNextToActive.value"); + expect(settingsDialogSource.match(/editOpenDataTabsNextToActive\.value = DEFAULT_EDITOR_SETTINGS\.openDataTabsNextToActive/g)).toHaveLength(2); + expect(settingsDialogSource).toContain('id="open-data-tabs-next-to-active" v-model="editOpenDataTabsNextToActive"'); + }); +}); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 5893aff519..4908aebbbc 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -58,6 +58,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "openTabsRestoreMode", "disconnectTabHandlingMode", "dataTabReuseMode", + "openDataTabsNextToActive", "prefillNewQueryWithSelect", "updateNotificationsEnabled", "sidebarObjectInfoMode", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 66263e36a5..a889f7aaad 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -159,6 +159,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "navigation-sidebar", category: "navigation", titleKey: "settings.sidebarActivation", targetId: "navigation" }, { id: "navigation-routine-source", category: "navigation", titleKey: "settings.routineSourceOpenMode", descriptionKey: "settings.routineSourceOpenModeDescription", targetId: "navigation" }, { id: "navigation-reuse-data-tab", category: "navigation", titleKey: "settings.reuseDataTab", descriptionKey: "settings.reuseDataTabDescription", targetId: "navigation" }, + { id: "navigation-open-data-tabs-next-to-active", category: "navigation", titleKey: "settings.openDataTabsNextToActive", descriptionKey: "settings.openDataTabsNextToActiveDescription", targetId: "navigation" }, { id: "navigation-object-display", category: "navigation", titleKey: "settings.sidebarObjectDisplay", targetId: "navigation" }, { id: "navigation-object-info", category: "navigation", titleKey: "settings.sidebarObjectInfoMode", descriptionKey: "settings.sidebarObjectInfoModeDescription", targetId: "navigation" }, { id: "navigation-table-search", category: "navigation", titleKey: "settings.sidebarTableSearchEnabled", descriptionKey: "settings.sidebarTableSearchEnabledDescription", targetId: "navigation" }, diff --git a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts index 8972ad6d7c..ac6d6eb6f0 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts @@ -59,6 +59,28 @@ describe("queryStore switchTab", () => { expect(queryStore.activeTabId).toBe(tab2Id); }); + it("inserts a tab after the active tab only when explicitly requested", async () => { + const queryStore = useQueryStore(); + const firstId = queryStore.createTab("pg-1", "app", "Query 1", "query"); + const trailingId = queryStore.createTab("pg-1", "app", "Query 2", "query"); + queryStore.switchTab(firstId); + + const dataId = queryStore.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true, insertAfterActive: true }); + const appendedId = queryStore.createTab("pg-1", "app", "Query 3", "query", undefined, undefined, undefined, { forceNew: true }); + + expect(queryStore.tabs.map((tab) => tab.id)).toEqual([firstId, dataId, trailingId, appendedId]); + }); + + it("appends when adjacent insertion has no valid active tab", async () => { + const queryStore = useQueryStore(); + const firstId = queryStore.createTab("pg-1", "app", "Query 1", "query"); + queryStore.activeTabId = "missing-tab"; + + const dataId = queryStore.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true, insertAfterActive: true }); + + expect(queryStore.tabs.map((tab) => tab.id)).toEqual([firstId, dataId]); + }); + it("deactivates settings page when reopening an existing special tab", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const { useSettingsStore } = await import("@/stores/settingsStore"); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 8063badd62..d09a9bf90a 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -28,6 +28,14 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ reuseDataTab: true } as any).dataTabReuseMode).toBe("same-table"); }); + it("keeps adjacent data-tab opening disabled unless explicitly enabled", () => { + expect(normalizeEditorSettings({}).openDataTabsNextToActive).toBe(false); + expect(normalizeEditorSettings({ openDataTabsNextToActive: true }).openDataTabsNextToActive).toBe(true); + expect(normalizeEditorSettings({ openDataTabsNextToActive: false }).openDataTabsNextToActive).toBe(false); + expect(normalizeEditorSettings({ openDataTabsNextToActive: "true" } as any).openDataTabsNextToActive).toBe(false); + expect(normalizeEditorSettings({ openDataTabsNextToActive: null } as any).openDataTabsNextToActive).toBe(false); + }); + it("defaults and bounds the regular expression match limit", () => { expect(normalizeEditorSettings({}).regexMaxMatchCount).toBe(1000); expect(normalizeEditorSettings({ regexMaxMatchCount: 2500 }).regexMaxMatchCount).toBe(2500); @@ -663,6 +671,23 @@ describe("settingsStore persisted settings initialization", () => { ); }); + it("loads and persists adjacent data-tab opening", async () => { + const loadEditorSettings = vi.fn().mockResolvedValue({ openDataTabsNextToActive: true }); + const saveEditorSettings = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + await store.initEditorSettings(); + + expect(store.editorSettings.openDataTabsNextToActive).toBe(true); + + await store.updateEditorSettingsAndPersist({ openDataTabsNextToActive: false }); + + expect(store.editorSettings.openDataTabsNextToActive).toBe(false); + expect(saveEditorSettings).toHaveBeenLastCalledWith(expect.objectContaining({ openDataTabsNextToActive: false })); + }); + it("shares concurrent initialization and applies startup changes after saved settings load", async () => { let resolveLoad!: (value: unknown) => void; const loadEditorSettings = vi.fn( diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ffb2bdf9a1..a4f6f84149 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1620,7 +1620,7 @@ export const useQueryStore = defineStore("query", () => { return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || "")); } - function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean } = {}) { + function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean } = {}) { if (title && !options.forceNew) { const existing = findTabByIdentity(connectionId, database, title, mode, schema, catalog); if (existing) { @@ -1646,7 +1646,9 @@ export const useQueryStore = defineStore("query", () => { mode, }; if (mode === "query") tab.originalSql = initialSql ?? ""; - tabs.value.push(tab); + const activeIndex = options.insertAfterActive ? tabs.value.findIndex((item) => item.id === activeTabId.value) : -1; + if (activeIndex >= 0) tabs.value.splice(activeIndex + 1, 0, tab); + else tabs.value.push(tab); if (options.activate !== false) activeTabId.value = id; return id; } diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 150c7ed9e6..521bad3eb0 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -580,6 +580,7 @@ export interface EditorSettings { openTabsRestoreMode: OpenTabsRestoreMode; disconnectTabHandlingMode: DisconnectTabHandlingMode; dataTabReuseMode: DataTabReuseMode; + openDataTabsNextToActive: boolean; prefillNewQueryWithSelect: boolean; updateNotificationsEnabled: boolean; sidebarHiddenTablePrefixes: string[]; @@ -778,6 +779,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { openTabsRestoreMode: "all", disconnectTabHandlingMode: "close-tabs", dataTabReuseMode: DEFAULT_DATA_TAB_REUSE_MODE, + openDataTabsNextToActive: false, prefillNewQueryWithSelect: true, updateNotificationsEnabled: true, sidebarHiddenTablePrefixes: [], @@ -1142,6 +1144,7 @@ export function normalizeEditorSettings(settings: Partial, exist } ).reuseDataTab, ), + openDataTabsNextToActive: typeof settings.openDataTabsNextToActive === "boolean" ? settings.openDataTabsNextToActive : DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive, prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), @@ -1702,6 +1705,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.openTabsRestoreMode !== undefined) editorSettings.value.openTabsRestoreMode = normalizeOpenTabsRestoreMode(partial.openTabsRestoreMode); if (partial.disconnectTabHandlingMode !== undefined) editorSettings.value.disconnectTabHandlingMode = normalizeDisconnectTabHandlingMode(partial.disconnectTabHandlingMode); if (partial.dataTabReuseMode !== undefined) editorSettings.value.dataTabReuseMode = normalizeDataTabReuseMode(partial.dataTabReuseMode); + if (partial.openDataTabsNextToActive !== undefined) editorSettings.value.openDataTabsNextToActive = partial.openDataTabsNextToActive === true; if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect; if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled; if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes);