fix: clear only the mirrors after a clear-all, never discard a second time

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 22:04:12 +02:00
co-authored by Claude Opus 5
parent ca5912c8b6
commit a1b85111de
3 changed files with 63 additions and 4 deletions
@@ -2565,6 +2565,45 @@ describe('global AI tools', () => {
}
})
// "Clear all" deletes each row, then drops the in-tab mirrors. That second pass must not go
// back through a live editor's discard: one that kept its draft, because it was changed after
// the discard was asked for, would have that newer edit taken on the way past.
it('does not discard again when clearing the mirrors after a clear-all', async () => {
const path = 'u/admin/cleared_res'
seedBackendDraft('resource', path, { path, value: { a: 2 } })
let discards = 0
registerLiveItemBridge({
seed: () => false,
read: () => ({ value: { path, value: { a: 3 } } }),
refresh: () => undefined,
noteRow: () => {},
itemDeleted: () => Promise.resolve(),
discard: () => {
discards++
return Promise.resolve('done' as const)
},
list: () => [{ workspace: WORKSPACE, itemKind: 'resource', path, value: { a: 3 } }]
})
try {
const { removed } = await deleteGlobalDraft(WORKSPACE, 'resource', path)
expect(removed).toBe(false)
expect(discards).toBe(1)
// The row is there on purpose, so the mirror pass leaves it alone entirely.
clearGlobalDrafts(WORKSPACE, new Set([path]))
expect(discards).toBe(1)
} finally {
registerLiveItemBridge({
seed: () => false,
read: () => undefined,
refresh: () => undefined,
noteRow: () => {},
itemDeleted: () => Promise.resolve(),
discard: () => undefined,
list: () => []
})
}
})
// A failed server delete must surface (throw), not silently report removed —
// the same guard the write path got, applied to the delete path.
it('deleteGlobalDraft throws when the server delete fails', async () => {
@@ -762,8 +762,18 @@ export async function flushGlobalDraftSaves(
return { unflushedPaths }
}
export function clearGlobalDrafts(workspace: string): void {
/**
* Drop the in-tab mirrors left after the rows themselves have been deleted. Only the mirrors:
* `clear` would route to a live editor's discard, and one that kept its draft — because it was
* changed after that discard was asked for — would have that newer edit taken on this pass,
* undoing exactly what the first one preserved. `keep` names the paths to leave alone.
*/
export function clearGlobalDrafts(
workspace: string,
keep: ReadonlySet<string> = new Set()
): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
UserDraft.clear(draft.itemKind, draft.path, { workspace })
if (keep.has(draft.path)) continue
UserDraft.forgetLocal(draft.itemKind, draft.path, { workspace })
}
}
@@ -60,14 +60,24 @@
// Delete each listed draft from the backend (the source of truth) — the
// local clearGlobalDrafts only clears in-tab cells, leaving persisted rows.
// Continue past a per-row failure so one bad delete doesn't strand the rest.
// A draft an open editor kept, or one whose delete failed, is still there on purpose or
// still to be dealt with: its mirror stays too, or the page would stop showing it.
const kept = new Set<string>()
for (const item of [...drafts]) {
try {
await deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
const { removed } = await deleteGlobalDraft(
$workspaceStore,
item.type,
item.path,
item.triggerKind
)
if (!removed) kept.add(item.path)
} catch (e) {
console.error('Failed to clear draft', item.path, e)
kept.add(item.path)
}
}
clearGlobalDrafts($workspaceStore)
clearGlobalDrafts($workspaceStore, kept)
refreshDrafts()
}
</script>