diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..8614e7e90f44 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,10 +425,31 @@ function useFileSaveCoordinator({ onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, + onRollback: ({ failedContents, confirmedContents, result }) => { + const overlay = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); + if (overlay !== null && overlay.contents !== failedContents) { + onPendingChange(relativePath, true); + return; + } + 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", + description: error instanceof Error ? error.message : 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], ); + useEffect(() => { + coordinator.syncConfirmed(contents); + }, [contents, coordinator]); useEffect(() => () => coordinator.dispose(), [coordinator]); return coordinator; } @@ -461,6 +484,7 @@ function EditableFileSurface({ environmentId, cwd, relativePath, + contents, onPendingChange, }); const editor = useMemo( @@ -722,6 +746,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..4d0c56e8e76a 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,185 @@ 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 failure = AsyncResult.failure(Cause.fail(new Error("write failed"))); const coordinator = new FileSaveCoordinator({ debounceMs: 500, + initialContents: "disk", + persist: vi.fn().mockResolvedValue(failure), + onPendingChange, + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + expect(onRollback).toHaveBeenCalledWith({ + failedContents: "latest", + confirmedContents: "disk", + result: failure, + }); + expect(onPendingChange).not.toHaveBeenCalledWith(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"); + }); + + 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("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", + persist: vi.fn().mockReturnValue(write.promise), + onPendingChange, + onConfirmed: vi.fn(), + onRollback, + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + coordinator.dispose(); + write.resolve(failure); + await Promise.resolve(); + expect(onRollback).toHaveBeenCalledWith({ + failedContents: "latest", + confirmedContents: "disk", + result: failure, + }); + 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, + onPendingChange: vi.fn(), onConfirmed: vi.fn(), + onRollback, }); + coordinator.syncConfirmed("refreshed"); coordinator.change("latest"); await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); - expect(onPendingChange).toHaveBeenCalledWith(true); - expect(onPendingChange).not.toHaveBeenCalledWith(false); + expect(onRollback).toHaveBeenCalledWith( + expect.objectContaining({ + failedContents: "latest", + confirmedContents: "refreshed", + }), + ); + }); + + 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 138f01d360e3..753fded6fff2 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -1,21 +1,35 @@ -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; + readonly initialContents: string; readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; readonly onConfirmed: (contents: string) => void; + readonly onRollback: (rollback: FileSaveRollback) => 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; @@ -25,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(); @@ -51,15 +71,39 @@ 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 result: AtomCommandResult | null = null; + let succeeded = false; + let interrupted = false; + try { + 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 (succeeded) this.options.onPendingChange(false); + if (interrupted) { + return; + } + if (!succeeded) { + this.latestRevision = 0; + this.latestContents = this.lastConfirmedContents; + this.options.onRollback({ + failedContents: contents, + confirmedContents: this.lastConfirmedContents, + result, + }); + return; + } + this.latestRevision = 0; + if (!this.disposed) this.options.onPendingChange(false); return; }