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. */
+35 -1
View File
@@ -29,6 +29,7 @@ const translations: Record<string, string> = {
"tabs.tooltipDatabase": "Database:",
"tabs.tooltipTable": "Table:",
"tabs.tooltipTableComment": "Table Comment:",
"tree.events": "Events",
"connectionGroup.ungroupedLabel": "Ungrouped",
"editor.noDatabase": "No database",
};
@@ -176,6 +177,11 @@ describe("tab group presentation", () => {
expect(tabDisplayTitle(queryTab({ mode: "objects", title: "public objects", objectBrowser: { schema: "public" } }), translate)).toBe("public@db");
});
it("uses the selected MySQL event name for event editor tabs", () => {
expect(tabDisplayTitle(queryTab({ mode: "objects", objectBrowser: { objectType: "tables", initialObjectFilter: "events", eventName: "cleanup_sessions" } }), translate)).toBe("cleanup_sessions@db");
expect(tabDisplayTitle(queryTab({ mode: "objects", objectBrowser: { objectType: "tables", initialObjectFilter: "events" } }), translate)).toBe("Events@db");
});
it("uses the live database and branch context for Dolt version control tabs", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "Production Dolt", db_type: "mysql", driver_profile: "dolt", database: "app" } as ConnectionConfig];
@@ -429,9 +435,37 @@ describe("statement execution markers", () => {
describe("shared tab presentation helpers", () => {
it("classifies tab icon colors without MQ special-casing", () => {
expect(tabIconClass(queryTab({ mode: "data" }))).toContain("text-emerald-600");
expect(tabIconClass(queryTab({ mode: "data" }))).toContain("text-green-500");
const connectionStore = useConnectionStore();
connectionStore.connections = [{ id: "dynamodb-1", name: "DynamoDB", db_type: "dynamodb", driver_profile: "dynamodb", color: "" } as ConnectionConfig];
expect(tabIconClass(queryTab({ connectionId: "dynamodb-1", mode: "data" }))).toContain("text-amber-500");
expect(tabIconClass(queryTab({ mode: "mq" }))).toBe("");
expect(tabIconClass(queryTab({ externalSqlFileMissing: true }))).toContain("text-amber-600");
expect(tabIconClass(queryTab({ mode: "users" }))).toBe("text-primary");
expect(tabIconClass(queryTab({ mode: "objects", objectBrowser: { objectType: "tables", initialObjectFilter: "events", eventName: "cleanup_sessions" } }))).toBe("text-orange-400");
});
it("uses logical Redis database labels instead of the connection title", () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [{ id: "redis-1", name: "Redis", db_type: "redis", driver_profile: "redis", color: "" } as ConnectionConfig];
expect(tabDisplayTitle(queryTab({ connectionId: "redis-1", database: "0", mode: "redis", sql: "" }), translate)).toBe("db0");
expect(tabDisplayTitle(queryTab({ connectionId: "redis-1", database: "1", mode: "redis", sql: "" }), translate)).toBe("db1");
});
it("keeps source tab colors aligned with the sidebar object palette", () => {
const colors = [
["PROCEDURE", "text-blue-500"],
["FUNCTION", "text-amber-500"],
["SEQUENCE", "text-emerald-500"],
["SYNONYM", "text-sky-500"],
["PACKAGE", "text-cyan-500"],
["PACKAGE_BODY", "text-cyan-400"],
["TYPE", "text-violet-500"],
["TYPE_BODY", "text-violet-400"],
] as const;
for (const [objectType, color] of colors) {
expect(tabIconClass(queryTab({ objectSource: { name: "object", objectType } }))).toContain(color);
}
});
it("builds active/inactive color styles for classic and non-classic layouts", () => {
+26 -1
View File
@@ -72,11 +72,18 @@ function queryTitle(tab: QueryTab): string | undefined {
return undefined;
}
export function isEventObjectBrowserTab(tab: QueryTab): boolean {
return tab.mode === "objects" && (tab.objectBrowser?.initialObjectFilter === "events" || tab.objectBrowser?.eventName !== undefined || tab.objectBrowser?.eventCreateRequestId !== undefined);
}
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
const settingsStore = useSettingsStore();
const compact = settingsStore.editorSettings.compactTabTitle;
if (isPreviewTab(tab)) return tab.title;
if (useConnectionStore().getConfig(tab.connectionId)?.db_type === "redis") {
return tab.database ? database : connectionDisplayName(tab.connectionId);
}
if (tab.mode === "data" && tab.tableMeta?.tableName) {
if (compact) return tab.tableMeta.tableName;
const suffix = tab.tableMeta.schema && tab.tableMeta.schema !== tab.database ? `@${database}.${tab.tableMeta.schema}` : `@${database}`;
@@ -153,6 +160,11 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
return `${t("tabs.databases")}@${connectionDisplayName(tab.connectionId)}`;
}
if (tab.mode === "objects") {
if (isEventObjectBrowserTab(tab)) {
const eventTitle = tab.objectBrowser?.eventName || t("tree.events");
if (compact) return eventTitle;
return `${eventTitle}@${database}`;
}
const schema = tab.objectBrowser?.schema;
const objectScope = tab.catalog ? `${tab.catalog}.${database}` : database;
if (compact) return schema || objectScope;
@@ -474,6 +486,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "consul-overview") return t("consul.ui.overview");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "databases") return t("tabs.databases");
if (isEventObjectBrowserTab(tab)) return t("tree.events");
if (tab.mode === "objects") return t("tabs.objects");
if (tab.mode === "users") return t("tabs.users");
if (tab.mode === "dolt-version-control") return t("doltVersionControl.title");
@@ -496,6 +509,7 @@ export function tabDatabaseIconType(tab: QueryTab): string {
}
export function tabIconClass(tab: QueryTab): string {
const connection = useConnectionStore().getConfig(tab.connectionId);
if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400";
if (tab.mode === "mq") return "";
if (tab.objectSource?.objectType === "VIEW") return "text-purple-500";
@@ -505,11 +519,22 @@ export function tabIconClass(tab: QueryTab): string {
if (tab.objectSource?.objectType === "TRIGGER") return "text-orange-300";
if (tab.objectSource?.objectType === "EVENT" || tab.objectSource?.objectType === "JOB") return "text-orange-400";
if (tab.objectSource?.objectType === "SEQUENCE") return "text-emerald-500";
if (tab.objectSource?.objectType === "SYNONYM") return "text-sky-500";
if (tab.objectSource?.objectType === "PACKAGE") return "text-cyan-500";
if (tab.objectSource?.objectType === "PACKAGE_BODY") return "text-cyan-400";
if (tab.objectSource?.objectType === "TYPE") return "text-violet-500";
if (tab.objectSource?.objectType === "TYPE_BODY") return "text-violet-400";
if (isEventObjectBrowserTab(tab)) return "text-orange-400";
if (tab.mode === "users") return "text-primary";
if (tab.mode === "redis") return "text-red-400";
if (tab.mode === "data" && tab.tableMeta?.tableType?.toUpperCase() === "VIEW") return "text-purple-500";
if (tab.mode === "data" && tab.tableMeta?.tableType?.toUpperCase() === "MATERIALIZED_VIEW") return "text-indigo-500";
if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
if (tab.mode === "data" && connection?.db_type === "dynamodb") return "text-amber-500";
if (tab.mode === "data" || tab.mode === "hbase") return "text-green-500";
if (tab.mode === "mongo") return "text-green-400";
if (tab.mode === "vector") return "text-cyan-400";
if (tab.mode === "structure") return "text-blue-500";
return "text-blue-600 dark:text-blue-400";
}