Skip to content
Source
Sign inSign up

addProjectToMachine.ts

202 lines7.6 KB

1import {
2 executeAtomQuery,
3 runAtomCommand,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import { scopedProjectKey } from "@t3tools/client-runtime/environment";
7import type {
8 EnvironmentId,
9 ProjectCloneSnapshot,
10 ProjectId,
11 ScopedProjectRef,
12} from "@t3tools/contracts";
13import { create } from "zustand";
14​
15import { appAtomRegistry } from "../rpc/atomRegistry";
16import { suggestAppAccessForRemote } from "../components/sourceControlAppAccessToast";
17import { environmentProjectClonesAtom, silenceProjectCloneToasts } from "../state/projectClones";
18import { projectEnvironment } from "../state/projects";
19import { sourceControlEnvironment } from "../state/sourceControl";
20​
21/** Matches the server's read cap: a larger file would arrive truncated. */
22export const COPYABLE_FILE_MAX_BYTES = 1024 * 1024;
23​
24const DEFAULT_COPIED_FILE_NAME = /^(?:\.env(?:\..+)?|\.envrc|\.dev\.vars)$/;
25​
26/** Env files are the ignored files people almost always want on a new machine. */
27export function isDefaultCopiedFile(path: string): boolean {
28 const name = path.split("/").at(-1) ?? path;
29 return DEFAULT_COPIED_FILE_NAME.test(name);
31​
32export interface AddProjectToMachineInput {
33 readonly source: { readonly environmentId: EnvironmentId; readonly cwd: string };
34 /** Where the source project sits below its repository root, or "". */
35 readonly subdirectory: string;
36 readonly target: { readonly environmentId: EnvironmentId; readonly label: string };
37 readonly projectId: ProjectId;
38 readonly title: string;
39 readonly remoteUrl: string;
40 readonly destinationPath: string;
41 readonly files: ReadonlyArray<string>;
43​
44function failureMessage(result: Parameters<typeof squashAtomCommandFailure>[0]): string {
45 const error = squashAtomCommandFailure(result);
46 return error instanceof Error ? error.message : "An error occurred.";
48​
49/**
50 * Resolves when the clone finishes, or null once it is removed. A failed or
51 * cancelled clone keeps waiting, since the composer offers a retry.
52 */
53function waitForClone(environmentId: EnvironmentId, projectId: ProjectId) {
54 return new Promise<ProjectCloneSnapshot | null>((resolve) => {
55 const atom = environmentProjectClonesAtom(environmentId);
56 let seen = false;
57 let settled = false;
58 let unsubscribe: (() => void) | null = null;
59 const check = (clones: ReadonlyArray<ProjectCloneSnapshot>) => {
60 if (settled) return;
61 const clone = clones.find((candidate) => candidate.projectId === projectId);
62 if (clone) seen = true;
63 const result = clone?.phase === "done" ? clone : !clone && seen ? null : undefined;
64 if (result === undefined) return;
65 settled = true;
66 unsubscribe?.();
67 resolve(result);
68 };
69 unsubscribe = appAtomRegistry.subscribe(atom, check);
70 check(appAtomRegistry.get(atom));
71 });
73​
74export type ProjectFileCopy =
75 | { readonly phase: "copying" }
76 | { readonly phase: "failed"; readonly title: string; readonly failures: ReadonlyArray<string> };
77​
78/** Ignored-file copies still owed to a cloned project, keyed by its scoped project key. */
79const useFileCopies = create<{ copies: Readonly<Record<string, ProjectFileCopy>> }>(() => ({
80 copies: {},
81}));
82​
83function setFileCopy(key: string, copy: ProjectFileCopy | null) {
84 useFileCopies.setState(({ copies }) => {
85 const { [key]: _previous, ...rest } = copies;
86 return { copies: copy === null ? rest : { ...rest, [key]: copy } };
87 });
89​
90/** The composer holds sends until the copy lands, and shows a failed copy until dismissed. */
91export function useProjectFileCopy(ref: ScopedProjectRef | null): ProjectFileCopy | null {
92 return useFileCopies((state) => (ref ? (state.copies[scopedProjectKey(ref)] ?? null) : null));
94​
95export function dismissProjectFileCopy(ref: ScopedProjectRef) {
96 setFileCopy(scopedProjectKey(ref), null);
98​
99/**
100 * Clones onto another machine, then relays the chosen ignored files, since the
101 * two servers never talk. Resolves with the cloning project once it starts; the
102 * caller opens it, and its composer shows progress, so nothing toasts.
103 */
104export async function addProjectToMachine(input: AddProjectToMachineInput): Promise<ProjectId> {
105 // Read first, so the copy reflects the files as they were when the user asked.
106 const reads = await Promise.all(
107 input.files.map(async (relativePath) => {
108 const result = await executeAtomQuery(
109 appAtomRegistry,
110 projectEnvironment.readFile({
111 environmentId: input.source.environmentId,
112 input: { cwd: input.source.cwd, relativePath },
113 }),
114 { label: "Read file to copy", reportFailure: false, refresh: true },
115 );
116 if (result._tag === "Failure") return { relativePath, error: failureMessage(result) };
117 if (result.value.truncated) return { relativePath, error: "Too large to copy." };
118 return { relativePath, contents: result.value.contents };
119 }),
120 );
121​
122 const requestedRef = { environmentId: input.target.environmentId, projectId: input.projectId };
123 const requestedKey = scopedProjectKey(requestedRef);
124 // Marked before the start request, since the clone list can arrive before its reply.
125 silenceProjectCloneToasts(requestedRef);
126 if (reads.length > 0) setFileCopy(requestedKey, { phase: "copying" });
127 const started = await runAtomCommand(
128 appAtomRegistry,
129 sourceControlEnvironment.startProjectClone,
130 {
131 environmentId: input.target.environmentId,
132 input: {
133 projectId: input.projectId,
134 title: input.title,
135 createdAt: new Date().toISOString(),
136 remoteUrl: input.remoteUrl,
137 destinationPath: input.destinationPath,
138 },
139 },
140 { label: "Add project to machine", reportFailure: false },
141 );
142 if (started._tag === "Failure") {
143 setFileCopy(requestedKey, null);
144 throw new Error(failureMessage(started));
145 }
146 // The server clones for the existing project when one already owns the empty destination.
147 const { projectId } = started.value;
148 const targetRef = { environmentId: input.target.environmentId, projectId };
149 const copyKey = scopedProjectKey(targetRef);
150 if (projectId !== input.projectId) {
151 silenceProjectCloneToasts(targetRef);
152 if (reads.length > 0) {
153 setFileCopy(copyKey, { phase: "copying" });
154 setFileCopy(requestedKey, null);
155 }
156 }
157 void suggestAppAccessForRemote(input.target.environmentId, input.remoteUrl);
158 if (reads.length === 0) return projectId;
159​
160 void (async () => {
161 const clone = await waitForClone(input.target.environmentId, projectId);
162 if (!clone) {
163 setFileCopy(copyKey, null);
164 return;
165 }
166 const failed: Array<string> = [];
167 for (const read of reads) {
168 if (!("contents" in read)) {
169 failed.push(`${read.relativePath}: ${read.error}`);
170 continue;
171 }
172 const written = await runAtomCommand(
173 appAtomRegistry,
174 projectEnvironment.writeFile,
175 {
176 environmentId: input.target.environmentId,
177 input: {
178 cwd: started.value.cwd,
179 relativePath: input.subdirectory
180 ? `${input.subdirectory}/${read.relativePath}`
181 : read.relativePath,
182 contents: read.contents,
183 },
184 },
185 { label: "Copy file to machine", reportFailure: false },
186 );
187 if (written._tag === "Failure")
188 failed.push(`${read.relativePath}: ${failureMessage(written)}`);
189 }
190 setFileCopy(
191 copyKey,
192 failed.length === 0
193 ? null
194 : {
195 phase: "failed",
196 title: `Could not copy ${failed.length} ${failed.length === 1 ? "file" : "files"} to ${input.target.label}`,
197 failures: failed,
198 },
199 );
200 })();
201 return projectId;