From 116aced217794534dc36d1f2fc5fb7c0f1c7df46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 5 Sep 2026 10:01:54 +0800 Subject: [PATCH] feat(editor): group editor tabs by database --- .../editor/EditorSettingsDialog.vue | 1 + .../components/layout/EditorGroupTabBar.vue | 62 +++++++++++++++++-- .../EditorGroupTabBar.groups.spec.ts | 30 ++++++++- .../src/components/layout/appTabBar.css | 45 +++++++++----- 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/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + apps/desktop/src/stores/settingsStore.ts | 2 +- 14 files changed, 127 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ed714dc054..496c63bfa1 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -5467,6 +5467,7 @@ onUnmounted(() => { {{ t("settings.tabGroupNone") }} {{ t("settings.tabGroupDatabaseType") }} + {{ t("settings.tabGroupDatabase") }} {{ t("settings.tabGroupConnection") }} diff --git a/apps/desktop/src/components/layout/EditorGroupTabBar.vue b/apps/desktop/src/components/layout/EditorGroupTabBar.vue index 894a6b9870..32de9417d9 100644 --- a/apps/desktop/src/components/layout/EditorGroupTabBar.vue +++ b/apps/desktop/src/components/layout/EditorGroupTabBar.vue @@ -221,6 +221,7 @@ watch( const tabGroupItems = computed(() => [ { value: "none", label: t("settings.tabGroupNone") }, { value: "database-type", label: t("settings.tabGroupDatabaseType") }, + { value: "database", label: t("settings.tabGroupDatabase") }, { value: "connection", label: t("settings.tabGroupConnection") }, ]); const tabSortItems = computed(() => [ @@ -246,7 +247,7 @@ async function persistTabPreferences(partial: TabPreferencePatch) { } function updateTabGroupMode(value: string) { - if (value === "none" || value === "database-type" || value === "connection") void persistTabPreferences({ tabGroupMode: value }); + if (value === "none" || value === "database-type" || value === "database" || value === "connection") void persistTabPreferences({ tabGroupMode: value }); } function updateTabSortMode(value: string) { @@ -257,16 +258,59 @@ function updateTabPlacement(value: string) { if (value === "top" || value === "bottom" || value === "left" || value === "right") void persistTabPreferences({ tabPlacement: value }); } +function databaseTabGroupKey(tab: QueryTab) { + const database = tab.database || ""; + // A connection-level tab has no database scope, so its catalog cannot split the group. + return JSON.stringify([tab.connectionId, database ? tab.catalog || "" : "", database]); +} + function tabGroupKey(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); if (settingsStore.editorSettings.tabGroupMode === "connection") return tab.connectionId; + if (settingsStore.editorSettings.tabGroupMode === "database") return databaseTabGroupKey(tab); return connection?.driver_profile || connection?.db_type || tab.connectionId || "unknown"; } +function compareTabGroupKeys(left: string, right: string) { + if (left === right) return 0; + const localized = left.localeCompare(right, undefined, { sensitivity: "base", numeric: true }); + // Collation may consider case-distinct identities equal; the raw tie-break keeps clusters contiguous. + return localized || (left < right ? -1 : 1); +} + function tabTitleText(tab: QueryTab) { return tabDisplayTitle(tab, t); } +function tabConnectionLabel(tab: QueryTab) { + return connectionStore.getConfig(tab.connectionId)?.name || tab.connectionId; +} + +function tabConnectionTargetLabel(tab: QueryTab) { + const connection = connectionStore.getConfig(tab.connectionId); + const host = connection?.host.trim(); + return connection && host ? `${host}:${connection.port}` : tab.connectionId; +} + +function databaseTabGroupBaseLabel(tab: QueryTab) { + if (!tab.database) return tabConnectionLabel(tab); + return [tab.database, ...(tab.catalog ? [tab.catalog] : [])].join(" · "); +} + +const databaseTabGroupIdentityDisplaysByLabel = computed(() => { + const identitiesByLabel = new Map>(); + for (const tab of props.tabs) { + const label = databaseTabGroupBaseLabel(tab); + const identities = identitiesByLabel.get(label) ?? new Map(); + identities.set(databaseTabGroupKey(tab), { + connectionLabel: tabConnectionLabel(tab), + connectionTargetLabel: tabConnectionTargetLabel(tab), + }); + identitiesByLabel.set(label, identities); + } + return identitiesByLabel; +}); + /** * Sorts a section of this pane's tabs for display. With a group mode active, * tabs cluster by group key first; within a cluster (and entirely under @@ -279,7 +323,7 @@ function sortDisplayedTabs(tabs: QueryTab[]) { .map((tab, index) => ({ tab, index })) .sort((left, right) => { if (groupMode !== "none") { - const group = tabGroupKey(left.tab).localeCompare(tabGroupKey(right.tab), undefined, { sensitivity: "base", numeric: true }); + const group = compareTabGroupKeys(tabGroupKey(left.tab), tabGroupKey(right.tab)); if (group) return group; } if (sortMode === "manual") return left.index - right.index; @@ -310,6 +354,17 @@ const editingTabGroupFallbackColor = ref(tabGroupPalette[0]!); function tabGroupDefaultLabel(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || tab.connectionId; + if (settingsStore.editorSettings.tabGroupMode === "database") { + const baseLabel = databaseTabGroupBaseLabel(tab); + const identityDisplays = databaseTabGroupIdentityDisplaysByLabel.value.get(baseLabel); + if ((identityDisplays?.size ?? 0) <= 1) return baseLabel; + const connectionLabel = tabConnectionLabel(tab); + const sameNameDisplays = [...identityDisplays!.values()].filter((display) => display.connectionLabel === connectionLabel); + if (sameNameDisplays.length <= 1) return `${baseLabel} · ${connectionLabel}`; + const connectionTargetLabel = tabConnectionTargetLabel(tab); + if (sameNameDisplays.filter((display) => display.connectionTargetLabel === connectionTargetLabel).length <= 1) return `${baseLabel} · ${connectionLabel} · ${connectionTargetLabel}`; + return `${baseLabel} · ${connectionLabel} · ${connectionTargetLabel} · ${tab.connectionId}`; + } return connection?.driver_label || connection?.driver_profile || connection?.db_type || tab.connectionId; } @@ -327,7 +382,7 @@ function tabGroupLabel(tab: QueryTab) { // Pinned and regular tabs form separate clusters even under the same key. function tabGroupId(tab: QueryTab) { - return `${tab.pinned ? "fixed" : "regular"}:${tabGroupKey(tab)}`; + return `${tab.pinned ? "fixed" : "regular"}:${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`; } function isTabGroupCollapsed(tab: QueryTab) { @@ -376,7 +431,6 @@ function groupColorStyle(color: string): CSSProperties { return { "--tab-group-color": color, "--tab-group-soft": hexToRgba(color, 0.12), - "--tab-group-rail": hexToRgba(color, 0.3), } as CSSProperties; } diff --git a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts index 2bbb3eba1c..8d18ab66a1 100644 --- a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts @@ -29,7 +29,7 @@ const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8"); describe("EditorGroupTabBar semantic tab groups", () => { it("keeps separate collapsed state for pinned and regular clusters under the same key", () => { expect(source).toContain("const collapsedTabGroups = ref>(new Set());"); - expect(source).toContain('return `${tab.pinned ? "fixed" : "regular"}:${tabGroupKey(tab)}`;'); + expect(source).toContain('return `${tab.pinned ? "fixed" : "regular"}:${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`;'); expect(source).toContain("function toggleTabGroup(tab: QueryTab)"); expect(source).toContain(':aria-expanded="!isTabGroupCollapsed(entry.tab)"'); }); @@ -180,7 +180,7 @@ describe("EditorGroupTabBar vertical placement", () => { expect(sharedStyles).toContain('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]'); expect(sharedStyles).toContain("inset 0 0 0 1px color-mix"); expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after"); - expect(sharedStyles).toContain("margin-inline: 2.5rem 0.5rem;"); + expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.5rem;"); }); }); @@ -315,6 +315,32 @@ describe("EditorGroupTabBar group behavior", () => { host.remove(); }); + it("groups by database identity, disambiguating same-name databases across connections", async () => { + const store = useQueryStore(); + const settings = useSettingsStore(); + settings.editorSettings.tabGroupMode = "database"; + const pgApp = store.createTab("pg-1", "app", "PG app", "query"); + store.createTab("mysql-1", "app", "MY app", "query"); + const pgConn = store.createTab("pg-1", "", "PG conn", "query"); + const mainGroup = store.groups[0]; + const { app, host } = mountBar(mainGroup.id, store.tabs.slice(), pgApp, pinia); + await settle(); + + const headers = Array.from(host.querySelectorAll(".tab-group-header")); + // Sorted by group key: the same-name "app" databases stay separate clusters + // disambiguated by connection label, and the database-less tab clusters by connection. + expect(headers.map((header) => header.title)).toEqual(["app · mysql-1", "pg-1", "app · pg-1"]); + expect(host.querySelectorAll("[data-tab-id]").length).toBe(3); + + // Collapsing one "app" cluster leaves the other same-name cluster expanded. + headers[0]!.click(); + await settle(); + expect(Array.from(host.querySelectorAll("[data-tab-id]")).map((pill) => pill.getAttribute("data-tab-id"))).toEqual([pgConn, pgApp]); + + app.unmount(); + host.remove(); + }); + it("exposes the drag-back hit-test anchor and highlights itself as the detached drop target", async () => { const store = useQueryStore(); const tabId = store.createTab("pg-1", "app", "PG 1", "query"); diff --git a/apps/desktop/src/components/layout/appTabBar.css b/apps/desktop/src/components/layout/appTabBar.css index 7fac0749fb..f92b4497a7 100644 --- a/apps/desktop/src/components/layout/appTabBar.css +++ b/apps/desktop/src/components/layout/appTabBar.css @@ -351,6 +351,12 @@ .vertical-tab-layout { flex: 0 0 15rem; + --tab-group-header-inset: 0.5rem; + --tab-group-root-rail-x: 1.16rem; + --tab-group-tab-inset: 2.5rem; + --tab-group-branch-width: 0.65rem; + --tab-group-row-half: 1rem; + --tab-group-header-gap: 0.08rem; } .vertical-tab-layout .app-tab-strip { @@ -440,7 +446,7 @@ width: calc(100% - 1rem) !important; height: 2rem; align-self: center; - margin: 0.35rem 0.5rem 0.08rem; + margin: 0.35rem var(--tab-group-header-inset) var(--tab-group-header-gap); padding: 0; color: var(--foreground); font-size: 0.75rem; @@ -448,6 +454,11 @@ line-height: 1rem; } +.vertical-tab-layout .tab-group-header, +.vertical-tab-layout .tab-group-tab { + --tab-group-vertical-rail: color-mix(in srgb, var(--tab-group-color) 30%, var(--background)); +} + .vertical-tab-layout .tab-group-header-content { width: 100%; height: 100%; @@ -474,12 +485,12 @@ content: ""; position: absolute; z-index: 1; - top: 1rem; - bottom: -1.08rem; - left: 0.66rem; - width: 0.7rem; - border-bottom: 1px solid var(--tab-group-rail); - border-left: 1px solid var(--tab-group-rail); + top: 50%; + bottom: calc(0rem - var(--tab-group-header-gap) - var(--tab-group-row-half)); + left: calc(var(--tab-group-root-rail-x) - var(--tab-group-header-inset)); + width: calc(var(--tab-group-tab-inset) - var(--tab-group-root-rail-x)); + border-bottom: 1px solid var(--tab-group-vertical-rail); + border-left: 1px solid var(--tab-group-vertical-rail); border-bottom-left-radius: 0.1875rem; pointer-events: none; } @@ -492,7 +503,7 @@ .vertical-tab-layout .tab-group-tab { position: relative; width: calc(100% - 3rem); - margin-inline: 2.5rem 0.5rem; + margin-inline: var(--tab-group-tab-inset) 0.5rem; padding-inline-start: 0.65rem !important; border-color: transparent; } @@ -500,12 +511,12 @@ .vertical-tab-layout .tab-group-tab::before { content: ""; position: absolute; - top: -0.25rem; - bottom: -0.25rem; - left: -0.65rem; + top: 0; + bottom: 0; + left: calc(0rem - var(--tab-group-branch-width)); width: 1px; border-radius: 9999px; - background: var(--tab-group-rail); + background: var(--tab-group-vertical-rail); pointer-events: none; } @@ -522,10 +533,14 @@ content: ""; position: absolute; top: calc(50% - 0.5px); - left: -0.65rem; - width: 0.65rem; + left: calc(0rem - var(--tab-group-branch-width)); + width: var(--tab-group-branch-width); height: 1px; border-radius: 0 9999px 9999px 0; - background: var(--tab-group-rail); + background: var(--tab-group-vertical-rail); pointer-events: none; } + +.vertical-tab-layout .tab-group-tab--first::after { + display: none; +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9a6c55fbd1..ce69992f36 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6523,6 +6523,7 @@ export default { tabGroup: "Group by", tabGroupNone: "No grouping", tabGroupDatabaseType: "Group by database type", + tabGroupDatabase: "Group by database", tabGroupConnection: "Group by connection", tabSort: "Sort by", tabSortManual: "Manual order", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index af043533c0..c7e3216f6d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6190,6 +6190,7 @@ export default withEnglishFallback({ tabGroup: "Agrupar por", tabGroupNone: "Sin agrupación", tabGroupDatabaseType: "Agrupar por tipo de base de datos", + tabGroupDatabase: "Agrupar por base de datos", tabGroupConnection: "Agrupar por conexión", tabSort: "Ordenar por", tabSortManual: "Orden manual", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d87004df10..9f3136de5f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6190,6 +6190,7 @@ export default withEnglishFallback({ tabGroup: "Raggruppa per", tabGroupNone: "Nessun raggruppamento", tabGroupDatabaseType: "Raggruppa per tipo di database", + tabGroupDatabase: "Raggruppa per database", tabGroupConnection: "Raggruppa per connessione", tabSort: "Ordina per", tabSortManual: "Ordine manuale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d289e27b4f..92e080f2b3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6228,6 +6228,7 @@ export default withEnglishFallback({ tabGroup: "グループ化", tabGroupNone: "グループ化しない", tabGroupDatabaseType: "データベース種別でグループ化", + tabGroupDatabase: "データベースごとにグループ化", tabGroupConnection: "接続ごとにグループ化", tabSort: "並べ替え", tabSortManual: "手動順", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3cc423783a..e389dad264 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5925,6 +5925,7 @@ export default withEnglishFallback({ tabGroup: "그룹 기준", tabGroupNone: "그룹화 안 함", tabGroupDatabaseType: "데이터베이스 유형별 그룹화", + tabGroupDatabase: "데이터베이스별 그룹화", tabGroupConnection: "연결별 그룹화", tabSort: "정렬 기준", tabSortManual: "수동 순서", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 493d7a59d8..bf4fd1254b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6192,6 +6192,7 @@ export default withEnglishFallback({ tabGroup: "Agrupar por", tabGroupNone: "Sem agrupamento", tabGroupDatabaseType: "Agrupar por tipo de banco de dados", + tabGroupDatabase: "Agrupar por banco de dados", tabGroupConnection: "Agrupar por conexão", tabSort: "Ordenar por", tabSortManual: "Ordem manual", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index c02a268dd7..3cf9b207d9 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6513,6 +6513,7 @@ export default withEnglishFallback({ tabGroup: "Gruplama ölçütü", tabGroupNone: "Gruplama yok", tabGroupDatabaseType: "Veritabanı türüne göre grupla", + tabGroupDatabase: "Veritabanına göre grupla", tabGroupConnection: "Bağlantıya göre grupla", tabSort: "Sıralama ölçütü", tabSortManual: "Elle sıralama", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3054483e7d..bcf2ca5c49 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6506,6 +6506,7 @@ export default withEnglishFallback({ tabGroup: "分组方式", tabGroupNone: "不分组", tabGroupDatabaseType: "按数据库类型分组", + tabGroupDatabase: "按数据库分组", tabGroupConnection: "按连接分组", tabSort: "排序方式", tabSortManual: "手动排序", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e4ff520925..15219cf8df 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5509,6 +5509,7 @@ export default withEnglishFallback({ tabGroup: "分組方式", tabGroupNone: "不分組", tabGroupDatabaseType: "依資料庫類型分組", + tabGroupDatabase: "依資料庫分組", tabGroupConnection: "依連線分組", tabSort: "排序方式", tabSortManual: "手動排序", diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index e1402416aa..f2939588e8 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -574,7 +574,7 @@ const TAB_LAYOUT_MODES = ["scroll", "wrap"] as const; export type TabLayoutMode = (typeof TAB_LAYOUT_MODES)[number]; const TAB_PLACEMENTS = ["top", "bottom", "left", "right"] as const; export type TabPlacement = (typeof TAB_PLACEMENTS)[number]; -const TAB_GROUP_MODES = ["none", "database-type", "connection"] as const; +const TAB_GROUP_MODES = ["none", "database-type", "database", "connection"] as const; export type TabGroupMode = (typeof TAB_GROUP_MODES)[number]; export interface TabGroupCustomization { name?: string;