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>
<SelectItem value="none">{{ t("settings.tabGroupNone") }}</SelectItem>
<SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem>
<SelectItem value="database">{{ t("settings.tabGroupDatabase") }}</SelectItem>
<SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem>
</SelectContent>
</Select>
@@ -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<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,
* 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;
}
@@ -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<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(':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<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 () => {
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "PG 1", "query");
@@ -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;
}