1import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2
3export interface FileSaveCoordinatorOptions<A, E> {
4 readonly debounceMs: number;
5 readonly canPersist?: () => boolean;
6 readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>;
7 readonly onPendingChange: (pending: boolean) => void;
8 /** Return false when another editor has newer unsaved contents. */
9 readonly onConfirmed: (contents: string) => boolean | void;
10}
11
12export class FileSaveCoordinator<A = unknown, E = unknown> {
13 private timer: ReturnType<typeof setTimeout> | null = null;
14 private latestContents = "";
15 private latestRevision = 0;
16 private confirmedRevision = 0;
17 private lastChangeAt = 0;
18 private saving = false;
19 private disposed = false;
20
21 constructor(private readonly options: FileSaveCoordinatorOptions<A, E>) {}
22
23 change(contents: string): void {
24 if (this.disposed) return;
25 this.latestContents = contents;
26 this.latestRevision += 1;
27 this.lastChangeAt = Date.now();
28 this.options.onPendingChange(true);
29 this.schedule(this.options.debounceMs);
30 }
31
32 dispose(): void {
33 this.disposed = true;
34 this.clearTimer();
35 if (this.latestRevision > 0) void this.persistLatest();
36 }
37
38 private schedule(delay: number): void {
39 this.clearTimer();
40 this.timer = setTimeout(() => {
41 this.timer = null;
42 void this.persistLatest();
43 }, delay);
44 }
45
46 private clearTimer(): void {
47 if (this.timer === null) return;
48 clearTimeout(this.timer);
49 this.timer = null;
50 }
51
52 private async persistLatest(): Promise<void> {
53 if (this.saving || this.latestRevision === this.confirmedRevision) return;
54 if (this.options.canPersist?.() === false) {
55 return;
56 }
57
58 this.saving = true;
59 const contents = this.latestContents;
60 const revision = this.latestRevision;
61 const result = await this.options.persist(contents);
62 const succeeded = result._tag === "Success";
63 let confirmed = false;
64 if (succeeded) {
65 this.confirmedRevision = revision;
66 confirmed = this.options.onConfirmed(contents) !== false;
67 }
68
69 this.saving = false;
70 if (revision === this.latestRevision) {
71 if (confirmed) this.options.onPendingChange(false);
72 return;
73 }
74
75 const remainingDebounce = Math.max(
76 0,
77 this.options.debounceMs - (Date.now() - this.lastChangeAt),
78 );
79 if (this.disposed) {
80 void this.persistLatest();
81 } else {
82 this.schedule(remainingDebounce);
83 }
84 }
85}