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
27 changes: 26 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Comment thread
cursor[bot] marked this conversation as resolved.
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,
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
},
}),
// initialContents is the loaded file at mount. Overlay edits must not
// rebuild the coordinator or last-confirmed state resets on every keystroke.
Comment thread
cursor[bot] marked this conversation as resolved.
[cwd, environmentId, onPendingChange, relativePath, writeFile],
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
);

useEffect(() => {
coordinator.syncConfirmed(contents);
}, [contents, coordinator]);
useEffect(() => () => coordinator.dispose(), [coordinator]);
return coordinator;
}
Expand Down Expand Up @@ -461,6 +484,7 @@ function EditableFileSurface({
environmentId,
cwd,
relativePath,
contents,
onPendingChange,
});
const editor = useMemo(
Expand Down Expand Up @@ -722,6 +746,7 @@ function RenderedMarkdownSurface({
environmentId,
cwd,
relativePath,
contents,
onPendingChange,
});

Expand Down
175 changes: 171 additions & 4 deletions apps/web/src/components/files/fileSaveCoordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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<AtomCommandResult<void, never>>>()
.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<AtomCommandResult<void, never>>>()
.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<AtomCommandResult<void, never>>>()
.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",
}),
);
});
});
58 changes: 51 additions & 7 deletions apps/web/src/components/files/fileSaveCoordinator.ts
Original file line number Diff line number Diff line change
@@ -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<A = unknown, E = unknown> {
readonly failedContents: string;
readonly confirmedContents: string;
readonly result: AtomCommandResult<A, E> | null;
}

export interface FileSaveCoordinatorOptions<A, E> {
readonly debounceMs: number;
readonly initialContents: string;
readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>;
readonly onPendingChange: (pending: boolean) => void;
readonly onConfirmed: (contents: string) => void;
readonly onRollback: (rollback: FileSaveRollback<A, E>) => void;
}

export class FileSaveCoordinator<A = unknown, E = unknown> {
private timer: ReturnType<typeof setTimeout> | null = null;
private latestContents = "";
private lastConfirmedContents: string;
private latestRevision = 0;
private lastChangeAt = 0;
private saving = false;
private disposed = false;

constructor(private readonly options: FileSaveCoordinatorOptions<A, E>) {}
constructor(private readonly options: FileSaveCoordinatorOptions<A, E>) {
this.lastConfirmedContents = options.initialContents;
}

change(contents: string): void {
this.latestContents = contents;
Expand All @@ -25,6 +39,12 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
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();
Expand All @@ -51,15 +71,39 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
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<A, E> | 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);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return;
}

Expand Down
Loading