feat(editor): handle tabs after connection disconnect

This commit is contained in:
javalover123 authored and GitHub committed 2026-09-20 01:12:21 +08:00
1 parent 3cb1faf1c6
commit 28cc1b9768
31 files changed
+1137 -60

No files matched your search

@@ -0,0 +1,320 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function installApiMocks() {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
connectDb: vi.fn().mockResolvedValue("preview-1"),
disconnectDb: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadConnections: vi.fn().mockResolvedValue([]),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
loadTableVGroups: vi.fn().mockResolvedValue({}),
saveTableVGroups: vi.fn().mockResolvedValue(undefined),
deleteTableVGroupsForConnection: vi.fn().mockResolvedValue(undefined),
saveOpenTabsState: vi.fn().mockResolvedValue(undefined),
loadOpenTabsState: vi.fn().mockResolvedValue(null),
// 关闭页签会顺带回收结果/客户端会话;缺失的导出会让清理路径报错刷屏。
closeQuerySession: vi.fn().mockResolvedValue(undefined),
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
forgetSessionCredential: vi.fn().mockResolvedValue(undefined),
}));
// Result-snapshot cleanup hits a relative URL, which node's fetch rejects outright.
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
return { ...actual, deleteTabResultSnapshotsForOwner: vi.fn().mockResolvedValue(undefined) };
});
}
function pgConnection(id: string, name: string, database?: string): ConnectionConfig {
return {
id,
name,
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
database,
read_only: false,
} as ConnectionConfig;
}
describe("connectionStore delete-time tab handling", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("keep-sql-tabs keeps SQL tabs, closes object tabs, and remembers the database", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
settingsStore.editorSettings.rememberConnectionDatabaseOnDelete = true;
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
const dataId = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true });
await store.removeConnection("conn-a");
expect(queryStore.tabs.some((tab) => tab.id === sqlId)).toBe(true);
expect(queryStore.tabs.some((tab) => tab.id === dataId)).toBe(false);
const kept = queryStore.tabs.find((tab) => tab.id === sqlId);
// 页签仍指向已删除的连接,但记录了原连接名,供新建同名连接重绑。
expect(kept?.connectionId).toBe("conn-a");
expect(kept?.detachedConnectionName).toBe("prod");
expect(settingsStore.editorSettings.rememberedConnectionDatabases.prod).toEqual({ database: "app", dbType: "postgres" });
});
it("keep-pinned-sql-tabs keeps pinned SQL tabs only", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.deleteConnectionTabHandlingMode = "keep-pinned-sql-tabs";
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const pinnedId = queryStore.createTab("conn-a", "app", "pinned.sql", "query");
const plainId = queryStore.createTab("conn-a", "app", "plain.sql", "query");
const dataId = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true });
queryStore.togglePinnedTab(pinnedId);
await store.removeConnection("conn-a");
expect(queryStore.tabs.map((tab) => tab.id)).toEqual([pinnedId]);
expect(queryStore.tabs.some((tab) => tab.id === plainId)).toBe(false);
expect(queryStore.tabs.some((tab) => tab.id === dataId)).toBe(false);
});
it("does not remember or detach tabs when the memory switch is off", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
settingsStore.editorSettings.rememberConnectionDatabaseOnDelete = false;
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
await store.removeConnection("conn-a");
const kept = queryStore.tabs.find((tab) => tab.id === sqlId);
expect(kept?.detachedConnectionName).toBeUndefined();
expect(settingsStore.editorSettings.rememberedConnectionDatabases).toEqual({});
});
it("rebinds kept SQL tabs and reuses the remembered database when a same-named connection is created", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
settingsStore.editorSettings.rememberConnectionDatabaseOnDelete = true;
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const defaultDbTab = queryStore.createTab("conn-a", "app", "draft.sql", "query");
// 页签可能指向该连接下的另一个库,重绑不应改写它的执行上下文。
const otherDbTab = queryStore.createTab("conn-a", "reporting", "report.sql", "query");
const noDbTab = queryStore.createTab("conn-a", "", "scratch.sql", "query");
await store.removeConnection("conn-a");
expect(queryStore.tabs.some((tab) => tab.id === defaultDbTab)).toBe(true);
// 新建同名连接(表单未填数据库)时,回填记住的数据库并把脱离页签重绑过来。
await store.addConnection(pgConnection("conn-b", "prod"));
expect(store.getConfig("conn-b")?.database).toBe("app");
const rebound = queryStore.tabs.find((tab) => tab.id === defaultDbTab);
expect(rebound?.connectionId).toBe("conn-b");
expect(rebound?.database).toBe("app");
expect(rebound?.detachedConnectionName).toBeUndefined();
expect(queryStore.tabs.find((tab) => tab.id === otherDbTab)).toMatchObject({ connectionId: "conn-b", database: "reporting" });
// 页签没有库时才回落到新连接的默认库。
expect(queryStore.tabs.find((tab) => tab.id === noDbTab)).toMatchObject({ connectionId: "conn-b", database: "app" });
});
it("still asks before discarding an unsaved SQL draft when the policy closes tabs", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.deleteConnectionTabHandlingMode = "close-tabs";
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const dirtyId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
queryStore.updateSql(dirtyId, "select 1;");
await store.removeConnection("conn-a");
// 删除连接不能静默丢掉未保存的草稿:先弹保存/放弃确认,而不是直接关掉。
expect(queryStore.showCloseConfirm).toBe(true);
expect(queryStore.pendingCloseTabId).toBe(dirtyId);
// 用户在确认框里选择取消时页签会留下,此时它仍带着原连接名,可被新建的同名连接接管。
expect(queryStore.tabs.find((tab) => tab.id === dirtyId)?.detachedConnectionName).toBe("prod");
queryStore.forceCloseAllPendingTabs();
expect(queryStore.tabs.some((tab) => tab.id === dirtyId)).toBe(false);
});
it("does not reuse the remembered database for a same-named connection of another type", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
settingsStore.editorSettings.rememberConnectionDatabaseOnDelete = true;
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "test", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
await store.removeConnection("conn-a");
// "test" 这类名字常被不同数据库类型复用;跨类型回填会把无意义的库名写进新连接配置。
await store.addConnection({ ...pgConnection("conn-b", "test"), db_type: "sqlite", driver_profile: "sqlite" } as ConnectionConfig);
expect(store.getConfig("conn-b")?.database).toBeUndefined();
// 页签重绑只看连接名,与类型无关。
expect(queryStore.tabs.find((tab) => tab.id === sqlId)?.connectionId).toBe("conn-b");
});
it("asks before discarding an unsaved structure draft when only SQL tabs are kept", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "kept.sql", "query");
const structureId = queryStore.createTab("conn-a", "app", "users", "structure", "public", undefined, undefined, { forceNew: true });
const structureTab = queryStore.tabs.find((tab) => tab.id === structureId);
if (!structureTab) throw new Error("structure tab was not created");
structureTab.structureDraft = { dirty: true } as never;
await store.removeConnection("conn-a");
// SQL 页签按策略保留;未保存的表结构草稿仍要先确认,不能静默丢弃。
expect(queryStore.tabs.some((tab) => tab.id === sqlId)).toBe(true);
expect(queryStore.showCloseConfirm).toBe(true);
expect(queryStore.pendingCloseTabId).toBe(structureId);
});
it("keep-all-tabs closes nothing and keeps the current results", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.deleteConnectionTabHandlingMode = "keep-all-tabs";
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "kept.sql", "query");
const dataId = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true });
const sqlTab = queryStore.tabs.find((tab) => tab.id === sqlId);
if (!sqlTab) throw new Error("query tab was not created");
sqlTab.result = { columns: [], rows: [] } as never;
await store.removeConnection("conn-a");
// 连表格页签也保留,且结果不被清空(对应断开设置里的「不关闭相关页签」)。
expect(queryStore.tabs.map((tab) => tab.id)).toEqual([sqlId, dataId]);
expect(queryStore.tabs.find((tab) => tab.id === sqlId)?.result).toBeDefined();
expect(queryStore.tabs.find((tab) => tab.id === sqlId)?.detachedConnectionName).toBe("prod");
expect(queryStore.showCloseConfirm).toBe(false);
});
it("reproduces the reported flow: keep-all-tabs set through the real settings write path", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
// 走设置面板真实使用的写入路径(而不是直接改字段),确保草稿/patch/归一化链路都覆盖。
settingsStore.updateEditorSettings({ deleteConnectionTabHandlingMode: "keep-all-tabs", rememberConnectionDatabaseOnDelete: true });
expect(settingsStore.editorSettings.deleteConnectionTabHandlingMode).toBe("keep-all-tabs");
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
queryStore.updateSql(sqlId, "select 1;");
// 真实删除流程:removeConnections 应用删除策略,随后 disconnect 只清会话。
await store.removeConnections(["conn-a"]);
await store.disconnect("conn-a", { skipTabHandling: true });
expect(queryStore.tabs.some((tab) => tab.id === sqlId)).toBe(true);
expect(queryStore.tabs.find((tab) => tab.id === sqlId)?.connectionId).toBe("conn-a");
});
it("keeps tabs untouched when a new connection has a different name", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.deleteConnectionTabHandlingMode = "keep-sql-tabs";
const store = useConnectionStore();
store.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
await store.removeConnection("conn-a");
await store.addConnection(pgConnection("conn-b", "staging"));
const orphan = queryStore.tabs.find((tab) => tab.id === sqlId);
expect(orphan?.connectionId).toBe("conn-a");
expect(orphan?.detachedConnectionName).toBe("prod");
});
});
@@ -30,6 +30,9 @@ function installApiMocks() {
loadTableVGroups: vi.fn().mockResolvedValue({}),
saveTableVGroups: vi.fn().mockResolvedValue(undefined),
deleteTableVGroupsForConnection: vi.fn().mockResolvedValue(undefined),
// 关闭页签会顺带回收结果/客户端会话;缺失的导出会让清理路径报错刷屏。
closeQuerySession: vi.fn().mockResolvedValue(undefined),
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
@@ -126,9 +129,10 @@ describe("connectionStore one_time runtime cleanup", () => {
expect(disconnectDb).not.toHaveBeenCalled();
});
// The connection is gone for good, so its tabs can never reconnect: executing in
// one would fail with "Connection config not found".
it("removeConnection closes the tabs of a one_time connection", async () => {
// 删除连接是终态,页签一律由「删除连接」策略决定,one_time 连接也不例外——它的页签虽然
// 无法再执行,但里面的 SQL 文本是用户的工作。默认 close-tabs 仍会关闭页签,只是未保存的
// 草稿必须先经保存/放弃确认,不能像以前那样被静默丢弃。
it("removeConnection asks before discarding an unsaved SQL draft of a one_time connection", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
@@ -141,7 +145,28 @@ describe("connectionStore one_time runtime cleanup", () => {
await store.removeConnection("preview-1");
expect(queryStore.showCloseConfirm).toBe(true);
expect(queryStore.pendingCloseTabId).toBe(queryId);
queryStore.forceCloseAllPendingTabs();
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
});
it("removeConnection keeps the SQL tabs of a one_time connection when the policy says so", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().updateEditorSettings({ deleteConnectionTabHandlingMode: "keep-all-tabs" });
const store = useConnectionStore();
const queryStore = useQueryStore();
store.connections = [previewConnection()];
const queryId = queryStore.createTab("preview-1", "", "sales.parquet", "query");
queryStore.updateSql(queryId, "select 1;");
await store.removeConnection("preview-1");
expect(queryStore.tabs.some((tab) => tab.id === queryId)).toBe(true);
expect(queryStore.showCloseConfirm).toBe(false);
});
@@ -166,7 +191,9 @@ describe("connectionStore one_time runtime cleanup", () => {
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
});
it("removeConnection keeps the tabs of a saved connection for the disconnect handling mode", async () => {
// 删除连接是终态(配置已从磁盘移除、无法重连),因此页签由「删除连接」策略决定,
// 不再复用「断开连接」策略。默认 close-tabs:删除即关闭该连接的页签。
it("removeConnection applies the delete tab handling mode to a saved connection", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
@@ -177,6 +204,6 @@ describe("connectionStore one_time runtime cleanup", () => {
await store.removeConnection("saved-1");
expect(queryStore.tabs.some((tab) => tab.connectionId === "saved-1")).toBe(true);
expect(queryStore.tabs.some((tab) => tab.connectionId === "saved-1")).toBe(false);
});
});
+35
View File
@@ -313,6 +313,41 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ restoreOpenTabsOnLaunch: true } as any).openTabsRestoreMode).toBe("all");
});
it("defaults the delete-time tab handling to closing tabs and preserves explicit modes", () => {
expect(normalizeEditorSettings({}).deleteConnectionTabHandlingMode).toBe("close-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-sql-tabs" }).deleteConnectionTabHandlingMode).toBe("keep-sql-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-pinned-sql-tabs" }).deleteConnectionTabHandlingMode).toBe("keep-pinned-sql-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-all-tabs" }).deleteConnectionTabHandlingMode).toBe("keep-all-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "invalid" as any }).deleteConnectionTabHandlingMode).toBe("close-tabs");
// 早期试验值收敛到最接近的正式取值。
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-tabs" } as any).deleteConnectionTabHandlingMode).toBe("keep-sql-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-pinned" } as any).deleteConnectionTabHandlingMode).toBe("keep-pinned-sql-tabs");
expect(normalizeEditorSettings({ deleteConnectionTabHandlingMode: "keep-all" } as any).deleteConnectionTabHandlingMode).toBe("keep-all-tabs");
});
it("remembers connection databases by default and sanitizes the stored map", () => {
expect(normalizeEditorSettings({}).rememberConnectionDatabaseOnDelete).toBe(true);
expect(normalizeEditorSettings({ rememberConnectionDatabaseOnDelete: false }).rememberConnectionDatabaseOnDelete).toBe(false);
expect(normalizeEditorSettings({ rememberConnectionDatabaseOnDelete: "true" } as any).rememberConnectionDatabaseOnDelete).toBe(true);
expect(normalizeEditorSettings({ rememberedConnectionDatabases: { prod: { database: "app", dbType: "postgres" } } }).rememberedConnectionDatabases).toEqual({ prod: { database: "app", dbType: "postgres" } });
// 空名、缺库名/类型、以及非对象条目全部丢弃。
expect(
normalizeEditorSettings({
rememberedConnectionDatabases: {
" ": { database: "app", dbType: "postgres" },
dev: { database: "shop", dbType: "mysql" },
noDb: { database: " ", dbType: "mysql" },
noType: { database: "shop" },
legacyString: "shop",
bad: 3,
other: null,
},
} as any).rememberedConnectionDatabases,
).toEqual({ dev: { database: "shop", dbType: "mysql" } });
expect(normalizeEditorSettings({ rememberedConnectionDatabases: [] } as any).rememberedConnectionDatabases).toEqual({});
expect(normalizeEditorSettings({ rememberedConnectionDatabases: "prod" } as any).rememberedConnectionDatabases).toEqual({});
});
it("keeps unsaved SQL drafts on quit by default and preserves explicit modes", () => {
expect(normalizeEditorSettings({}).appCloseUnsavedTabsMode).toBe("keep-drafts");
expect(normalizeEditorSettings({ appCloseUnsavedTabsMode: "prompt" }).appCloseUnsavedTabsMode).toBe("prompt");
+85 -29
View File
@@ -132,8 +132,9 @@ import { loadTimeoutInheritanceBackup, saveTimeoutInheritanceBackup } from "@/li
import { migrateSqlServerLegacyCompatibilityConfig, requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache";
import { deletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
import { disposeSqlServerActivityTracesForConnection, hasSqlServerActivityTraceForConnection } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection, visibleDatabasePatternsAreEnabled } from "@/lib/database/visibleDatabases";
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, isDraftVisibleSchemasConnectionId, normalizeVisibleDatabaseSelection, visibleDatabasePatternsAreEnabled } from "@/lib/database/visibleDatabases";
import {
buildObjectGroupPlaceholderNodes,
buildGroupedObjectTreeNodes,
@@ -1118,27 +1119,43 @@ export const useConnectionStore = defineStore("connection", () => {
/**
* A one-time connection is deleted outright rather than left behind for a
* reconnect, so its tabs have nothing to point at once it is gone: executing in
* one would fail with "Connection config not found". Close them regardless of
* `disconnectTabHandlingMode`, which only makes sense for connections that can
* still be reconnected.
* reconnect, so its runtime record has nothing left to point at.
*
* Tab handling is deliberately NOT done here: `applyDeletedConnectionTabHandling`
* already applied `deleteConnectionTabHandlingMode` to these connections, so
* force-closing here would silently override the user's policy (and discard
* unsaved SQL drafts) — exactly what that setting exists to prevent.
*/
async function closeOneTimeConnectionTabs(connectionIds: string[]) {
if (!connectionIds.length) return;
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
for (const connectionId of connectionIds) {
queryStore.closeConnectionTabs(connectionId, { force: true });
}
async function cleanupRemovedOneTimeConnections(connectionIds: string[]) {
releaseOneTimeRuntimeConnections(connectionIds);
}
async function cleanupRemovedOneTimeConnections(connectionIds: string[]) {
try {
await closeOneTimeConnectionTabs(connectionIds);
} catch (error) {
console.warn("[DBX][connection:delete:one-time-tab-cleanup-failed]", { connectionIds, error });
/**
* 删除连接后的页签处理:按 `deleteConnectionTabHandlingMode` 决定保留哪些 SQL 页签,
* 并在开启「记住连接名与数据库」时记录 连接名 → { 数据库名, 类型 },供新建同名连接回填与重绑。
*
* `one_time` 临时连接同样走这套策略:它的页签虽然无法再执行,但里面的 SQL 文本是用户的工作,
* 不该被静默丢弃。
*
* 只排除可见 schema 选择器的临时草稿连接——它们只活在弹窗交互期间,不会出现在页签里。
* 空名连接无法按名字重绑,只跳过「记住连接名」;页签本身仍要按策略关闭/保留,
* 否则会留下指向已删除连接的孤儿页签。
*/
async function applyDeletedConnectionTabHandling(configs: readonly ConnectionConfig[]) {
const targets = configs.filter((config) => !isDraftVisibleSchemasConnectionId(config.id));
if (!targets.length) return;
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
const keep = deletedConnectionTabKeepMode(settingsStore.editorSettings.deleteConnectionTabHandlingMode);
const remember = settingsStore.editorSettings.rememberConnectionDatabaseOnDelete;
const namedTargets = targets.filter((config) => config.name.trim() !== "");
if (remember && namedTargets.length) settingsStore.rememberConnectionDatabases(namedTargets.map((config) => [config.name, config.database, config.db_type] as const));
for (const config of targets) {
queryStore.detachConnectionTabsForDelete(config.id, { keep, connectionName: remember && config.name.trim() !== "" ? config.name : undefined });
}
releaseOneTimeRuntimeConnections(connectionIds);
// 页签处理是同步的内存操作,但落盘是防抖的。这里立即冲刷,避免随后重启恢复出
// 已被策略关闭的页签。
await queryStore.flushPendingPersist().catch(() => undefined);
}
function cancelDisconnectKey(connectionId: string, attempt: number): string {
@@ -3454,6 +3471,12 @@ export const useConnectionStore = defineStore("connection", () => {
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {
const normalized = normalizeConnection(config);
if (normalized.save_password === false) normalized.password = "";
const isNewConnection = !connections.value.some((c) => c.id === normalized.id);
// 新建同名同类型连接时回填「记住的数据库」,让删除连接后重建的流程少一步手选。
if (isNewConnection && !normalized.database?.trim()) {
const remembered = settingsStore.rememberedDatabaseForConnection(normalized.name, normalized.db_type);
if (remembered) normalized.database = remembered;
}
await persistTimeoutInheritance(normalized.id, normalized.connect_timeout_inherit === true, normalized.query_timeout_inherit === true);
const existing = connections.value.findIndex((c) => c.id === normalized.id);
const nextConnections = [...connections.value];
@@ -3470,6 +3493,14 @@ export const useConnectionStore = defineStore("connection", () => {
rebuildTreeNodes();
persistSidebarLayoutDebounced();
stopCreatingConnectionInGroup();
// 删除连接时保留下来的 SQL 页签按连接名重新绑定到新连接上(含数据库回填)。
if (isNewConnection) {
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
if (queryStore.rebindDetachedTabs(normalized.name, normalized.id, normalized.database) > 0) {
await queryStore.flushPendingPersist().catch(() => undefined);
}
}
}
function copyConnectionsToTreeClipboard(connectionIds: Iterable<string>): number {
@@ -3590,12 +3621,20 @@ export const useConnectionStore = defineStore("connection", () => {
const removedIds = new Set(connectionIds);
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
// 连接配置在 applyConnectionRemoval 后就读不到了,先抓取快照供删除策略使用。
const removedConfigs = connectionIds.map((id) => getConfig(id)).filter((config): config is ConnectionConfig => !!config);
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
let nextLayout = sidebarLayout.value;
for (const id of removedIds) nextLayout = removeConnectionFromSidebarLayout(nextLayout, id);
await persistConnectionDeletion(nextConnections, nextLayout);
applyConnectionRemoval(removedIds, nextConnections, nextLayout);
purgeTableVGroupsForConnections(removedIds);
// 删除已经落盘完成;页签处理失败只告警,不能让已成功的删除以异常收场。
try {
await applyDeletedConnectionTabHandling(removedConfigs);
} catch (error) {
console.warn("[DBX][connection:delete:tab-handling-failed]", { connectionIds: [...removedIds], error });
}
await cleanupRemovedOneTimeConnections(oneTimeIds);
}
@@ -4274,7 +4313,14 @@ export const useConnectionStore = defineStore("connection", () => {
clearConnectionHealthCheck(connectionId);
}
async function disconnect(connectionId: string) {
/**
* 断开连接。
*
* `options.skipTabHandling` 用于「删除连接」流程:删除时页签已按
* `deleteConnectionTabHandlingMode` 处理过,这里只做会话清理,不能再套用
* `disconnectTabHandlingMode`,否则会把刚保留下来的 SQL 页签又关掉。
*/
async function disconnect(connectionId: string, options: { skipTabHandling?: boolean } = {}) {
const stateRevision = bumpConnectionStateRevision(connectionId);
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
if (hasSqlServerActivityTraceForConnection(connectionId)) await disposeSqlServerActivityTracesForConnection(connectionId);
@@ -4314,16 +4360,18 @@ export const useConnectionStore = defineStore("connection", () => {
// 同表必须重新拉取结构)。completion/object-browser 缓存上面已清;
// 这里再走统一 helper,bump 连接代次并清 data-tab freshness。
invalidateConnectionMetadataLifetime(connectionId);
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
case "close-tabs":
queryStore.closeConnectionTabs(connectionId);
break;
case "keep-tabs-clear-results":
queryStore.releaseConnectionTabs(connectionId);
break;
case "keep-tabs-keep-results":
queryStore.rollbackConnectionTransactions(connectionId);
break;
if (!options.skipTabHandling) {
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
case "close-tabs":
queryStore.closeConnectionTabs(connectionId);
break;
case "keep-tabs-clear-results":
queryStore.releaseConnectionTabs(connectionId);
break;
case "keep-tabs-keep-results":
queryStore.rollbackConnectionTransactions(connectionId);
break;
}
}
// Tab handling is synchronous in memory, but persistence is debounced. Flush
// the scoped tab state before disconnect returns so a quick reconnect/restart
@@ -8944,6 +8992,8 @@ export const useConnectionStore = defineStore("connection", () => {
const connectionIds = deleteConnections ? connectionIdsInGroupsOp(sidebarLayout.value, uniqueGroupIds).filter((id) => connections.value.some((connection) => connection.id === id)) : [];
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
const removedConnectionIds = new Set(connectionIds);
// 删除策略需要连接配置,applyConnectionRemoval 之后就读不到了,先抓快照。
const removedConnectionConfigs = connectionIds.map((id) => getConfig(id)).filter((config): config is ConnectionConfig => !!config);
const nextConnections = removedConnectionIds.size ? connections.value.filter((connection) => !removedConnectionIds.has(connection.id)) : connections.value;
let layoutAfterConnectionRemoval = previousLayout;
for (const id of removedConnectionIds) layoutAfterConnectionRemoval = removeConnectionFromSidebarLayout(layoutAfterConnectionRemoval, id);
@@ -8954,6 +9004,12 @@ export const useConnectionStore = defineStore("connection", () => {
if (removedConnectionIds.size) {
applyConnectionRemoval(removedConnectionIds, nextConnections, nextLayout);
purgeTableVGroupsForConnections(removedConnectionIds);
// 删除已经落盘完成;页签处理失败只告警,不能让已成功的删除以异常收场。
try {
await applyDeletedConnectionTabHandling(removedConnectionConfigs);
} catch (error) {
console.warn("[DBX][connection:delete:tab-handling-failed]", { connectionIds: [...removedConnectionIds], error });
}
} else {
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
+84 -19
View File
@@ -7,6 +7,7 @@ import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { DeletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
@@ -2423,6 +2424,7 @@ export const useQueryStore = defineStore("query", () => {
whereInput: t.whereInput,
pinned: t.pinned,
mode: t.mode,
detachedConnectionName: t.detachedConnectionName,
autoCommit: t.autoCommit,
resultAutoSave: t.resultAutoSave,
uiState: t.uiState,
@@ -4578,33 +4580,94 @@ export const useQueryStore = defineStore("query", () => {
return refreshed;
}
/** 释放单个页签的运行期状态:回滚事务、清空结果与执行态,但保留页签本身。 */
function releaseTabRuntimeState(tab: QueryTab) {
rollbackTabTransaction(tab, { resetAutoCommit: true });
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
}
/**
* 释放匹配的页签:关闭非 SQL 页签,并清空 SQL 页签的运行期状态(保留页签本身)。
*/
function releaseTabsWhere(predicate: (tab: QueryTab) => boolean) {
closeTabsWhere((tab) => predicate(tab) && tab.mode !== "query");
tabs.value
.filter((tab) => predicate(tab))
.forEach((tab) => {
rollbackTabTransaction(tab, { resetAutoCommit: true });
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
});
tabs.value.filter((tab) => predicate(tab)).forEach(releaseTabRuntimeState);
}
function releaseConnectionTabs(connectionId: string) {
releaseTabsWhere((tab) => tab.connectionId === connectionId);
}
/**
* 删除连接时的页签处理:连接配置已从磁盘移除、无法再重连,因此策略与断开连接分开。
* - `none`:关闭该连接的全部页签(默认)
* - `sql`:保留全部 SQL 页签(`mode === "query"`),其余关闭
* - `pinned-sql`:只保留固定的 SQL 页签,其余关闭
* - `all`:不关闭任何页签,保留 SQL 文本与当前结果(与断开设置的「不关闭相关页签」一致)
*
* 关闭一律走非 force 路径(`closeScopedTabsWhere` → `beginBatchClose`):干净页签立即关闭,
* 未保存的 SQL 草稿与表结构草稿都要经保存/放弃确认,删除连接不能静默丢改动。
*
* 该连接下的 SQL 页签都会记录原连接名(`detachedConnectionName`),便于新建同名连接后
* 重新绑定;包括因用户在确认框中选择取消而残留的页签。
*/
function detachConnectionTabsForDelete(connectionId: string, options: { keep?: DeletedConnectionTabKeepMode; connectionName?: string } = {}) {
const keepMode = options.keep ?? "none";
const keep = keepMode === "all" ? () => true : keepMode === "pinned-sql" ? (tab: QueryTab) => tab.mode === "query" && tab.pinned === true : keepMode === "sql" ? (tab: QueryTab) => tab.mode === "query" : () => false;
const connectionName = options.connectionName?.trim();
// 先给该连接下的 SQL 页签打上脱离标记。关闭流程可能因为「未保存」确认被用户取消,
// 取消后残留的页签同样需要能按连接名重绑到新建的同名连接。
if (connectionName) {
for (const tab of tabs.value) {
if (tab.connectionId !== connectionId || tab.mode !== "query") continue;
tab.detachedConnectionName = connectionName;
}
}
if (keepMode === "all") {
// 连接已消失,任何未结束的事务都不可能再提交,但仍保留页签与已加载的结果。
rollbackConnectionTransactions(connectionId);
return;
}
closeScopedTabsWhere((tab) => tab.connectionId === connectionId && !keep(tab));
tabs.value.filter((tab) => tab.connectionId === connectionId && keep(tab)).forEach(releaseTabRuntimeState);
}
/**
* 新建同名连接后,把因删除连接而保留下来的 SQL 页签重新绑定到新连接。
* 返回重新绑定的页签数量;同名页签已指向该连接时只清除脱离标记。
*
* 页签自身记录的库优先——它可能指向该连接下的另一个库;只有页签没有库时才回落到
* 新连接的默认库,避免重绑把页签的执行上下文改掉。
*/
function rebindDetachedTabs(connectionName: string, connectionId: string, database?: string): number {
const name = connectionName.trim();
if (!name) return 0;
const fallbackDatabase = database?.trim();
let rebound = 0;
for (const tab of tabs.value) {
if (tab.detachedConnectionName !== name) continue;
tab.detachedConnectionName = undefined;
if (tab.connectionId === connectionId) continue;
tab.connectionId = connectionId;
if (fallbackDatabase && !tab.database?.trim()) tab.database = fallbackDatabase;
rebound += 1;
}
return rebound;
}
function releaseDatabaseTabs(connectionId: string, database: string) {
releaseTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
}
@@ -8830,6 +8893,8 @@ export const useQueryStore = defineStore("query", () => {
refreshDataTabsForTable,
releaseConnectionTabs,
releaseDatabaseTabs,
detachConnectionTabsForDelete,
rebindDetachedTabs,
staleConnectionDataTabMetadata,
isDatabaseOpen,
openDatabaseKeys,
+103
View File
@@ -673,6 +673,13 @@ export const SIDEBAR_INDENT_DEFAULT = 16;
const DISCONNECT_TAB_HANDLING_MODES = ["close-tabs", "keep-tabs-clear-results", "keep-tabs-keep-results"] as const;
export type DisconnectTabHandlingMode = (typeof DISCONNECT_TAB_HANDLING_MODES)[number];
/**
* 删除连接(连接配置已从磁盘移除,无法再重连)时,对该连接已打开页签的处理策略。
* 与 {@link DisconnectTabHandlingMode} 分开:断开连接仍可重连,删除则不会。
*/
const DELETE_CONNECTION_TAB_HANDLING_MODES = ["close-tabs", "keep-sql-tabs", "keep-pinned-sql-tabs", "keep-all-tabs"] as const;
export type DeleteConnectionTabHandlingMode = (typeof DELETE_CONNECTION_TAB_HANDLING_MODES)[number];
const CLICK_TABLE_NAVIGATION_TARGETS = ["data", "ddl"] as const;
export type ClickTableNavigationTarget = (typeof CLICK_TABLE_NAVIGATION_TARGETS)[number];
@@ -744,6 +751,16 @@ export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [
export type SidebarObjectInfoMode = "comment-inline" | "comment-aligned" | "comment-right" | "size" | "hidden";
/**
* 删除连接时记住的「连接名 → 数据库」。带上 `dbType` 是为了只在新建同名**同类型**
* 连接时回填——像 "test"/"local" 这类名字常被不同数据库类型复用,跨类型回填会把
* 无意义的库名写进新连接配置。
*/
export interface RememberedConnectionDatabase {
database: string;
dbType: string;
}
export interface EditorSettings {
fontFamily: string;
fontSize: number;
@@ -863,6 +880,11 @@ export interface EditorSettings {
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: number;
openTabsRestoreMode: OpenTabsRestoreMode;
disconnectTabHandlingMode: DisconnectTabHandlingMode;
deleteConnectionTabHandlingMode: DeleteConnectionTabHandlingMode;
/** 删除连接时记住「连接名 → 数据库名」,新建同名同类型连接时自动回填并重绑保留的 SQL 页签。 */
rememberConnectionDatabaseOnDelete: boolean;
/** 已记住的「连接名 → { 数据库名, 数据库类型 }」映射,用于新建同名连接时自动选中数据库。 */
rememberedConnectionDatabases: Record<string, RememberedConnectionDatabase>;
dataTabReuseMode: DataTabReuseMode;
openDataTabsNextToActive: boolean;
prefillNewQueryWithSelect: boolean;
@@ -1121,6 +1143,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
openTabsRestoreMode: "all",
disconnectTabHandlingMode: "close-tabs",
deleteConnectionTabHandlingMode: "close-tabs",
rememberConnectionDatabaseOnDelete: true,
rememberedConnectionDatabases: {},
dataTabReuseMode: DEFAULT_DATA_TAB_REUSE_MODE,
openDataTabsNextToActive: false,
prefillNewQueryWithSelect: true,
@@ -1310,6 +1335,35 @@ function normalizeDisconnectTabHandlingMode(value: unknown, legacyCloseTabsOnDis
return DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode;
}
function normalizeDeleteConnectionTabHandlingMode(value: unknown): DeleteConnectionTabHandlingMode {
if (DELETE_CONNECTION_TAB_HANDLING_MODES.includes(value as DeleteConnectionTabHandlingMode)) {
return value as DeleteConnectionTabHandlingMode;
}
// 兼容早期试验值:把「保留页签」统一收敛到最接近的正式取值。
if (value === "keep-tabs" || value === "keep-sql") return "keep-sql-tabs";
if (value === "keep-pinned" || value === "keep-fixed-tabs") return "keep-pinned-sql-tabs";
if (value === "keep-all") return "keep-all-tabs";
return DEFAULT_EDITOR_SETTINGS.deleteConnectionTabHandlingMode;
}
/** 记住的连接名 → 数据库映射:只保留合法条目,并限制条目数量避免无限增长。 */
const REMEMBERED_CONNECTION_DATABASES_LIMIT = 200;
function normalizeRememberedConnectionDatabases(value: unknown): Record<string, RememberedConnectionDatabase> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const entries: [string, RememberedConnectionDatabase][] = [];
for (const [name, entry] of Object.entries(value as Record<string, unknown>)) {
const key = name.trim();
if (!key || !entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const { database, dbType } = entry as { database?: unknown; dbType?: unknown };
if (typeof database !== "string" || !database.trim() || typeof dbType !== "string") continue;
entries.push([key, { database, dbType }]);
}
if (entries.length <= REMEMBERED_CONNECTION_DATABASES_LIMIT) return Object.fromEntries(entries);
// 超出上限时保留最后写入的部分(对象插入顺序即写入顺序)。
return Object.fromEntries(entries.slice(entries.length - REMEMBERED_CONNECTION_DATABASES_LIMIT));
}
function normalizeClickTableNavigationTarget(value: unknown): ClickTableNavigationTarget {
return value === "data" || value === "ddl" ? value : DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget;
}
@@ -1650,6 +1704,9 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
}
).closeQueryTabsOnDisconnect,
),
deleteConnectionTabHandlingMode: normalizeDeleteConnectionTabHandlingMode(settings.deleteConnectionTabHandlingMode),
rememberConnectionDatabaseOnDelete: typeof settings.rememberConnectionDatabaseOnDelete === "boolean" ? settings.rememberConnectionDatabaseOnDelete : DEFAULT_EDITOR_SETTINGS.rememberConnectionDatabaseOnDelete,
rememberedConnectionDatabases: normalizeRememberedConnectionDatabases(settings.rememberedConnectionDatabases),
dataTabReuseMode: normalizeDataTabReuseMode(
settings.dataTabReuseMode,
(
@@ -2414,6 +2471,9 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.sidebarBrowseObjectsOnDatabaseActivation !== undefined) editorSettings.value.sidebarBrowseObjectsOnDatabaseActivation = partial.sidebarBrowseObjectsOnDatabaseActivation === true;
if (partial.openTabsRestoreMode !== undefined) editorSettings.value.openTabsRestoreMode = normalizeOpenTabsRestoreMode(partial.openTabsRestoreMode);
if (partial.disconnectTabHandlingMode !== undefined) editorSettings.value.disconnectTabHandlingMode = normalizeDisconnectTabHandlingMode(partial.disconnectTabHandlingMode);
if (partial.deleteConnectionTabHandlingMode !== undefined) editorSettings.value.deleteConnectionTabHandlingMode = normalizeDeleteConnectionTabHandlingMode(partial.deleteConnectionTabHandlingMode);
if (partial.rememberConnectionDatabaseOnDelete !== undefined) editorSettings.value.rememberConnectionDatabaseOnDelete = partial.rememberConnectionDatabaseOnDelete === true;
if (partial.rememberedConnectionDatabases !== undefined) editorSettings.value.rememberedConnectionDatabases = normalizeRememberedConnectionDatabases(partial.rememberedConnectionDatabases);
if (partial.dataTabReuseMode !== undefined) editorSettings.value.dataTabReuseMode = normalizeDataTabReuseMode(partial.dataTabReuseMode);
if (partial.openDataTabsNextToActive !== undefined) editorSettings.value.openDataTabsNextToActive = partial.openDataTabsNextToActive === true;
if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect;
@@ -2526,6 +2586,46 @@ export const useSettingsStore = defineStore("settings", () => {
});
}
/**
* 批量记住「连接名 → 数据库名 + 类型」;数据库名或类型为空表示清除该条目。
* 删除连接时写入,新建同名同类型连接时回填。整体只触发一次设置落盘。
*/
function rememberConnectionDatabases(entries: Iterable<readonly [string, string | undefined, string | undefined]>) {
const next = { ...editorSettings.value.rememberedConnectionDatabases };
let changed = false;
for (const [connectionName, database, dbType] of entries) {
const name = connectionName.trim();
const value = database?.trim();
if (!name || !value || !dbType) {
if (name && name in next) {
delete next[name];
changed = true;
}
continue;
}
const previous = next[name];
if (previous?.database === value && previous.dbType === dbType) continue;
// 重新插入以刷新写入顺序,让上限裁剪优先淘汰最旧的条目。
delete next[name];
next[name] = { database: value, dbType };
changed = true;
}
if (!changed) return;
updateEditorSettings({ rememberedConnectionDatabases: next });
}
/** 只在连接名与数据库类型都匹配时返回记住的数据库;类型不同视为无关记录。 */
function rememberedDatabaseForConnection(connectionName: string, dbType: string): string {
const name = connectionName.trim();
if (!name || !dbType) return "";
const entry = editorSettings.value.rememberedConnectionDatabases[name];
return entry && entry.dbType === dbType ? entry.database : "";
}
function clearRememberedConnectionDatabases() {
updateEditorSettings({ rememberedConnectionDatabases: {} });
}
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
const columnFormatters = { ...editorSettings.value.columnFormatters };
const normalized = normalizeColumnFormatter(formatter);
@@ -2677,6 +2777,9 @@ export const useSettingsStore = defineStore("settings", () => {
initMcpGlobalPolicy,
updateMcpGlobalPolicy,
updateColumnFormatter,
rememberConnectionDatabases,
rememberedDatabaseForConnection,
clearRememberedConnectionDatabases,
customColumnFormatterDeleteVersion,
upsertCustomColumnFormatter,
deleteCustomColumnFormatter,