mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(tabs): 为标签页添加自定义标题后缀功能 (#11708)
* feat(tabs): 为标签页添加自定义标题后缀功能 * feat(tabs): 添加清除标签后缀功能并更新相关国际化文本
This commit is contained in:
@@ -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<string | null>(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<HTMLInputElement>("[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<HTMLInputElement>("[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?
|
||||
/>
|
||||
<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)">{{ tabTitleText(entry.tab) }}</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden">
|
||||
<span class="min-w-0 truncate" :style="tabTitleStyle(entry.tab)">{{ tabTitleBase(entry.tab) }}</span>
|
||||
<span v-if="tabTitleSuffixText(entry.tab)" class="tab-title-suffix max-w-full shrink-0 overflow-hidden pl-1 text-ellipsis whitespace-nowrap" :style="tabTitleStyle(entry.tab)" data-tab-title-suffix>{{ tabTitleSuffixText(entry.tab) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ReadOnlySessionControl v-if="!isTabBarCollapsed" :connection-id="entry.tab.connectionId" compact />
|
||||
<button
|
||||
@@ -2017,7 +2117,10 @@ 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)">{{ tabTitleText(tab) }}</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden">
|
||||
<span class="min-w-0 truncate" :style="tabTitleStyle(tab)">{{ tabTitleBase(tab) }}</span>
|
||||
<span v-if="tabTitleSuffixText(tab)" class="tab-title-suffix max-w-full shrink-0 overflow-hidden pl-1 text-ellipsis whitespace-nowrap" :style="tabTitleStyle(tab)">{{ tabTitleSuffixText(tab) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
@@ -2093,5 +2196,38 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog v-model:open="tabSuffixEditorOpen">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("tabs.suffixDialogTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-3">
|
||||
<label class="grid gap-1.5 text-sm">
|
||||
<span class="font-medium">{{ t("tabs.suffixLabel") }}</span>
|
||||
<Input v-model="tabSuffixInput" data-tab-suffix-input :maxlength="TAB_TITLE_SUFFIX_MAX_LENGTH" :placeholder="t('tabs.suffixPlaceholder')" class="h-9" @keydown.enter="handleTabSuffixKeydown" />
|
||||
</label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("tabs.suffixHint") }}</p>
|
||||
<p class="text-xs text-muted-foreground" data-tab-suffix-limit>{{ t("tabs.suffixMaxLength", { max: TAB_TITLE_SUFFIX_MAX_LENGTH }) }}</p>
|
||||
<p class="break-all rounded-md bg-muted px-2 py-1.5 text-xs" data-tab-suffix-preview>{{ tabSuffixEditorPreview }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button v-if="tabSuffixEditingTab?.titleSuffix" variant="outline" data-tab-suffix-clear @click="clearTabSuffix">{{ t("common.clear") }}</Button>
|
||||
<Button variant="outline" @click="tabSuffixEditorOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button data-tab-suffix-save @click="saveTabSuffix">{{ t("common.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog v-model:open="tabSuffixDuplicateOpen">
|
||||
<DialogContent class="sm:max-w-[400px]" data-tab-suffix-duplicate>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("tabs.suffixDuplicateTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="break-all text-sm text-muted-foreground">{{ t("tabs.suffixDuplicateMessage", { title: tabSuffixDuplicateTitle }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" data-tab-suffix-duplicate-back @click="backToTabSuffixEditor">{{ t("tabs.suffixDuplicateBack") }}</Button>
|
||||
<Button data-tab-suffix-duplicate-confirm @click="confirmDuplicateTabSuffix">{{ t("tabs.suffixDuplicateUseAnyway") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+412
@@ -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: `<div class="ctx-menu-stub"><button v-for="(item, index) in items.filter((entry) => entry.visible !== false && !entry.separator)" :key="index" type="button" class="ctx-item" @click="item.action && item.action()">{{ item.label }}</button><slot /></div>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: { name: "TooltipStub", template: `<div><slot /></div>` },
|
||||
TooltipTrigger: { name: "TooltipTriggerStub", template: `<div><slot /></div>` },
|
||||
TooltipContent: { name: "TooltipContentStub", template: `<div><slot /></div>` },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: { name: "PopoverStub", props: ["open"], template: `<div v-if="open"><slot /></div>` },
|
||||
PopoverContent: { name: "PopoverContentStub", template: `<div><slot /></div>` },
|
||||
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/connection/ReadOnlySessionControl.vue", () => ({
|
||||
default: { name: "ReadOnlySessionControlStub", template: `<span />` },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/layout/TabExecutionStatus.vue", () => ({
|
||||
default: { name: "TabExecutionStatusStub", template: `<span><slot /></span>` },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
default: { name: "DatabaseIconStub", template: `<span />` },
|
||||
}));
|
||||
|
||||
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<typeof createPinia>) {
|
||||
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<HTMLElement>(`[data-tab-id="${tabId}"]`)!.closest<HTMLElement>(".ctx-menu-stub")!;
|
||||
return Array.from(stub.querySelectorAll<HTMLButtonElement>(":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<HTMLElement>(`[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<T extends HTMLElement>(selector: string): T | null {
|
||||
return document.body.querySelector<T>(selector);
|
||||
}
|
||||
|
||||
async function typeSuffix(value: string) {
|
||||
const input = byAttr<HTMLInputElement>("[data-tab-suffix-input]")!;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("EditorGroupTabBar tab title suffix", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
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<typeof useQueryStore>) => [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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLElement>(".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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[data-tab-suffix-save]")!.click();
|
||||
await settle();
|
||||
byAttr<HTMLButtonElement>("[data-tab-suffix-duplicate-back]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(byAttr("[data-tab-suffix-duplicate]")).toBeNull();
|
||||
expect(byAttr<HTMLInputElement>("[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<HTMLButtonElement>("[data-tab-suffix-save]")!.click();
|
||||
await settle();
|
||||
byAttr<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "下に分割",
|
||||
|
||||
@@ -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: "아래로 분할",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Разделить снизу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "拆分并下移",
|
||||
|
||||
@@ -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: "拆分並下移",
|
||||
|
||||
@@ -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' })]);
|
||||
|
||||
|
||||
@@ -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> = {}) => 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];
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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<string, string> {
|
||||
const titles = new Map<string, string>();
|
||||
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<string, TabTitleParts> {
|
||||
const parts = new Map<string, TabTitleParts>();
|
||||
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<string, QueryTab[]>();
|
||||
// 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<string, Set<string>>();
|
||||
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") });
|
||||
|
||||
@@ -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<typeof import("@/lib/backend/api")>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user