props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
'tab-group-tab--last': entry.grouping && entry.groupLast,
},
]"
- :style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyle(entry.tab) : undefined, tabDropStyle(entry.tab)]"
+ :style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyleForTabEntry(entry) : undefined, tabDropStyle(entry.tab), entry.grouping ? tabGroupTabDepthStyle(entry) : undefined]"
:data-active-tab="isTabActive(entry.tab)"
:data-tab-id="entry.tab.id"
@pointerdown="handleTabPointerDown($event, entry.tab)"
diff --git a/apps/desktop/src/components/layout/TabGroupTreeGuides.vue b/apps/desktop/src/components/layout/TabGroupTreeGuides.vue
new file mode 100644
index 0000000000..063aaa9870
--- /dev/null
+++ b/apps/desktop/src/components/layout/TabGroupTreeGuides.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
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 137cacc798..60167ddbac 100644
--- a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts
@@ -75,6 +75,8 @@ function mountBar(groupId: string, tabs: ReturnType {
host.remove();
});
+ it("renders connection-to-database-to-tab guide segments for multiple databases", async () => {
+ const store = useQueryStore();
+ const settings = useSettingsStore();
+ settings.editorSettings.tabGroupMode = "sidebar";
+ settings.editorSettings.tabPlacement = "left";
+ store.createTab("mysql-1", "app", "App 1", "query");
+ store.createTab("mysql-1", "app", "App 2", "query");
+ store.createTab("mysql-1", "audit", "Audit", "query");
+ const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), store.activeTabId, pinia);
+ await settle();
+
+ expect(host.querySelectorAll(".tab-group-header")).toHaveLength(3);
+ expect(host.querySelectorAll("[data-tree-level='connection']")).toHaveLength(1);
+ expect(host.querySelectorAll("[data-tree-level='database']")).toHaveLength(2);
+ expect(host.querySelector("[data-tree-level='connection'] .tab-group-connection-icon")).not.toBeNull();
+ expect(host.querySelector("[data-tree-level='database'] .tab-group-library-icon")).not.toBeNull();
+ expect(host.querySelectorAll(".tab-tree-guide--through")).toHaveLength(2);
+ expect(host.querySelectorAll(".tab-tree-guide--branch")).toHaveLength(2);
+ expect(host.querySelectorAll(".tab-tree-guide--last-branch")).toHaveLength(3);
+
+ const databaseHeader = host.querySelector("[data-tree-level='database']")!;
+ databaseHeader.click();
+ await settle();
+ expect(databaseHeader.querySelector(".tab-tree-guide--start")).toBeNull();
+
+ app.unmount();
+ host.remove();
+ });
+
+ it("renders standalone plugin workbenches outside the connection hierarchy", async () => {
+ const store = useQueryStore();
+ const settings = useSettingsStore();
+ settings.editorSettings.tabGroupMode = "sidebar";
+ settings.editorSettings.tabPlacement = "left";
+ const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server" });
+ const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), pluginTabId, pinia);
+ await settle();
+
+ expect(host.querySelectorAll(".tab-group-header")).toHaveLength(0);
+ expect(host.querySelector(`[data-tab-id="${pluginTabId}"]`)?.textContent).toContain("SSH server");
+
+ app.unmount();
+ host.remove();
+ });
+
+ it("uses plugin-aware icons for sidebar connection headers", async () => {
+ const connectionStore = useConnectionStore();
+ connectionStore.connections = [
+ {
+ id: "ssh-1",
+ name: "SSH",
+ db_type: "plugin",
+ driver_profile: "plugin",
+ host: "127.0.0.1",
+ port: 22,
+ color: "",
+ plugin_id: "io.dbx.ssh",
+ plugin_connection_provider: "io.dbx.ssh.connection",
+ } as ConnectionConfig,
+ ];
+ const store = useQueryStore();
+ const settings = useSettingsStore();
+ settings.editorSettings.tabGroupMode = "sidebar";
+ settings.editorSettings.tabPlacement = "left";
+ const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1" });
+ const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), pluginTabId, pinia);
+ for (let index = 0; index < 20 && !host.querySelector("[data-tree-level='connection'] img"); index += 1) {
+ await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
+ }
+
+ expect(host.querySelector("[data-tree-level='connection'] img")).not.toBeNull();
+
+ app.unmount();
+ host.remove();
+ });
+
+ it("labels Redis database branches with their indexes and aliases", async () => {
+ const connectionStore = useConnectionStore();
+ connectionStore.connections = [
+ {
+ id: "redis-1",
+ name: "Redis Cache",
+ db_type: "redis",
+ driver_profile: "redis",
+ host: "127.0.0.1",
+ port: 6379,
+ color: "",
+ redis_database_aliases: { "1": "Sessions" },
+ } as ConnectionConfig,
+ ];
+ const store = useQueryStore();
+ const settings = useSettingsStore();
+ settings.editorSettings.tabGroupMode = "sidebar";
+ settings.editorSettings.tabPlacement = "left";
+ const db0 = store.createTab("redis-1", "0", "Redis 0", "redis");
+ store.createTab("redis-1", "1", "Redis 1", "redis");
+ const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), db0, pinia);
+ await settle();
+
+ const headers = Array.from(host.querySelectorAll(".tab-group-header"));
+ expect(headers.map((header) => header.title)).toEqual(["Redis Cache", "db0", "db1 · Sessions"]);
+
+ app.unmount();
+ host.remove();
+ });
+
+ it("collapses every sidebar connection and database branch from a header menu", async () => {
+ const store = useQueryStore();
+ const settings = useSettingsStore();
+ settings.editorSettings.tabGroupMode = "sidebar";
+ settings.editorSettings.tabPlacement = "left";
+ store.createTab("mysql-1", "app", "App", "query");
+ store.createTab("mysql-1", "audit", "Audit", "query");
+ store.createTab("pg-1", "app", "Postgres", "query");
+ const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), store.activeTabId, pinia);
+ await settle();
+
+ host.querySelector(".tab-group-header")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
+ await settle();
+ const menu = document.body.querySelector("[data-dbx-context-menu]")!;
+ const collapseAll = Array.from(menu.querySelectorAll("button")).find((button) => button.textContent?.includes("Collapse all"));
+ expect(collapseAll).toBeDefined();
+ collapseAll!.click();
+ await settle();
+
+ expect(Array.from(host.querySelectorAll(".tab-group-header"), (header) => header.getAttribute("aria-expanded"))).toEqual(["false", "false", "false", "false", "false"]);
+ expect(host.querySelectorAll(".tab-group-entry--collapsed")).toHaveLength(3);
+
+ app.unmount();
+ host.remove();
+ });
+
it("sizes the header chevron and rotates it only while the cluster is collapsed", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
diff --git a/apps/desktop/src/components/layout/appTabBar.css b/apps/desktop/src/components/layout/appTabBar.css
index 9e9069f8f2..67468b1c08 100644
--- a/apps/desktop/src/components/layout/appTabBar.css
+++ b/apps/desktop/src/components/layout/appTabBar.css
@@ -460,6 +460,10 @@
box-shadow 150ms ease;
}
+.tab-group-header--hidden {
+ display: none;
+}
+
.tab-group-header:focus-visible {
box-shadow: 0 0 0 2px var(--ring);
}
@@ -477,6 +481,10 @@
transform 120ms ease;
}
+.tab-group-entry .tab-group-tab {
+ margin-inline-start: var(--tab-group-depth-offset, 0rem);
+}
+
.tab-group-marker {
width: 0.22rem;
height: 1rem;
@@ -1177,6 +1185,10 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
padding-inline: 0.55rem;
}
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header-content {
+ padding-inline-start: calc(0.55rem + var(--tab-group-depth-offset, 0rem));
+}
+
.vertical-tab-layout .tab-group-header:hover .tab-group-header-content,
.vertical-tab-layout .tab-group-header--active .tab-group-header-content {
background: var(--tab-group-soft);
@@ -1200,6 +1212,83 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
pointer-events: none;
}
+/* Sidebar mode uses row-local guide segments. Keeping the guides on each
+ * rendered row makes sibling databases connect correctly and naturally
+ * shortens the rail when a group is collapsed. */
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header::after,
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-tab::before,
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-tab::after {
+ display: none;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header,
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-entry {
+ position: relative;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header {
+ --tab-tree-guide-before: -0.35rem;
+ --tab-tree-guide-after: calc(0rem - var(--tab-group-header-gap));
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guides {
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ pointer-events: none;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ /* Keep the guide's center aligned with the marker and its branches. */
+ left: calc(var(--tab-tree-guide-base-x) + var(--tab-tree-guide-depth) * 0.75rem - 0.5px);
+ width: 1px;
+ color: var(--tab-tree-parent-color);
+ opacity: 0.35;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide[data-depth="1"] {
+ color: var(--tab-tree-child-color);
+}
+
+/* Every non-final sibling owns a full-height rail and a centered branch.
+ * Only the final sibling ends the rail at its center. Header rails also own
+ * their margins, so collapsed subtrees leave no gaps between database rows. */
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide::before {
+ content: "";
+ position: absolute;
+ top: var(--tab-tree-guide-before, 0);
+ bottom: var(--tab-tree-guide-after, 0);
+ left: 0;
+ width: 1px;
+ background: currentColor;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--start::before {
+ top: 50%;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--last-branch::before {
+ bottom: 50%;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--branch::after,
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--last-branch::after {
+ content: "";
+ position: absolute;
+ top: calc(50% - 0.5px);
+ left: 0.5px;
+ width: calc(var(--tab-tree-guide-child-x) - (var(--tab-tree-guide-base-x) + var(--tab-tree-guide-depth) * 0.75rem));
+ height: 1px;
+ background: currentColor;
+}
+
+.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-entry--collapsed .tab-tree-guides {
+ display: none;
+}
+
.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed) .tab-group-marker {
position: relative;
z-index: 2;
@@ -1207,8 +1296,8 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
.vertical-tab-layout .tab-group-tab {
position: relative;
- width: calc(100% - var(--tab-group-tab-inset) - 0.25rem);
- margin-inline: var(--tab-group-tab-inset) 0.25rem;
+ width: calc(100% - var(--tab-group-tab-inset) - var(--tab-group-vertical-depth-offset, 0rem) - 0.25rem);
+ margin-inline: calc(var(--tab-group-tab-inset) + var(--tab-group-vertical-depth-offset, 0rem)) 0.25rem;
padding-inline: 0.2rem 0.25rem !important;
border-color: transparent;
}
diff --git a/apps/desktop/src/components/ui/LightDropdown.vue b/apps/desktop/src/components/ui/LightDropdown.vue
index ba00f17dfe..2adb920290 100644
--- a/apps/desktop/src/components/ui/LightDropdown.vue
+++ b/apps/desktop/src/components/ui/LightDropdown.vue
@@ -41,6 +41,7 @@ const props = withDefaults(
closeOnSelect?: boolean;
label?: string;
matchTriggerWidth?: boolean;
+ maxHeight?: string;
}>(),
{
ariaLabel: undefined,
@@ -65,6 +66,7 @@ const props = withDefaults(
closeOnSelect: true,
label: undefined,
matchTriggerWidth: true,
+ maxHeight: "min(420px, calc(100vh - 16px))",
},
);
@@ -85,7 +87,7 @@ const menuStyle = computed(() => ({
left: `${x.value}px`,
top: `${y.value}px`,
minWidth: props.matchTriggerWidth ? `${minWidth.value}px` : undefined,
- maxHeight: "min(420px, calc(100vh - 16px))",
+ maxHeight: props.maxHeight,
}));
function onScroll(event: Event) {
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index 179d1b1cc5..6a653cbe7a 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -7081,6 +7081,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Verilənlər bazasının növünə görə qruplaşdır",
tabGroupDatabase: "Verilənlər bazasına görə qruplaşdır",
tabGroupConnection: "Əlaqəyə görə qruplaşdır",
+ tabGroupSidebar: "Əlaqə, verilənlər bazası və cədvələ görə qruplaşdır",
tabSort: "Sıralama meyarı",
tabSortManual: "Əl ilə sıralama",
tabSortCreated: "Yaradılma vaxtı",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 5124fd1ff7..dda622bf6a 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -7934,6 +7934,7 @@ export default {
tabGroupDatabaseType: "Group by database type",
tabGroupDatabase: "Group by database",
tabGroupConnection: "Group by connection",
+ tabGroupSidebar: "Group by connection, database and table",
tabSort: "Sort by",
tabSortManual: "Manual order",
tabSortCreated: "Creation time",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 284778c3ae..713aeb2361 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -7271,6 +7271,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Agrupar por tipo de base de datos",
tabGroupDatabase: "Agrupar por base de datos",
tabGroupConnection: "Agrupar por conexión",
+ tabGroupSidebar: "Agrupar por conexión, base de datos y tabla",
tabSort: "Ordenar por",
tabSortManual: "Orden manual",
tabSortCreated: "Hora de creación",
diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts
index 26d5a48aef..bda6facc1e 100644
--- a/apps/desktop/src/i18n/locales/id.ts
+++ b/apps/desktop/src/i18n/locales/id.ts
@@ -7745,6 +7745,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Kelompokkan berdasarkan jenis database",
tabGroupDatabase: "Kelompokkan berdasarkan database",
tabGroupConnection: "Kelompokkan berdasarkan koneksi",
+ tabGroupSidebar: "Kelompokkan berdasarkan koneksi, database, dan tabel",
tabSort: "Urutkan berdasarkan",
tabSortManual: "Urutan manual",
tabSortCreated: "Waktu pembuatan",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 6d090ae877..cbb8c3508b 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -7049,6 +7049,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Raggruppa per tipo di database",
tabGroupDatabase: "Raggruppa per database",
tabGroupConnection: "Raggruppa per connessione",
+ tabGroupSidebar: "Raggruppa per connessione, database e tabella",
tabSort: "Ordina per",
tabSortManual: "Ordine manuale",
tabSortCreated: "Data di creazione",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index b06f63e2dc..fa8bf2d9ce 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -7080,6 +7080,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "データベース種別でグループ化",
tabGroupDatabase: "データベースごとにグループ化",
tabGroupConnection: "接続ごとにグループ化",
+ tabGroupSidebar: "接続、データベース、テーブルでグループ化",
tabSort: "並べ替え",
tabSortManual: "手動順",
tabSortCreated: "作成日時",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 5700e715a7..4587499541 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -6855,6 +6855,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "데이터베이스 유형별 그룹화",
tabGroupDatabase: "데이터베이스별 그룹화",
tabGroupConnection: "연결별 그룹화",
+ tabGroupSidebar: "연결, 데이터베이스 및 테이블별 그룹화",
tabSort: "정렬 기준",
tabSortManual: "수동 순서",
tabSortCreated: "생성 시간",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index d2e4d05d58..8e9603da8a 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -7051,6 +7051,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Agrupar por tipo de banco de dados",
tabGroupDatabase: "Agrupar por banco de dados",
tabGroupConnection: "Agrupar por conexão",
+ tabGroupSidebar: "Agrupar por conexão, banco de dados e tabela",
tabSort: "Ordenar por",
tabSortManual: "Ordem manual",
tabSortCreated: "Hora de criação",
diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts
index 474455ab64..2cccf677ff 100644
--- a/apps/desktop/src/i18n/locales/ru.ts
+++ b/apps/desktop/src/i18n/locales/ru.ts
@@ -8264,6 +8264,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Группировать по типу базы данных",
tabGroupDatabase: "Группировать по базе данных",
tabGroupConnection: "Группировать по подключению",
+ tabGroupSidebar: "Группировать по подключению, базе данных и таблице",
tabSort: "Сортировать по",
tabSortManual: "Ручной порядок",
tabSortCreated: "Время создания",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index fa7b29ba4e..aaef6f7f49 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -6963,6 +6963,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Veritabanı türüne göre grupla",
tabGroupDatabase: "Veritabanına göre grupla",
tabGroupConnection: "Bağlantıya göre grupla",
+ tabGroupSidebar: "Bağlantı, veritabanı ve tabloya göre grupla",
tabSort: "Sıralama ölçütü",
tabSortManual: "Elle sıralama",
tabSortCreated: "Oluşturulma zamanı",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index eeab10f2a4..d92d0f49d3 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -7920,6 +7920,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "按数据库类型分组",
tabGroupDatabase: "按数据库分组",
tabGroupConnection: "按连接分组",
+ tabGroupSidebar: "按连接、库和表分组",
tabSort: "排序方式",
tabSortManual: "手动排序",
tabSortCreated: "按创建时间",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 3ac0859242..b18783439a 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -6403,6 +6403,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "依資料庫類型分組",
tabGroupDatabase: "依資料庫分組",
tabGroupConnection: "依連線分組",
+ tabGroupSidebar: "依連線、資料庫與資料表分組",
tabSort: "排序方式",
tabSortManual: "手動排序",
tabSortCreated: "依建立時間",
diff --git a/apps/desktop/src/lib/tabs/__tests__/tabGroupTree.spec.ts b/apps/desktop/src/lib/tabs/__tests__/tabGroupTree.spec.ts
new file mode 100644
index 0000000000..16670289e6
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/__tests__/tabGroupTree.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { buildConnectionDatabaseTabGroupStripEntries } from "@/lib/tabs/tabGroupTree";
+import type { QueryTab } from "@/types/database";
+
+function tab(id: string, connectionId: string, database: string, catalog?: string): QueryTab {
+ return { id, connectionId, database, catalog, title: id } as QueryTab;
+}
+
+describe("buildConnectionDatabaseTabGroupStripEntries", () => {
+ it("builds connection, database and tab levels without sidebar folders", () => {
+ const entries = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app"), tab("orders", "mysql", "app"), tab("audit", "mysql", "logs"), tab("users", "pg", "app")], false);
+
+ expect(entries.map((entry) => (entry.kind === "header" ? `${entry.level}:${entry.label}` : `tab:${entry.tab.id}`))).toEqual(["connection:mysql", "database:app", "tab:users", "tab:orders", "database:logs", "tab:audit", "connection:pg", "database:app", "tab:users"]);
+ expect(entries.filter((entry) => entry.kind === "header").every((entry) => entry.level === "connection" || entry.level === "database")).toBe(true);
+ const mysqlConnection = entries[0];
+ expect(mysqlConnection?.kind === "header" && mysqlConnection.treeGuides).toEqual([{ depth: 0, kind: "start" }]);
+ const firstDatabase = entries[1];
+ expect(firstDatabase?.kind === "header" && firstDatabase.treeGuides).toEqual([
+ { depth: 0, kind: "branch" },
+ { depth: 1, kind: "start" },
+ ]);
+ const firstDatabaseLastTab = entries[3];
+ expect(firstDatabaseLastTab?.kind === "tab" && firstDatabaseLastTab.treeGuides).toEqual([
+ { depth: 0, kind: "through" },
+ { depth: 1, kind: "last-branch" },
+ ]);
+ const postgresConnection = entries[6];
+ expect(postgresConnection?.kind === "header" && postgresConnection.treeGuides).toEqual([{ depth: 0, kind: "start" }]);
+ });
+
+ it("keeps pinned and regular hierarchy IDs separate", () => {
+ const regular = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app")], false);
+ const pinned = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app")], true);
+
+ expect(regular[0]?.groupId).not.toBe(pinned[0]?.groupId);
+ expect(regular[1]?.groupId).not.toBe(pinned[1]?.groupId);
+ });
+});
diff --git a/apps/desktop/src/lib/tabs/tabGroupTree.ts b/apps/desktop/src/lib/tabs/tabGroupTree.ts
new file mode 100644
index 0000000000..2ce9d15a45
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/tabGroupTree.ts
@@ -0,0 +1,146 @@
+import type { QueryTab } from "@/types/database";
+
+export type TabHierarchyLevel = "connection" | "database";
+
+export interface TabGroupTreeGuide {
+ depth: number;
+ kind: "start" | "through" | "branch" | "last-branch";
+}
+
+export interface ConnectionDatabaseTabGroupHeaderEntry {
+ kind: "header";
+ key: string;
+ groupId: string;
+ label: string;
+ level: TabHierarchyLevel;
+ depth: number;
+ count: number;
+ pinned: boolean;
+ tab: QueryTab;
+ tabIds: string[];
+ ancestorIds: string[];
+ treeGuides: TabGroupTreeGuide[];
+}
+
+export interface ConnectionDatabaseTabGroupTabEntry {
+ kind: "tab";
+ key: string;
+ groupId: string;
+ tab: QueryTab;
+ depth: number;
+ groupFirst: boolean;
+ groupLast: boolean;
+ grouping: true;
+ ancestorIds: string[];
+ treeGuides: TabGroupTreeGuide[];
+}
+
+export type ConnectionDatabaseTabGroupStripEntry = ConnectionDatabaseTabGroupHeaderEntry | ConnectionDatabaseTabGroupTabEntry;
+
+const CONNECTION_GROUP_ID_PREFIX = "sidebar-connection:";
+const DATABASE_GROUP_ID_PREFIX = "sidebar-database:";
+
+function sectionPrefix(pinned: boolean): string {
+ return pinned ? "fixed:" : "regular:";
+}
+
+function connectionGroupId(connectionId: string, pinned: boolean): string {
+ return `${sectionPrefix(pinned)}${CONNECTION_GROUP_ID_PREFIX}${connectionId}`;
+}
+
+function databaseGroupKey(tab: QueryTab): string {
+ return JSON.stringify([tab.connectionId, tab.catalog || "", tab.database || ""]);
+}
+
+function databaseGroupId(tab: QueryTab, pinned: boolean): string {
+ return `${sectionPrefix(pinned)}${DATABASE_GROUP_ID_PREFIX}${databaseGroupKey(tab)}`;
+}
+
+/**
+ * Projects the current pane into a stable three-level hierarchy:
+ * connection -> database -> table/query tab.
+ *
+ * The sidebar's user-created folders are deliberately ignored. Tabs without
+ * a database still receive a database bucket so every tab follows the same
+ * shape and remains reachable through the connection branch.
+ */
+export function buildConnectionDatabaseTabGroupStripEntries(tabs: QueryTab[], pinned: boolean): ConnectionDatabaseTabGroupStripEntry[] {
+ if (tabs.length === 0) return [];
+
+ const tabsByConnection = new Map();
+ for (const tab of tabs) {
+ const bucket = tabsByConnection.get(tab.connectionId) ?? [];
+ bucket.push(tab);
+ tabsByConnection.set(tab.connectionId, bucket);
+ }
+
+ const entries: ConnectionDatabaseTabGroupStripEntry[] = [];
+ for (const [connectionId, connectionTabs] of tabsByConnection) {
+ const connectionIdValue = connectionGroupId(connectionId, pinned);
+ const databaseBuckets = new Map();
+ for (const tab of connectionTabs) {
+ const key = databaseGroupKey(tab);
+ const bucket = databaseBuckets.get(key) ?? [];
+ bucket.push(tab);
+ databaseBuckets.set(key, bucket);
+ }
+ const databaseEntries = [...databaseBuckets.values()];
+ entries.push({
+ kind: "header",
+ key: `header:${connectionIdValue}`,
+ groupId: connectionIdValue,
+ label: connectionId,
+ level: "connection",
+ depth: 0,
+ count: connectionTabs.length,
+ pinned,
+ tab: connectionTabs[0]!,
+ tabIds: connectionTabs.map((tab) => tab.id),
+ ancestorIds: [],
+ treeGuides: [{ depth: 0, kind: "start" }],
+ });
+
+ databaseEntries.forEach((databaseTabs, databaseIndex) => {
+ const lastDatabase = databaseIndex === databaseEntries.length - 1;
+ const representativeTab = databaseTabs[0]!;
+ const databaseId = databaseGroupId(representativeTab, pinned);
+ entries.push({
+ kind: "header",
+ key: `header:${databaseId}`,
+ groupId: databaseId,
+ label: representativeTab.database || representativeTab.catalog || connectionId,
+ level: "database",
+ depth: 1,
+ count: databaseTabs.length,
+ pinned,
+ tab: representativeTab,
+ tabIds: databaseTabs.map((tab) => tab.id),
+ ancestorIds: [connectionIdValue],
+ treeGuides: [
+ { depth: 0, kind: lastDatabase ? "last-branch" : "branch" },
+ { depth: 1, kind: "start" },
+ ],
+ });
+
+ databaseTabs.forEach((tab, index) => {
+ entries.push({
+ kind: "tab",
+ key: tab.id,
+ groupId: databaseId,
+ tab,
+ depth: 2,
+ groupFirst: index === 0,
+ groupLast: index === databaseTabs.length - 1,
+ grouping: true,
+ ancestorIds: [connectionIdValue, databaseId],
+ // Continue the connection rail across this database's tabs only
+ // when another database follows. Every table owns a centered branch
+ // to its own database rail; only the final table ends that rail.
+ treeGuides: [...(lastDatabase ? [] : [{ depth: 0, kind: "through" as const }]), { depth: 1, kind: index === databaseTabs.length - 1 ? "last-branch" : "branch" }],
+ });
+ });
+ });
+ }
+
+ return entries;
+}
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index 24565c05ed..3104fd5ab0 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -706,7 +706,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", "database", "connection"] as const;
+const TAB_GROUP_MODES = ["none", "database-type", "database", "connection", "sidebar"] as const;
export type TabGroupMode = (typeof TAB_GROUP_MODES)[number];
export interface TabGroupCustomization {
name?: string;