mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(tabs): assign duplicate-tab numbers once without renumbering on close
This commit is contained in:
+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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user