| 1 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 2 | |
| 3 | export 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 | |
| 12 | export 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 | } |