From 4ea45a83aa82982a8af4cc6602fab89d4bfa1683 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 14 Sep 2026 20:00:03 +0200 Subject: [PATCH] fix: hold a discard superseded by a later draft, whether or not the move lands Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF --- frontend/src/lib/itemStore.svelte.ts | 3 +++ frontend/src/lib/itemStore.test.ts | 35 ++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 029434e0fe..e7ee5a5320 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -474,6 +474,9 @@ class Entry { this.lastOutsideAsk = 'discard' return this.run(async () => { this.discardsAsked-- + // A draft written again after this discard was asked outranks it, however the move it + // waited for turned out: the ask that came last is the one that holds. + if (this.lastOutsideAsk === 'write') return { removed: false } if (!this.loaded || this.retired) return { removed: false } if (this.origin === 'draft') { const kept = snapshot(this.value) diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 1dd8a7f282..a6864485d5 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -620,7 +620,8 @@ describe('item store: one entry per key', () => { const b = { ...deployedRes, path: 'u/me/b' } const moveOnto = async ( openBefore: boolean, - outside: (store: ReturnType, open: () => void) => void + outside: (store: ReturnType, open: () => void) => void, + refuse?: string ) => { const rows = fakeRows() const store = createItemStore(rows.port) @@ -629,23 +630,30 @@ describe('item store: one entry per key', () => { const { handle: moving } = store.acquire( { workspace: 'w', kind: 'resource', path: temporary }, { workspace: 'w', path: temporary, template: b }, - adapter({}, () => gate.promise) + adapter({}, async () => { + await gate.promise + if (refuse) throw new Error(refuse) + }) ) await settle() moving.value = { ...b, args: { a: 2 } } const moved = moving.save() await settle() - const open = () => - store.acquire( + let opened: ItemHandle | undefined + const open = () => { + opened = store.acquire( { workspace: 'w', kind: 'resource', path: 'u/me/b' }, { workspace: 'w', path: 'u/me/b' }, adapter({ deployed: b }) - ) + ).handle + } if (openBefore) open() outside(store, open) gate.resolve() - expect(await moved).toMatchObject({ ok: true, moved: true }) - return { moving, rows } + expect(await moved).toMatchObject(refuse ? { ok: false } : { ok: true, moved: true }) + await settle() + // A refused move leaves the item where it was, with the editor that opened on it. + return { moving, shown: refuse ? opened! : moving, rows } } // Deleted, then written again: the newer draft is the one that survives the move. const again = { ...b, description: 'written again after the delete' } @@ -666,6 +674,19 @@ describe('item store: one entry per key', () => { expect(same.moving.value).toEqual(again) expect(same.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) + // The move failing changes none of that: the last ask is still the one that holds. + const failed = await moveOnto( + true, + (store) => { + store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' }) + store.bridge.discard('w', 'resource', 'u/me/b') + store.bridge.seed('w', 'resource', 'u/me/b', again) + }, + 'refused by the server' + ) + expect(failed.shown.value).toEqual(again) + expect(failed.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) + // Deleted last: the delete is what holds, however many drafts preceded it. const deleted = await moveOnto(true, (store) => { store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' })