From 885ccf43ba243479b99fb90c120bb557672e86d8 Mon Sep 17 00:00:00 2001 From: Yifeng Xu Date: Mon, 12 Oct 2026 10:06:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(tabs):=20=E4=B8=BA=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E5=90=8E=E7=BC=80=E5=8A=9F=E8=83=BD=20(#11708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tabs): 为标签页添加自定义标题后缀功能 * feat(tabs): 添加清除标签后缀功能并更新相关国际化文本 --- .../components/layout/EditorGroupTabBar.vue | 148 ++++++- .../EditorGroupTabBar.titleSuffix.spec.ts | 412 ++++++++++++++++++ apps/desktop/src/i18n/locales/az.ts | 11 + apps/desktop/src/i18n/locales/en.ts | 11 + apps/desktop/src/i18n/locales/es.ts | 11 + apps/desktop/src/i18n/locales/id.ts | 11 + apps/desktop/src/i18n/locales/it.ts | 11 + apps/desktop/src/i18n/locales/ja.ts | 11 + apps/desktop/src/i18n/locales/ko.ts | 11 + apps/desktop/src/i18n/locales/pt-BR.ts | 11 + apps/desktop/src/i18n/locales/ru.ts | 11 + apps/desktop/src/i18n/locales/tr.ts | 11 + apps/desktop/src/i18n/locales/zh-CN.ts | 11 + apps/desktop/src/i18n/locales/zh-TW.ts | 11 + .../__tests__/app/openTabsPersistence.spec.ts | 18 + .../__tests__/tabs/tabPresentation.spec.ts | 117 +++++ .../src/lib/app/openTabsPersistence.ts | 3 + apps/desktop/src/lib/tabs/tabPresentation.ts | 87 +++- .../__tests__/queryStore.titleSuffix.spec.ts | 96 ++++ apps/desktop/src/stores/queryStore.ts | 19 +- apps/desktop/src/types/database.ts | 5 + 21 files changed, 1024 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.titleSuffix.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.titleSuffix.spec.ts diff --git a/apps/desktop/src/components/layout/EditorGroupTabBar.vue b/apps/desktop/src/components/layout/EditorGroupTabBar.vue index 2829614f75..1ff5bf56f6 100644 --- a/apps/desktop/src/components/layout/EditorGroupTabBar.vue +++ b/apps/desktop/src/components/layout/EditorGroupTabBar.vue @@ -61,6 +61,7 @@ import { Search, Server, Settings, + Tag, Ungroup, X, } from "@lucide/vue"; @@ -89,7 +90,7 @@ import { copyToClipboard } from "@/lib/common/clipboard"; import { redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag"; import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu"; -import { tabConnectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabDisplayTitles, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation"; +import { findTabWithSameTitleSuffix, joinTabTitleParts, TAB_TITLE_SUFFIX_MAX_LENGTH, tabConnectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabDisplayTitleParts, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation"; import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; import "./appTabBar.css"; import type { QueryTab } from "@/types/database"; @@ -445,10 +446,21 @@ function compareTabGroupKeys(left: string, right: string) { // 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)); +const tabTitleParts = computed(() => tabDisplayTitleParts(queryStore.tabs, t)); + +function tabTitleBase(tab: QueryTab) { + return (tabTitleParts.value.get(tab.id) ?? { base: tabDisplayTitle(tab, t), suffix: "" }).base; +} + +// The suffix is rendered outside the truncating title element so a narrow tab +// (tabMaxWidth) shortens the title instead of hiding the part that tells +// duplicate tabs apart. +function tabTitleSuffixText(tab: QueryTab) { + return tabTitleParts.value.get(tab.id)?.suffix ?? ""; +} function tabTitleText(tab: QueryTab) { - return tabTitles.value.get(tab.id) ?? tabDisplayTitle(tab, t); + return joinTabTitleParts({ base: tabTitleBase(tab), suffix: tabTitleSuffixText(tab) }); } function tabConnectionLabel(tab: QueryTab) { @@ -1143,7 +1155,8 @@ function tabDropStyle(tab: QueryTab): CSSProperties | undefined { function createTabDragGhost(sourceEl: HTMLElement, x: number, y: number) { const ghost = document.createElement("div"); const textNode = sourceEl.querySelector(".truncate"); - ghost.textContent = textNode?.textContent || ""; + const suffixNode = sourceEl.querySelector("[data-tab-title-suffix]"); + ghost.textContent = [textNode?.textContent, suffixNode?.textContent].filter(Boolean).join(" "); ghost.style.cssText = `position: fixed; pointer-events: none; z-index: 9999; opacity: 0.9; box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: var(--dbx-radius-fixed-6); background: var(--background, #fff); border: 1px solid var(--border, #e5e7eb); max-width: 200px; height: 28px; padding: 0 12px; font-size: 12px; line-height: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; left: ${x + 12}px; top: ${y - 14}px;`; document.body.appendChild(ghost); return ghost; @@ -1233,6 +1246,84 @@ function canRenameTab(tab: QueryTab) { return tab.mode === "query"; } +// Every tab except a query tab derives its label from its connection, database +// and object (table, collection, key, plugin page...), so it cannot be renamed; +// the suffix is how users tell several tabs of the same target apart. Query tabs +// already have a full rename, which lets them choose the whole title. +function canSetTabSuffix(tab: QueryTab) { + return tab.mode !== "query"; +} + +const tabSuffixEditorOpen = ref(false); +const tabSuffixEditingTabId = ref(null); +const tabSuffixInput = ref(""); +const tabSuffixDuplicateOpen = ref(false); +const tabSuffixDuplicateTitle = ref(""); +const tabSuffixEditingTab = computed(() => queryStore.tabs.find((tab) => tab.id === tabSuffixEditingTabId.value)); +const tabSuffixEditorPreview = computed(() => { + const tab = tabSuffixEditingTab.value; + if (!tab) return ""; + return joinTabTitleParts({ base: tabTitleBase(tab), suffix: tabSuffixInput.value.trim() }); +}); + +function openTabSuffixEditor(tab: QueryTab) { + tabSuffixEditingTabId.value = tab.id; + tabSuffixInput.value = tab.titleSuffix ?? ""; + tabSuffixEditorOpen.value = true; + nextTick(() => { + const input = document.querySelector("[data-tab-suffix-input]"); + input?.focus(); + input?.select(); + }); +} + +function applyTabSuffix(tab: QueryTab, suffix: string) { + queryStore.setTabTitleSuffix(tab.id, suffix); + tabSuffixDuplicateOpen.value = false; + tabSuffixEditorOpen.value = false; + tabSuffixEditingTabId.value = null; +} + +function saveTabSuffix() { + const tab = tabSuffixEditingTab.value; + if (!tab) { + tabSuffixEditorOpen.value = false; + return; + } + const suffix = tabSuffixInput.value.trim(); + const duplicate = findTabWithSameTitleSuffix(queryStore.tabs, tab, suffix, t); + if (duplicate) { + tabSuffixDuplicateTitle.value = tabTitleText(duplicate); + tabSuffixDuplicateOpen.value = true; + return; + } + applyTabSuffix(tab, suffix); +} + +function clearTabSuffix() { + const tab = tabSuffixEditingTab.value; + if (tab) applyTabSuffix(tab, ""); + else tabSuffixEditorOpen.value = false; +} + +function backToTabSuffixEditor() { + tabSuffixDuplicateOpen.value = false; + nextTick(() => document.querySelector("[data-tab-suffix-input]")?.focus()); +} + +function confirmDuplicateTabSuffix() { + const tab = tabSuffixEditingTab.value; + if (tab) applyTabSuffix(tab, tabSuffixInput.value); + else tabSuffixDuplicateOpen.value = false; +} + +function handleTabSuffixKeydown(event: KeyboardEvent) { + // Enter that confirms an IME composition (e.g. Chinese pinyin) must not save. + if (event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + saveTabSuffix(); +} + function isDetachableTab(tab: QueryTab) { return tab.mode === "query" || tab.mode === "data"; } @@ -1312,6 +1403,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { onRename: () => startRenameTab(tab), onDuplicate: () => queryStore.duplicateTab(tab.id), }), + { + label: t("contextMenu.setTabSuffix"), + action: () => openTabSuffixEditor(tab), + icon: Tag, + visible: canSetTabSuffix(tab), + }, { label: t("contextMenu.copyName"), action: async () => { @@ -1846,7 +1943,10 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs? /> - {{ tabTitleText(entry.tab) }} + + {{ tabTitleBase(entry.tab) }} + {{ tabTitleSuffixText(entry.tab) }} + + + + + + + + + + {{ t("tabs.suffixDuplicateTitle") }} + +

{{ t("tabs.suffixDuplicateMessage", { title: tabSuffixDuplicateTitle }) }}

+ + + + +
+
diff --git a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.titleSuffix.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.titleSuffix.spec.ts new file mode 100644 index 0000000000..8551dd50af --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.titleSuffix.spec.ts @@ -0,0 +1,412 @@ +// @vitest-environment happy-dom +import { createApp, nextTick } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/ui/CustomContextMenu.vue", () => ({ + default: { + name: "CustomContextMenuStub", + props: ["items"], + template: `
`, + }, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: { name: "TooltipStub", template: `
` }, + TooltipTrigger: { name: "TooltipTriggerStub", template: `
` }, + TooltipContent: { name: "TooltipContentStub", template: `
` }, +})); + +vi.mock("@/components/ui/popover", () => ({ + Popover: { name: "PopoverStub", props: ["open"], template: `
` }, + PopoverContent: { name: "PopoverContentStub", template: `
` }, + PopoverTrigger: { name: "PopoverTriggerStub", template: `
` }, +})); + +vi.mock("@/components/connection/ReadOnlySessionControl.vue", () => ({ + default: { name: "ReadOnlySessionControlStub", template: `` }, +})); + +vi.mock("@/components/layout/TabExecutionStatus.vue", () => ({ + default: { name: "TabExecutionStatusStub", template: `` }, +})); + +vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ + default: { name: "DatabaseIconStub", template: `` }, +})); + +import EditorGroupTabBar from "../EditorGroupTabBar.vue"; +import { useQueryStore } from "@/stores/queryStore"; +import { TAB_TITLE_SUFFIX_MAX_LENGTH } from "@/lib/tabs/tabPresentation"; + +function mountBar(groupId: string, tabIds: string[], activeTabId: string, pinia: ReturnType) { + const store = useQueryStore(); + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp(EditorGroupTabBar, { + groupId, + tabs: tabIds.map((id) => store.tabs.find((tab) => tab.id === id)!), + activeTabId, + }); + app.use(pinia); + app.use( + createI18n({ + legacy: false, + locale: "en", + messages: { + en: { + common: { cancel: "Cancel", save: "Save", close: "Close", clear: "Clear" }, + connectionGroup: { ungroupedLabel: "Ungrouped" }, + contextMenu: { setTabSuffix: "Set suffix", renameTab: "Rename", duplicateTab: "Duplicate", copyName: "Copy name", closeTab: "Close" }, + sidebar: { locateActiveTab: "Locate" }, + tabs: { + suffixDialogTitle: "Set tab suffix", + suffixLabel: "Suffix", + suffixPlaceholder: "e.g. pending", + suffixHint: "hint", + suffixMaxLength: "Maximum {max} characters.", + suffixDuplicateTitle: "Duplicate suffix", + suffixDuplicateMessage: 'Another tab is already shown as "{title}".', + suffixDuplicateBack: "Change suffix", + suffixDuplicateUseAnyway: "Use anyway", + }, + }, + }, + }), + ); + app.mount(host); + return { app, host }; +} + +async function settle() { + await nextTick(); + await nextTick(); + await nextTick(); +} + +function menuButtons(host: HTMLElement, tabId: string): HTMLButtonElement[] { + const stub = host.querySelector(`[data-tab-id="${tabId}"]`)!.closest(".ctx-menu-stub")!; + return Array.from(stub.querySelectorAll(":scope > button.ctx-item")); +} + +function menuLabels(host: HTMLElement, tabId: string): string[] { + return menuButtons(host, tabId).map((button) => button.textContent ?? ""); +} + +function pill(host: HTMLElement, tabId: string): HTMLElement { + return host.querySelector(`[data-tab-id="${tabId}"]`)!; +} + +/** Opens the suffix editor through the real "Set suffix" menu entry. */ +async function openSuffixEditor(host: HTMLElement, tabId: string) { + menuButtons(host, tabId) + .find((button) => button.textContent === "Set suffix")! + .click(); + await settle(); +} + +function byAttr(selector: string): T | null { + return document.body.querySelector(selector); +} + +async function typeSuffix(value: string) { + const input = byAttr("[data-tab-suffix-input]")!; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); +} + +describe("EditorGroupTabBar tab title suffix", () => { + let pinia: ReturnType; + + beforeEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + pinia = createPinia(); + setActivePinia(pinia); + }); + + function openTwoDataTabs() { + const store = useQueryStore(); + const first = store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const second = store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + return { store, first, second }; + } + + it("offers the suffix action on every non-query tab and no separate clear action", async () => { + const { store, first } = openTwoDataTabs(); + const queryId = store.createTab("pg-1", "app", "Query 1", "query"); + const { app, host } = mountBar(store.groups[0].id, [first, queryId], first, pinia); + await settle(); + + expect(menuLabels(host, first)).toContain("Set suffix"); + expect(menuLabels(host, queryId)).not.toContain("Set suffix"); + + store.setTabTitleSuffix(first, "待办"); + await settle(); + expect(menuLabels(host, first)).not.toContain("Clear suffix"); + + app.unmount(); + host.remove(); + }); + + // Two tabs on the same target, per kind of tab the reporter listed: SQL data + // tabs (mysql / postgres / sqlserver), mongodb, redis and plugin pages. + const duplicateTabKinds: Array<{ name: string; /** false when the store already gives the two tabs different titles. */ collides: boolean; open: (store: ReturnType) => [string, string] }> = [ + { + name: "table data (mysql / postgres / sqlserver)", + collides: true, + open: (store) => [store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true }), store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true })], + }, + { + name: "mongodb collection", + collides: true, + open: (store) => [store.createTab("mongo-1", "app", "orders", "mongo", undefined, undefined, undefined, { forceNew: true }), store.createTab("mongo-1", "app", "orders", "mongo", undefined, undefined, undefined, { forceNew: true })], + }, + { + name: "redis database", + collides: true, + open: (store) => [store.createTab("redis-1", "0", "Redis 0", "redis", undefined, undefined, undefined, { forceNew: true }), store.createTab("redis-1", "0", "Redis 0", "redis", undefined, undefined, undefined, { forceNew: true })], + }, + { + name: "plugin workbench", + // openPluginWorkbench numbers sibling sessions itself ("SSH server", "SSH server (1)"). + collides: false, + open: (store) => [store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true }), store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true })], + }, + ]; + + it.each(duplicateTabKinds)("sets a custom suffix on a $name tab and only warns when the labels would really match", async ({ open, collides }) => { + const store = useQueryStore(); + const [first, second] = open(store); + expect(first).not.toBe(second); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + expect(menuLabels(host, second)).toContain("Set suffix"); + + await openSuffixEditor(host, second); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBe("待办"); + expect(pill(host, second).querySelector("[data-tab-title-suffix]")?.textContent).toBe("待办"); + // The title itself is untouched, so the object it points at stays visible. + expect(pill(host, second).querySelector(".truncate")?.textContent).toBeTruthy(); + + // Giving the other tab the same suffix. + await openSuffixEditor(host, first); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + if (collides) { + expect(byAttr("[data-tab-suffix-duplicate]")).not.toBeNull(); + expect(store.tabs.find((tab) => tab.id === first)?.titleSuffix).toBeUndefined(); + } else { + // The two tabs already read differently, so the same suffix is not a clash. + expect(byAttr("[data-tab-suffix-duplicate]")).toBeNull(); + expect(store.tabs.find((tab) => tab.id === first)?.titleSuffix).toBe("待办"); + } + + app.unmount(); + host.remove(); + }); + + it("renders the suffix beside the truncating title and ellipsizes only the suffix itself when space runs out", async () => { + const { store, first, second } = openTwoDataTabs(); + store.setTabTitleSuffix(second, "待审核"); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + const tab = pill(host, second); + const suffix = tab.querySelector("[data-tab-title-suffix]")!; + expect(suffix.textContent).toBe("待审核"); + // A sibling of the title, not inside it: the title gives way first. + expect(suffix.closest(".truncate")).toBeNull(); + const title = tab.querySelector(".truncate")!; + expect(title.textContent).not.toContain("待审核"); + expect(title.classList.contains("min-w-0")).toBe(true); + // The suffix keeps its width until the whole title area is used up... + expect(suffix.classList.contains("shrink-0")).toBe(true); + expect(suffix.classList.contains("whitespace-nowrap")).toBe(true); + // ...and only then ends in an ellipsis instead of being clipped hard. + expect(suffix.classList.contains("max-w-full")).toBe(true); + expect(suffix.classList.contains("overflow-hidden")).toBe(true); + expect(suffix.classList.contains("text-ellipsis")).toBe(true); + // Both live in one shrinkable container, so max-w-full resolves against it. + expect(suffix.parentElement).toBe(title.parentElement); + expect(suffix.parentElement!.classList.contains("min-w-0")).toBe(true); + expect(suffix.parentElement!.classList.contains("overflow-hidden")).toBe(true); + + app.unmount(); + host.remove(); + }); + + it("tells the user the suffix length limit in the dialog", async () => { + const { store, first, second } = openTwoDataTabs(); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + + expect(TAB_TITLE_SUFFIX_MAX_LENGTH).toBe(8); + expect(byAttr("[data-tab-suffix-limit]")?.textContent?.trim()).toBe("Maximum 8 characters."); + + app.unmount(); + host.remove(); + }); + + it("saves a unique suffix straight from the dialog without asking for confirmation", async () => { + const { store, first, second } = openTwoDataTabs(); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + + expect(byAttr("[data-tab-suffix-duplicate]")).toBeNull(); + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBe("待办"); + expect(pill(host, second).querySelector("[data-tab-title-suffix]")?.textContent).toBe("待办"); + expect(byAttr("[data-tab-suffix-input]")).toBeNull(); + + app.unmount(); + host.remove(); + }); + + it("warns instead of saving when another tab already shows the same suffix", async () => { + const { store, first, second } = openTwoDataTabs(); + store.setTabTitleSuffix(first, "待办"); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + + const warning = byAttr("[data-tab-suffix-duplicate]"); + expect(warning).not.toBeNull(); + // The warning names the tab that already uses the suffix. + expect(warning!.textContent).toContain("users"); + expect(warning!.textContent).toContain("待办"); + // Nothing is applied until the user confirms. + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBeUndefined(); + + app.unmount(); + host.remove(); + }); + + it("returns to the editor without saving when the user backs out of the duplicate warning", async () => { + const { store, first, second } = openTwoDataTabs(); + store.setTabTitleSuffix(first, "待办"); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + byAttr("[data-tab-suffix-duplicate-back]")!.click(); + await settle(); + + expect(byAttr("[data-tab-suffix-duplicate]")).toBeNull(); + expect(byAttr("[data-tab-suffix-input]")?.value).toBe("待办"); + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBeUndefined(); + + app.unmount(); + host.remove(); + }); + + it("applies the duplicate suffix once the user confirms", async () => { + const { store, first, second } = openTwoDataTabs(); + store.setTabTitleSuffix(first, "待办"); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + await typeSuffix("待办"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + byAttr("[data-tab-suffix-duplicate-confirm]")!.click(); + await settle(); + + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBe("待办"); + expect(byAttr("[data-tab-suffix-duplicate]")).toBeNull(); + expect(byAttr("[data-tab-suffix-input]")).toBeNull(); + + app.unmount(); + host.remove(); + }); + + it("also warns when the typed suffix equals another tab's automatic number", async () => { + const { store, first, second } = openTwoDataTabs(); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + // Both tabs collide on the same title, so the first one is numbered "1". + expect(pill(host, first).querySelector("[data-tab-title-suffix]")?.textContent).toBe("1"); + + await openSuffixEditor(host, second); + await typeSuffix("1"); + byAttr("[data-tab-suffix-save]")!.click(); + await settle(); + + expect(byAttr("[data-tab-suffix-duplicate]")).not.toBeNull(); + + app.unmount(); + host.remove(); + }); + + it("clears the suffix and falls back to the automatic number", async () => { + const { store, first, second } = openTwoDataTabs(); + store.setTabTitleSuffix(second, "待办"); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + expect(byAttr("[data-tab-suffix-clear]")).not.toBeNull(); + byAttr("[data-tab-suffix-clear]")!.click(); + await settle(); + + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBeUndefined(); + expect(pill(host, second).querySelector("[data-tab-title-suffix]")?.textContent).toBe("2"); + + app.unmount(); + host.remove(); + }); + + it("limits the suffix input to the length that always fits in the narrowest tab", async () => { + const { store, first, second } = openTwoDataTabs(); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + + expect(byAttr("[data-tab-suffix-input]")?.maxLength).toBe(TAB_TITLE_SUFFIX_MAX_LENGTH); + + app.unmount(); + host.remove(); + }); + + it("does not save when Enter only confirms an IME composition", async () => { + const { store, first, second } = openTwoDataTabs(); + const { app, host } = mountBar(store.groups[0].id, [first, second], first, pinia); + await settle(); + + await openSuffixEditor(host, second); + await typeSuffix("待处理"); + const input = byAttr("[data-tab-suffix-input]")!; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })); + await settle(); + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBeUndefined(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await settle(); + expect(store.tabs.find((tab) => tab.id === second)?.titleSuffix).toBe("待处理"); + + app.unmount(); + host.remove(); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 971a8f4fd7..bbdc2aae3e 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1621,6 +1621,7 @@ export default withEnglishFallback({ executionSummary: "Xülasə", messages: "Mesajlar", tooltipTitle: "Başlıq:", + tooltipSuffix: "Şəkilçi:", tooltipFilePath: "Faylın yolu:", tooltipFileStatus: "Faylın vəziyyəti:", externalFileMissing: "DBX-dən kənarda silinib və ya köçürülüb", @@ -1689,6 +1690,15 @@ export default withEnglishFallback({ groupColorAuto: "Standart rəngdən istifadə et", groupColorCustom: "Fərdi rəng seç", resetGroup: "Sıfırla", + suffixDialogTitle: "Vərəq şəkilçisini təyin et", + suffixLabel: "Şəkilçi", + suffixPlaceholder: "məs. gözləyir", + suffixHint: "Avtomatik nömrənin əvəzinə vərəq başlığından sonra göstərilir. Nömrəyə qayıtmaq üçün boş buraxın.", + suffixMaxLength: "Maksimum {max} simvol.", + suffixDuplicateTitle: "Təkrarlanan şəkilçi", + suffixDuplicateMessage: 'Başqa bir vərəq artıq "{title}" kimi göstərilir. Eyni şəkilçidən istifadə iki vərəqi ayırd etməyi çətinləşdirir.', + suffixDuplicateBack: "Şəkilçini dəyiş", + suffixDuplicateUseAnyway: "Yenə də istifadə et", settingsSaveFailed: "Vərəq parametrlərini yadda saxlamaq mümkün olmadı: {message}", }, databaseBrowser: { @@ -3523,6 +3533,7 @@ export default withEnglishFallback({ unpinTab: "Vərəqin bərkidilməsini ləğv et", refreshTab: "Məlumatları yenilə", renameTab: "Vərəqin adını dəyiş", + setTabSuffix: "Vərəq şəkilçisini təyin et", duplicateTab: "Vərəqin surətini yarat", splitRight: "Sağa böl", splitDown: "Aşağıya böl", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 93914defdd..d939cfae9c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1993,6 +1993,7 @@ export default { executionSummary: "Summary", messages: "Messages", tooltipTitle: "Title:", + tooltipSuffix: "Suffix:", tooltipFilePath: "File Path:", tooltipFileStatus: "File Status:", externalFileMissing: "Deleted or moved outside DBX", @@ -2074,6 +2075,15 @@ export default { groupColorAuto: "Use default color", groupColorCustom: "Choose a custom color", resetGroup: "Reset", + suffixDialogTitle: "Set tab suffix", + suffixLabel: "Suffix", + suffixPlaceholder: "e.g. pending", + suffixHint: "Shown after the tab title instead of the automatic number. Leave empty to restore the number.", + suffixMaxLength: "Maximum {max} characters.", + suffixDuplicateTitle: "Duplicate suffix", + suffixDuplicateMessage: 'Another tab is already shown as "{title}". Using the same suffix makes the two tabs hard to tell apart.', + suffixDuplicateBack: "Change suffix", + suffixDuplicateUseAnyway: "Use anyway", settingsSaveFailed: "Could not save tab settings: {message}", }, databaseBrowser: { @@ -4059,6 +4069,7 @@ export default { unpinTab: "Unpin Tab", refreshTab: "Refresh Data", renameTab: "Rename Tab", + setTabSuffix: "Set Tab Suffix", duplicateTab: "Duplicate Tab", splitRight: "Split Right", splitDown: "Split Down", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ab9743b183..708892b3bf 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1985,6 +1985,7 @@ export default withEnglishFallback({ mongo: "Mongo", objects: "Objetos", tooltipTitle: "Título:", + tooltipSuffix: "Sufijo:", tooltipFilePath: "Ruta del archivo:", tooltipFileStatus: "Estado del archivo:", externalFileMissing: "Eliminado o movido fuera de DBX", @@ -2066,6 +2067,15 @@ export default withEnglishFallback({ groupColorAuto: "Usar color predeterminado", groupColorCustom: "Elegir color personalizado", resetGroup: "Restablecer", + suffixDialogTitle: "Definir sufijo de pestaña", + suffixLabel: "Sufijo", + suffixPlaceholder: "p. ej., pendiente", + suffixHint: "Se muestra después del título de la pestaña en lugar del número automático. Déjalo vacío para volver al número.", + suffixMaxLength: "Máximo {max} caracteres.", + suffixDuplicateTitle: "Sufijo duplicado", + suffixDuplicateMessage: 'Ya hay otra pestaña mostrada como "{title}". Usar el mismo sufijo dificulta distinguir ambas pestañas.', + suffixDuplicateBack: "Cambiar sufijo", + suffixDuplicateUseAnyway: "Usar de todos modos", settingsSaveFailed: "No se pudo guardar la configuración de pestañas: {message}", gridfs: "GridFS", etcdDashboard: "Panel de etcd", @@ -3961,6 +3971,7 @@ export default withEnglishFallback({ unpinTab: "Desfijar pestaña", refreshTab: "Actualizar datos", renameTab: "Renombrar pestaña", + setTabSuffix: "Definir sufijo de pestaña", duplicateTab: "Duplicar pestaña", splitRight: "Dividir a la derecha", splitDown: "Dividir hacia abajo", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 28f154bd28..d47c0027bb 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -1885,6 +1885,7 @@ export default withEnglishFallback({ executionSummary: "Ringkasan", messages: "Pesan", tooltipTitle: "Judul:", + tooltipSuffix: "Akhiran:", tooltipFilePath: "Jalur File:", tooltipFileStatus: "Status File:", externalFileMissing: "Dihapus atau dipindahkan di luar DBX", @@ -1953,6 +1954,15 @@ export default withEnglishFallback({ groupColorAuto: "Gunakan warna default", groupColorCustom: "Pilih warna kustom", resetGroup: "Atur ulang", + suffixDialogTitle: "Atur akhiran tab", + suffixLabel: "Akhiran", + suffixPlaceholder: "mis. tertunda", + suffixHint: "Ditampilkan setelah judul tab sebagai pengganti nomor otomatis. Kosongkan untuk mengembalikan nomor.", + suffixMaxLength: "Maksimal {max} karakter.", + suffixDuplicateTitle: "Akhiran duplikat", + suffixDuplicateMessage: 'Tab lain sudah ditampilkan sebagai "{title}". Memakai akhiran yang sama menyulitkan membedakan kedua tab.', + suffixDuplicateBack: "Ubah akhiran", + suffixDuplicateUseAnyway: "Tetap gunakan", settingsSaveFailed: "Tidak dapat menyimpan pengaturan tab: {message}", }, databaseBrowser: { @@ -3841,6 +3851,7 @@ export default withEnglishFallback({ unpinTab: "Lepas Sematan Tab", refreshTab: "Segarkan Data", renameTab: "Ganti Nama Tab", + setTabSuffix: "Atur Akhiran Tab", duplicateTab: "Duplikat Tab", splitRight: "Pisah ke Kanan", splitDown: "Pisah ke Bawah", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 84dadfa525..6f3849fa64 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1894,6 +1894,7 @@ export default withEnglishFallback({ executionSummary: "Riepilogo", messages: "Messaggi", tooltipTitle: "Titolo:", + tooltipSuffix: "Suffisso:", tooltipFileStatus: "Stato file:", externalFileMissing: "Eliminato o spostato fuori da DBX", tooltipConnection: "Connessione:", @@ -1965,6 +1966,15 @@ export default withEnglishFallback({ groupColorAuto: "Usa colore predefinito", groupColorCustom: "Scegli colore personalizzato", resetGroup: "Ripristina", + suffixDialogTitle: "Imposta suffisso scheda", + suffixLabel: "Suffisso", + suffixPlaceholder: "es. in sospeso", + suffixHint: "Mostrato dopo il titolo della scheda al posto del numero automatico. Lascia vuoto per ripristinare il numero.", + suffixMaxLength: "Massimo {max} caratteri.", + suffixDuplicateTitle: "Suffisso duplicato", + suffixDuplicateMessage: 'Un\'altra scheda è già mostrata come "{title}". Usare lo stesso suffisso rende difficile distinguere le due schede.', + suffixDuplicateBack: "Cambia suffisso", + suffixDuplicateUseAnyway: "Usa comunque", settingsSaveFailed: "Impossibile salvare le impostazioni delle schede: {message}", gridfs: "GridFS", etcdDashboard: "etcd dashboard", @@ -3805,6 +3815,7 @@ export default withEnglishFallback({ unpinTab: "Sblocca scheda", refreshTab: "Aggiorna Dati", renameTab: "Rinomina Scheda", + setTabSuffix: "Imposta suffisso scheda", duplicateTab: "Duplica Scheda", splitRight: "Dividi a destra", splitDown: "Dividi in basso", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3fcdd44e85..bf62d85432 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1912,6 +1912,7 @@ export default withEnglishFallback({ executionSummary: "サマリー", messages: "メッセージ", tooltipTitle: "タイトル:", + tooltipSuffix: "サフィックス:", tooltipFilePath: "ファイルパス:", tooltipFileStatus: "ファイルの状態:", externalFileMissing: "DBXの外部で削除または移動されました", @@ -1984,6 +1985,15 @@ export default withEnglishFallback({ groupColorAuto: "既定の色を使用", groupColorCustom: "カスタムカラーを選択", resetGroup: "リセット", + suffixDialogTitle: "タブのサフィックスを設定", + suffixLabel: "サフィックス", + suffixPlaceholder: "例: 対応待ち", + suffixHint: "自動採番の代わりにタブ名の後ろに表示されます。空にすると自動採番に戻ります。", + suffixMaxLength: "上限は {max} 文字です。", + suffixDuplicateTitle: "サフィックスが重複しています", + suffixDuplicateMessage: "すでに「{title}」と表示されているタブがあります。同じサフィックスを使うと区別しにくくなります。", + suffixDuplicateBack: "サフィックスを変更", + suffixDuplicateUseAnyway: "そのまま使用", settingsSaveFailed: "タブ設定を保存できません: {message}", zookeeper: "ZooKeeper", consul: "Consul", @@ -3826,6 +3836,7 @@ export default withEnglishFallback({ unpinTab: "タブの固定を解除", refreshTab: "データを更新", renameTab: "タブ名を変更", + setTabSuffix: "タブのサフィックスを設定", duplicateTab: "タブを複製", splitRight: "右に分割", splitDown: "下に分割", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6bb220c4f9..27867c1be0 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1817,6 +1817,7 @@ export default withEnglishFallback({ executionSummary: "요약", messages: "메시지", tooltipTitle: "제목:", + tooltipSuffix: "접미사:", tooltipFilePath: "파일 경로:", tooltipFileStatus: "파일 상태:", externalFileMissing: "DBX 외부에서 삭제되거나 이동됨", @@ -1889,6 +1890,15 @@ export default withEnglishFallback({ groupColorAuto: "기본 색상 사용", groupColorCustom: "사용자 지정 색상 선택", resetGroup: "초기화", + suffixDialogTitle: "탭 접미사 설정", + suffixLabel: "접미사", + suffixPlaceholder: "예: 처리 대기", + suffixHint: "자동 번호 대신 탭 제목 뒤에 표시됩니다. 비워 두면 자동 번호로 돌아갑니다.", + suffixMaxLength: "최대 {max}자까지 입력할 수 있습니다.", + suffixDuplicateTitle: "접미사 중복", + suffixDuplicateMessage: '이미 "{title}"(으)로 표시되는 탭이 있습니다. 같은 접미사를 쓰면 두 탭을 구분하기 어렵습니다.', + suffixDuplicateBack: "접미사 변경", + suffixDuplicateUseAnyway: "그대로 사용", settingsSaveFailed: "탭 설정을 저장하지 못했습니다: {message}", }, databaseBrowser: { @@ -3754,6 +3764,7 @@ export default withEnglishFallback({ unpinTab: "탭 고정 해제", refreshTab: "데이터 새로고침", renameTab: "탭 이름 변경", + setTabSuffix: "탭 접미사 설정", duplicateTab: "탭 복제", splitRight: "오른쪽으로 분할", splitDown: "아래로 분할", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 98bfd7e080..c2c00787b5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1895,6 +1895,7 @@ export default withEnglishFallback({ executionSummary: "Resumo", messages: "Mensagens", tooltipTitle: "Título:", + tooltipSuffix: "Sufixo:", tooltipFilePath: "Caminho do arquivo:", tooltipFileStatus: "Status do arquivo:", externalFileMissing: "Excluído ou movido fora do DBX", @@ -1967,6 +1968,15 @@ export default withEnglishFallback({ groupColorAuto: "Usar cor padrão", groupColorCustom: "Escolher cor personalizada", resetGroup: "Redefinir", + suffixDialogTitle: "Definir sufixo da aba", + suffixLabel: "Sufixo", + suffixPlaceholder: "ex.: pendente", + suffixHint: "Exibido após o título da aba no lugar do número automático. Deixe vazio para voltar ao número.", + suffixMaxLength: "Máximo de {max} caracteres.", + suffixDuplicateTitle: "Sufixo duplicado", + suffixDuplicateMessage: 'Já existe outra aba exibida como "{title}". Usar o mesmo sufixo dificulta diferenciar as duas abas.', + suffixDuplicateBack: "Alterar sufixo", + suffixDuplicateUseAnyway: "Usar mesmo assim", settingsSaveFailed: "Falha ao salvar configurações de abas: {message}", gridfs: "GridFS", etcdDashboard: "painel etcd", @@ -3805,6 +3815,7 @@ export default withEnglishFallback({ unpinTab: "Desafixar aba", refreshTab: "Atualizar Dados", renameTab: "Renomear Aba", + setTabSuffix: "Definir sufixo da aba", duplicateTab: "Duplicar Aba", splitRight: "Dividir à direita", splitDown: "Dividir para baixo", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index d4a5a3e0ef..a59e4800d7 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -1901,6 +1901,7 @@ export default withEnglishFallback({ executionSummary: "Сводка", messages: "Сообщения", tooltipTitle: "Заголовок:", + tooltipSuffix: "Суффикс:", tooltipFilePath: "Путь к файлу:", tooltipFileStatus: "Состояние файла:", externalFileMissing: "Удалён или перемещён вне DBX", @@ -1982,6 +1983,15 @@ export default withEnglishFallback({ groupColorAuto: "Использовать цвет по умолчанию", groupColorCustom: "Выбрать свой цвет", resetGroup: "Сбросить", + suffixDialogTitle: "Задать суффикс вкладки", + suffixLabel: "Суффикс", + suffixPlaceholder: "напр. в ожидании", + suffixHint: "Отображается после заголовка вкладки вместо автоматического номера. Оставьте пустым, чтобы вернуть номер.", + suffixMaxLength: "Максимум {max} символов.", + suffixDuplicateTitle: "Суффикс уже используется", + suffixDuplicateMessage: "Другая вкладка уже отображается как «{title}». С одинаковым суффиксом две вкладки будет трудно различить.", + suffixDuplicateBack: "Изменить суффикс", + suffixDuplicateUseAnyway: "Всё равно использовать", settingsSaveFailed: "Не удалось сохранить настройки вкладок: {message}", }, databaseBrowser: { @@ -3915,6 +3925,7 @@ export default withEnglishFallback({ unpinTab: "Открепить вкладку", refreshTab: "Обновить данные", renameTab: "Переименовать вкладку", + setTabSuffix: "Задать суффикс вкладки", duplicateTab: "Дублировать вкладку", splitRight: "Разделить справа", splitDown: "Разделить снизу", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 610c66e09e..bf44aaa40a 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1620,6 +1620,7 @@ export default withEnglishFallback({ executionSummary: "Özet", messages: "Mesajlar", tooltipTitle: "Başlık:", + tooltipSuffix: "Son ek:", tooltipFilePath: "Dosya Yolu:", tooltipFileStatus: "Dosya Durumu:", externalFileMissing: "DBX dışında silindi veya taşındı", @@ -1688,6 +1689,15 @@ export default withEnglishFallback({ groupColorAuto: "Varsayılan rengi kullan", groupColorCustom: "Özel bir renk seçin", resetGroup: "Sıfırla", + suffixDialogTitle: "Sekme son ekini ayarla", + suffixLabel: "Son ek", + suffixPlaceholder: "örn. beklemede", + suffixHint: "Otomatik numara yerine sekme başlığından sonra gösterilir. Numaraya dönmek için boş bırakın.", + suffixMaxLength: "En fazla {max} karakter.", + suffixDuplicateTitle: "Yinelenen son ek", + suffixDuplicateMessage: 'Başka bir sekme zaten "{title}" olarak gösteriliyor. Aynı son eki kullanmak iki sekmeyi ayırt etmeyi zorlaştırır.', + suffixDuplicateBack: "Son eki değiştir", + suffixDuplicateUseAnyway: "Yine de kullan", settingsSaveFailed: "Sekme ayarları kaydedilemedi: {message}", }, databaseBrowser: { @@ -3494,6 +3504,7 @@ export default withEnglishFallback({ unpinTab: "Sekme Sabitlemesini Kaldır", refreshTab: "Verileri Yenile", renameTab: "Sekmeyi Yeniden Adlandır", + setTabSuffix: "Sekme Son Ekini Ayarla", duplicateTab: "Sekmeyi Çoğalt", splitRight: "Sağa Böl", splitDown: "Aşağı Böl", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 55616c53a7..bf7aa5dde9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1929,6 +1929,7 @@ export default withEnglishFallback({ executionSummary: "摘要", messages: "消息", tooltipTitle: "标题:", + tooltipSuffix: "后缀:", tooltipFilePath: "文件路径:", tooltipFileStatus: "文件状态:", externalFileMissing: "已在 DBX 外部删除或移动", @@ -2010,6 +2011,15 @@ export default withEnglishFallback({ groupColorAuto: "使用默认颜色", groupColorCustom: "选择自定义颜色", resetGroup: "恢复默认", + suffixDialogTitle: "设置标签后缀", + suffixLabel: "后缀", + suffixPlaceholder: "例如:待处理", + suffixHint: "显示在标签标题之后,取代自动编号。留空则恢复为自动编号。", + suffixMaxLength: "上限为 {max} 个字符。", + suffixDuplicateTitle: "后缀重复", + suffixDuplicateMessage: "已有标签显示为“{title}”,使用相同后缀会让两个标签难以区分。", + suffixDuplicateBack: "修改后缀", + suffixDuplicateUseAnyway: "仍然使用", settingsSaveFailed: "无法保存标签设置:{message}", }, databaseBrowser: { @@ -3991,6 +4001,7 @@ export default withEnglishFallback({ unpinTab: "取消固定标签页", refreshTab: "刷新数据", renameTab: "重命名标签页", + setTabSuffix: "设置标签后缀", duplicateTab: "复制标签页", splitRight: "拆分并右移", splitDown: "拆分并下移", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ae047598e2..e06caec49c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1898,6 +1898,7 @@ export default withEnglishFallback({ executionSummary: "摘要", messages: "訊息", tooltipTitle: "標題:", + tooltipSuffix: "後綴:", tooltipFilePath: "檔案路徑:", tooltipFileStatus: "檔案狀態:", externalFileMissing: "已在 DBX 外部刪除或移動", @@ -1970,6 +1971,15 @@ export default withEnglishFallback({ groupColorAuto: "使用預設顏色", groupColorCustom: "選擇自訂顏色", resetGroup: "重設", + suffixDialogTitle: "設定分頁後綴", + suffixLabel: "後綴", + suffixPlaceholder: "例如:待處理", + suffixHint: "顯示在分頁標題之後,取代自動編號。留空則恢復為自動編號。", + suffixMaxLength: "上限為 {max} 個字元。", + suffixDuplicateTitle: "後綴重複", + suffixDuplicateMessage: "已有分頁顯示為「{title}」,使用相同後綴會讓兩個分頁難以區分。", + suffixDuplicateBack: "修改後綴", + suffixDuplicateUseAnyway: "仍然使用", settingsSaveFailed: "無法儲存分頁設定:{message}", gridfs: "GridFS", etcdDashboard: "etcd 儀表板", @@ -3807,6 +3817,7 @@ export default withEnglishFallback({ unpinTab: "取消固定分頁", refreshTab: "重新整理資料", renameTab: "重新命名分頁", + setTabSuffix: "設定分頁後綴", duplicateTab: "複製分頁", splitRight: "拆分並右移", splitDown: "拆分並下移", diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index 2d993997f9..80a63ddd8c 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -27,6 +27,24 @@ describe("openTabsPersistence originalSql round-trip", () => { expect(restored.uiState).toEqual({ activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false }); }); + it("preserves a custom title suffix across a data tab round-trip", () => { + const [restored] = roundTrip([queryTab({ mode: "data", titleSuffix: "pending" })]); + + expect(restored.titleSuffix).toBe("pending"); + }); + + it.each(["redis", "mongo", "plugin-workbench", "objects"] as const)("preserves a custom title suffix across a %s tab round-trip", (mode) => { + const [restored] = roundTrip([queryTab({ mode, titleSuffix: "待办" })]); + + expect(restored.titleSuffix).toBe("待办"); + }); + + it("does not persist an empty title suffix", () => { + const [saved] = serializeOpenTabs([queryTab({ mode: "data", titleSuffix: " " })]); + + expect(saved).not.toHaveProperty("titleSuffix"); + }); + it("preserves the structured sort field across a data tab round-trip", () => { const [restored] = roundTrip([queryTab({ mode: "data", orderByInput: '"status" ASC', structuredOrderByInput: '"created_at" DESC' })]); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 61a7df9d1f..e4a536a8c4 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -17,7 +17,9 @@ import { tabColorStyle, tabConnectionColor, tabDatabaseIconType, + findTabWithSameTitleSuffix, tabDisplayTitle, + tabDisplayTitleParts, tabDisplayTitles, syncTabTitleNumbers, tabIconClass, @@ -464,6 +466,121 @@ describe("tab group presentation", () => { expect(titles.get("tab-preview")).toBe("SQL"); }); + describe("custom title suffix", () => { + const dataTab = (id: string, overrides: Partial = {}) => queryTab({ id, mode: "data", title: "ticket_bug", tableMeta: { schema: "feedback_v2", tableName: "ticket_bug", columns: [], primaryKeys: [] }, ...overrides }); + + function seedConnection() { + useConnectionStore().connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig]; + } + + it("shows the custom suffix instead of the automatic number", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2")]; + + const titles = titlesWithNumbers(tabs); + expect(titles.get("tab-1")).toBe("ticket_bug@db.feedback_v2 pending"); + // The custom tab leaves the numbered pool, so the other tab is alone in it + // and keeps its plain title rather than becoming "… 1". + expect(titles.get("tab-2")).toBe("ticket_bug@db.feedback_v2"); + }); + + it("keeps numbering the remaining tabs when only some have a custom suffix", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2"), dataTab("tab-3")]; + + const titles = titlesWithNumbers(tabs); + expect(titles.get("tab-1")).toBe("ticket_bug@db.feedback_v2 pending"); + expect(titles.get("tab-2")).toBe("ticket_bug@db.feedback_v2 1"); + expect(titles.get("tab-3")).toBe("ticket_bug@db.feedback_v2 2"); + }); + + it("splits the label into base and suffix so the suffix can be rendered untruncated", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2"), dataTab("tab-3")]; + syncTabTitleNumbers(tabs, translate); + + const parts = tabDisplayTitleParts(tabs, translate); + expect(parts.get("tab-1")).toEqual({ base: "ticket_bug@db.feedback_v2", suffix: "pending" }); + expect(parts.get("tab-2")).toEqual({ base: "ticket_bug@db.feedback_v2", suffix: "1" }); + }); + + it("falls back to a number when the custom suffix is cleared", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2")]; + titlesWithNumbers(tabs); + + delete tabs[0]!.titleSuffix; + const titles = titlesWithNumbers(tabs); + expect(titles.get("tab-1")).toBe("ticket_bug@db.feedback_v2 1"); + expect(titles.get("tab-2")).toBe("ticket_bug@db.feedback_v2 2"); + }); + + it("treats a blank custom suffix as no suffix", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: " " }), dataTab("tab-2")]; + + const titles = titlesWithNumbers(tabs); + expect(titles.get("tab-1")).toBe("ticket_bug@db.feedback_v2 1"); + }); + + it("does not mint an automatic number that another tab already claimed as a custom suffix", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "1" }), dataTab("tab-2"), dataTab("tab-3")]; + + const titles = titlesWithNumbers(tabs); + expect(titles.get("tab-1")).toBe("ticket_bug@db.feedback_v2 1"); + expect(titles.get("tab-2")).toBe("ticket_bug@db.feedback_v2 2"); + expect(titles.get("tab-3")).toBe("ticket_bug@db.feedback_v2 3"); + }); + + it("applies the custom suffix in compact mode too", () => { + seedConnection(); + useSettingsStore().editorSettings.compactTabTitle = true; + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2")]; + + expect(titlesWithNumbers(tabs).get("tab-1")).toBe("ticket_bug pending"); + }); + + it("lists the full custom suffix in the tab tooltip, because the strip may ellipsize it", () => { + seedConnection(); + const withSuffix = tabTooltipLines(dataTab("tab-1", { titleSuffix: "客服待处理单" }), translate); + expect(withSuffix[0]).toEqual({ label: "tabs.tooltipSuffix", value: "客服待处理单" }); + + const without = tabTooltipLines(dataTab("tab-2"), translate); + expect(without.some((line) => line.label === "tabs.tooltipSuffix")).toBe(false); + }); + + describe("findTabWithSameTitleSuffix", () => { + it("finds another tab that already shows the same custom suffix", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2")]; + syncTabTitleNumbers(tabs, translate); + + expect(findTabWithSameTitleSuffix(tabs, tabs[1]!, "pending", translate)?.id).toBe("tab-1"); + expect(findTabWithSameTitleSuffix(tabs, tabs[1]!, " pending ", translate)?.id).toBe("tab-1"); + }); + + it("also finds a tab whose automatic number equals the requested suffix", () => { + seedConnection(); + const tabs = [dataTab("tab-1"), dataTab("tab-2"), dataTab("tab-3")]; + syncTabTitleNumbers(tabs, translate); + + expect(findTabWithSameTitleSuffix(tabs, tabs[2]!, "1", translate)?.id).toBe("tab-1"); + }); + + it("ignores the tab being edited, different tables, empty suffixes and unused suffixes", () => { + seedConnection(); + const tabs = [dataTab("tab-1", { titleSuffix: "pending" }), dataTab("tab-2"), dataTab("tab-3", { tableMeta: { schema: "feedback_v2", tableName: "other", columns: [], primaryKeys: [] } })]; + syncTabTitleNumbers(tabs, translate); + + expect(findTabWithSameTitleSuffix(tabs, tabs[0]!, "pending", translate)).toBeUndefined(); + expect(findTabWithSameTitleSuffix(tabs, tabs[2]!, "pending", translate)).toBeUndefined(); + expect(findTabWithSameTitleSuffix(tabs, tabs[1]!, "", translate)).toBeUndefined(); + expect(findTabWithSameTitleSuffix(tabs, tabs[1]!, "done", translate)).toBeUndefined(); + }); + }); + }); + 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]; diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index bcd9d9ef6c..7bd59ee6eb 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -27,6 +27,8 @@ export interface SavedOpenTab { createdAt?: number; title: string; customTitle?: boolean; + /** 用户自定义的标签后缀,取代自动编号 */ + titleSuffix?: string; connectionId: string; database: string; /** 原连接已被删除但页签被保留时记录的原连接名,用于新建同名连接后重新绑定。 */ @@ -174,6 +176,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { ...(typeof tab.createdAt === "number" ? { createdAt: tab.createdAt } : {}), title: tab.title, ...(tab.customTitle ? { customTitle: true } : {}), + ...(tab.titleSuffix?.trim() ? { titleSuffix: tab.titleSuffix.trim() } : {}), connectionId: tab.connectionId, database: tab.database, ...(tab.detachedConnectionName ? { detachedConnectionName: tab.detachedConnectionName } : {}), diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 03b3a9f5dc..1d7157548c 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -114,6 +114,27 @@ export function isEventObjectBrowserTab(tab: QueryTab): boolean { return tab.mode === "objects" && (tab.objectBrowser?.initialObjectFilter === "events" || tab.objectBrowser?.eventName !== undefined || tab.objectBrowser?.eventCreateRequestId !== undefined); } +/** + * Longest custom suffix accepted by the tab suffix editor. + * + * The suffix keeps its full width until the whole title area is used up: the + * title shortens first, and only then does the suffix itself end in an ellipsis. + * 8 characters fit completely in every `tabMaxWidth` preset from 240px up, even + * with pin and read-only controls; the narrowest presets may ellipsize it, and the + * tab tooltip always shows the full text. + */ +export const TAB_TITLE_SUFFIX_MAX_LENGTH = 8; + +/** A tab label split so the suffix can be rendered without being truncated. */ +export interface TabTitleParts { + base: string; + suffix: string; +} + +export function joinTabTitleParts(parts: TabTitleParts): string { + return parts.suffix ? `${parts.base} ${parts.suffix}` : parts.base; +} + /** * Display titles for a whole tab list. * @@ -121,7 +142,7 @@ export function isEventObjectBrowserTab(tab: QueryTab): boolean { * 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. + * before. A tab with a user-chosen `titleSuffix` shows that instead of a number. * * 编号不在这里计算,而是由 `syncTabTitleNumbers` 一次性写进标签(见该函数):渲染 * 函数只看标签上已有的编号,这样关闭一个重名标签不会让后面的标签被重新编号 @@ -129,13 +150,47 @@ export function isEventObjectBrowserTab(tab: QueryTab): boolean { */ export function tabDisplayTitles(tabs: QueryTab[], t: Translate): Map { const titles = new Map(); - for (const tab of tabs) { - const title = tabDisplayTitle(tab, t); - titles.set(tab.id, tabTitleNumber(tab, title) !== undefined ? `${title} ${tabTitleNumber(tab, title)}` : title); - } + for (const [id, parts] of tabDisplayTitleParts(tabs, t)) titles.set(id, joinTabTitleParts(parts)); return titles; } +/** + * Same labels as `tabDisplayTitles`, split into the title and its suffix so the + * tab strip can truncate the title while always showing the suffix in full. + */ +export function tabDisplayTitleParts(tabs: QueryTab[], t: Translate): Map { + const parts = new Map(); + for (const tab of tabs) { + const base = tabDisplayTitle(tab, t); + parts.set(tab.id, { base, suffix: tabTitleSuffix(tab, base) }); + } + return parts; +} + +function customTitleSuffix(tab: QueryTab): string | undefined { + return tab.titleSuffix?.trim() || undefined; +} + +/** The suffix a tab currently shows: its custom one, else its automatic number. */ +function tabTitleSuffix(tab: QueryTab, title: string): string { + const custom = customTitleSuffix(tab); + if (custom) return custom; + const number = tabTitleNumber(tab, title); + return number !== undefined ? String(number) : ""; +} + +/** + * Finds another open tab that would show exactly the same label as `target` + * once `suffix` is applied to it — same title and same suffix, whether the other + * tab's suffix is custom or an automatic number. + */ +export function findTabWithSameTitleSuffix(tabs: QueryTab[], target: QueryTab, suffix: string, t: Translate): QueryTab | undefined { + const wanted = suffix.trim(); + if (!wanted) return undefined; + const base = tabDisplayTitle(target, t); + return tabs.find((tab) => tab.id !== target.id && tabDisplayTitle(tab, t) === base && tabTitleSuffix(tab, base) === wanted); +} + function tabTitleNumber(tab: QueryTab, title: string): number | undefined { if (isPreviewTab(tab)) return undefined; return tab.titleNumber !== undefined && tab.titleNumberKey === title ? tab.titleNumber : undefined; @@ -153,14 +208,32 @@ function tabTitleNumber(tab: QueryTab, title: string): number | undefined { * stays unsuffixed, which keeps single-tab windows unchanged, while a tab that * carries a number keeps it until it is closed. * + * Tabs with a custom `titleSuffix` are skipped: they show that suffix instead of a + * number and do not count towards the numbered group. + * * Callers own the tab list, so this runs from the store whenever the list or * any contributing title changes — never from a render/computed path. */ export function syncTabTitleNumbers(tabs: QueryTab[], t: Translate): void { const groups = new Map(); + // Custom suffixes already shown under each title, so a freshly minted number + // never lands on a label another tab has claimed (e.g. a custom suffix "2"). + const customSuffixes = new Map>(); for (const tab of tabs) { if (isPreviewTab(tab)) continue; const title = tabDisplayTitle(tab, t); + const custom = customTitleSuffix(tab); + if (custom) { + // A custom suffix replaces the number, so the tab leaves the numbering + // pool. Dropping its old number means clearing the suffix later mints a + // fresh one instead of resurrecting a stale value. + tab.titleNumber = undefined; + tab.titleNumberKey = undefined; + const used = customSuffixes.get(title); + if (used) used.add(custom); + else customSuffixes.set(title, new Set([custom])); + continue; + } const group = groups.get(title); if (group) group.push(tab); else groups.set(title, [tab]); @@ -182,6 +255,7 @@ export function syncTabTitleNumbers(tabs: QueryTab[], t: Translate): void { let next = numbered.reduce((highest, tab) => Math.max(highest, tab.titleNumber ?? 0), 0) + 1; for (const tab of group) { if (tabTitleNumber(tab, title) !== undefined) continue; + while (customSuffixes.get(title)?.has(String(next))) next += 1; tab.titleNumber = next; tab.titleNumberKey = title; next += 1; @@ -324,6 +398,9 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v if (tab.mode === "query" && queryTitle(tab)) { lines.unshift({ label: t("tabs.tooltipTitle"), value: tab.title }); } + // The suffix can be ellipsized in a narrow tab, so the tooltip is where its full text lives. + const customSuffix = customTitleSuffix(tab); + if (customSuffix) lines.unshift({ label: t("tabs.tooltipSuffix"), value: customSuffix }); if (tab.mode === "query" && tab.externalSqlPath) { lines.push({ label: t("tabs.tooltipFilePath"), value: tab.externalSqlPath }); if (tab.externalSqlFileMissing) lines.push({ label: t("tabs.tooltipFileStatus"), value: t("tabs.externalFileMissing") }); diff --git a/apps/desktop/src/stores/__tests__/queryStore.titleSuffix.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.titleSuffix.spec.ts new file mode 100644 index 0000000000..b1a51806c5 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.titleSuffix.spec.ts @@ -0,0 +1,96 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TAB_TITLE_SUFFIX_MAX_LENGTH } from "@/lib/tabs/tabPresentation"; + +const mocks = vi.hoisted(() => ({ + saveOpenTabsState: vi.fn(), +})); + +vi.mock("@/lib/backend/api", async (importOriginal) => ({ + ...(await importOriginal()), + saveOpenTabsState: mocks.saveOpenTabsState, +})); + +describe("queryStore tab title suffix", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + mocks.saveOpenTabsState.mockReset(); + mocks.saveOpenTabsState.mockResolvedValue(undefined); + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }); + setActivePinia(createPinia()); + }); + + async function createStoreWithDataTab() { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("pg-1", "app", "users", "data", "public"); + return { store, id }; + } + + it("sets, trims and clears the suffix", async () => { + const { store, id } = await createStoreWithDataTab(); + + expect(store.setTabTitleSuffix(id, " 待办 ")).toBe(true); + expect(store.tabs[0]?.titleSuffix).toBe("待办"); + + store.setTabTitleSuffix(id, " "); + expect(store.tabs[0]).not.toHaveProperty("titleSuffix"); + }); + + it("returns false for an unknown tab", async () => { + const { store } = await createStoreWithDataTab(); + + expect(store.setTabTitleSuffix("missing", "x")).toBe(false); + }); + + it("caps the suffix length", async () => { + const { store, id } = await createStoreWithDataTab(); + + store.setTabTitleSuffix(id, "x".repeat(TAB_TITLE_SUFFIX_MAX_LENGTH + 10)); + + expect(store.tabs[0]?.titleSuffix).toHaveLength(TAB_TITLE_SUFFIX_MAX_LENGTH); + }); + + it("does not copy the suffix onto a duplicated tab", async () => { + const { store, id } = await createStoreWithDataTab(); + store.setTabTitleSuffix(id, "待办"); + + store.duplicateTab(id); + + expect(store.tabs).toHaveLength(2); + expect(store.tabs[0]?.titleSuffix).toBe("待办"); + expect(store.tabs[1]?.titleSuffix).toBeUndefined(); + }); + + it("persists a suffix change on the debounced save", async () => { + vi.useFakeTimers(); + const { store, id } = await createStoreWithDataTab(); + // Let the creation-time save settle so only the suffix change is observed. + await vi.advanceTimersByTimeAsync(400); + mocks.saveOpenTabsState.mockClear(); + + store.setTabTitleSuffix(id, "待办"); + await vi.advanceTimersByTimeAsync(400); + + expect(mocks.saveOpenTabsState).toHaveBeenCalled(); + const payload = mocks.saveOpenTabsState.mock.calls.at(-1)?.[0] as { tabs: Array<{ id: string; titleSuffix?: string }> }; + expect(payload.tabs.find((tab) => tab.id === id)?.titleSuffix).toBe("待办"); + }); + + it("stops numbering a tab once it gets a custom suffix and numbers it again after clearing", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const first = store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const second = store.createTab("pg-1", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const numberOf = (id: string) => store.tabs.find((tab) => tab.id === id)?.titleNumber; + expect([numberOf(first), numberOf(second)]).toEqual([1, 2]); + + store.setTabTitleSuffix(second, "待办"); + expect(numberOf(second)).toBeUndefined(); + + store.setTabTitleSuffix(second, ""); + expect(numberOf(second)).toBe(2); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index e0d389fb6b..c7294fc29e 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -116,7 +116,7 @@ import { clearDataGridStructuredFilterStatesForTab, copyDataGridStructuredFilter import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize"; -import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey, syncTabTitleNumbers, tabDisplayTitle } from "@/lib/tabs/tabPresentation"; +import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey, syncTabTitleNumbers, TAB_TITLE_SUFFIX_MAX_LENGTH, tabDisplayTitle } from "@/lib/tabs/tabPresentation"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery"; import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive"; @@ -1098,7 +1098,7 @@ export const useQueryStore = defineStore("query", () => { // 里而不是渲染函数里,是为了避免在 computed 求值过程中写标签状态;getter 只读 // 参与标题计算的字段(id + 显示标题),分配编号本身不会再次触发它。 watch( - () => tabs.value.map((tab) => `${tab.id}\u0000${tabDisplayTitle(tab, t)}`), + () => tabs.value.map((tab) => `${tab.id}\u0000${tabDisplayTitle(tab, t)}\u0000${tab.titleSuffix ?? ""}`), () => syncTabTitleNumbers(tabs.value, t), { immediate: true, flush: "sync" }, ); @@ -2621,6 +2621,7 @@ export const useQueryStore = defineStore("query", () => { tabs.value.map((t) => ({ id: t.id, title: t.title, + titleSuffix: t.titleSuffix, connectionId: t.connectionId, database: t.database, schema: t.schema, @@ -5579,6 +5580,19 @@ export const useQueryStore = defineStore("query", () => { return true; } + /** + * Sets (or clears, with an empty string) the user-chosen suffix shown after a + * tab's title. The suffix replaces the automatic number for that tab only. + */ + function setTabTitleSuffix(id: string, suffix: string) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab) return false; + const trimmed = suffix.trim().slice(0, TAB_TITLE_SUFFIX_MAX_LENGTH); + if (trimmed) tab.titleSuffix = trimmed; + else delete tab.titleSuffix; + return true; + } + function linkSavedSql(id: string, savedSqlId: string, title?: string) { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; @@ -10045,6 +10059,7 @@ export const useQueryStore = defineStore("query", () => { rollbackTransaction, ensureManualTransactionSession, renameTab, + setTabTitleSuffix, openDatabaseBrowser, openDatabaseSearch, openDriverProfileWorkspace, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 11e79c5e9f..444a2c1982 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1995,6 +1995,11 @@ export interface QueryTab { */ titleNumber?: number; titleNumberKey?: string; + /** + * User-chosen suffix shown after the tab title (set from the tab context menu). + * It replaces the automatic `titleNumber` for this tab and is persisted with it. + */ + titleSuffix?: string; /** Force the editor to word-wrap regardless of the global setting, e.g. for auto-generated single-line templates. */ forceWordWrap?: boolean; connectionId: string;