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

This commit is contained in:
zipg
2026-09-22 10:58:15 +08:00
committed by GitHub
parent b7152a29cd
commit 8858a731aa
8 changed files with 138 additions and 20 deletions
+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();
+10 -1
View File
@@ -77,7 +77,7 @@ import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGr
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 } from "@/lib/tabs/tabPresentation";
import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey, syncTabTitleNumbers, 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";
@@ -919,6 +919,15 @@ export const useQueryStore = defineStore("query", () => {
});
}
watch(() => tabs.value.map((tab) => tab.id), ensureTabCreationTimes, { immediate: true, flush: "sync" });
// 重名标签的编号同样在这里集中分配:编号只在标签首次重名时发一次,关闭标签不会
// 让幸存标签改名(#9938),新建的标签接着最大编号往后排。放在 store 的 watcher
// 里而不是渲染函数里,是为了避免在 computed 求值过程中写标签状态;getter 只读
// 参与标题计算的字段(id + 显示标题),分配编号本身不会再次触发它。
watch(
() => tabs.value.map((tab) => `${tab.id}\u0000${tabDisplayTitle(tab, t)}`),
() => syncTabTitleNumbers(tabs.value, t),
{ immediate: true, flush: "sync" },
);
// Default auto-commit for a fresh query tab follows the user's global
// "默认事务提交方式" setting (Settings > Editor), not a hard-coded constant.
const defaultAutoCommitForDbTypeWithSetting = (dbType?: string) => defaultAutoCommitForDbType(dbType, settingsStore.editorSettings.defaultTransactionMode);
+10
View File
@@ -1665,6 +1665,16 @@ export interface QueryTab {
createdAt?: number;
title: string;
customTitle?: boolean;
/**
* 同名标签之间用来区分的稳定编号,以及分配编号时的那个显示标题。
*
* 编号只在标签首次出现重名时分配一次,之后即使其它重名标签被关闭也不再回收到
* 其它标签上:关闭中间的标签不会让后面的标签改名(#9938)。因为标签的显示标题
* 会随库名切换、重命名、紧凑标题设置而变,所以用 titleNumberKey 记住分配时的
* 标题,标题变了就重新参与分配。
*/
titleNumber?: number;
titleNumberKey?: string;
/** Force the editor to word-wrap regardless of the global setting, e.g. for auto-generated single-line templates. */
forceWordWrap?: boolean;
connectionId: string;
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB