feat(editor): group editor tabs by database
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user