From bb2a65d618560dce339edac364f3fa0e672e13da Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 20 Sep 2026 00:05:37 +0200 Subject: [PATCH] fix: end a conflict resolution when the editing session does, not the component Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/ResourceEditor.svelte | 21 ++++++------ .../components/ResourceEditorDrawer.svelte | 4 +++ .../src/lib/components/VariableEditor.svelte | 32 +++++++++++++------ 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index a7a63af6ad..0763701468 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -265,14 +265,17 @@ /** A resolution is in flight. Both buttons go disabled: clicking the other one midway would * race two resolutions of one conflict against each other. */ let resolvingConflict = $state(false) - /** Identifies the editor instance and the resolution within it. Comparing `selected`/path - * alone is not enough: closing and reopening the same item gives a *new* editor those same - * values, so a resolution left over from the old one would pass that check and write into it. - * Bumped per resolution and zeroed on teardown, so a stale one can always tell it is stale. */ + /** Which editing session a conflict resolution belongs to. Comparing `selected`/path is not + * enough — this component outlives the drawer and reopening the same resource reuses it with + * those same values — so a resolution carries the session it started in and every step checks + * it is still the current one. */ let resolveGeneration = 0 - onDestroy(() => { - resolveGeneration = -1 - }) + /** Nothing outstanding speaks for this editor any more. Exported because the drawer, not this + * component, is what knows a session has ended. */ + export function endEditingSession(): void { + resolveGeneration++ + } + onDestroy(endEditingSession) /** The server refused this tab's autosave because the row moved under it: another tab, or the * AI chat, which writes these drafts too. Nothing typed here reaches the server until the user * picks a version, and the unsaved-changes banner says the opposite — that the edits are held @@ -290,10 +293,10 @@ async function resolveDraftConflict(keepMine: boolean): Promise { const ws = selected const p = initialPath - if (!ws || !p || resolvingConflict || resolveGeneration < 0) return + if (!ws || !p || resolvingConflict) return const query = { workspace: ws, itemKind: 'resource' as const, path: p } const gen = ++resolveGeneration - const stillOurs = () => gen === resolveGeneration && selected === ws && initialPath === p + const stillOurs = () => gen === resolveGeneration && selected === ws resolvingConflict = true try { if (keepMine) { diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 052c8c9e92..153eb58d5d 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -49,6 +49,7 @@ localDraftDeployed: () => unknown localDraftCurrent: () => unknown discardLocalDraft: () => void + endEditingSession: () => void } | undefined = $state(undefined) let hasLocalDraft = $state(false) @@ -131,6 +132,9 @@ size="50rem" {disableChatOffset} on:close={() => { + // The editor outlives this drawer, so tell it the session is over: a conflict resolution + // still in flight must not land on whatever the next opening shows. + resourceEditor?.endEditingSession?.() if (keepAnchorOnClose) { keepAnchorOnClose = false return diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 438a6ba1fb..1d300ea480 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -128,14 +128,17 @@ /** A resolution is in flight. Both buttons go disabled: clicking the other one midway would * race two resolutions of one conflict against each other. */ let resolvingConflict = $state(false) - /** Identifies the editor instance and the resolution within it. Comparing `selected`/path - * alone is not enough: closing and reopening the same item gives a *new* editor those same - * values, so a resolution left over from the old one would pass that check and write into it. - * Bumped per resolution and zeroed on teardown, so a stale one can always tell it is stale. */ + /** Which editing session a conflict resolution belongs to. Comparing `selected`/path is not + * enough — reopening the same variable reuses this component and those same values — so a + * resolution carries the session it started in and every step checks it is still the current + * one. Ended by `endEditingSession`, which every entry point and the teardown go through. */ let resolveGeneration = 0 - onDestroy(() => { - resolveGeneration = -1 - }) + /** Nothing outstanding speaks for this editor any more: a different variable, a different + * session on the same one, or the component going away. */ + function endEditingSession(): void { + resolveGeneration++ + } + onDestroy(endEditingSession) /** The server refused this tab's autosave because the row moved under it: another tab, or the * AI chat, which writes these drafts too. Nothing typed here reaches the server until the user * picks a version, and the unsaved-changes banner says the opposite — that the edits are held @@ -153,10 +156,10 @@ async function resolveDraftConflict(keepMine: boolean): Promise { const ws = selected const p = editPath - if (!ws || !p || resolvingConflict || resolveGeneration < 0) return + if (!ws || !p || resolvingConflict) return const query = { workspace: ws, itemKind: 'variable' as const, path: p } const gen = ++resolveGeneration - const stillOurs = () => gen === resolveGeneration && selected === ws && editPath === p + const stillOurs = () => gen === resolveGeneration && selected === ws resolvingConflict = true try { if (keepMine) { @@ -305,6 +308,8 @@ }) function reset() { + // A new session starts here, so anything still running for the last one is spent. + endEditingSession() // Clearing workspaceSpecs triggers useMany's reconcile to release // every acquired entry. The $derived `states` then collapses to {}. workspaceSpecs = [] @@ -410,7 +415,14 @@ } - clearPageDrawerAnchor(VARIABLES_PATH)}> + { + endEditingSession() + clearPageDrawerAnchor(VARIABLES_PATH) + }} +>