fix(tabs): assign duplicate-tab numbers once without renumbering on close
This commit is contained in:
8 files changed
+138
-20
No files matched your search
+56
-3
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user