feat(editor): group editor tabs by database

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-05 10:01:54 +08:00
1 parent b5fcb3a832
commit 116aced217
14 files changed
+127 -22

No files matched your search

@@ -5467,6 +5467,7 @@ onUnmounted(() => {
<SelectContent> <SelectContent>
<SelectItem value="none">{{ t("settings.tabGroupNone") }}</SelectItem> <SelectItem value="none">{{ t("settings.tabGroupNone") }}</SelectItem>
<SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem> <SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem>
<SelectItem value="database">{{ t("settings.tabGroupDatabase") }}</SelectItem>
<SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem> <SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -221,6 +221,7 @@ watch(
const tabGroupItems = computed(() => [ const tabGroupItems = computed(() => [
{ value: "none", label: t("settings.tabGroupNone") }, { value: "none", label: t("settings.tabGroupNone") },
{ value: "database-type", label: t("settings.tabGroupDatabaseType") }, { value: "database-type", label: t("settings.tabGroupDatabaseType") },
{ value: "database", label: t("settings.tabGroupDatabase") },
{ value: "connection", label: t("settings.tabGroupConnection") }, { value: "connection", label: t("settings.tabGroupConnection") },
]); ]);
const tabSortItems = computed(() => [ const tabSortItems = computed(() => [
@@ -246,7 +247,7 @@ async function persistTabPreferences(partial: TabPreferencePatch) {
} }
function updateTabGroupMode(value: string) { 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) { 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 }); 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) { function tabGroupKey(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId); const connection = connectionStore.getConfig(tab.connectionId);
if (settingsStore.editorSettings.tabGroupMode === "connection") return 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"; 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) { function tabTitleText(tab: QueryTab) {
return tabDisplayTitle(tab, t); 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<string, Map<string, { connectionLabel: string; connectionTargetLabel: string }>>();
for (const tab of props.tabs) {
const label = databaseTabGroupBaseLabel(tab);
const identities = identitiesByLabel.get(label) ?? new Map<string, { connectionLabel: string; connectionTargetLabel: string }>();
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, * 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 * 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 })) .map((tab, index) => ({ tab, index }))
.sort((left, right) => { .sort((left, right) => {
if (groupMode !== "none") { 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 (group) return group;
} }
if (sortMode === "manual") return left.index - right.index; if (sortMode === "manual") return left.index - right.index;
@@ -310,6 +354,17 @@ const editingTabGroupFallbackColor = ref(tabGroupPalette[0]!);
function tabGroupDefaultLabel(tab: QueryTab) { function tabGroupDefaultLabel(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId); const connection = connectionStore.getConfig(tab.connectionId);
if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || 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; 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. // Pinned and regular tabs form separate clusters even under the same key.
function tabGroupId(tab: QueryTab) { 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) { function isTabGroupCollapsed(tab: QueryTab) {
@@ -376,7 +431,6 @@ function groupColorStyle(color: string): CSSProperties {
return { return {
"--tab-group-color": color, "--tab-group-color": color,
"--tab-group-soft": hexToRgba(color, 0.12), "--tab-group-soft": hexToRgba(color, 0.12),
"--tab-group-rail": hexToRgba(color, 0.3),
} as CSSProperties; } as CSSProperties;
} }
@@ -29,7 +29,7 @@ const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
describe("EditorGroupTabBar semantic tab groups", () => { describe("EditorGroupTabBar semantic tab groups", () => {
it("keeps separate collapsed state for pinned and regular clusters under the same key", () => { it("keeps separate collapsed state for pinned and regular clusters under the same key", () => {
expect(source).toContain("const collapsedTabGroups = ref<Set<string>>(new Set());"); expect(source).toContain("const collapsedTabGroups = ref<Set<string>>(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("function toggleTabGroup(tab: QueryTab)");
expect(source).toContain(':aria-expanded="!isTabGroupCollapsed(entry.tab)"'); 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('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]');
expect(sharedStyles).toContain("inset 0 0 0 1px color-mix"); 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(".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(); 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<HTMLButtonElement>(".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 () => { it("exposes the drag-back hit-test anchor and highlights itself as the detached drop target", async () => {
const store = useQueryStore(); const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "PG 1", "query"); const tabId = store.createTab("pg-1", "app", "PG 1", "query");
@@ -351,6 +351,12 @@
.vertical-tab-layout { .vertical-tab-layout {
flex: 0 0 15rem; 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 { .vertical-tab-layout .app-tab-strip {
@@ -440,7 +446,7 @@
width: calc(100% - 1rem) !important; width: calc(100% - 1rem) !important;
height: 2rem; height: 2rem;
align-self: center; 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; padding: 0;
color: var(--foreground); color: var(--foreground);
font-size: 0.75rem; font-size: 0.75rem;
@@ -448,6 +454,11 @@
line-height: 1rem; 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 { .vertical-tab-layout .tab-group-header-content {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -474,12 +485,12 @@
content: ""; content: "";
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: 1rem; top: 50%;
bottom: -1.08rem; bottom: calc(0rem - var(--tab-group-header-gap) - var(--tab-group-row-half));
left: 0.66rem; left: calc(var(--tab-group-root-rail-x) - var(--tab-group-header-inset));
width: 0.7rem; width: calc(var(--tab-group-tab-inset) - var(--tab-group-root-rail-x));
border-bottom: 1px solid var(--tab-group-rail); border-bottom: 1px solid var(--tab-group-vertical-rail);
border-left: 1px solid var(--tab-group-rail); border-left: 1px solid var(--tab-group-vertical-rail);
border-bottom-left-radius: 0.1875rem; border-bottom-left-radius: 0.1875rem;
pointer-events: none; pointer-events: none;
} }
@@ -492,7 +503,7 @@
.vertical-tab-layout .tab-group-tab { .vertical-tab-layout .tab-group-tab {
position: relative; position: relative;
width: calc(100% - 3rem); 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; padding-inline-start: 0.65rem !important;
border-color: transparent; border-color: transparent;
} }
@@ -500,12 +511,12 @@
.vertical-tab-layout .tab-group-tab::before { .vertical-tab-layout .tab-group-tab::before {
content: ""; content: "";
position: absolute; position: absolute;
top: -0.25rem; top: 0;
bottom: -0.25rem; bottom: 0;
left: -0.65rem; left: calc(0rem - var(--tab-group-branch-width));
width: 1px; width: 1px;
border-radius: 9999px; border-radius: 9999px;
background: var(--tab-group-rail); background: var(--tab-group-vertical-rail);
pointer-events: none; pointer-events: none;
} }
@@ -522,10 +533,14 @@
content: ""; content: "";
position: absolute; position: absolute;
top: calc(50% - 0.5px); top: calc(50% - 0.5px);
left: -0.65rem; left: calc(0rem - var(--tab-group-branch-width));
width: 0.65rem; width: var(--tab-group-branch-width);
height: 1px; height: 1px;
border-radius: 0 9999px 9999px 0; border-radius: 0 9999px 9999px 0;
background: var(--tab-group-rail); background: var(--tab-group-vertical-rail);
pointer-events: none; pointer-events: none;
} }
.vertical-tab-layout .tab-group-tab--first::after {
display: none;
}
+1
View File
@@ -6523,6 +6523,7 @@ export default {
tabGroup: "Group by", tabGroup: "Group by",
tabGroupNone: "No grouping", tabGroupNone: "No grouping",
tabGroupDatabaseType: "Group by database type", tabGroupDatabaseType: "Group by database type",
tabGroupDatabase: "Group by database",
tabGroupConnection: "Group by connection", tabGroupConnection: "Group by connection",
tabSort: "Sort by", tabSort: "Sort by",
tabSortManual: "Manual order", tabSortManual: "Manual order",
+1
View File
@@ -6190,6 +6190,7 @@ export default withEnglishFallback({
tabGroup: "Agrupar por", tabGroup: "Agrupar por",
tabGroupNone: "Sin agrupación", tabGroupNone: "Sin agrupación",
tabGroupDatabaseType: "Agrupar por tipo de base de datos", tabGroupDatabaseType: "Agrupar por tipo de base de datos",
tabGroupDatabase: "Agrupar por base de datos",
tabGroupConnection: "Agrupar por conexión", tabGroupConnection: "Agrupar por conexión",
tabSort: "Ordenar por", tabSort: "Ordenar por",
tabSortManual: "Orden manual", tabSortManual: "Orden manual",
+1
View File
@@ -6190,6 +6190,7 @@ export default withEnglishFallback({
tabGroup: "Raggruppa per", tabGroup: "Raggruppa per",
tabGroupNone: "Nessun raggruppamento", tabGroupNone: "Nessun raggruppamento",
tabGroupDatabaseType: "Raggruppa per tipo di database", tabGroupDatabaseType: "Raggruppa per tipo di database",
tabGroupDatabase: "Raggruppa per database",
tabGroupConnection: "Raggruppa per connessione", tabGroupConnection: "Raggruppa per connessione",
tabSort: "Ordina per", tabSort: "Ordina per",
tabSortManual: "Ordine manuale", tabSortManual: "Ordine manuale",
+1
View File
@@ -6228,6 +6228,7 @@ export default withEnglishFallback({
tabGroup: "グループ化", tabGroup: "グループ化",
tabGroupNone: "グループ化しない", tabGroupNone: "グループ化しない",
tabGroupDatabaseType: "データベース種別でグループ化", tabGroupDatabaseType: "データベース種別でグループ化",
tabGroupDatabase: "データベースごとにグループ化",
tabGroupConnection: "接続ごとにグループ化", tabGroupConnection: "接続ごとにグループ化",
tabSort: "並べ替え", tabSort: "並べ替え",
tabSortManual: "手動順", tabSortManual: "手動順",
+1
View File
@@ -5925,6 +5925,7 @@ export default withEnglishFallback({
tabGroup: "그룹 기준", tabGroup: "그룹 기준",
tabGroupNone: "그룹화 안 함", tabGroupNone: "그룹화 안 함",
tabGroupDatabaseType: "데이터베이스 유형별 그룹화", tabGroupDatabaseType: "데이터베이스 유형별 그룹화",
tabGroupDatabase: "데이터베이스별 그룹화",
tabGroupConnection: "연결별 그룹화", tabGroupConnection: "연결별 그룹화",
tabSort: "정렬 기준", tabSort: "정렬 기준",
tabSortManual: "수동 순서", tabSortManual: "수동 순서",
+1
View File
@@ -6192,6 +6192,7 @@ export default withEnglishFallback({
tabGroup: "Agrupar por", tabGroup: "Agrupar por",
tabGroupNone: "Sem agrupamento", tabGroupNone: "Sem agrupamento",
tabGroupDatabaseType: "Agrupar por tipo de banco de dados", tabGroupDatabaseType: "Agrupar por tipo de banco de dados",
tabGroupDatabase: "Agrupar por banco de dados",
tabGroupConnection: "Agrupar por conexão", tabGroupConnection: "Agrupar por conexão",
tabSort: "Ordenar por", tabSort: "Ordenar por",
tabSortManual: "Ordem manual", tabSortManual: "Ordem manual",
+1
View File
@@ -6513,6 +6513,7 @@ export default withEnglishFallback({
tabGroup: "Gruplama ölçütü", tabGroup: "Gruplama ölçütü",
tabGroupNone: "Gruplama yok", tabGroupNone: "Gruplama yok",
tabGroupDatabaseType: "Veritabanı türüne göre grupla", tabGroupDatabaseType: "Veritabanı türüne göre grupla",
tabGroupDatabase: "Veritabanına göre grupla",
tabGroupConnection: "Bağlantıya göre grupla", tabGroupConnection: "Bağlantıya göre grupla",
tabSort: "Sıralama ölçütü", tabSort: "Sıralama ölçütü",
tabSortManual: "Elle sıralama", tabSortManual: "Elle sıralama",
+1
View File
@@ -6506,6 +6506,7 @@ export default withEnglishFallback({
tabGroup: "分组方式", tabGroup: "分组方式",
tabGroupNone: "不分组", tabGroupNone: "不分组",
tabGroupDatabaseType: "按数据库类型分组", tabGroupDatabaseType: "按数据库类型分组",
tabGroupDatabase: "按数据库分组",
tabGroupConnection: "按连接分组", tabGroupConnection: "按连接分组",
tabSort: "排序方式", tabSort: "排序方式",
tabSortManual: "手动排序", tabSortManual: "手动排序",
+1
View File
@@ -5509,6 +5509,7 @@ export default withEnglishFallback({
tabGroup: "分組方式", tabGroup: "分組方式",
tabGroupNone: "不分組", tabGroupNone: "不分組",
tabGroupDatabaseType: "依資料庫類型分組", tabGroupDatabaseType: "依資料庫類型分組",
tabGroupDatabase: "依資料庫分組",
tabGroupConnection: "依連線分組", tabGroupConnection: "依連線分組",
tabSort: "排序方式", tabSort: "排序方式",
tabSortManual: "手動排序", tabSortManual: "手動排序",
+1 -1
View File
@@ -574,7 +574,7 @@ const TAB_LAYOUT_MODES = ["scroll", "wrap"] as const;
export type TabLayoutMode = (typeof TAB_LAYOUT_MODES)[number]; export type TabLayoutMode = (typeof TAB_LAYOUT_MODES)[number];
const TAB_PLACEMENTS = ["top", "bottom", "left", "right"] as const; const TAB_PLACEMENTS = ["top", "bottom", "left", "right"] as const;
export type TabPlacement = (typeof TAB_PLACEMENTS)[number]; 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 type TabGroupMode = (typeof TAB_GROUP_MODES)[number];
export interface TabGroupCustomization { export interface TabGroupCustomization {
name?: string; name?: string;