From b0045bba91d75a55006b110272e4af1b36709007 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:29:16 +0300 Subject: [PATCH 1/5] fix(web): roll back file editor when save fails The editor showed new text before the disk write finished. A failed write left that text on screen and kept the file marked unsaved. Reload then showed the old file. On a failed save of the latest edit, restore the last confirmed text, clear the unsaved mark, and toast. --- .../src/components/files/FilePreviewPanel.tsx | 16 ++++++- .../files/fileSaveCoordinator.test.ts | 42 +++++++++++++++++-- .../components/files/fileSaveCoordinator.ts | 26 +++++++++--- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..f01d460d4f33 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -404,16 +404,18 @@ function useFileSaveCoordinator({ environmentId, cwd, relativePath, + contents, onPendingChange, }: Pick< EditableFileSurfaceProps, - "environmentId" | "cwd" | "relativePath" | "onPendingChange" + "environmentId" | "cwd" | "relativePath" | "contents" | "onPendingChange" >): FileSaveCoordinator { const writeFile = useAtomCommand(projectEnvironment.writeFile); const coordinator = useMemo( () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, + initialContents: contents, onPendingChange: (pending) => onPendingChange(relativePath, pending), persist: (nextContents) => writeFile({ @@ -423,7 +425,17 @@ function useFileSaveCoordinator({ onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, + onRollback: (confirmedContents) => { + setProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); + toastManager.add({ + type: "error", + title: "Could not save file", + description: relativePath, + }); + }, }), + // initialContents is the loaded file at mount. Overlay edits must not + // rebuild the coordinator or last-confirmed state resets on every keystroke. [cwd, environmentId, onPendingChange, relativePath, writeFile], ); @@ -461,6 +473,7 @@ function EditableFileSurface({ environmentId, cwd, relativePath, + contents, onPendingChange, }); const editor = useMemo( @@ -722,6 +735,7 @@ function RenderedMarkdownSurface({ environmentId, cwd, relativePath, + contents, onPendingChange, }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 1acbb0c1d205..56af7d38ae92 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -27,9 +27,11 @@ describe("FileSaveCoordinator", () => { const onConfirmed = vi.fn(); const coordinator = new FileSaveCoordinator({ debounceMs: 500, + initialContents: "disk", persist, onPendingChange, onConfirmed, + onRollback: vi.fn(), }); coordinator.change("first"); @@ -55,9 +57,11 @@ describe("FileSaveCoordinator", () => { const onPendingChange = vi.fn(); const coordinator = new FileSaveCoordinator({ debounceMs: 500, + initialContents: "disk", persist, onPendingChange, onConfirmed: vi.fn(), + onRollback: vi.fn(), }); coordinator.change("first"); @@ -73,22 +77,54 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); }); - it("leaves the file pending when the latest write fails", async () => { + it("rolls back to the last confirmed contents when the latest write fails", async () => { vi.useFakeTimers(); const onPendingChange = vi.fn(); + const onRollback = vi.fn(); const coordinator = new FileSaveCoordinator({ debounceMs: 500, + initialContents: "disk", persist: vi .fn() .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))), onPendingChange, onConfirmed: vi.fn(), + onRollback, }); coordinator.change("latest"); await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); - expect(onPendingChange).toHaveBeenCalledWith(true); - expect(onPendingChange).not.toHaveBeenCalledWith(false); + expect(onRollback).toHaveBeenCalledWith("disk"); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + }); + + it("does not roll back a newer edit when an older write fails", async () => { + vi.useFakeTimers(); + const firstWrite = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(firstWrite.promise) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const onRollback = vi.fn(); + const onConfirmed = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist, + onPendingChange: vi.fn(), + onConfirmed, + onRollback, + }); + + coordinator.change("first"); + await vi.advanceTimersByTimeAsync(500); + coordinator.change("latest"); + firstWrite.resolve(AsyncResult.failure(Cause.fail(new Error("write failed")))); + await vi.runAllTimersAsync(); + + expect(onRollback).not.toHaveBeenCalled(); + expect(persist).toHaveBeenLastCalledWith("latest"); + expect(onConfirmed).toHaveBeenCalledWith("latest"); }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 138f01d360e3..5697f211e1a4 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -2,20 +2,25 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; export interface FileSaveCoordinatorOptions { readonly debounceMs: number; + readonly initialContents: string; readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; readonly onConfirmed: (contents: string) => void; + readonly onRollback: (contents: string) => void; } export class FileSaveCoordinator { private timer: ReturnType | null = null; private latestContents = ""; + private lastConfirmedContents: string; private latestRevision = 0; private lastChangeAt = 0; private saving = false; private disposed = false; - constructor(private readonly options: FileSaveCoordinatorOptions) {} + constructor(private readonly options: FileSaveCoordinatorOptions) { + this.lastConfirmedContents = options.initialContents; + } change(contents: string): void { this.latestContents = contents; @@ -51,15 +56,24 @@ export class FileSaveCoordinator { this.saving = true; const contents = this.latestContents; const revision = this.latestRevision; - const result = await this.options.persist(contents); - const succeeded = result._tag === "Success"; - if (succeeded) { - this.options.onConfirmed(contents); + let succeeded = false; + try { + const result = await this.options.persist(contents); + succeeded = result._tag === "Success"; + if (succeeded) { + this.lastConfirmedContents = contents; + this.options.onConfirmed(contents); + } + } catch { + succeeded = false; } this.saving = false; if (revision === this.latestRevision) { - if (succeeded) this.options.onPendingChange(false); + if (!succeeded) { + this.options.onRollback(this.lastConfirmedContents); + } + this.options.onPendingChange(false); return; } From a58ea12a327ba7f1f8e5cc9336bfe382046cc1eb Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:49:30 +0300 Subject: [PATCH 2/5] fix(web): keep file-save rollback from wiping later edits A failed save rolled the overlay back even when the write was interrupted, the coordinator was unmounted, or a newer edit already sat in the shared cache. Dispose could then write the discarded text. Skip interrupt and unmount. Clear the overlay only if it still matches the failed write. Use a later idle refresh as the confirmed baseline. Toast the write error. --- .../src/components/files/FilePreviewPanel.tsx | 15 ++- .../files/fileSaveCoordinator.test.ts | 103 +++++++++++++++++- .../components/files/fileSaveCoordinator.ts | 42 ++++++- 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f01d460d4f33..f0d7659439d5 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -59,6 +59,7 @@ import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { + clearProjectFileQueryData, confirmProjectFileQueryData, getOptimisticProjectFileQueryData, setProjectFileQueryData, @@ -425,12 +426,17 @@ function useFileSaveCoordinator({ onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, - onRollback: (confirmedContents) => { - setProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); + onRollback: ({ failedContents, result }) => { + const overlay = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); + if (overlay !== null && overlay.contents !== failedContents) { + return; + } + clearProjectFileQueryData(environmentId, cwd, relativePath); + const error = result === null ? null : squashAtomCommandFailure(result); toastManager.add({ type: "error", title: "Could not save file", - description: relativePath, + description: error instanceof Error ? error.message : relativePath, }); }, }), @@ -439,6 +445,9 @@ function useFileSaveCoordinator({ [cwd, environmentId, onPendingChange, relativePath, writeFile], ); + useEffect(() => { + coordinator.syncConfirmed(contents); + }, [contents, coordinator]); useEffect(() => () => coordinator.dispose(), [coordinator]); return coordinator; } diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 56af7d38ae92..ec86d9182569 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -81,12 +81,11 @@ describe("FileSaveCoordinator", () => { vi.useFakeTimers(); const onPendingChange = vi.fn(); const onRollback = vi.fn(); + const failure = AsyncResult.failure(Cause.fail(new Error("write failed"))); const coordinator = new FileSaveCoordinator({ debounceMs: 500, initialContents: "disk", - persist: vi - .fn() - .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))), + persist: vi.fn().mockResolvedValue(failure), onPendingChange, onConfirmed: vi.fn(), onRollback, @@ -95,7 +94,11 @@ describe("FileSaveCoordinator", () => { coordinator.change("latest"); await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); - expect(onRollback).toHaveBeenCalledWith("disk"); + expect(onRollback).toHaveBeenCalledWith({ + failedContents: "latest", + confirmedContents: "disk", + result: failure, + }); expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); }); @@ -127,4 +130,96 @@ describe("FileSaveCoordinator", () => { expect(persist).toHaveBeenLastCalledWith("latest"); expect(onConfirmed).toHaveBeenCalledWith("latest"); }); + + it("does not roll back an interrupted write", async () => { + vi.useFakeTimers(); + const onRollback = vi.fn(); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist: vi.fn().mockResolvedValue(AsyncResult.failure(Cause.interrupt(1))), + onPendingChange, + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + expect(onRollback).not.toHaveBeenCalled(); + expect(onPendingChange).not.toHaveBeenCalledWith(false); + }); + + it("does not roll back or clear pending after dispose", async () => { + vi.useFakeTimers(); + const write = deferred(); + const onRollback = vi.fn(); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist: vi.fn().mockReturnValue(write.promise), + onPendingChange, + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + coordinator.dispose(); + write.resolve(AsyncResult.failure(Cause.fail(new Error("write failed")))); + await Promise.resolve(); + expect(onRollback).not.toHaveBeenCalled(); + expect(onPendingChange).not.toHaveBeenCalledWith(false); + }); + + it("does not persist a discarded failed edit on dispose", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onRollback: vi.fn(), + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + expect(persist).toHaveBeenCalledOnce(); + coordinator.dispose(); + await Promise.resolve(); + expect(persist).toHaveBeenCalledOnce(); + }); + + it("uses a later confirmed refresh as the rollback baseline", async () => { + vi.useFakeTimers(); + const onRollback = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist: vi + .fn() + .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))), + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.syncConfirmed("refreshed"); + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + expect(onRollback).toHaveBeenCalledWith( + expect.objectContaining({ + failedContents: "latest", + confirmedContents: "refreshed", + }), + ); + }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 5697f211e1a4..a01998c94b1a 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -1,4 +1,13 @@ -import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, +} from "@t3tools/client-runtime/state/runtime"; + +export interface FileSaveRollback { + readonly failedContents: string; + readonly confirmedContents: string; + readonly result: AtomCommandResult | null; +} export interface FileSaveCoordinatorOptions { readonly debounceMs: number; @@ -6,7 +15,7 @@ export interface FileSaveCoordinatorOptions { readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; readonly onConfirmed: (contents: string) => void; - readonly onRollback: (contents: string) => void; + readonly onRollback: (rollback: FileSaveRollback) => void; } export class FileSaveCoordinator { @@ -30,6 +39,12 @@ export class FileSaveCoordinator { this.schedule(this.options.debounceMs); } + syncConfirmed(contents: string): void { + if (this.latestRevision === 0 && !this.saving) { + this.lastConfirmedContents = contents; + } + } + dispose(): void { this.disposed = true; this.clearTimer(); @@ -56,24 +71,41 @@ export class FileSaveCoordinator { this.saving = true; const contents = this.latestContents; const revision = this.latestRevision; + let result: AtomCommandResult | null = null; let succeeded = false; + let interrupted = false; try { - const result = await this.options.persist(contents); + result = await this.options.persist(contents); succeeded = result._tag === "Success"; + interrupted = !succeeded && isAtomCommandInterrupted(result); if (succeeded) { this.lastConfirmedContents = contents; this.options.onConfirmed(contents); } } catch { succeeded = false; + interrupted = false; } this.saving = false; if (revision === this.latestRevision) { + if (interrupted) { + return; + } if (!succeeded) { - this.options.onRollback(this.lastConfirmedContents); + this.latestRevision = 0; + this.latestContents = this.lastConfirmedContents; + if (!this.disposed) { + this.options.onRollback({ + failedContents: contents, + confirmedContents: this.lastConfirmedContents, + result, + }); + this.options.onPendingChange(false); + } + return; } - this.options.onPendingChange(false); + if (!this.disposed) this.options.onPendingChange(false); return; } From dc19c82101c197277c13aa27a8edd3211379a590 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:11 +0300 Subject: [PATCH 3/5] fix(web): roll back a failed save even after the editor unmounts A write that failed after dispose left the next surface showing the optimistic text and still marked unsaved. Rollback now still runs. If a newer overlay is already there, keep that text and mark the file pending again. --- apps/web/src/components/files/FilePreviewPanel.tsx | 1 + .../components/files/fileSaveCoordinator.test.ts | 13 +++++++++---- .../src/components/files/fileSaveCoordinator.ts | 14 ++++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f0d7659439d5..735ccb17c552 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -429,6 +429,7 @@ function useFileSaveCoordinator({ onRollback: ({ failedContents, result }) => { const overlay = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); if (overlay !== null && overlay.contents !== failedContents) { + onPendingChange(relativePath, true); return; } clearProjectFileQueryData(environmentId, cwd, relativePath); diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index ec86d9182569..d60b1bfa4ad8 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -151,11 +151,12 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange).not.toHaveBeenCalledWith(false); }); - it("does not roll back or clear pending after dispose", async () => { + it("still rolls back a failed write that finishes after dispose", async () => { vi.useFakeTimers(); const write = deferred(); const onRollback = vi.fn(); const onPendingChange = vi.fn(); + const failure = AsyncResult.failure(Cause.fail(new Error("write failed"))); const coordinator = new FileSaveCoordinator({ debounceMs: 500, initialContents: "disk", @@ -168,10 +169,14 @@ describe("FileSaveCoordinator", () => { coordinator.change("latest"); await vi.advanceTimersByTimeAsync(500); coordinator.dispose(); - write.resolve(AsyncResult.failure(Cause.fail(new Error("write failed")))); + write.resolve(failure); await Promise.resolve(); - expect(onRollback).not.toHaveBeenCalled(); - expect(onPendingChange).not.toHaveBeenCalledWith(false); + expect(onRollback).toHaveBeenCalledWith({ + failedContents: "latest", + confirmedContents: "disk", + result: failure, + }); + expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); }); it("does not persist a discarded failed edit on dispose", async () => { diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index a01998c94b1a..66cbecd52dd2 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -95,14 +95,12 @@ export class FileSaveCoordinator { if (!succeeded) { this.latestRevision = 0; this.latestContents = this.lastConfirmedContents; - if (!this.disposed) { - this.options.onRollback({ - failedContents: contents, - confirmedContents: this.lastConfirmedContents, - result, - }); - this.options.onPendingChange(false); - } + this.options.onRollback({ + failedContents: contents, + confirmedContents: this.lastConfirmedContents, + result, + }); + this.options.onPendingChange(false); return; } if (!this.disposed) this.options.onPendingChange(false); From 236f52901e0062ac9355e6dc4df1391de79beb9d Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:07:23 +0300 Subject: [PATCH 4/5] fix(web): let rollback own pending and last confirmed text The coordinator was clearing pending right after onRollback, which wiped the unsaved mark when a newer overlay was kept. Rollback now sets pending itself. Failed saves restore the last confirmed text in the overlay, then confirm it, so a stale readFile does not flash the pre-save file. The toast only squashes Failure results. --- apps/web/src/components/files/FilePreviewPanel.tsx | 9 +++++---- .../web/src/components/files/fileSaveCoordinator.test.ts | 4 ++-- apps/web/src/components/files/fileSaveCoordinator.ts | 1 - 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 735ccb17c552..8614e7e90f44 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -59,7 +59,6 @@ import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { - clearProjectFileQueryData, confirmProjectFileQueryData, getOptimisticProjectFileQueryData, setProjectFileQueryData, @@ -426,14 +425,16 @@ function useFileSaveCoordinator({ onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, - onRollback: ({ failedContents, result }) => { + onRollback: ({ failedContents, confirmedContents, result }) => { const overlay = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); if (overlay !== null && overlay.contents !== failedContents) { onPendingChange(relativePath, true); return; } - clearProjectFileQueryData(environmentId, cwd, relativePath); - const error = result === null ? null : squashAtomCommandFailure(result); + setProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); + confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); + onPendingChange(relativePath, false); + const error = result?._tag === "Failure" ? squashAtomCommandFailure(result) : null; toastManager.add({ type: "error", title: "Could not save file", diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index d60b1bfa4ad8..77924fd8c400 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -99,7 +99,7 @@ describe("FileSaveCoordinator", () => { confirmedContents: "disk", result: failure, }); - expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + expect(onPendingChange).not.toHaveBeenCalledWith(false); }); it("does not roll back a newer edit when an older write fails", async () => { @@ -176,7 +176,7 @@ describe("FileSaveCoordinator", () => { confirmedContents: "disk", result: failure, }); - expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); + expect(onPendingChange).not.toHaveBeenCalledWith(false); }); it("does not persist a discarded failed edit on dispose", async () => { diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 66cbecd52dd2..a4573ae39bde 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -100,7 +100,6 @@ export class FileSaveCoordinator { confirmedContents: this.lastConfirmedContents, result, }); - this.options.onPendingChange(false); return; } if (!this.disposed) this.options.onPendingChange(false); From de49ca51f059cc5e0c5701fc5431208987af8ba5 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:14:35 +0300 Subject: [PATCH 5/5] fix(web): allow file refresh to update save baseline after success A successful save left latestRevision set, so idle query refreshes never updated lastConfirmedContents. A later failed edit then rolled back to the old local save instead of the file now on disk. Reset latestRevision after a successful latest write. --- .../files/fileSaveCoordinator.test.ts | 31 +++++++++++++++++++ .../components/files/fileSaveCoordinator.ts | 1 + 2 files changed, 32 insertions(+) diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 77924fd8c400..4d0c56e8e76a 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -227,4 +227,35 @@ describe("FileSaveCoordinator", () => { }), ); }); + + it("lets idle refreshes update the baseline after a successful save", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValueOnce(AsyncResult.success(undefined)) + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("write failed")))); + const onRollback = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + initialContents: "disk", + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.change("saved"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + coordinator.syncConfirmed("refreshed"); + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + expect(onRollback).toHaveBeenCalledWith( + expect.objectContaining({ + failedContents: "latest", + confirmedContents: "refreshed", + }), + ); + }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index a4573ae39bde..753fded6fff2 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -102,6 +102,7 @@ export class FileSaveCoordinator { }); return; } + this.latestRevision = 0; if (!this.disposed) this.options.onPendingChange(false); return; }