feat(editor): handle tabs after connection disconnect
This commit is contained in:
1 parent
3cb1faf1c6
commit
28cc1b9768
31 files changed
+1137
-60
No files matched your search
@@ -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" });
|
||||
});
|
||||
});
|
||||
Vendored
+35
-2
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user