improve(tabs): suffix duplicate tab titles with numbers
This commit is contained in:
8 files changed
+94
-15
No files matched your search
@@ -8,7 +8,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
|
||||
import { tabDisplayTitle } from "@/lib/tabs/tabPresentation";
|
||||
import { tabDisplayTitle, tabDisplayTitles } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import "./appTabBar.css";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -69,9 +70,15 @@ function setTabBarTarget(groupId: string, element: unknown) {
|
||||
const closeConfirmDirtyCount = computed(() => queryStore.closeConfirmDirtyTabIds.length);
|
||||
const showCloseConfirmBulkActions = computed(() => closeConfirmDirtyCount.value > 1);
|
||||
const closeConfirmDirtyTabs = computed(() => queryStore.closeConfirmDirtyTabIds.map((id) => queryStore.tabs.find((tab) => tab.id === id)).filter((tab): tab is NonNullable<ReturnType<typeof queryStore.tabs.find>> => !!tab));
|
||||
// Numbered across every open tab so unsaved-change confirmations show the
|
||||
// same suffixed titles as the tab strip.
|
||||
const tabTitles = computed(() => tabDisplayTitles(queryStore.tabs, t));
|
||||
function closeConfirmTabTitle(tab: QueryTab): string {
|
||||
return tabTitles.value.get(tab.id) ?? tabDisplayTitle(tab, t);
|
||||
}
|
||||
const closeConfirmCurrentTitle = computed(() => {
|
||||
const focusedTab = closeConfirmDirtyTabs.value.find((tab) => tab.id === queryStore.pendingCloseTabId) ?? closeConfirmDirtyTabs.value[0];
|
||||
return focusedTab ? tabDisplayTitle(focusedTab, t) : "";
|
||||
return focusedTab ? closeConfirmTabTitle(focusedTab) : "";
|
||||
});
|
||||
const closeConfirmMessage = computed(() => {
|
||||
const params = {
|
||||
@@ -249,7 +256,7 @@ function handleCancelClose() {
|
||||
<div v-for="tab in closeConfirmDirtyTabs" :key="tab.id" class="flex min-w-0 items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm" :class="tab.id === queryStore.pendingCloseTabId ? 'bg-muted text-foreground' : 'text-muted-foreground'">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="tab.id === queryStore.pendingCloseTabId ? 'bg-foreground' : 'bg-muted-foreground/50'" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ tabDisplayTitle(tab, t) }}
|
||||
{{ closeConfirmTabTitle(tab) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,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, tabDatabaseIconType, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabDisplayTitles, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import "./appTabBar.css";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -266,7 +266,7 @@ const filteredGroupTabs = computed(() => {
|
||||
if (!query) {
|
||||
return props.tabs;
|
||||
}
|
||||
return props.tabs.filter((tab) => tabDisplayTitle(tab, t).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query));
|
||||
return props.tabs.filter((tab) => tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
|
||||
watch(tabOverflowOpen, (open) => {
|
||||
@@ -411,8 +411,12 @@ function compareTabGroupKeys(left: string, right: string) {
|
||||
return localized || (left < right ? -1 : 1);
|
||||
}
|
||||
|
||||
// Numbered across every open tab (not just this group's) so a tab keeps the
|
||||
// same label when the same query tab is moved between panes.
|
||||
const tabTitles = computed(() => tabDisplayTitles(queryStore.tabs, t));
|
||||
|
||||
function tabTitleText(tab: QueryTab) {
|
||||
return tabDisplayTitle(tab, t);
|
||||
return tabTitles.value.get(tab.id) ?? tabDisplayTitle(tab, t);
|
||||
}
|
||||
|
||||
function tabConnectionLabel(tab: QueryTab) {
|
||||
@@ -843,7 +847,7 @@ type StripEntry = { kind: "header"; key: string; tab: QueryTab; pinned: boolean;
|
||||
* pills. Collapsed pills remain mounted so their visibility can animate.
|
||||
*/
|
||||
function tabMatchesSearch(tab: QueryTab, query: string) {
|
||||
const title = tabDisplayTitle(tab, t).toLocaleLowerCase();
|
||||
const title = tabTitleText(tab).toLocaleLowerCase();
|
||||
return title.includes(query) || tab.title.toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
@@ -1107,7 +1111,7 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
||||
label: t("contextMenu.copyName"),
|
||||
action: async () => {
|
||||
try {
|
||||
await copyToClipboard(tabDisplayTitle(tab, t));
|
||||
await copyToClipboard(tabTitleText(tab));
|
||||
toast(t("connection.copied"), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -1617,7 +1621,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
/>
|
||||
<span v-else-if="!isTabBarCollapsed" class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden text-foreground">
|
||||
<span v-if="isDirtyTab(entry.tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(entry.tab)">{{ tabDisplayTitle(entry.tab, t) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(entry.tab)">{{ tabTitleText(entry.tab) }}</span>
|
||||
</span>
|
||||
<ReadOnlySessionControl v-if="!isTabBarCollapsed" :connection-id="entry.tab.connectionId" compact />
|
||||
<button
|
||||
@@ -1748,7 +1752,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
<div
|
||||
class="group flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
|
||||
:class="isTabActive(tab) ? 'bg-accent/70 text-accent-foreground' : ''"
|
||||
:title="tabDisplayTitle(tab, t)"
|
||||
:title="tabTitleText(tab)"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
@click="
|
||||
@@ -1766,7 +1770,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
</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>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
</span>
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("AppTabBar close confirmation layout", () => {
|
||||
const end = tabBarSource.indexOf("</PopoverContent>", start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
expect(tabBarSource.slice(start, end)).toContain("tabDisplayTitle(tab, t)");
|
||||
expect(tabBarSource.slice(start, end)).toContain("closeConfirmTabTitle(tab)");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { connectionColor, connectionDisplayName, tabDisplayTitle, tabModeLabel } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionColor, connectionDisplayName, tabDisplayTitle, tabDisplayTitles, tabModeLabel } from "@/lib/tabs/tabPresentation";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -21,6 +22,10 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const listRef = ref<HTMLElement>();
|
||||
// Number from the canonical tab order, not the switcher's most-recent-first
|
||||
// list, so suffixes match the tab strip and stay stable while cycling.
|
||||
const queryStore = useQueryStore();
|
||||
const tabTitles = computed(() => tabDisplayTitles(queryStore.tabs, t));
|
||||
|
||||
function tabColor(tab: QueryTab): string {
|
||||
return connectionColor(tab.connectionId);
|
||||
@@ -64,7 +69,7 @@ watch(
|
||||
>
|
||||
<TabModeIcon :tab="tab" class="h-4 w-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ tabDisplayTitle(tab, t) }}</div>
|
||||
<div class="truncate text-sm font-medium">{{ tabTitles.get(tab.id) ?? tabDisplayTitle(tab, t) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ connectionDisplayName(tab.connectionId) }}<template v-if="tab.database"> · {{ tab.database }}</template>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
tabColorStyle,
|
||||
tabDatabaseIconType,
|
||||
tabDisplayTitle,
|
||||
tabDisplayTitles,
|
||||
tabIconClass,
|
||||
tabTooltipLines,
|
||||
tabularResultItems,
|
||||
@@ -191,6 +192,37 @@ describe("query result grid identity", () => {
|
||||
});
|
||||
|
||||
describe("tab group presentation", () => {
|
||||
it("numbers colliding query tab titles so several tabs stay distinguishable", () => {
|
||||
const store = useConnectionStore();
|
||||
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
|
||||
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" }), queryTab({ id: "tab-3" })];
|
||||
|
||||
const titles = tabDisplayTitles(tabs, translate);
|
||||
expect([...titles.values()]).toEqual(["PostgreSQL@db 1", "PostgreSQL@db 2", "PostgreSQL@db 3"]);
|
||||
});
|
||||
|
||||
it("leaves tab titles untouched while they are unique", () => {
|
||||
const store = useConnectionStore();
|
||||
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
|
||||
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2", database: "other" }), queryTab({ id: "tab-3", customTitle: true, title: "orders.sql", savedSqlId: "sql-1" })];
|
||||
|
||||
const titles = tabDisplayTitles(tabs, translate);
|
||||
expect(titles.get("tab-1")).toBe("PostgreSQL@db");
|
||||
expect(titles.get("tab-2")).toBe("PostgreSQL@other");
|
||||
expect(titles.get("tab-3")).toBe("orders.sql");
|
||||
});
|
||||
|
||||
it("numbers duplicate custom titles in strip order and skips preview tabs", () => {
|
||||
const store = useConnectionStore();
|
||||
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig, { id: "conn-preview", name: "[Preview] PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
|
||||
const tabs = [queryTab({ id: "tab-1", customTitle: true, title: "orders.sql", savedSqlId: "sql-1" }), queryTab({ id: "tab-preview", connectionId: "conn-preview" }), queryTab({ id: "tab-2", customTitle: true, title: "orders.sql", savedSqlId: "sql-2" })];
|
||||
|
||||
const titles = tabDisplayTitles(tabs, translate);
|
||||
expect(titles.get("tab-1")).toBe("orders.sql 1");
|
||||
expect(titles.get("tab-2")).toBe("orders.sql 2");
|
||||
expect(titles.get("tab-preview")).toBe("SQL");
|
||||
});
|
||||
|
||||
it("does not expose the internal objects mode in object browser tab titles", () => {
|
||||
const store = useConnectionStore();
|
||||
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
|
||||
|
||||
@@ -78,6 +78,37 @@ export function isEventObjectBrowserTab(tab: QueryTab): boolean {
|
||||
return tab.mode === "objects" && (tab.objectBrowser?.initialObjectFilter === "events" || tab.objectBrowser?.eventName !== undefined || tab.objectBrowser?.eventCreateRequestId !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display titles for a whole tab list.
|
||||
*
|
||||
* Tabs whose plain title collides (most obviously several query tabs on the
|
||||
* same connection and database, which all render `connection@database`) get a
|
||||
* 1-based numeric suffix so the strip stays readable. The first tab keeps no
|
||||
* suffix when its title is unique, so single-tab windows look exactly as
|
||||
* before.
|
||||
*/
|
||||
export function tabDisplayTitles(tabs: QueryTab[], t: Translate): Map<string, string> {
|
||||
const titles = new Map<string, string>();
|
||||
for (const tab of tabs) titles.set(tab.id, tabDisplayTitle(tab, t));
|
||||
|
||||
const collisions = new Map<string, number>();
|
||||
for (const tab of tabs) {
|
||||
if (isPreviewTab(tab)) continue;
|
||||
const title = titles.get(tab.id) ?? "";
|
||||
collisions.set(title, (collisions.get(title) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const seen = new Map<string, number>();
|
||||
for (const tab of tabs) {
|
||||
const title = titles.get(tab.id) ?? "";
|
||||
if (isPreviewTab(tab) || (collisions.get(title) ?? 0) < 2) continue;
|
||||
const index = (seen.get(title) ?? 0) + 1;
|
||||
seen.set(title, index);
|
||||
titles.set(tab.id, `${title} ${index}`);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
Reference in new issue
Block a user