1import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts";
2import { createRef, useEffect, useMemo } from "react";
3
4import { projectEnvironment } from "~/state/projects";
5import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
6import { useAtomCommand } from "~/state/use-atom-command";
7
8import { FileSaveCoordinator } from "./fileSaveCoordinator";
9import {
10 confirmProjectFileQueryData,
11 getUnsavedProjectFileQueryData,
12} from "./projectFilesQueryState";
13
14const FILE_SAVE_DEBOUNCE_MS = 500;
15
16interface FileSaveOptions {
17 environmentId: EnvironmentId;
18 cwd: string;
19 relativePath: string;
20 onPendingChange: (relativePath: string, pending: boolean) => void;
21}
22
23export function useFileSaveCoordinator({
24 environmentId,
25 cwd,
26 relativePath,
27 onPendingChange,
28}: FileSaveOptions): Pick<FileSaveCoordinator, "change"> {
29 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
30 const writeFile = useAtomCommand(projectEnvironment.writeFile);
31 const session = useMemo(() => {
32 const coordinatorRef = createRef<FileSaveCoordinator>();
33 return {
34 change: (contents: string) => coordinatorRef.current?.change(contents),
35 setup: () => {
36 const coordinator = new FileSaveCoordinator({
37 debounceMs: FILE_SAVE_DEBOUNCE_MS,
38 canPersist: () => readEnvironmentScope(environmentId, AuthFilesystemWriteScope),
39 onPendingChange: (pending) => onPendingChange(relativePath, pending),
40 persist: (nextContents) =>
41 writeFile({
42 environmentId,
43 input: { cwd, relativePath, contents: nextContents },
44 }),
45 onConfirmed: (confirmedContents) =>
46 confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents),
47 });
48 coordinatorRef.current = coordinator;
49 return () => {
50 coordinatorRef.current = null;
51 coordinator.dispose();
52 };
53 },
54 };
55 }, [cwd, environmentId, onPendingChange, relativePath, writeFile]);
56
57 // StrictMode replays effect setup. Retired file sessions stay inert, while the
58 // replay gets a fresh coordinator instead of reusing a disposed one.
59 useEffect(session.setup, [session]);
60 useEffect(() => {
61 if (!canWriteFiles) return;
62 let cancelled = false;
63 // Replay must retire the first session before recovery queues a draft to flush.
64 queueMicrotask(() => {
65 if (cancelled) return;
66 const unsaved = getUnsavedProjectFileQueryData(environmentId, cwd, relativePath);
67 if (unsaved) session.change(unsaved.contents);
68 });
69 return () => {
70 cancelled = true;
71 };
72 }, [canWriteFiles, cwd, environmentId, relativePath, session]);
73 return session;
74}