1import { useParams } from "@tanstack/react-router";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import {
4 type AtomCommandResult,
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import {
9 projectCloneDisplayName,
10 projectCloneProgressSummary,
11 type EnvironmentId,
12 type ProjectCloneSnapshot,
13 type ProjectId,
14} from "@t3tools/contracts";
15import { useCallback, useEffect, useRef } from "react";
16
17import { useNewThreadHandler } from "../hooks/useHandleNewThread";
18import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
19import { useEnvironments } from "../state/environments";
20import { useEnvironmentProjectClones } from "../state/projectClones";
21import { sourceControlEnvironment } from "../state/sourceControl";
22import { useAtomCommand } from "../state/use-atom-command";
23import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
24import { toastManager } from "./ui/toast";
25import { stackedThreadToast } from "./ui/toastHelpers";
26
27/**
28 * One toast per clone in flight, on every environment. The palette that
29 * started a clone closes right away, so this is where its progress lives:
30 * the toast updates in place as git reports stages, then settles into a
31 * success or failure state with the matching action.
32 */
33export function ProjectCloneToastCoordinator() {
34 const { environments } = useEnvironments();
35 return environments.map((environment) => (
36 <EnvironmentCloneToasts
37 key={environment.environmentId}
38 environmentId={environment.environmentId}
39 />
40 ));
41}
42
43interface TrackedToast {
44 readonly toastId: ReturnType<typeof toastManager.add>;
45 /** The last snapshot rendered, so an identical redraw does not touch the toast. */
46 readonly renderedKey: string;
47 readonly phase: ProjectCloneSnapshot["phase"];
48}
49
50function renderKey(clone: ProjectCloneSnapshot): string {
51 return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`;
52}
53
54function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) {
55 const clones = useEnvironmentProjectClones(environmentId);
56 const handleNewThread = useNewThreadHandler();
57 const { draftId: routeDraftId } = useParams({ strict: false });
58 const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
59 reportFailure: false,
60 });
61 const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
62 reportFailure: false,
63 });
64 // The toast mirrors the server's clone state, so a request that never got
65 // there needs its own feedback.
66 const runCloneAction = useCallback(
67 async (title: string, action: () => Promise<AtomCommandResult<unknown, unknown>>) => {
68 const result = await action();
69 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
70 const error = squashAtomCommandFailure(result);
71 toastManager.add(
72 stackedThreadToast({
73 type: "error",
74 title,
75 description: error instanceof Error ? error.message : "An error occurred.",
76 }),
77 );
78 }
79 },
80 [],
81 );
82 const removeClonedProject = useRemoveClonedProject();
83 const toasts = useRef(new Map<ProjectId, TrackedToast>());
84
85 // Whether the user is already looking at this project's draft: the composer
86 // banner shows the same progress and actions there, so the toast steps
87 // aside and comes back if they navigate away mid-clone.
88 const isViewingProjectDraft = useCallback(
89 (projectId: ProjectId) => {
90 if (!routeDraftId) return false;
91 const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId);
92 return draft?.environmentId === environmentId && draft.projectId === projectId;
93 },
94 [environmentId, routeDraftId],
95 );
96
97 const openProject = useCallback(
98 (projectId: ProjectId) => {
99 void handleNewThread(scopeProjectRef(environmentId, projectId));
100 },
101 [environmentId, handleNewThread],
102 );
103
104 useEffect(() => {
105 const seen = new Set<ProjectId>();
106 for (const clone of clones) {
107 seen.add(clone.projectId);
108 const key = renderKey(clone);
109 const tracked = toasts.current.get(clone.projectId);
110 const name = projectCloneDisplayName(clone);
111 // Handlers run later than this pass, so they look the toast up then.
112 const closeToast = () => {
113 const current = toasts.current.get(clone.projectId);
114 if (!current) return;
115 toastManager.close(current.toastId);
116 toasts.current.delete(clone.projectId);
117 };
118 if (isViewingProjectDraft(clone.projectId)) {
119 closeToast();
120 continue;
121 }
122 if (tracked?.renderedKey === key) continue;
123
124 if (clone.phase === "running") {
125 const options = stackedThreadToast({
126 type: "loading",
127 title: `Cloning ${name}`,
128 description: projectCloneProgressSummary(clone),
129 timeout: 0,
130 actionProps: {
131 children: "Cancel",
132 onClick: () => {
133 void runCloneAction("Failed to cancel clone", () =>
134 cancelClone({ environmentId, input: { projectId: clone.projectId } }),
135 );
136 },
137 },
138 data: { hideCopyButton: true },
139 });
140 if (tracked) {
141 toastManager.update(tracked.toastId, options);
142 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" });
143 } else {
144 const toastId = toastManager.add(options);
145 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" });
146 }
147 continue;
148 }
149
150 if (clone.phase === "done") {
151 const options = stackedThreadToast({
152 type: "success",
153 title: `Cloned ${name}`,
154 description: clone.destinationPath,
155 timeout: 8_000,
156 actionProps: {
157 children: "Open project",
158 onClick: () => {
159 closeToast();
160 openProject(clone.projectId);
161 },
162 },
163 data: { hideCopyButton: true },
164 });
165 if (tracked) {
166 toastManager.update(tracked.toastId, options);
167 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" });
168 } else {
169 const toastId = toastManager.add(options);
170 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" });
171 }
172 continue;
173 }
174
175 // Failed or cancelled: the project stays, pointing at an empty folder.
176 // Retry from here; the draft's composer banner offers the same.
177 const cancelled = clone.phase === "cancelled";
178 const options = stackedThreadToast({
179 type: cancelled ? "info" : "error",
180 title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`,
181 description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."),
182 timeout: 0,
183 actionProps: {
184 children: "Retry",
185 onClick: () => {
186 void runCloneAction("Failed to retry clone", () =>
187 retryClone({ environmentId, input: { projectId: clone.projectId } }),
188 );
189 },
190 },
191 data: {
192 ...(cancelled ? { hideCopyButton: true } : {}),
193 secondaryActionProps: {
194 children: "Remove project",
195 onClick: () => {
196 // The server drops the clone with the project, which closes
197 // this toast; a failed removal leaves it (and Retry) in place.
198 void removeClonedProject({ environmentId, projectId: clone.projectId });
199 },
200 },
201 },
202 });
203 if (tracked) {
204 toastManager.update(tracked.toastId, options);
205 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase });
206 } else {
207 const toastId = toastManager.add(options);
208 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase });
209 }
210 }
211
212 // A clone the server stopped tracking (done and expired, or its project
213 // was removed) takes its toast with it, unless it already settled into a
214 // timed success toast that dismisses itself.
215 for (const [projectId, tracked] of toasts.current) {
216 if (seen.has(projectId)) continue;
217 if (tracked.phase !== "done") toastManager.close(tracked.toastId);
218 toasts.current.delete(projectId);
219 }
220 }, [
221 cancelClone,
222 clones,
223 environmentId,
224 isViewingProjectDraft,
225 openProject,
226 removeClonedProject,
227 retryClone,
228 runCloneAction,
229 ]);
230
231 useEffect(
232 () => () => {
233 for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId);
234 toasts.current.clear();
235 },
236 [],
237 );
238
239 return null;
240}