feat(tabs): polish tab groups color bars and object icons

This commit is contained in:
zipg authored and GitHub committed 2026-09-11 21:10:49 +08:00
1 parent 074216ed0f
commit 48d6c94aad
6 files changed
+299 -14

No files matched your search

@@ -38,6 +38,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue";
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
@@ -51,7 +52,7 @@ import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag";
import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import "./appTabBar.css";
import type { QueryTab } from "@/types/database";
@@ -338,6 +339,9 @@ function updateTabPlacement(value: string) {
function databaseTabGroupKey(tab: QueryTab) {
const database = tab.database || "";
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") {
return JSON.stringify([tab.connectionId, tab.catalog || "", "redis"]);
}
// A connection-level tab has no database scope, so its catalog cannot split the group.
return JSON.stringify([tab.connectionId, database ? tab.catalog || "" : "", database]);
}
@@ -371,6 +375,7 @@ function tabConnectionTargetLabel(tab: QueryTab) {
}
function databaseTabGroupBaseLabel(tab: QueryTab) {
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") return tabConnectionLabel(tab);
if (!tab.database) return tabConnectionLabel(tab);
return [tab.database, ...(tab.catalog ? [tab.catalog] : [])].join(" · ");
}
@@ -425,6 +430,7 @@ const collapsingTabGroups = ref<Set<string>>(new Set());
const tabGroupCollapseTimers = new Map<string, number>();
const TAB_GROUP_COLLAPSE_MS = 140;
const pendingTabScrollRestore = ref<{ fixed: number; regular: number } | null>(null);
const pendingExpandedTabGroupReveal = ref<string | null>(null);
const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"];
const tabGroupEditorOpen = ref(false);
const editingTabGroupKey = ref("");
@@ -493,6 +499,29 @@ function restoreTabScrollPosition(position: { fixed: number; regular: number })
if (regularTabsRowRef.value) regularTabsRowRef.value.scrollLeft = position.regular;
}
function revealExpandedTabGroupStartIfHidden(groupId: string) {
if (pendingExpandedTabGroupReveal.value !== groupId) return;
pendingExpandedTabGroupReveal.value = null;
if (isWrapLayout.value || isVerticalLayout.value) return;
const entries = Array.from(tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]") ?? []).filter((entry) => entry.dataset.tabGroupId === groupId && !entry.classList.contains("tab-group-entry--collapsed"));
const pills = entries.map((entry) => entry.querySelector<HTMLElement>(".tab-group-tab")).filter((pill): pill is HTMLElement => !!pill);
const firstPill = pills[0];
if (!firstPill) return;
const scrollContainer = hasHorizontalFixedRows.value ? firstPill.closest<HTMLElement>(".tab-section--horizontal") : tabsContainerRef.value;
if (!scrollContainer) return;
const viewport = scrollContainer.getBoundingClientRect();
const firstRect = firstPill.getBoundingClientRect();
const viewportPadding = 4;
const scrollRight = firstRect.right - (viewport.right - viewportPadding);
const scrollLeft = firstRect.left - (viewport.left + viewportPadding);
const scrollDelta = scrollRight > 0 ? scrollRight : scrollLeft < 0 ? scrollLeft : 0;
if (scrollDelta === 0) return;
scrollContainer.scrollBy({ left: scrollDelta, behavior: tabScrollBehavior.value });
}
function captureExpandedTabGroupWidths(groupIds: Set<string>) {
if (isWrapLayout.value || isVerticalLayout.value) return;
tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]").forEach((entry) => {
@@ -552,6 +581,8 @@ function handleTabGroupTransitionEnd(event: TransitionEvent) {
const entry = event.currentTarget as HTMLElement;
if (!entry.classList.contains("tab-group-entry--collapsed")) {
entry.style.removeProperty("--tab-group-entry-expanded-width");
const groupId = entry.dataset.tabGroupId;
if (groupId) revealExpandedTabGroupStartIfHidden(groupId);
}
refreshHorizontalTabOverflow();
}
@@ -564,11 +595,13 @@ function toggleTabGroup(tab: QueryTab) {
}
const next = new Set(collapsedTabGroups.value);
if (next.has(groupId)) {
pendingExpandedTabGroupReveal.value = !isWrapLayout.value && !isVerticalLayout.value ? groupId : null;
next.delete(groupId);
collapsedTabGroups.value = next;
nextTick(refreshHorizontalTabOverflow);
return;
}
if (pendingExpandedTabGroupReveal.value === groupId) pendingExpandedTabGroupReveal.value = null;
beginTabGroupCollapse(new Set([groupId]));
}
@@ -1449,9 +1482,10 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
@contextmenu="openTabGroupContextMenu($event, onContextMenu)"
>
<span class="tab-group-header-content">
<span class="tab-group-marker" aria-hidden="true" />
<span v-if="isVerticalLayout" class="tab-group-marker" aria-hidden="true" />
<Pin v-if="entry.pinned" class="tab-group-pin" aria-hidden="true" />
<ChevronDown class="tab-group-chevron" :class="isTabGroupCollapsed(entry.tab) ? '-rotate-90' : ''" aria-hidden="true" />
<ChevronDown class="tab-group-chevron" :class="{ 'tab-group-chevron--collapsed': isTabGroupCollapsed(entry.tab) }" aria-hidden="true" />
<DatabaseIcon :db-type="tabDatabaseIconType(entry.tab)" class="tab-group-database-icon" aria-hidden="true" />
<span class="tab-group-label">{{ tabGroupLabel(entry.tab) }}</span>
<span v-if="isTabGroupCollapsed(entry.tab)" class="tab-group-count">{{ entry.count }}</span>
</span>
@@ -1635,7 +1669,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
"
>
<TabExecutionStatus :tab="tab">
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" />
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" :class="tabIconClass(tab)" />
</TabExecutionStatus>
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Activity, AlertTriangle, CalendarClock, Code2, Database, Eye, FunctionSquare, Gauge, KeyRound, ListOrdered, Network, PencilRuler, ShieldCheck, Table, TableProperties, Zap } from "@lucide/vue";
import { Activity, AlertTriangle, Braces, CalendarClock, Clock, Code2, Database, Eye, FileCode, Gauge, KeyRound, Link2, ListTree, Network, Package, PencilRuler, ScrollText, ShieldCheck, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
import { isEventObjectBrowserTab, tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
import type { QueryTab } from "@/types/database";
// Mirrors EditorGroupTabBar's per-mode tab icon chain so surfaces outside the
@@ -21,12 +21,20 @@ defineProps<{ tab: QueryTab }>();
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" />
<Network v-else-if="tab.mode === 'nacos'" />
<Database v-else-if="tab.mode === 'databases'" />
<Clock v-else-if="isEventObjectBrowserTab(tab)" />
<TableProperties v-else-if="tab.mode === 'objects'" />
<UsersRound v-else-if="tab.mode === 'users'" />
<PencilRuler v-else-if="tab.mode === 'structure'" />
<FunctionSquare v-else-if="tab.objectSource?.objectType === 'PROCEDURE' || tab.objectSource?.objectType === 'FUNCTION'" />
<ScrollText v-else-if="tab.objectSource?.objectType === 'PROCEDURE'" />
<Braces v-else-if="tab.objectSource?.objectType === 'FUNCTION'" />
<Zap v-else-if="tab.objectSource?.objectType === 'TRIGGER'" />
<CalendarClock v-else-if="tab.objectSource?.objectType === 'EVENT' || tab.objectSource?.objectType === 'JOB'" />
<ListOrdered v-else-if="tab.objectSource?.objectType === 'SEQUENCE'" />
<Clock v-else-if="tab.objectSource?.objectType === 'EVENT' || tab.objectSource?.objectType === 'JOB'" />
<ListTree v-else-if="tab.objectSource?.objectType === 'SEQUENCE'" />
<Link2 v-else-if="tab.objectSource?.objectType === 'SYNONYM'" />
<Package v-else-if="tab.objectSource?.objectType === 'PACKAGE'" />
<FileCode v-else-if="tab.objectSource?.objectType === 'PACKAGE_BODY'" />
<Braces v-else-if="tab.objectSource?.objectType === 'TYPE'" />
<FileCode v-else-if="tab.objectSource?.objectType === 'TYPE_BODY'" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
@@ -7,8 +7,10 @@ import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { createApp, nextTick, reactive } from "vue";
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: { name: "TooltipStub", template: `<div><slot /></div>` },
@@ -171,6 +173,8 @@ describe("EditorGroupTabBar semantic tab groups", () => {
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after");
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\) \.tab-group-header::after\s*\{[^}]*bottom:\s*0;/s);
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after");
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\) \.tab-group-tab::after\s*\{[^}]*bottom:\s*-0\.5px;[^}]*background:\s*var\(--tab-group-color\);/s);
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;[^}]*bottom:\s*auto;/s);
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;/s);
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode.classic-wrap .tab-section--horizontal > .app-tab-pill");
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal > .app-tab-pill");
@@ -434,6 +438,70 @@ describe("EditorGroupTabBar group behavior", () => {
host.remove();
});
it("only reveals a right-edge group when expansion leaves every member offscreen", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "connection";
settings.editorSettings.tabLayout = "scroll";
const mysql = store.createTab("mysql-1", "app", "MY 1", "query");
store.createTab("pg-1", "app", "PG 1", "query");
store.createTab("pg-1", "app", "PG 2", "query");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), mysql, pinia);
await settle();
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
const pgHeader = headers.find((header) => header.title === "pg-1")!;
pgHeader.click();
await settle();
pgHeader.click();
await settle();
const container = host.querySelector<HTMLElement>(".app-tab-scroll")!;
container.getBoundingClientRect = () => ({ left: 0, right: 300 }) as DOMRect;
const pgEntries = Array.from(host.querySelectorAll<HTMLElement>('[data-tab-group-id="regular:connection:pg-1"]'));
const pgPills = pgEntries.map((entry) => entry.querySelector<HTMLElement>(".tab-group-tab")!);
pgPills.forEach((pill, index) => {
pill.getBoundingClientRect = () => ({ left: 320 + index * 100, right: 420 + index * 100 }) as DOMRect;
});
const scrollBy = vi.fn();
container.scrollBy = scrollBy;
const transitionEnd = new Event("transitionend", { bubbles: true });
Object.defineProperty(transitionEnd, "propertyName", { value: "max-width" });
pgEntries[0]!.dispatchEvent(transitionEnd);
expect(scrollBy).toHaveBeenCalledWith({ left: 124, behavior: "smooth" });
pgHeader.click();
await settle();
pgHeader.click();
await settle();
pgPills[0]!.getBoundingClientRect = () => ({ left: 250, right: 350 }) as DOMRect;
const partialScrollBy = vi.fn();
container.scrollBy = partialScrollBy;
const visibleTransitionEnd = new Event("transitionend", { bubbles: true });
Object.defineProperty(visibleTransitionEnd, "propertyName", { value: "max-width" });
pgEntries[0]!.dispatchEvent(visibleTransitionEnd);
expect(partialScrollBy).toHaveBeenCalledWith({ left: 54, behavior: "smooth" });
pgHeader.click();
await settle();
pgHeader.click();
await settle();
pgPills[0]!.getBoundingClientRect = () => ({ left: 100, right: 200 }) as DOMRect;
const fullyVisibleScrollBy = vi.fn();
container.scrollBy = fullyVisibleScrollBy;
const fullyVisibleTransitionEnd = new Event("transitionend", { bubbles: true });
Object.defineProperty(fullyVisibleTransitionEnd, "propertyName", { value: "max-width" });
pgEntries[0]!.dispatchEvent(fullyVisibleTransitionEnd);
expect(fullyVisibleScrollBy).not.toHaveBeenCalled();
app.unmount();
host.remove();
});
it("groups by database identity, disambiguating same-name databases across connections", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
@@ -461,6 +529,80 @@ describe("EditorGroupTabBar group behavior", () => {
host.remove();
});
it("keeps Redis logical databases in one database group", 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: "" } as ConnectionConfig];
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "database";
const db0 = store.createTab("redis-1", "0", "Redis 0", "redis");
const db1 = store.createTab("redis-1", "1", "Redis 1", "redis");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), db0, pinia);
await settle();
// db0 and db1 are logical databases of one connection, so a single header
// labeled by the connection owns both pills instead of one cluster per db.
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
expect(headers.map((header) => header.title)).toEqual(["Redis Cache"]);
const groupIds = new Set(Array.from(host.querySelectorAll("[data-tab-group-id]"), (entry) => entry.getAttribute("data-tab-group-id")));
expect(groupIds.size).toBe(1);
expect(host.querySelectorAll("[data-tab-id]")).toHaveLength(2);
expect(host.querySelector(`[data-tab-id="${db0}"]`)?.textContent).toContain("db0");
expect(host.querySelector(`[data-tab-id="${db1}"]`)?.textContent).toContain("db1");
// Collapsing the cluster folds both logical databases into one count badge.
headers[0]!.click();
await settle();
expect(host.querySelector(".tab-group-count")?.textContent).toBe("2");
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();
settings.editorSettings.tabGroupMode = "connection";
const tabId = store.createTab("pg-1", "app", "PG 1", "query");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), tabId, pinia);
const styles = document.createElement("style");
styles.textContent = `html { font-size: 16px; }\n${sharedStyles}`;
document.head.appendChild(styles);
try {
await settle();
const header = host.querySelector<HTMLButtonElement>(".tab-group-header")!;
// Horizontal headers lead with the database glyph; the rail marker stays vertical-only.
expect(header.querySelector(".tab-group-database-icon")).not.toBeNull();
expect(header.querySelector(".tab-group-marker")).toBeNull();
const chevron = header.querySelector<HTMLElement>(".tab-group-chevron")!;
expect(getComputedStyle(chevron).width).toBe("14px");
expect(getComputedStyle(chevron).height).toBe("14px");
expect(getComputedStyle(chevron).transition).toContain("transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1)");
expect(getComputedStyle(chevron).transform).toBe("rotate(0deg)");
header.click();
await settle();
expect(chevron.classList.contains("tab-group-chevron--collapsed")).toBe(true);
expect(header.getAttribute("aria-expanded")).toBe("false");
expect(getComputedStyle(chevron).transform).toBe("rotate(-90deg)");
header.click();
await settle();
expect(chevron.classList.contains("tab-group-chevron--collapsed")).toBe(false);
expect(getComputedStyle(chevron).transform).toBe("rotate(0deg)");
// The rail marker appears only under a vertical placement.
settings.editorSettings.tabPlacement = "left";
await settle();
expect(host.querySelector(".tab-group-header .tab-group-marker")).not.toBeNull();
} finally {
app.unmount();
host.remove();
styles.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");
@@ -483,10 +483,24 @@
}
.tab-group-chevron {
width: 0.75rem;
height: 0.75rem;
width: 0.875rem;
height: 0.875rem;
flex: none;
transform: rotate(0deg);
transform-origin: center;
transition:
transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
color 150ms ease;
}
.tab-group-chevron--collapsed {
transform: rotate(-90deg);
}
.tab-group-database-icon {
width: 0.875rem;
height: 0.875rem;
flex: none;
transition: transform 150ms ease;
}
.tab-group-pin {
@@ -611,6 +625,11 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color) 35%, transparent);
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header:hover .tab-group-chevron,
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-chevron {
color: var(--tab-group-color);
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after {
content: "";
position: absolute;
@@ -676,6 +695,29 @@
opacity: 0.82;
}
/* Wrapped group entries can span multiple flex rows. Draw the accent on each
* member pill as well so every row keeps its own bottom rule when the entry's
* bounding box does not cover the pill's painted height. */
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode) .tab-group-tab::after {
content: "";
display: block;
position: absolute;
z-index: 1;
right: 0;
bottom: -0.5px;
left: 0;
height: 2px;
border-radius: 9999px;
background: var(--tab-group-color);
pointer-events: none;
opacity: 0.82;
}
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode)[data-placement="bottom"] .tab-group-tab::after {
top: -0.5px;
bottom: auto;
}
/* Single-row entries clip their contents while animating group expansion.
* Draw the accent on the pill so it remains visible, compensating for the
* pill's 0.5px border to align it with the header accent. */