fix(tabs): assign duplicate-tab numbers once without renumbering on close

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 10:58:15 +08:00
1 parent b7152a29cd
commit 8858a731aa
8 files changed
+138 -20

No files matched your search

+56 -3
View File
@@ -16,6 +16,7 @@ import {
tabDatabaseIconType,
tabDisplayTitle,
tabDisplayTitles,
syncTabTitleNumbers,
tabIconClass,
tabTooltipLines,
tabularResultItems,
@@ -192,21 +193,73 @@ describe("query result grid identity", () => {
});
describe("tab group presentation", () => {
// 编号由 store 在标签列表变化时分配,渲染函数只读结果;这里把两步串起来,
// 与真实调用顺序保持一致(#9938)。
const titlesWithNumbers = (tabs: QueryTab[]) => {
syncTabTitleNumbers(tabs, translate);
return tabDisplayTitles(tabs, translate);
};
it("numbers colliding query tab titles so several tabs stay distinguishable", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" }), queryTab({ id: "tab-3" })];
const titles = tabDisplayTitles(tabs, translate);
const titles = titlesWithNumbers(tabs);
expect([...titles.values()]).toEqual(["PostgreSQL@db 1", "PostgreSQL@db 2", "PostgreSQL@db 3"]);
});
it("keeps the remaining numbers when a tab in the middle is closed", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" }), queryTab({ id: "tab-3" })];
titlesWithNumbers(tabs);
const titles = titlesWithNumbers(tabs.filter((tab) => tab.id !== "tab-2"));
expect(titles.get("tab-1")).toBe("PostgreSQL@db 1");
expect(titles.get("tab-3")).toBe("PostgreSQL@db 3");
});
it("keeps the number on the last surviving tab of a closed group", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" })];
titlesWithNumbers(tabs);
expect(titlesWithNumbers([tabs[0]!]).get("tab-1")).toBe("PostgreSQL@db 1");
});
it("continues after the highest number still in use when a new tab opens", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" }), queryTab({ id: "tab-3" })];
titlesWithNumbers(tabs);
const next = [...tabs.filter((tab) => tab.id !== "tab-2"), queryTab({ id: "tab-4" })];
const titles = titlesWithNumbers(next);
expect(titles.get("tab-1")).toBe("PostgreSQL@db 1");
expect(titles.get("tab-3")).toBe("PostgreSQL@db 3");
expect(titles.get("tab-4")).toBe("PostgreSQL@db 4");
});
it("drops the number of a tab whose title stops colliding", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2" })];
titlesWithNumbers(tabs);
tabs[1]!.database = "other";
const titles = titlesWithNumbers(tabs);
expect(titles.get("tab-1")).toBe("PostgreSQL@db 1");
expect(titles.get("tab-2")).toBe("PostgreSQL@other");
});
it("leaves tab titles untouched while they are unique", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1" }), queryTab({ id: "tab-2", database: "other" }), queryTab({ id: "tab-3", customTitle: true, title: "orders.sql", savedSqlId: "sql-1" })];
const titles = tabDisplayTitles(tabs, translate);
const titles = titlesWithNumbers(tabs);
expect(titles.get("tab-1")).toBe("PostgreSQL@db");
expect(titles.get("tab-2")).toBe("PostgreSQL@other");
expect(titles.get("tab-3")).toBe("orders.sql");
@@ -217,7 +270,7 @@ describe("tab group presentation", () => {
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig, { id: "conn-preview", name: "[Preview] PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "app" } as ConnectionConfig];
const tabs = [queryTab({ id: "tab-1", customTitle: true, title: "orders.sql", savedSqlId: "sql-1" }), queryTab({ id: "tab-preview", connectionId: "conn-preview" }), queryTab({ id: "tab-2", customTitle: true, title: "orders.sql", savedSqlId: "sql-2" })];
const titles = tabDisplayTitles(tabs, translate);
const titles = titlesWithNumbers(tabs);
expect(titles.get("tab-1")).toBe("orders.sql 1");
expect(titles.get("tab-2")).toBe("orders.sql 2");
expect(titles.get("tab-preview")).toBe("SQL");
+62 -16
View File
@@ -66,7 +66,9 @@ export function databaseDisplayNameForTab(connectionId: string, database: string
export function isPreviewTab(tab: QueryTab): boolean {
const connectionStore = useConnectionStore();
const config = connectionStore.getConfig(tab.connectionId);
return !!config?.name.startsWith("[Preview]");
// Tolerant of a config that has no name yet (partially loaded or migrated
// connection): such a tab is simply not a preview tab.
return Boolean(config?.name?.startsWith("[Preview]"));
}
function queryTitle(tab: QueryTab): string | undefined {
@@ -86,29 +88,73 @@ export function isEventObjectBrowserTab(tab: QueryTab): boolean {
* 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.
*
* 编号不在这里计算,而是由 `syncTabTitleNumbers` 一次性写进标签(见该函数):渲染
* 函数只看标签上已有的编号,这样关闭一个重名标签不会让后面的标签被重新编号
* (#9938)。未分配编号的标签按原样显示。
*/
export function tabDisplayTitles(tabs: QueryTab[], t: Translate): Map<string, string> {
const titles = new Map<string, string>();
for (const tab of tabs) titles.set(tab.id, tabDisplayTitle(tab, t));
const collisions = new Map<string, number>();
for (const tab of tabs) {
if (isPreviewTab(tab)) continue;
const title = titles.get(tab.id) ?? "";
collisions.set(title, (collisions.get(title) ?? 0) + 1);
}
const seen = new Map<string, number>();
for (const tab of tabs) {
const title = titles.get(tab.id) ?? "";
if (isPreviewTab(tab) || (collisions.get(title) ?? 0) < 2) continue;
const index = (seen.get(title) ?? 0) + 1;
seen.set(title, index);
titles.set(tab.id, `${title} ${index}`);
const title = tabDisplayTitle(tab, t);
titles.set(tab.id, tabTitleNumber(tab, title) !== undefined ? `${title} ${tabTitleNumber(tab, title)}` : title);
}
return titles;
}
function tabTitleNumber(tab: QueryTab, title: string): number | undefined {
if (isPreviewTab(tab)) return undefined;
return tab.titleNumber !== undefined && tab.titleNumberKey === title ? tab.titleNumber : undefined;
}
/**
* Assigns the stable numeric suffix used by `tabDisplayTitles` to every tab
* whose plain title collides with another tab's.
*
* The number is minted once — when the collision first appears — and is never
* recycled afterwards: closing the middle tab of `x 1 / x 2 / x 3` leaves
* `x 1 / x 3` instead of renumbering the survivor to `x 2` (#9938), and a newly
* created tab continues after the highest number still in use (so the next tab
* there becomes `x 4`). A tab that no longer collides and was never numbered
* stays unsuffixed, which keeps single-tab windows unchanged, while a tab that
* carries a number keeps it until it is closed.
*
* 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[]>();
for (const tab of tabs) {
if (isPreviewTab(tab)) continue;
const title = tabDisplayTitle(tab, t);
const group = groups.get(title);
if (group) group.push(tab);
else groups.set(title, [tab]);
}
for (const [title, group] of groups) {
const numbered = group.filter((tab) => tabTitleNumber(tab, title) !== undefined);
if (group.length < 2 && numbered.length === 0) {
// A lone, never-numbered tab shows its plain title; drop a number that a
// previous title left behind so state does not accumulate stale suffixes.
for (const tab of group) {
if (tab.titleNumber !== undefined) {
tab.titleNumber = undefined;
tab.titleNumberKey = undefined;
}
}
continue;
}
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;
tab.titleNumber = next;
tab.titleNumberKey = title;
next += 1;
}
}
}
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
const settingsStore = useSettingsStore();