Skip to content
Source
Sign inSign up

ProjectCloneToastCoordinator.tsx

229 lines7.9 KB

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 { useEffect, useRef } from "react";
16​
17import { useNewThreadHandler } from "../hooks/useHandleNewThread";
18import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
19import { useEnvironments } from "../state/environments";
20import {
21 isProjectCloneToastSilenced,
22 silenceProjectCloneToasts,
23 useEnvironmentProjectClones,
24} from "../state/projectClones";
25import { sourceControlEnvironment } from "../state/sourceControl";
26import { useAtomCommand } from "../state/use-atom-command";
27import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
28import { toastManager } from "./ui/toast";
29import { stackedThreadToast } from "./ui/toastHelpers";
30​
31export function ProjectCloneToastCoordinator() {
32 const { environments } = useEnvironments();
33 return environments.map((environment) => (
34 <EnvironmentCloneToasts
35 key={environment.environmentId}
36 environmentId={environment.environmentId}
37 />
38 ));
40​
41interface TrackedToast {
42 readonly toastId: ReturnType<typeof toastManager.add>;
43 readonly renderedKey: string;
44 readonly phase: ProjectCloneSnapshot["phase"];
46​
47function renderKey(clone: ProjectCloneSnapshot): string {
48 return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.stalled === true}:${clone.error ?? ""}`;
50​
51async function runCloneAction(
52 title: string,
53 action: () => Promise<AtomCommandResult<unknown, unknown>>,
54) {
55 const result = await action();
56 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
57 const error = squashAtomCommandFailure(result);
58 toastManager.add(
59 stackedThreadToast({
60 type: "error",
61 title,
62 description: error instanceof Error ? error.message : "An error occurred.",
63 }),
64 );
65 }
67​
68function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) {
69 const clones = useEnvironmentProjectClones(environmentId);
70 const handleNewThread = useNewThreadHandler();
71 const { draftId: routeDraftId } = useParams({ strict: false });
72 const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
73 reportFailure: false,
74 });
75 const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
76 reportFailure: false,
77 });
78 const removeClonedProject = useRemoveClonedProject();
79 const toasts = useRef(new Map<ProjectId, TrackedToast>());
80​
81 const isViewingProjectDraft = (projectId: ProjectId) => {
82 if (!routeDraftId) return false;
83 const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId);
84 return draft?.environmentId === environmentId && draft.projectId === projectId;
85 };
86​
87 const openProject = (projectId: ProjectId) => {
88 void handleNewThread(scopeProjectRef(environmentId, projectId));
89 };
90​
91 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens, updates and closes imperative toastManager toasts to match the current project clones
92 useEffect(() => {
93 const seen = new Set<ProjectId>();
94 for (const clone of clones) {
95 seen.add(clone.projectId);
96 const key = renderKey(clone);
97 const tracked = toasts.current.get(clone.projectId);
98 const name = projectCloneDisplayName(clone);
99 const closeToast = () => {
100 const current = toasts.current.get(clone.projectId);
101 if (!current) return;
102 toastManager.close(current.toastId);
103 toasts.current.delete(clone.projectId);
104 };
105 const ref = { environmentId, projectId: clone.projectId };
106 if (isViewingProjectDraft(clone.projectId)) {
107 silenceProjectCloneToasts(ref);
108 closeToast();
109 continue;
110 }
111 if (tracked?.renderedKey === key) continue;
112 if (
113 isProjectCloneToastSilenced(ref) &&
114 (clone.phase === "running" || clone.phase === "done")
115 ) {
116 closeToast();
117 continue;
118 }
119​
120 if (clone.phase === "running") {
121 const options = stackedThreadToast({
122 type: "loading",
123 title: `Cloning ${name}`,
124 description: projectCloneProgressSummary(clone),
125 timeout: 0,
126 actionProps: {
127 children: "Cancel",
128 onClick: () => {
129 void runCloneAction("Failed to cancel clone", () =>
130 cancelClone({ environmentId, input: { projectId: clone.projectId } }),
131 );
132 },
133 },
134 data: { hideCopyButton: true },
135 });
136 if (tracked) {
137 toastManager.update(tracked.toastId, options);
138 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" });
139 } else {
140 const toastId = toastManager.add(options);
141 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" });
142 }
143 continue;
144 }
145​
146 if (clone.phase === "done") {
147 const options = stackedThreadToast({
148 type: "success",
149 title: `Cloned ${name}`,
150 description: clone.destinationPath,
151 timeout: 8_000,
152 actionProps: {
153 children: "Open project",
154 onClick: () => {
155 closeToast();
156 openProject(clone.projectId);
157 },
158 },
159 data: { hideCopyButton: true },
160 });
161 if (tracked) {
162 toastManager.update(tracked.toastId, options);
163 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" });
164 } else {
165 const toastId = toastManager.add(options);
166 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" });
167 }
168 continue;
169 }
170​
171 const cancelled = clone.phase === "cancelled";
172 const options = stackedThreadToast({
173 type: cancelled ? "info" : "error",
174 title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`,
175 description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."),
176 timeout: 0,
177 actionProps: {
178 children: "Retry",
179 onClick: () => {
180 void runCloneAction("Failed to retry clone", () =>
181 retryClone({ environmentId, input: { projectId: clone.projectId } }),
182 );
183 },
184 },
185 data: {
186 ...(cancelled ? { hideCopyButton: true } : {}),
187 secondaryActionProps: {
188 children: "Remove project",
189 onClick: () => {
190 void removeClonedProject({ environmentId, projectId: clone.projectId });
191 },
192 },
193 },
194 });
195 if (tracked) {
196 toastManager.update(tracked.toastId, options);
197 toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase });
198 } else {
199 const toastId = toastManager.add(options);
200 toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase });
201 }
202 }
203​
204 for (const [projectId, tracked] of toasts.current) {
205 if (seen.has(projectId)) continue;
206 if (tracked.phase !== "done") toastManager.close(tracked.toastId);
207 toasts.current.delete(projectId);
208 }
209 }, [
210 cancelClone,
211 clones,
212 environmentId,
213 isViewingProjectDraft,
214 openProject,
215 removeClonedProject,
216 retryClone,
217 ]);
218​
219 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes every tracked clone toast in toastManager when the coordinator unmounts
220 useEffect(
221 () => () => {
222 for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId);
223 toasts.current.clear();
224 },
225 [],
226 );
227​
228 return null;