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

@@ -2820,6 +2820,30 @@ watch(
},
);
// 删除连接时若开启了「记住连接名与数据库」,新建同名**同类型**连接会自动选中记住的数据库。
// 只在数据库字段为空、或仍是上一次自动回填的值时才覆盖,避免抢走用户手输的内容。
const lastRememberedDatabaseAutofill = ref("");
watch(
() => [open.value, editingId.value, form.value.name, form.value.db_type] as const,
([isOpen, editing, rawName, dbType]) => {
if (!isOpen || editing) {
lastRememberedDatabaseAutofill.value = "";
return;
}
const name = (rawName ?? "").trim();
const remembered = name ? settingsStore.rememberedDatabaseForConnection(name, dbType) : "";
const current = (form.value.database ?? "").trim();
if (current === remembered) {
lastRememberedDatabaseAutofill.value = remembered;
return;
}
if (current && current !== lastRememberedDatabaseAutofill.value) return;
form.value.database = remembered || undefined;
lastRememberedDatabaseAutofill.value = remembered;
},
{ immediate: true },
);
const databaseLabel = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAlias");
if (form.value.db_type === "oracle") return t("connection.serviceName");
@@ -83,6 +83,7 @@ import {
type DesktopIconTheme,
type InterfaceLayout,
type DisconnectTabHandlingMode,
type DeleteConnectionTabHandlingMode,
type DataTabReuseMode,
type DataGridFilterEditorView,
type MultiStatementDefaultView,
@@ -723,6 +724,15 @@ const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSel
const editSidebarBrowseObjectsOnDatabaseActivation = ref(settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation);
const editOpenTabsRestoreMode = ref<OpenTabsRestoreMode>(settingsStore.editorSettings.openTabsRestoreMode);
const editDisconnectTabHandlingMode = ref<DisconnectTabHandlingMode>(settingsStore.editorSettings.disconnectTabHandlingMode);
const editDeleteConnectionTabHandlingMode = ref<DeleteConnectionTabHandlingMode>(settingsStore.editorSettings.deleteConnectionTabHandlingMode);
const editRememberConnectionDatabaseOnDelete = ref(settingsStore.editorSettings.rememberConnectionDatabaseOnDelete);
// 「记住的连接名 → 数据库」是累积数据而非偏好设置,不进弹窗草稿;这里直接读写 store,
// 让清除操作立即生效,不受弹窗的保存/取消影响。
const rememberedConnectionDatabaseCount = computed(() => Object.keys(settingsStore.editorSettings.rememberedConnectionDatabases).length);
function clearRememberedConnectionDatabases() {
settingsStore.clearRememberedConnectionDatabases();
}
const editDataTabReuseMode = ref<DataTabReuseMode>(settingsStore.editorSettings.dataTabReuseMode);
const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive);
const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect);
@@ -798,6 +808,20 @@ const disconnectTabHandlingModeDescriptionKey = computed(() => {
return "disconnectTabHandlingModeCloseTabsDescription";
});
const deleteConnectionTabHandlingModeDescriptionKey = computed(() => {
switch (editDeleteConnectionTabHandlingMode.value) {
case "close-tabs":
return "deleteConnectionTabHandlingModeCloseTabsDescription";
case "keep-sql-tabs":
return "deleteConnectionTabHandlingModeKeepSqlTabsDescription";
case "keep-pinned-sql-tabs":
return "deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription";
case "keep-all-tabs":
return "deleteConnectionTabHandlingModeKeepAllTabsDescription";
}
return "deleteConnectionTabHandlingModeCloseTabsDescription";
});
const normalizedEditTableColumnTemplateFields = computed(() => tableColumnTemplateRowsToSettings(editTableColumnTemplateRows.value));
const visibleTableColumnTemplateRows = computed(() =>
editTableColumnTemplateRows.value.filter((row) => {
@@ -969,6 +993,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
sidebarBrowseObjectsOnDatabaseActivation: editSidebarBrowseObjectsOnDatabaseActivation.value,
openTabsRestoreMode: editOpenTabsRestoreMode.value,
disconnectTabHandlingMode: editDisconnectTabHandlingMode.value,
deleteConnectionTabHandlingMode: editDeleteConnectionTabHandlingMode.value,
rememberConnectionDatabaseOnDelete: editRememberConnectionDatabaseOnDelete.value,
dataTabReuseMode: editDataTabReuseMode.value,
openDataTabsNextToActive: editOpenDataTabsNextToActive.value,
prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value,
@@ -1610,6 +1636,8 @@ function syncEditorSettingsDraftFromStore() {
editSidebarBrowseObjectsOnDatabaseActivation.value = settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = settingsStore.editorSettings.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = settingsStore.editorSettings.disconnectTabHandlingMode;
editDeleteConnectionTabHandlingMode.value = settingsStore.editorSettings.deleteConnectionTabHandlingMode;
editRememberConnectionDatabaseOnDelete.value = settingsStore.editorSettings.rememberConnectionDatabaseOnDelete;
editDataTabReuseMode.value = settingsStore.editorSettings.dataTabReuseMode;
editOpenDataTabsNextToActive.value = settingsStore.editorSettings.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect;
@@ -1737,6 +1765,8 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
sidebarBrowseObjectsOnDatabaseActivation: editSidebarBrowseObjectsOnDatabaseActivation,
openTabsRestoreMode: editOpenTabsRestoreMode,
disconnectTabHandlingMode: editDisconnectTabHandlingMode,
deleteConnectionTabHandlingMode: editDeleteConnectionTabHandlingMode,
rememberConnectionDatabaseOnDelete: editRememberConnectionDatabaseOnDelete,
dataTabReuseMode: editDataTabReuseMode,
openDataTabsNextToActive: editOpenDataTabsNextToActive,
prefillNewQueryWithSelect: editPrefillNewQueryWithSelect,
@@ -2177,6 +2207,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editSidebarBrowseObjectsOnDatabaseActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode;
editDeleteConnectionTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.deleteConnectionTabHandlingMode;
editRememberConnectionDatabaseOnDelete.value = DEFAULT_EDITOR_SETTINGS.rememberConnectionDatabaseOnDelete;
editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode;
editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
@@ -2339,6 +2371,8 @@ function resetAllDefaults() {
editSidebarBrowseObjectsOnDatabaseActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode;
editDeleteConnectionTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.deleteConnectionTabHandlingMode;
editRememberConnectionDatabaseOnDelete.value = DEFAULT_EDITOR_SETTINGS.rememberConnectionDatabaseOnDelete;
editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode;
editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
@@ -2595,6 +2629,12 @@ function onDisconnectTabHandlingModeChange(v: any) {
}
}
function onDeleteConnectionTabHandlingModeChange(v: any) {
if (v === "close-tabs" || v === "keep-sql-tabs" || v === "keep-pinned-sql-tabs" || v === "keep-all-tabs") {
editDeleteConnectionTabHandlingMode.value = v;
}
}
function onLocaleChange(v: any) {
if (typeof v === "string") void setLocale(v as Locale);
}
@@ -7122,6 +7162,52 @@ onUnmounted(() => {
{{ t(`settings.${disconnectTabHandlingModeDescriptionKey}`) }}
</p>
</div>
<div class="settings-item space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="delete-connection-tab-handling-mode">{{ t("settings.deleteConnectionTabHandlingMode") }}</Label>
<HelpTooltip :label="t('settings.deleteConnectionTabHandlingMode')">
{{ t("settings.deleteConnectionTabHandlingModeDescription") }}
</HelpTooltip>
</div>
<Select :model-value="editDeleteConnectionTabHandlingMode" @update:model-value="onDeleteConnectionTabHandlingModeChange">
<SelectTrigger id="delete-connection-tab-handling-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="close-tabs">{{ t("settings.deleteConnectionTabHandlingModeCloseTabs") }}</SelectItem>
<SelectItem value="keep-sql-tabs">{{ t("settings.deleteConnectionTabHandlingModeKeepSqlTabs") }}</SelectItem>
<SelectItem value="keep-pinned-sql-tabs">
{{ t("settings.deleteConnectionTabHandlingModeKeepPinnedSqlTabs") }}
</SelectItem>
<SelectItem value="keep-all-tabs">{{ t("settings.deleteConnectionTabHandlingModeKeepAllTabs") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ t(`settings.${deleteConnectionTabHandlingModeDescriptionKey}`) }}
</p>
</div>
<div class="settings-item flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<Label for="remember-connection-database-on-delete">{{ t("settings.rememberConnectionDatabaseOnDelete") }}</Label>
<HelpTooltip :label="t('settings.rememberConnectionDatabaseOnDelete')">
{{ t("settings.rememberConnectionDatabaseOnDeleteDescription") }}
</HelpTooltip>
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.rememberConnectionDatabaseOnDeleteHint") }}
</p>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted-foreground">
{{ t("settings.rememberedConnectionDatabaseCount", { count: rememberedConnectionDatabaseCount }) }}
</span>
<Button variant="outline" size="sm" :disabled="rememberedConnectionDatabaseCount === 0" @click="clearRememberedConnectionDatabases">
{{ t("settings.clearRememberedConnectionDatabases") }}
</Button>
</div>
</div>
<Switch id="remember-connection-database-on-delete" v-model="editRememberConnectionDatabaseOnDelete" />
</div>
<div class="settings-item space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-object-info-mode">{{ t("settings.sidebarObjectInfoMode") }}</Label>
@@ -153,7 +153,8 @@ async function confirmDeleteSelectedConnections() {
try {
await connectionStore.removeConnections(ids);
for (const connectionId of ids) {
connectionStore.disconnect(connectionId).catch((error) => {
// 页签已由 removeConnections 按「删除连接」策略处理,这里只清会话。
connectionStore.disconnect(connectionId, { skipTabHandling: true }).catch((error) => {
console.warn("[DBX][connection:delete:disconnect-failed]", { connectionId, error });
});
}
@@ -416,13 +416,14 @@ function tabTitleText(tab: QueryTab) {
}
function tabConnectionLabel(tab: QueryTab) {
return connectionStore.getConfig(tab.connectionId)?.name || tab.connectionId;
// 连接已删除但页签被保留时,回退到原连接名,避免显示成裸 uuid。
return connectionStore.getConfig(tab.connectionId)?.name || tab.detachedConnectionName || tab.connectionId;
}
function tabConnectionTargetLabel(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId);
const host = connection?.host.trim();
return connection && host ? `${host}:${connection.port}` : tab.connectionId;
return connection && host ? `${host}:${connection.port}` : tab.detachedConnectionName || tab.connectionId;
}
function databaseTabGroupBaseLabel(tab: QueryTab) {
@@ -0,0 +1,112 @@
import { createPinia, setActivePinia } from "pinia";
import { shallowRef } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
const mocks = vi.hoisted(() => ({ toast: vi.fn() }));
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }),
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
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),
}));
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;
}
function connectionNode(connectionId: string): TreeNode {
return { id: connectionId, label: connectionId, type: "connection", connectionId, isExpanded: false, children: [] };
}
describe("删除连接的真实侧边栏流程", () => {
beforeEach(() => {
setActivePinia(createPinia());
installLocalStorage();
installApiMocks();
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("keep-all-tabs:走 confirmDelete 后 SQL 页签仍在", async () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const { useSidebarConnectionMutationRuntime } = await import("@/composables/useSidebarConnectionMutationRuntime");
const { showDeleteConfirm } = await import("@/components/sidebar/sidebarTreeDialogState");
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ deleteConnectionTabHandlingMode: "keep-all-tabs", rememberConnectionDatabaseOnDelete: true });
const connectionStore = useConnectionStore();
connectionStore.connections = [pgConnection("conn-a", "prod", "app")];
const queryStore = useQueryStore();
const sqlId = queryStore.createTab("conn-a", "app", "draft.sql", "query");
queryStore.updateSql(sqlId, "select 1;");
const node = connectionNode("conn-a");
const runtime = useSidebarConnectionMutationRuntime({
activeNode: shallowRef(node),
releaseActiveNodeReference: vi.fn(),
selectedTreeNodesInVisibleOrder: () => [node],
connectionStore,
queryStore,
requestGroupRename: vi.fn(),
openVisibleDatabases: vi.fn(),
openVisibleSchemas: vi.fn(),
} as never);
runtime.deleteConnection();
expect(showDeleteConfirm.value).toBe(true);
await runtime.confirmDelete();
expect(connectionStore.connections.some((c) => c.id === "conn-a")).toBe(false);
expect(queryStore.tabs.some((tab) => tab.id === sqlId)).toBe(true);
expect(settingsStore.editorSettings.rememberedConnectionDatabases.prod).toEqual({ database: "app", dbType: "postgres" });
});
});
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shallowRef } from "vue";
import { connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionDeleteTargetSnapshot, connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import type { TreeNode } from "@/types/database";
const mocks = vi.hoisted(() => ({
@@ -174,6 +174,35 @@ describe("sidebar connection move selection", () => {
});
});
describe("sidebar connection deletion selection", () => {
beforeEach(() => {
vi.clearAllMocks();
sidebarFormTarget.value = null;
connectionDeleteTargetSnapshot.value = [];
showDeleteConfirm.value = false;
});
it("deletes the selected connections and leaves tab handling to the delete policy", async () => {
const nodes = [connectionNode("conn-1"), connectionNode("conn-2")];
const store = connectionStore(nodes.map((node) => node.id));
const { deleteConnection, confirmDelete } = runtime(nodes[0], store, nodes);
deleteConnection();
expect(showDeleteConfirm.value).toBe(true);
await confirmDelete();
expect(store.removeConnections).toHaveBeenCalledWith(["conn-1", "conn-2"]);
// removeConnections 已按「删除连接」策略处理页签,disconnect 必须跳过断开策略,
// 否则会把刚保留下来的 SQL 页签又关掉。
expect(store.disconnect.mock.calls).toEqual([
["conn-1", { skipTabHandling: true }],
["conn-2", { skipTabHandling: true }],
]);
expect(mocks.toast).toHaveBeenCalledWith('connection.deletedSelected:{"count":2}', 2000);
});
});
describe("sidebar connection group deletion selection", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -221,7 +250,11 @@ describe("sidebar connection group deletion selection", () => {
expect(store.removeConnections).not.toHaveBeenCalled();
expect(store.deleteConnectionGroups).toHaveBeenCalledWith(["group-1", "group-2"], true);
expect(store.disconnect.mock.calls).toEqual([["conn-1"], ["conn-2"]]);
// 页签已由 deleteConnectionGroups 按「删除连接」策略处理,disconnect 只清会话。
expect(store.disconnect.mock.calls).toEqual([
["conn-1", { skipTabHandling: true }],
["conn-2", { skipTabHandling: true }],
]);
expect(showDeleteGroupConfirm.value).toBe(false);
expect(connectionGroupDeleteTargetSnapshot.value).toEqual([]);
expect(deleteConnectionsWithGroup.value).toBe(false);
@@ -116,7 +116,9 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
await connectionStore.removeConnections(connectionIds);
options.releaseActiveNodeReference(targets.map((target) => target.id));
for (const connectionId of connectionIds) {
connectionStore.disconnect(connectionId).catch((error) => {
// 页签已由 removeConnections 按「删除连接」策略处理,这里只清会话,
// 避免再套用「断开连接」策略把刚保留的 SQL 页签关掉。
connectionStore.disconnect(connectionId, { skipTabHandling: true }).catch((error) => {
// Removal has already succeeded; disconnect cleanup must not turn it into a failed delete.
console.warn("[DBX][connection:delete:disconnect-failed]", { connectionId, error });
});
@@ -412,7 +414,8 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
const connectionIds = await connectionStore.deleteConnectionGroups(groupIds, deleteConnectionsWithGroup.value);
options.releaseActiveNodeReference(groupIds);
for (const connectionId of connectionIds) {
connectionStore.disconnect(connectionId).catch((error) => {
// 页签已由 deleteConnectionGroups 按「删除连接」策略处理,这里只清会话。
connectionStore.disconnect(connectionId, { skipTabHandling: true }).catch((error) => {
console.warn("[DBX][connection-group:delete:disconnect-failed]", { connectionId, error });
});
}
+15
View File
@@ -7470,6 +7470,21 @@ export default {
disconnectTabHandlingModeKeepTabsClearResultsDescription: "Close table data, object browser, and structure tabs while keeping SQL query tabs and SQL text, and also clear execution state and result sessions.",
disconnectTabHandlingModeKeepTabsKeepResults: "Do not close related tabs",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Keep related tabs, SQL text, and current results without extra cleanup.",
deleteConnectionTabHandlingMode: "Tabs after connection deletion",
deleteConnectionTabHandlingModeDescription: "Choose how related tabs are handled after a connection is deleted. A deleted connection cannot be reconnected, so kept SQL tabs are scratch workspaces only.",
deleteConnectionTabHandlingModeCloseTabs: "Close all tabs",
deleteConnectionTabHandlingModeCloseTabsDescription: "Close every tab that belongs to the deleted connection.",
deleteConnectionTabHandlingModeKeepSqlTabs: "Keep all SQL tabs",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "Close table, object browser, and structure tabs while keeping every SQL tab of this connection (pinned or not) and its SQL text, and clear execution state and results.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "Keep pinned SQL tabs",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "Keep only pinned SQL tabs; every other tab, including unpinned SQL tabs, is closed.",
deleteConnectionTabHandlingModeKeepAllTabs: "Keep all tabs",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "Close nothing and keep SQL text and current results. Open transactions are rolled back because the connection is gone.",
rememberConnectionDatabaseOnDelete: "Remember connection and database names",
rememberConnectionDatabaseOnDeleteDescription: "Remember a connection's name and database when it is deleted, and preselect them when a connection with the same name is created again.",
rememberConnectionDatabaseOnDeleteHint: "When enabled, creating a connection with the same name and database type prefills that database, and kept SQL tabs are rebound to the new connection automatically.",
rememberedConnectionDatabaseCount: "Remembered entries: {count}",
clearRememberedConnectionDatabases: "Clear remembered entries",
reuseDataTab: "Reuse data tab",
reuseDataTabDescription: "Choose how opening a table uses data tabs.",
openDataTabsNextToActive: "Open new data tabs next to the active tab",
+15
View File
@@ -7015,6 +7015,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "Cierra pestañas de datos, explorador de objetos y estructura, conserva las pestañas SQL y limpia el estado de ejecución y las sesiones.",
disconnectTabHandlingModeKeepTabsKeepResults: "No cerrar pestañas relacionadas",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Conserva las pestañas relacionadas, el texto SQL y los resultados actuales sin limpieza adicional.",
deleteConnectionTabHandlingMode: "Pestañas tras eliminar la conexión",
deleteConnectionTabHandlingModeDescription: "Elige cómo se tratan las pestañas relacionadas al eliminar una conexión. Una conexión eliminada no se puede reconectar, así que las pestañas SQL conservadas son solo un espacio de borrador.",
deleteConnectionTabHandlingModeCloseTabs: "Cerrar todas las pestañas",
deleteConnectionTabHandlingModeCloseTabsDescription: "Cierra todas las pestañas que pertenecen a la conexión eliminada.",
deleteConnectionTabHandlingModeKeepSqlTabs: "Conservar todas las pestañas SQL",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "Cierra las pestañas de tabla, explorador de objetos y estructura, conserva todas las pestañas SQL de esta conexión (fijadas o no) y su texto SQL, y limpia el estado de ejecución y los resultados.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "Conservar pestañas SQL fijadas",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "Conserva solo las pestañas SQL fijadas; el resto, incluidas las pestañas SQL sin fijar, se cierran.",
deleteConnectionTabHandlingModeKeepAllTabs: "Conservar todas las pestañas",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "No cierra ninguna pestaña y conserva el texto SQL y los resultados actuales. Las transacciones abiertas se revierten porque la conexión ya no existe.",
rememberConnectionDatabaseOnDelete: "Recordar nombres de conexión y base de datos",
rememberConnectionDatabaseOnDeleteDescription: "Recuerda el nombre y la base de datos de una conexión al eliminarla, y los preselecciona al crear otra con el mismo nombre.",
rememberConnectionDatabaseOnDeleteHint: "Si se activa, al crear una conexión con el mismo nombre y tipo de base de datos se rellena esa base de datos y las pestañas SQL conservadas se vuelven a vincular automáticamente a la nueva conexión.",
rememberedConnectionDatabaseCount: "Entradas recordadas: {count}",
clearRememberedConnectionDatabases: "Borrar las entradas recordadas",
reuseDataTab: "Reutilizar pestaña de datos",
reuseDataTabDescription: "Elige cómo se usan las pestañas de datos al abrir una tabla.",
openDataTabsNextToActive: "Abrir nuevas pestañas de datos junto a la activa",
+15
View File
@@ -7015,6 +7015,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "Chiudi i dati delle tabelle, l'esplorazione degli oggetti e le schede della struttura mantenendo le schede delle query SQL e il testo SQL, e cancella anche lo stato di esecuzione e le sessioni dei risultati.",
disconnectTabHandlingModeKeepTabsKeepResults: "Non chiudere le schede correlate",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Mantieni le schede correlate, il testo SQL e i risultati correnti senza ulteriore pulizia.",
deleteConnectionTabHandlingMode: "Schede dopo l'eliminazione della connessione",
deleteConnectionTabHandlingModeDescription: "Scegli come gestire le schede correlate quando una connessione viene eliminata. Una connessione eliminata non può essere riconnessa, quindi le schede SQL mantenute sono solo uno spazio di lavoro provvisorio.",
deleteConnectionTabHandlingModeCloseTabs: "Chiudi tutte le schede",
deleteConnectionTabHandlingModeCloseTabsDescription: "Chiude tutte le schede che appartengono alla connessione eliminata.",
deleteConnectionTabHandlingModeKeepSqlTabs: "Mantieni tutte le schede SQL",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "Chiude le schede di tabella, esplorazione oggetti e struttura, mantiene tutte le schede SQL di questa connessione (fissate o no) e il relativo testo SQL e azzera stato di esecuzione e risultati.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "Mantieni le schede SQL fissate",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "Mantiene solo le schede SQL fissate; tutte le altre, comprese le schede SQL non fissate, vengono chiuse.",
deleteConnectionTabHandlingModeKeepAllTabs: "Mantieni tutte le schede",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "Non chiude nessuna scheda e mantiene il testo SQL e i risultati correnti. Le transazioni aperte vengono annullate perché la connessione non esiste più.",
rememberConnectionDatabaseOnDelete: "Ricorda i nomi di connessione e database",
rememberConnectionDatabaseOnDeleteDescription: "Ricorda il nome e il database di una connessione quando viene eliminata e li preseleziona quando ne viene creata una con lo stesso nome.",
rememberConnectionDatabaseOnDeleteHint: "Se attivo, creando una connessione con lo stesso nome e tipo di database il database viene precompilato e le schede SQL mantenute vengono ricollegate automaticamente alla nuova connessione.",
rememberedConnectionDatabaseCount: "Voci ricordate: {count}",
clearRememberedConnectionDatabases: "Cancella le voci ricordate",
reuseDataTab: "Riusa scheda dati",
reuseDataTabDescription: "Scegli come usare le schede dati quando apri una tabella.",
openDataTabsNextToActive: "Apri le nuove schede dati accanto a quella attiva",
+15
View File
@@ -7039,6 +7039,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "テーブルデータ、オブジェクトブラウザ、構造タブを閉じ、SQLクエリタブとSQLテキストを保持し、実状態と結果セッションもクリアします。",
disconnectTabHandlingModeKeepTabsKeepResults: "関連タブを閉じない",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。",
deleteConnectionTabHandlingMode: "接続削除後のタブ処理",
deleteConnectionTabHandlingModeDescription: "接続を削除した後に関連タブをどう扱うかを選択します。削除した接続は再接続できないため、保持された SQL タブは下書き用の作業領域にすぎません。",
deleteConnectionTabHandlingModeCloseTabs: "すべてのタブを閉じる",
deleteConnectionTabHandlingModeCloseTabsDescription: "削除した接続に属するすべてのタブを閉じます。",
deleteConnectionTabHandlingModeKeepSqlTabs: "すべての SQL タブを保持",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "テーブル、オブジェクトブラウザー、構造などのタブを閉じ、この接続のすべての SQL タブ(固定・未固定を問わず)とその SQL テキストは保持しつつ、実行状態と結果をクリアします。",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "固定した SQL タブを保持",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "固定した SQL タブのみを保持し、それ以外(固定していない SQL タブを含む)はすべて閉じます。",
deleteConnectionTabHandlingModeKeepAllTabs: "すべてのタブを保持",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "どのタブも閉じず、SQL テキストと現在の結果を保持します。接続が削除済みのため、未完了のトランザクションはロールバックされます。",
rememberConnectionDatabaseOnDelete: "接続名とデータベース名を記憶",
rememberConnectionDatabaseOnDeleteDescription: "接続を削除するときに名前とデータベースを記憶し、同名の接続を再度作成するときに自動で選択します。",
rememberConnectionDatabaseOnDeleteHint: "有効にすると、同名・同タイプの接続を作成したときにそのデータベースが自動入力され、保持された SQL タブも新しい接続に自動で再バインドされます。",
rememberedConnectionDatabaseCount: "記憶済みの項目: {count}",
clearRememberedConnectionDatabases: "記憶した項目を消去",
reuseDataTab: "データタブを再利用",
reuseDataTabDescription: "テーブルを開くときのデータタブの使い方を選択します。",
openDataTabsNextToActive: "新しいデータタブをアクティブタブの隣に開く",
+15
View File
@@ -6771,6 +6771,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "테이블 데이터, 객체 브라우저, 구조 탭을 닫고 SQL 쿼리 탭과 SQL 텍스트를 유지하며 실행 상태와 결과 세션도 지웁니다.",
disconnectTabHandlingModeKeepTabsKeepResults: "관련 탭 닫지 않기",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.",
deleteConnectionTabHandlingMode: "연결 삭제 후 탭 처리",
deleteConnectionTabHandlingModeDescription: "연결을 삭제한 뒤 관련 탭을 어떻게 처리할지 선택합니다. 삭제한 연결은 다시 연결할 수 없으므로 유지된 SQL 탭은 임시 작업 공간일 뿐입니다.",
deleteConnectionTabHandlingModeCloseTabs: "모든 탭 닫기",
deleteConnectionTabHandlingModeCloseTabsDescription: "삭제한 연결에 속한 모든 탭을 닫습니다.",
deleteConnectionTabHandlingModeKeepSqlTabs: "모든 SQL 탭 유지",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "테이블, 개체 브라우저, 구조 탭은 닫고 이 연결의 모든 SQL 탭(고정 여부와 무관)과 그 SQL 텍스트는 유지하며 실행 상태와 결과는 지웁니다.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "고정된 SQL 탭 유지",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "고정된 SQL 탭만 유지하고 나머지 탭(고정되지 않은 SQL 탭 포함)은 모두 닫습니다.",
deleteConnectionTabHandlingModeKeepAllTabs: "모든 탭 유지",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "어떤 탭도 닫지 않고 SQL 텍스트와 현재 결과를 유지합니다. 연결이 삭제되었으므로 끝나지 않은 트랜잭션은 롤백됩니다.",
rememberConnectionDatabaseOnDelete: "연결 이름과 데이터베이스 이름 기억",
rememberConnectionDatabaseOnDeleteDescription: "연결을 삭제할 때 이름과 데이터베이스를 기억하고, 같은 이름의 연결을 다시 만들 때 자동으로 선택합니다.",
rememberConnectionDatabaseOnDeleteHint: "활성화하면 같은 이름과 같은 유형의 연결을 만들 때 해당 데이터베이스가 자동으로 채워지고, 유지된 SQL 탭도 새 연결에 자동으로 다시 바인딩됩니다.",
rememberedConnectionDatabaseCount: "기억된 항목: {count}",
clearRememberedConnectionDatabases: "기억된 항목 지우기",
reuseDataTab: "데이터 탭 재사용",
reuseDataTabDescription: "테이블을 열 때 데이터 탭을 사용하는 방식을 선택합니다.",
openDataTabsNextToActive: "새 데이터 탭을 활성 탭 옆에 열기",
+15
View File
@@ -7017,6 +7017,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "Fechar abas de dados de tabela, navegador de objetos e estrutura, mantendo as abas de consultas SQL e o texto SQL, e também limpar o estado de execução e as sessões de resultados.",
disconnectTabHandlingModeKeepTabsKeepResults: "Não fechar abas relacionadas",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Manter abas relacionadas, texto SQL e resultados atuais sem limpeza adicional.",
deleteConnectionTabHandlingMode: "Abas após excluir a conexão",
deleteConnectionTabHandlingModeDescription: "Escolha como as abas relacionadas são tratadas após excluir uma conexão. Uma conexão excluída não pode ser reconectada, então as abas SQL mantidas são apenas uma área de rascunho.",
deleteConnectionTabHandlingModeCloseTabs: "Fechar todas as abas",
deleteConnectionTabHandlingModeCloseTabsDescription: "Fecha todas as abas pertencentes à conexão excluída.",
deleteConnectionTabHandlingModeKeepSqlTabs: "Manter todas as abas SQL",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "Fecha as abas de tabela, navegador de objetos e estrutura, mantém todas as abas SQL desta conexão (fixadas ou não) e seu texto SQL e limpa o estado de execução e os resultados.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "Manter abas SQL fixadas",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "Mantém apenas as abas SQL fixadas; todas as outras, incluindo as abas SQL não fixadas, são fechadas.",
deleteConnectionTabHandlingModeKeepAllTabs: "Manter todas as abas",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "Não fecha nenhuma aba e mantém o texto SQL e os resultados atuais. Transações abertas são revertidas porque a conexão não existe mais.",
rememberConnectionDatabaseOnDelete: "Lembrar nomes de conexão e banco de dados",
rememberConnectionDatabaseOnDeleteDescription: "Lembra o nome e o banco de dados de uma conexão ao excluí-la e os pré-seleciona ao criar outra com o mesmo nome.",
rememberConnectionDatabaseOnDeleteHint: "Quando ativado, criar uma conexão com o mesmo nome e tipo de banco de dados preenche esse banco de dados e as abas SQL mantidas são religadas automaticamente à nova conexão.",
rememberedConnectionDatabaseCount: "Entradas lembradas: {count}",
clearRememberedConnectionDatabases: "Limpar entradas lembradas",
reuseDataTab: "Reutilizar aba de dados",
reuseDataTabDescription: "Escolha como as abas de dados são usadas ao abrir uma tabela.",
openDataTabsNextToActive: "Abrir novas abas de dados ao lado da aba ativa",
+15
View File
@@ -7118,6 +7118,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "Tablo verisi, nesne gezgini ve yapı sekmelerini kapatırken SQL sorgu sekmelerini ve SQL metnini koru; ayrıca yürütme durumunu ve sonuç oturumlarını temizle.",
disconnectTabHandlingModeKeepTabsKeepResults: "İlgili sekmeleri kapatma",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "İlgili sekmeleri, SQL metnini ve geçerli sonuçları ek bir temizlik yapmadan koru.",
deleteConnectionTabHandlingMode: "Bağlantı silindikten sonra sekmeler",
deleteConnectionTabHandlingModeDescription: "Bir bağlantı silindikten sonra ilgili sekmelerin nasıl ele alınacağını seçin. Silinen bir bağlantıya yeniden bağlanılamaz, bu nedenle korunan SQL sekmeleri yalnızca taslak çalışma alanıdır.",
deleteConnectionTabHandlingModeCloseTabs: "Tüm sekmeleri kapat",
deleteConnectionTabHandlingModeCloseTabsDescription: "Silinen bağlantıya ait tüm sekmeleri kapatır.",
deleteConnectionTabHandlingModeKeepSqlTabs: "Tüm SQL sekmelerini koru",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "Tablo, nesne tarayıcısı ve yapı sekmelerini kapatır; bu bağlantının tüm SQL sekmelerini (sabitlenmiş olsun olmasın) ve SQL metnini korur, yürütme durumunu ve sonuçları temizler.",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "Sabitlenmiş SQL sekmelerini koru",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "Yalnızca sabitlenmiş SQL sekmelerini korur; sabitlenmemiş SQL sekmeleri dahil diğer tüm sekmeler kapatılır.",
deleteConnectionTabHandlingModeKeepAllTabs: "Tüm sekmeleri koru",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "Hiçbir sekmeyi kapatmaz, SQL metnini ve geçerli sonuçları korur. Bağlantı silindiği için açık işlemler geri alınır.",
rememberConnectionDatabaseOnDelete: "Bağlantı ve veritabanı adlarını hatırla",
rememberConnectionDatabaseOnDeleteDescription: "Bir bağlantı silindiğinde adını ve veritabanını hatırlar ve aynı adla yeni bir bağlantı oluşturulduğunda bunları önceden seçer.",
rememberConnectionDatabaseOnDeleteHint: "Etkinleştirildiğinde, aynı ad ve aynı veritabanı türüyle bir bağlantı oluşturmak o veritabanını önceden doldurur ve korunan SQL sekmeleri yeni bağlantıya otomatik olarak yeniden bağlanır.",
rememberedConnectionDatabaseCount: "Hatırlanan kayıt: {count}",
clearRememberedConnectionDatabases: "Hatırlanan kayıtları temizle",
reuseDataTab: "Veri sekmesini yeniden kullan",
reuseDataTabDescription: "Bir tablo açıldığında veri sekmelerinin nasıl kullanılacağını seçin.",
openDataTabsNextToActive: "Yeni veri sekmelerini etkin sekmenin yanında aç",
+15
View File
@@ -7443,6 +7443,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "关闭表数据、对象浏览和结构等页签,保留 SQL 查询页签和 SQL 文本,同时清理执行状态与结果会话。",
disconnectTabHandlingModeKeepTabsKeepResults: "不关闭相关页签",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。",
deleteConnectionTabHandlingMode: "删除连接后的标签页处理",
deleteConnectionTabHandlingModeDescription: "删除连接后相关标签页的保留策略。删除后连接无法重连,保留的 SQL 页签只是草稿工作区。",
deleteConnectionTabHandlingModeCloseTabs: "关闭全部页签",
deleteConnectionTabHandlingModeCloseTabsDescription: "删除连接时直接关闭该连接下的全部页签。",
deleteConnectionTabHandlingModeKeepSqlTabs: "保留所有 SQL 页签",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "关闭表格、对象浏览和结构等页签,保留该连接下的全部 SQL 页签(含未固定的)及其 SQL 文本,并清空执行状态与结果。",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "保留 SQL 固定页签",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "只保留已固定的 SQL 页签,其余页签(含未固定的 SQL 页签)全部关闭。",
deleteConnectionTabHandlingModeKeepAllTabs: "保留全部页签",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "不关闭任何页签,保留 SQL 文本与当前结果;因连接已删除,未结束的事务会被回滚。",
rememberConnectionDatabaseOnDelete: "记住连接名与数据库名",
rememberConnectionDatabaseOnDeleteDescription: "删除连接时记住它的名称与数据库,再次新建同名连接时自动选中。",
rememberConnectionDatabaseOnDeleteHint: "开启后,新建同名同类型连接会自动回填该数据库,保留下来的 SQL 页签也会自动重新绑定到新连接。",
rememberedConnectionDatabaseCount: "已记住 {count} 条记录",
clearRememberedConnectionDatabases: "清除已记住的记录",
reuseDataTab: "复用数据标签页",
reuseDataTabDescription: "选择打开表时如何使用数据标签页。",
openDataTabsNextToActive: "在当前标签页旁打开新数据标签页",
+15
View File
@@ -6341,6 +6341,21 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsClearResultsDescription: "關閉資料表資料、物件瀏覽和結構等分頁,保留 SQL 查詢分頁與 SQL 文字,同時清理執行狀態與結果工作階段。",
disconnectTabHandlingModeKeepTabsKeepResults: "不關閉相關分頁",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。",
deleteConnectionTabHandlingMode: "刪除連線後的分頁處理",
deleteConnectionTabHandlingModeDescription: "刪除連線後相關分頁的保留策略。刪除後連線無法重新連線,保留的 SQL 分頁只是草稿工作區。",
deleteConnectionTabHandlingModeCloseTabs: "關閉全部分頁",
deleteConnectionTabHandlingModeCloseTabsDescription: "刪除連線時直接關閉該連線下的全部分頁。",
deleteConnectionTabHandlingModeKeepSqlTabs: "保留所有 SQL 分頁",
deleteConnectionTabHandlingModeKeepSqlTabsDescription: "關閉表格、物件瀏覽與結構等分頁,保留該連線下的全部 SQL 分頁(含未固定的)及其 SQL 文字,並清空執行狀態與結果。",
deleteConnectionTabHandlingModeKeepPinnedSqlTabs: "保留 SQL 固定分頁",
deleteConnectionTabHandlingModeKeepPinnedSqlTabsDescription: "只保留已固定的 SQL 分頁,其餘分頁(含未固定的 SQL 分頁)全部關閉。",
deleteConnectionTabHandlingModeKeepAllTabs: "保留全部分頁",
deleteConnectionTabHandlingModeKeepAllTabsDescription: "不關閉任何分頁,保留 SQL 文字與目前結果;由於連線已刪除,未結束的交易會被回滾。",
rememberConnectionDatabaseOnDelete: "記住連線名稱與資料庫名稱",
rememberConnectionDatabaseOnDeleteDescription: "刪除連線時記住它的名稱與資料庫,再次建立同名連線時自動選取。",
rememberConnectionDatabaseOnDeleteHint: "開啟後,建立同名同類型連線會自動填入該資料庫,保留下來的 SQL 分頁也會自動重新綁定到新連線。",
rememberedConnectionDatabaseCount: "已記住 {count} 筆記錄",
clearRememberedConnectionDatabases: "清除已記住的記錄",
reuseDataTab: "重複使用資料分頁",
reuseDataTabDescription: "選擇開啟資料表時如何使用資料分頁。",
openDataTabsNextToActive: "在目前分頁旁開啟新的資料分頁",
@@ -250,3 +250,28 @@ describe("openTabsPersistence originalSql round-trip", () => {
});
});
});
describe("openTabsPersistence detached connection tabs", () => {
it("preserves the original connection name of a kept SQL tab across a round-trip", () => {
const [restored] = roundTrip([queryTab({ connectionId: "deleted-conn", detachedConnectionName: "prod" })]);
expect(restored.connectionId).toBe("deleted-conn");
expect(restored.detachedConnectionName).toBe("prod");
});
it("keeps a detached SQL tab even though its connection no longer exists", () => {
const saved = serializeOpenTabs([queryTab({ id: "orphan", connectionId: "deleted-conn", detachedConnectionName: "prod" })]);
const { tabs } = restoreOpenTabsPayload({ tabs: saved, activeTabId: "orphan" }, { validConnectionIds: ["other-conn"] });
// SQL 页签不受 validConnectionIds 过滤,删除连接后保留下来的草稿页签才能跨重启存活。
expect(tabs.map((tab) => tab.id)).toEqual(["orphan"]);
expect(tabs[0].detachedConnectionName).toBe("prod");
});
it("omits the detached connection name when the tab is bound to a live connection", () => {
const [saved] = serializeOpenTabs([queryTab({})]);
expect(saved.detachedConnectionName).toBeUndefined();
});
});
@@ -23,6 +23,8 @@ export interface SavedOpenTab {
customTitle?: boolean;
connectionId: string;
database: string;
/** 原连接已被删除但页签被保留时记录的原连接名,用于新建同名连接后重新绑定。 */
detachedConnectionName?: string;
catalog?: string;
schema?: string;
sql: string;
@@ -163,6 +165,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.customTitle ? { customTitle: true } : {}),
connectionId: tab.connectionId,
database: tab.database,
...(tab.detachedConnectionName ? { detachedConnectionName: tab.detachedConnectionName } : {}),
...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}),
schema: tab.schema,
sql: shouldPersistTabSql(tab) ? tab.sql : "",
@@ -300,3 +300,8 @@ const DRAFT_VISIBLE_SCHEMAS_PREFIX = "__visible_schema_draft_";
export function buildDraftVisibleSchemasConnectionId(seed: string): string {
return `${DRAFT_VISIBLE_SCHEMAS_PREFIX}${seed}`;
}
/** 可见 schema 选择器用的临时连接:只存在于弹窗交互期间,不是用户保存的连接。 */
export function isDraftVisibleSchemasConnectionId(connectionId: string): boolean {
return connectionId.startsWith(DRAFT_VISIBLE_SCHEMAS_PREFIX);
}
@@ -84,6 +84,8 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarBrowseObjectsOnDatabaseActivation",
"openTabsRestoreMode",
"disconnectTabHandlingMode",
"deleteConnectionTabHandlingMode",
"rememberConnectionDatabaseOnDelete",
"dataTabReuseMode",
"openDataTabsNextToActive",
"prefillNewQueryWithSelect",
@@ -198,6 +198,8 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "navigation-hidden-tables", category: "navigation", titleKey: "settings.sidebarHiddenTablePrefixes", descriptionKey: "settings.sidebarHiddenTablePrefixesDescription", targetId: "navigation" },
{ id: "navigation-table-page-size", category: "navigation", titleKey: "settings.sidebarTablePageSize", descriptionKey: "settings.sidebarTablePageSizeDescription", targetId: "navigation" },
{ id: "navigation-disconnect-tabs", category: "navigation", titleKey: "settings.disconnectTabHandlingMode", descriptionKey: "settings.disconnectTabHandlingModeDescription", targetId: "navigation" },
{ id: "navigation-delete-connection-tabs", category: "navigation", titleKey: "settings.deleteConnectionTabHandlingMode", descriptionKey: "settings.deleteConnectionTabHandlingModeDescription", targetId: "navigation" },
{ id: "navigation-remember-connection-database", category: "navigation", titleKey: "settings.rememberConnectionDatabaseOnDelete", descriptionKey: "settings.rememberConnectionDatabaseOnDeleteDescription", targetId: "navigation" },
{ id: "query-page-size", category: "data", titleKey: "settings.queryPageSize", descriptionKey: "settings.queryPageSizeDescription", targetId: "data" },
{ id: "data-page-size", category: "data", titleKey: "settings.tableOpenPageSize", descriptionKey: "settings.tableOpenPageSizeDescription", targetId: "data" },
{ id: "data-table-open-sort-mode", category: "data", titleKey: "settings.tableOpenSortMode", descriptionKey: "settings.tableOpenSortDescription", targetId: "data" },
@@ -121,6 +121,8 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"sidebarBrowseObjectsOnDatabaseActivation",
"openTabsRestoreMode",
"disconnectTabHandlingMode",
"deleteConnectionTabHandlingMode",
"rememberConnectionDatabaseOnDelete",
"dataTabReuseMode",
"openDataTabsNextToActive",
"clickTableNavigationTarget",
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { deletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
describe("deletedConnectionTabKeepMode", () => {
it("maps each delete-time tab handling mode to its keep scope", () => {
expect(deletedConnectionTabKeepMode("close-tabs")).toBe("none");
expect(deletedConnectionTabKeepMode("keep-sql-tabs")).toBe("sql");
expect(deletedConnectionTabKeepMode("keep-pinned-sql-tabs")).toBe("pinned-sql");
expect(deletedConnectionTabKeepMode("keep-all-tabs")).toBe("all");
});
});
@@ -0,0 +1,21 @@
import type { DeleteConnectionTabHandlingMode } from "@/stores/settingsStore";
/**
* 删除连接时对已打开页签的保留范围。
*
* 与「断开连接」不同,删除后连接配置已从磁盘移除、无法再重连,因此被保留下来的
* SQL 页签只是草稿工作区;它们会记录原连接名,等新建同名连接时再重新绑定。
*
* - `none`:关闭全部页签
* - `sql`:保留全部 SQL 页签(清空结果),其余关闭
* - `pinned-sql`:只保留固定的 SQL 页签(清空结果),其余关闭
* - `all`:不关闭任何页签,保留 SQL 文本与当前结果,只回滚事务
*/
export type DeletedConnectionTabKeepMode = "none" | "sql" | "pinned-sql" | "all";
export function deletedConnectionTabKeepMode(mode: DeleteConnectionTabHandlingMode): DeletedConnectionTabKeepMode {
if (mode === "keep-sql-tabs") return "sql";
if (mode === "keep-pinned-sql-tabs") return "pinned-sql";
if (mode === "keep-all-tabs") return "all";
return "none";
}
@@ -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,
+5
View File
@@ -1599,6 +1599,11 @@ export interface QueryTab {
forceWordWrap?: boolean;
connectionId: string;
database: string;
/**
* 所属连接被删除后,被保留下来的 SQL 页签会记录原连接名。新建同名连接时按此
* 字段把页签重新绑定到新连接上;绑定完成后清空。
*/
detachedConnectionName?: string;
/** Optional branch context for a driver-profile database workspace. */
workspaceBranch?: string;
schema?: string;