From 0fd7b0d41b2bfce62fb17659c96bb7b7d32a0008 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 19 Jul 2026 11:12:04 +0800 Subject: [PATCH] fix(connection): preserve edited name after tests --- .../connection/ConnectionDialog.vue | 35 ++++++++-- .../__tests__/connectionEditDraftSync.spec.ts | 68 +++++++++++++++++-- .../connection/connectionEditDraftSync.ts | 14 ++++ 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 5867a018b..51fcf8aa3 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -21,7 +21,7 @@ import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stor import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts"; -import { connectionEditDraftSyncAction } from "./connectionEditDraftSync"; +import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; @@ -1299,12 +1299,35 @@ function applySuccessfulConnectionTest(result: ConnectionTestResult, config: Con testedGeneratedName.value = config.name; } -async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) { +async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string, runId: number) { if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return; const fingerprint = connectionConfigFingerprint(config, sourceName); - if (fingerprint !== savedConnectionConfigFingerprint.value) return; + let currentDraftFingerprint: string; try { - await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo); + const currentDraft = connectionConfigForSubmit(editingId.value, form.value.name); + currentDraftFingerprint = connectionConfigFingerprint(currentDraft, form.value.name); + } catch { + return; + } + // An in-flight test must not publish its saved snapshot after the user edits, + // switches, or closes the draft that initiated it. + if ( + !canPersistConnectionTestResult({ + testConfigId: config.id, + activeDraftId: editingId.value, + testRunId: runId, + activeTestRunId: testRunId, + submittedFingerprint: fingerprint, + savedFingerprint: savedConnectionConfigFingerprint.value, + currentDraftFingerprint, + }) + ) { + return; + } + const persistedDraftId = editingId.value; + try { + await store.updateConnectionDatabaseInfo(persistedDraftId, result.databaseInfo); + if (runId !== testRunId || editingId.value !== persistedDraftId) return; savedDatabaseInfo.value = { ...result.databaseInfo }; savedDatabaseInfoFingerprint.value = fingerprint; } catch { @@ -2358,7 +2381,7 @@ async function testConnection() { successfulConfig = connectionConfigForSubmit(config.id, config.name); } applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName); - void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName); + void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName, runId); clearEditedConnectionErrorAfterSuccessfulTest(); } catch (e: any) { if (runId !== testRunId) return; @@ -2372,7 +2395,7 @@ async function testConnection() { } if (fallback) { applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName); - void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName); + void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName, runId); clearEditedConnectionErrorAfterSuccessfulTest(); } else { clearTestedConnectionInfo(); diff --git a/apps/desktop/src/components/connection/__tests__/connectionEditDraftSync.spec.ts b/apps/desktop/src/components/connection/__tests__/connectionEditDraftSync.spec.ts index a007adad0..fb7fbb195 100644 --- a/apps/desktop/src/components/connection/__tests__/connectionEditDraftSync.spec.ts +++ b/apps/desktop/src/components/connection/__tests__/connectionEditDraftSync.spec.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { connectionEditDraftSyncAction } from "../connectionEditDraftSync"; +import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "../connectionEditDraftSync"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} describe("connectionEditDraftSyncAction", () => { it("hydrates normal connection edits without reloading an active draft", () => { @@ -11,12 +19,64 @@ describe("connectionEditDraftSyncAction", () => { }); it("keeps a copied connection draft when its saved snapshot is refreshed", () => { - const draft = { id: "connection-b", host: "edited.example.test", password: "edited-secret" }; - const savedCopySnapshot = { id: "connection-b", host: "original.example.test", password: "original-secret" }; + const draft = { id: "connection-b", name: "edited copy", host: "edited.example.test", password: "edited-secret" }; + const savedCopySnapshot = { id: "connection-b", name: "original copy", host: "original.example.test", password: "original-secret" }; const action = connectionEditDraftSyncAction(savedCopySnapshot.id, true, draft.id); expect(action).toBe("preserve"); - expect(draft).toEqual({ id: "connection-b", host: "edited.example.test", password: "edited-secret" }); + expect(draft).toEqual({ id: "connection-b", name: "edited copy", host: "edited.example.test", password: "edited-secret" }); + }); + + it("does not persist an asynchronous test after the user edits the copied name", async () => { + const pendingTest = deferred(); + const draft = { id: "connection-b", name: "original copy", fingerprint: "saved-copy" }; + const completion = pendingTest.promise.then((submittedFingerprint) => + canPersistConnectionTestResult({ + testConfigId: draft.id, + activeDraftId: draft.id, + testRunId: 7, + activeTestRunId: 7, + submittedFingerprint, + savedFingerprint: "saved-copy", + currentDraftFingerprint: draft.fingerprint, + }), + ); + + draft.name = "edited copy"; + draft.fingerprint = "edited-copy"; + pendingTest.resolve("saved-copy"); + + expect(await completion).toBe(false); + expect(draft.name).toBe("edited copy"); + }); + + it("rejects test side effects after switching targets or closing the dialog", () => { + const baseState = { + testConfigId: "connection-a", + testRunId: 4, + submittedFingerprint: "saved-a", + savedFingerprint: "saved-a", + currentDraftFingerprint: "saved-a", + }; + + expect(canPersistConnectionTestResult({ ...baseState, activeDraftId: "connection-b", activeTestRunId: 5 })).toBe(false); + expect(canPersistConnectionTestResult({ ...baseState, activeDraftId: null, activeTestRunId: 5 })).toBe(false); + expect(connectionEditDraftSyncAction("connection-b", true, "connection-a")).toBe("hydrate"); + expect(connectionEditDraftSyncAction("connection-a", false, "connection-a")).toBe("preserve"); + }); + + it("preserves the normal unchanged test persistence path", () => { + expect( + canPersistConnectionTestResult({ + testConfigId: "connection-a", + activeDraftId: "connection-a", + testRunId: 3, + activeTestRunId: 3, + submittedFingerprint: "saved-a", + savedFingerprint: "saved-a", + currentDraftFingerprint: "saved-a", + }), + ).toBe(true); }); }); diff --git a/apps/desktop/src/components/connection/connectionEditDraftSync.ts b/apps/desktop/src/components/connection/connectionEditDraftSync.ts index 3c78fa898..23744a8a5 100644 --- a/apps/desktop/src/components/connection/connectionEditDraftSync.ts +++ b/apps/desktop/src/components/connection/connectionEditDraftSync.ts @@ -1,5 +1,15 @@ export type ConnectionEditDraftSyncAction = "hydrate" | "reset" | "preserve"; +export interface ConnectionTestPersistenceState { + testConfigId: string; + activeDraftId: string | null; + testRunId: number; + activeTestRunId: number; + submittedFingerprint: string; + savedFingerprint: string; + currentDraftFingerprint: string; +} + export function connectionEditDraftSyncAction(configId: string | null, isOpen: boolean, activeDraftId: string | null): ConnectionEditDraftSyncAction { if (!isOpen) return "preserve"; // Saved snapshots for the active connection must not overwrite its draft, @@ -7,3 +17,7 @@ export function connectionEditDraftSyncAction(configId: string | null, isOpen: b if (configId && configId === activeDraftId) return "preserve"; return configId ? "hydrate" : "reset"; } + +export function canPersistConnectionTestResult(state: ConnectionTestPersistenceState): boolean { + return state.testConfigId === state.activeDraftId && state.testRunId === state.activeTestRunId && state.submittedFingerprint === state.savedFingerprint && state.currentDraftFingerprint === state.submittedFingerprint; +}