Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/web/src/components/files/fileSaveCoordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,117 @@ describe("FileSaveCoordinator", () => {
expect(onPendingChange.mock.calls.at(-1)).toEqual([false]);
});

it("does not rewrite already saved contents when the editor closes", async () => {
vi.useFakeTimers();
const persist = vi
.fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
.mockResolvedValue(AsyncResult.success(undefined));
const coordinator = new FileSaveCoordinator({
debounceMs: 500,
persist,
onPendingChange: vi.fn(),
onConfirmed: vi.fn(),
});

coordinator.change("edited");
await vi.advanceTimersByTimeAsync(500);
expect(persist).toHaveBeenCalledOnce();

coordinator.dispose();
await vi.runAllTimersAsync();
expect(persist).toHaveBeenCalledOnce();
});

it("saves an edit made inside the debounce window when the editor closes", async () => {
vi.useFakeTimers();
const persist = vi
.fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
.mockResolvedValue(AsyncResult.success(undefined));
const coordinator = new FileSaveCoordinator({
debounceMs: 500,
persist,
onPendingChange: vi.fn(),
onConfirmed: vi.fn(),
});

coordinator.change("unsaved");
coordinator.dispose();
await vi.runAllTimersAsync();
expect(persist).toHaveBeenCalledOnce();
expect(persist).toHaveBeenCalledWith("unsaved");
});

it("flushes an edit made while a write was in flight when the editor closes", async () => {
vi.useFakeTimers();
const inFlight = deferred();
const persist = vi
.fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValue(AsyncResult.success(undefined));
const coordinator = new FileSaveCoordinator({
debounceMs: 500,
persist,
onPendingChange: vi.fn(),
onConfirmed: vi.fn(),
});

coordinator.change("first");
await vi.advanceTimersByTimeAsync(500);
coordinator.change("latest");
coordinator.dispose();
inFlight.resolve(AsyncResult.success(undefined));
await vi.runAllTimersAsync();

expect(persist).toHaveBeenCalledTimes(2);
expect(persist).toHaveBeenLastCalledWith("latest");
});

it("does not rewrite a write that lands while the editor closes", async () => {
vi.useFakeTimers();
const inFlight = deferred();
const persist = vi
.fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValue(AsyncResult.success(undefined));
const coordinator = new FileSaveCoordinator({
debounceMs: 500,
persist,
onPendingChange: vi.fn(),
onConfirmed: vi.fn(),
});

coordinator.change("only");
await vi.advanceTimersByTimeAsync(500);
coordinator.dispose();
inFlight.resolve(AsyncResult.success(undefined));
await vi.runAllTimersAsync();

expect(persist).toHaveBeenCalledOnce();
});

it("retries a failed write when the editor closes", async () => {
vi.useFakeTimers();
const persist = vi
.fn()
.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("write failed"))))
.mockResolvedValue(AsyncResult.success(undefined));
const coordinator = new FileSaveCoordinator({
debounceMs: 500,
persist,
onPendingChange: vi.fn(),
onConfirmed: vi.fn(),
});

coordinator.change("latest");
await vi.advanceTimersByTimeAsync(500);
expect(persist).toHaveBeenCalledOnce();

coordinator.dispose();
await vi.runAllTimersAsync();
expect(persist).toHaveBeenCalledTimes(2);
expect(persist).toHaveBeenLastCalledWith("latest");
});

it("leaves the file pending when the latest write fails", async () => {
vi.useFakeTimers();
const onPendingChange = vi.fn();
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/files/fileSaveCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
private timer: ReturnType<typeof setTimeout> | null = null;
private latestContents = "";
private latestRevision = 0;
// Revision of the last edit confirmed on disk, so `dispose` can tell an
// unsaved edit from one the debounce already wrote and only flush the former.
private persistedRevision = 0;
private lastChangeAt = 0;
private saving = false;
private disposed = false;
Expand All @@ -28,7 +31,7 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
dispose(): void {
this.disposed = true;
this.clearTimer();
if (this.latestRevision > 0) void this.persistLatest();
if (this.latestRevision > this.persistedRevision) void this.persistLatest();
}

private schedule(delay: number): void {
Expand All @@ -54,6 +57,7 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
const result = await this.options.persist(contents);
const succeeded = result._tag === "Success";
if (succeeded) {
this.persistedRevision = revision;
this.options.onConfirmed(contents);
}

Expand Down
Loading