1import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts";
2import { type AtomCommandResult, runAtomCommand } from "@t3tools/client-runtime/state/runtime";
3import { useEffect } from "react";
4
5import { appAtomRegistry } from "~/rpc/atomRegistry";
6import { projectEnvironment } from "~/state/projects";
7import { environmentSession, readEnvironmentScope, sessionHasScope } from "~/state/session";
8
9import { FileSaveCoordinator } from "./fileSaveCoordinator";
10import {
11 confirmProjectFileQueryData,
12 getUnsavedProjectFileQueryData,
13} from "./projectFilesQueryState";
14
15export interface FileSaveTarget {
16 readonly environmentId: EnvironmentId;
17 readonly cwd: string;
18 readonly relativePath: string;
19}
20
21export type FilePendingListener = (relativePath: string, pending: boolean) => void;
22
23export interface FileSaveSessionDeps {
24 readonly debounceMs: number;
25 readonly canPersist: (target: FileSaveTarget) => boolean;
26 /** Calls `onWritable` each time the target environment regains write access. */
27 readonly subscribeWritable: (target: FileSaveTarget, onWritable: () => void) => () => void;
28 readonly persist: (
29 target: FileSaveTarget,
30 contents: string,
31 ) => Promise<AtomCommandResult<unknown, unknown>>;
32 readonly confirm: (target: FileSaveTarget, contents: string) => boolean;
33 readonly readUnsaved: (target: FileSaveTarget) => string | null;
34}
35
36interface FileSaveSession {
37 readonly coordinator: FileSaveCoordinator;
38 /** Lease count per listener; surfaces may share one listener. */
39 readonly listeners: Map<FilePendingListener, number>;
40 readonly unsubscribeWritable: () => void;
41 leases: number;
42 /** The last release flushed, and the session stays open until that settles. */
43 flushingOnRelease: boolean;
44}
45
46/**
47 * One save session, so one ordered write queue, per file. A released session
48 * stays open until its flush settles, so a surface rejoining meanwhile reuses
49 * it instead of racing the flush. A session that cannot write closes at once,
50 * and the next one, or regained write access, resumes the unsaved draft.
51 */
52export function createFileSaveSessions(deps: FileSaveSessionDeps) {
53 const sessions = new Map<string, FileSaveSession>();
54
55 const recover = (target: FileSaveTarget, coordinator: FileSaveCoordinator) => {
56 if (!deps.canPersist(target)) return;
57 const unsaved = deps.readUnsaved(target);
58 if (unsaved !== null) coordinator.change(unsaved);
59 };
60
61 const create = (key: string, target: FileSaveTarget): FileSaveSession => {
62 const listeners = new Map<FilePendingListener, number>();
63 const coordinator: FileSaveCoordinator = new FileSaveCoordinator({
64 debounceMs: deps.debounceMs,
65 canPersist: () => deps.canPersist(target),
66 // The last surface's listeners outlive it: a flush that settles after
67 // the session is released still has to clear the pending marker.
68 onPendingChange: (pending) => {
69 for (const listener of listeners.keys()) listener(target.relativePath, pending);
70 },
71 persist: (contents) => deps.persist(target, contents),
72 onConfirmed: (contents) => deps.confirm(target, contents),
73 onSettled: () => {
74 if (!session.flushingOnRelease) return;
75 session.flushingOnRelease = false;
76 if (session.leases === 0) {
77 if (sessions.get(key) === session) sessions.delete(key);
78 session.unsubscribeWritable();
79 } else if (coordinator.unconfirmedContents !== null) {
80 // A surface rejoined while the release flush failed; retry it once.
81 recover(target, coordinator);
82 }
83 },
84 });
85 const unsubscribeWritable = deps.subscribeWritable(target, () => recover(target, coordinator));
86 const session: FileSaveSession = {
87 coordinator,
88 listeners,
89 unsubscribeWritable,
90 leases: 0,
91 flushingOnRelease: false,
92 };
93 return session;
94 };
95
96 const acquire = (target: FileSaveTarget, onPendingChange: FilePendingListener): (() => void) => {
97 const key = fileSaveKey(target);
98 const existing = sessions.get(key);
99 const session = existing ?? create(key, target);
100 sessions.set(key, session);
101 session.leases += 1;
102 session.listeners.set(onPendingChange, (session.listeners.get(onPendingChange) ?? 0) + 1);
103 if (!existing) recover(target, session.coordinator);
104 else if (session.coordinator.unconfirmedContents !== null) {
105 onPendingChange(target.relativePath, true);
106 }
107
108 let released = false;
109 return () => {
110 if (released) return;
111 released = true;
112 session.leases -= 1;
113 if (session.leases > 0) {
114 const count = (session.listeners.get(onPendingChange) ?? 1) - 1;
115 if (count > 0) session.listeners.set(onPendingChange, count);
116 else session.listeners.delete(onPendingChange);
117 return;
118 }
119 session.flushingOnRelease = true;
120 session.coordinator.flush();
121 };
122 };
123
124 return {
125 /** Holds the file's session open until the returned release runs. */
126 acquire,
127 /** Edits for a file nobody holds open are dropped, so retired editors stay inert. */
128 change(target: FileSaveTarget, contents: string): void {
129 const session = sessions.get(fileSaveKey(target));
130 if (session && session.leases > 0) session.coordinator.change(contents);
131 },
132 };
133}
134
135function fileSaveKey({ environmentId, cwd, relativePath }: FileSaveTarget): string {
136 return `${environmentId}\u0000${cwd}\u0000${relativePath}`;
137}
138
139const fileSaveSessions = createFileSaveSessions({
140 debounceMs: 500,
141 canPersist: ({ environmentId }) => readEnvironmentScope(environmentId, AuthFilesystemWriteScope),
142 subscribeWritable: ({ environmentId }, onWritable) => {
143 let writable = readEnvironmentScope(environmentId, AuthFilesystemWriteScope);
144 return appAtomRegistry.subscribe(
145 environmentSession.sessionStateAtom(environmentId),
146 (state) => {
147 const next = sessionHasScope(state, AuthFilesystemWriteScope);
148 if (next && !writable) onWritable();
149 writable = next;
150 },
151 );
152 },
153 persist: ({ environmentId, cwd, relativePath }, contents) =>
154 runAtomCommand(appAtomRegistry, projectEnvironment.writeFile, {
155 environmentId,
156 input: { cwd, relativePath, contents },
157 }),
158 confirm: ({ environmentId, cwd, relativePath }, contents) =>
159 confirmProjectFileQueryData(environmentId, cwd, relativePath, contents),
160 readUnsaved: ({ environmentId, cwd, relativePath }) =>
161 getUnsavedProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? null,
162});
163
164/** Holds the file's save session while the surface is mounted; the last surface to leave flushes. */
165export function useFileSaveSession(
166 { environmentId, cwd, relativePath }: FileSaveTarget,
167 onPendingChange: FilePendingListener,
168): void {
169 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the lease is a mount-scoped external resource; React runs every unmount cleanup before mount setups, so a surface switch flushes before the next surface recovers.
170 useEffect(
171 () => fileSaveSessions.acquire({ environmentId, cwd, relativePath }, onPendingChange),
172 [environmentId, cwd, relativePath, onPendingChange],
173 );
174}
175
176export function changeFileSaveSession(target: FileSaveTarget, contents: string): void {
177 fileSaveSessions.change(target, contents);
178}