1import { useAtomValue } from "@effect/atom-react";
2import type {
3 AtomCommandFailure,
4 AtomCommandResult,
5 AtomCommandSuccess,
6} from "@t3tools/client-runtime/state/runtime";
7import {
8 VcsActionUnavailableError,
9 type VcsActionOperation,
10 type RunVcsStackedActionInput,
11} from "@t3tools/client-runtime/state/vcs";
12import {
13 AuthOrchestrationOperateScope,
14 AuthSourceControlWriteScope,
15 EnvironmentAuthorizationError,
16 type EnvironmentId,
17 type GitResolvePullRequestResult,
18 type SourceControlCloneProtocol,
19 type SourceControlProviderKind,
20 type SourceControlRepositoryVisibility,
21 type ThreadId,
22} from "@t3tools/contracts";
23import * as Cause from "effect/Cause";
24import { AsyncResult } from "effect/reactivity";
25
26import { appAtomRegistry } from "../rpc/atomRegistry";
27import { gitEnvironment } from "./git";
28import { useEnvironmentQuery } from "./query";
29import { readEnvironmentScope, useEnvironmentScope } from "./session";
30import { sourceControlEnvironment } from "./sourceControl";
31import { useAtomCommand } from "./use-atom-command";
32import { vcsActionManager, vcsEnvironment } from "./vcs";
33import { suggestAppAccessForRemote } from "../components/sourceControlAppAccessToast";
34
35export type SourceControlActionKind =
36 | "init"
37 | "pull"
38 | "publishRepository"
39 | "runStackedAction"
40 | "preparePullRequestThread";
41
42export interface SourceControlActionScope {
43 readonly environmentId: EnvironmentId | null;
44 readonly cwd: string | null;
45}
46
47interface SourceControlActionState<
48 TArgs extends ReadonlyArray<unknown>,
49 R extends AtomCommandResult<unknown, unknown>,
50> {
51 readonly isPending: boolean;
52 readonly isAllowed: boolean;
53 readonly error: unknown;
54 readonly run: (
55 ...args: TArgs
56 ) => Promise<
57 AtomCommandResult<
58 AtomCommandSuccess<R>,
59 AtomCommandFailure<R> | VcsActionUnavailableError | EnvironmentAuthorizationError
60 >
61 >;
62 readonly resetError: () => void;
63}
64
65const ACTION_OPERATION = {
66 init: "init",
67 pull: "pull",
68 publishRepository: "publish_repository",
69 runStackedAction: "run_change_request",
70 preparePullRequestThread: "prepare_pull_request_thread",
71} as const satisfies Record<SourceControlActionKind, VcsActionOperation>;
72
73function useAction<
74 TArgs extends ReadonlyArray<unknown>,
75 R extends AtomCommandResult<unknown, unknown>,
76>(input: {
77 readonly kind: SourceControlActionKind;
78 readonly label: string;
79 readonly scope: SourceControlActionScope;
80 readonly action: (...args: TArgs) => Promise<R>;
81 readonly onSuccess?: () => void;
82 readonly managedExternally?: boolean;
83}): SourceControlActionState<TArgs, R> {
84 const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope);
85 const operation = ACTION_OPERATION[input.kind];
86 const state = useAtomValue(vcsActionManager.stateAtom(input.scope));
87 const ownsState = state.operation === operation;
88
89 const resetError = () => {
90 vcsActionManager.resetError(appAtomRegistry, input.scope, operation);
91 };
92
93 const run = async (...args: TArgs) => {
94 if (
95 input.scope.environmentId === null ||
96 !readEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope)
97 ) {
98 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
99 Cause.fail(
100 new EnvironmentAuthorizationError({
101 requiredScope: AuthSourceControlWriteScope,
102 message: "This connection cannot change source control.",
103 }),
104 ),
105 );
106 }
107 const execute = async (): Promise<
108 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>
109 > => {
110 const result = await input.action(...args);
111 if (AsyncResult.isSuccess(result)) {
112 input.onSuccess?.();
113 }
114 return result as AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>;
115 };
116 return input.managedExternally === true
117 ? execute()
118 : vcsActionManager.track(
119 appAtomRegistry,
120 input.scope,
121 {
122 operation,
123 label: input.label,
124 },
125 execute,
126 );
127 };
128
129 return {
130 isAllowed,
131 error: ownsState ? state.error : null,
132 isPending: ownsState && state.isRunning,
133 resetError,
134 run,
135 };
136}
137
138function resolveScope(scope: SourceControlActionScope) {
139 if (scope.environmentId === null || scope.cwd === null) {
140 return null;
141 }
142 return {
143 environmentId: scope.environmentId,
144 cwd: scope.cwd,
145 };
146}
147
148export function useSourceControlActionRunning(
149 scope: SourceControlActionScope,
150 kinds: ReadonlyArray<SourceControlActionKind>,
151): boolean {
152 const state = useAtomValue(vcsActionManager.stateAtom(scope));
153 return (
154 state.isRunning &&
155 state.operation !== null &&
156 kinds.some((kind) => ACTION_OPERATION[kind] === state.operation)
157 );
158}
159
160export function useVcsInitAction(scope: SourceControlActionScope) {
161 const init = useAtomCommand(vcsEnvironment.init, { reportFailure: false });
162 const action = async () => {
163 const target = resolveScope(scope);
164 if (target === null) {
165 return AsyncResult.failure<never, VcsActionUnavailableError>(
166 Cause.fail(
167 new VcsActionUnavailableError({
168 operation: "init",
169 environmentId: scope.environmentId,
170 cwd: scope.cwd,
171 }),
172 ),
173 );
174 }
175 return init({
176 environmentId: target.environmentId,
177 input: { cwd: target.cwd },
178 });
179 };
180 return useAction({ kind: "init", label: "Initializing repository", scope, action });
181}
182
183export function useVcsPullAction(scope: SourceControlActionScope) {
184 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
185 const status = useEnvironmentQuery(
186 scope.environmentId !== null && scope.cwd !== null
187 ? vcsEnvironment.status({
188 environmentId: scope.environmentId,
189 input: { cwd: scope.cwd },
190 })
191 : null,
192 );
193 const action = async () => {
194 const target = resolveScope(scope);
195 if (target === null) {
196 return AsyncResult.failure<never, VcsActionUnavailableError>(
197 Cause.fail(
198 new VcsActionUnavailableError({
199 operation: "pull",
200 environmentId: scope.environmentId,
201 cwd: scope.cwd,
202 }),
203 ),
204 );
205 }
206 return pull({
207 environmentId: target.environmentId,
208 input: { cwd: target.cwd },
209 });
210 };
211 return useAction({
212 kind: "pull",
213 label: "Pulling latest changes...",
214 scope,
215 action,
216 onSuccess: status.refresh,
217 });
218}
219
220export function useGitStackedAction(scope: SourceControlActionScope) {
221 const runStackedAction = useAtomCommand(vcsActionManager.runStackedAction(scope), {
222 reportFailure: false,
223 });
224 const status = useEnvironmentQuery(
225 scope.environmentId !== null && scope.cwd !== null
226 ? vcsEnvironment.status({
227 environmentId: scope.environmentId,
228 input: { cwd: scope.cwd },
229 })
230 : null,
231 );
232
233 const action = async (input: RunVcsStackedActionInput) => {
234 if (resolveScope(scope) === null) {
235 return AsyncResult.failure<never, VcsActionUnavailableError>(
236 Cause.fail(
237 new VcsActionUnavailableError({
238 operation: "run_change_request",
239 environmentId: scope.environmentId,
240 cwd: scope.cwd,
241 }),
242 ),
243 );
244 }
245 return runStackedAction(input);
246 };
247
248 return useAction({
249 kind: "runStackedAction",
250 label: "Running source control action",
251 scope,
252 action,
253 onSuccess: status.refresh,
254 managedExternally: true,
255 });
256}
257
258export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) {
259 const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
260 reportFailure: false,
261 });
262 const status = useEnvironmentQuery(
263 scope.environmentId !== null && scope.cwd !== null
264 ? vcsEnvironment.status({
265 environmentId: scope.environmentId,
266 input: { cwd: scope.cwd },
267 })
268 : null,
269 );
270 const action = async (input: {
271 provider: SourceControlProviderKind;
272 repository: string;
273 visibility: SourceControlRepositoryVisibility;
274 remoteName: string;
275 protocol: SourceControlCloneProtocol;
276 }) => {
277 const target = resolveScope(scope);
278 if (target === null) {
279 return AsyncResult.failure<never, VcsActionUnavailableError>(
280 Cause.fail(
281 new VcsActionUnavailableError({
282 operation: "publish_repository",
283 environmentId: scope.environmentId,
284 cwd: scope.cwd,
285 }),
286 ),
287 );
288 }
289 const result = await publishRepository({
290 environmentId: target.environmentId,
291 input: {
292 cwd: target.cwd,
293 ...input,
294 },
295 });
296 if (result._tag === "Success") {
297 void suggestAppAccessForRemote(target.environmentId, result.value.remoteUrl);
298 }
299 return result;
300 };
301 return useAction({
302 kind: "publishRepository",
303 label: "Publishing repository",
304 scope,
305 action,
306 onSuccess: status.refresh,
307 });
308}
309
310export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) {
311 const preparePullRequestThread = useAtomCommand(gitEnvironment.preparePullRequestThread, {
312 reportFailure: false,
313 });
314 const action = async (input: {
315 reference: string;
316 mode: "local" | "worktree";
317 threadId?: ThreadId;
318 }) => {
319 const target = resolveScope(scope);
320 if (target === null) {
321 return AsyncResult.failure<never, VcsActionUnavailableError>(
322 Cause.fail(
323 new VcsActionUnavailableError({
324 operation: "prepare_pull_request_thread",
325 environmentId: scope.environmentId,
326 cwd: scope.cwd,
327 }),
328 ),
329 );
330 }
331 if (
332 input.mode === "worktree" &&
333 input.threadId !== undefined &&
334 !readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope)
335 ) {
336 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
337 Cause.fail(
338 new EnvironmentAuthorizationError({
339 requiredScope: AuthOrchestrationOperateScope,
340 message: "This connection cannot change threads.",
341 }),
342 ),
343 );
344 }
345 return preparePullRequestThread({
346 environmentId: target.environmentId,
347 input: {
348 cwd: target.cwd,
349 reference: input.reference,
350 mode: input.mode,
351 ...(input.threadId ? { threadId: input.threadId } : {}),
352 },
353 });
354 };
355 return useAction({
356 kind: "preparePullRequestThread",
357 label: "Preparing pull request thread",
358 scope,
359 action,
360 });
361}
362
363export interface PullRequestResolutionTarget {
364 readonly environmentId: EnvironmentId | null;
365 readonly cwd: string | null;
366 readonly reference: string | null;
367}
368
369export function readCachedPullRequestResolution(
370 target: PullRequestResolutionTarget,
371): GitResolvePullRequestResult | null {
372 if (target.environmentId === null || target.cwd === null || target.reference === null) {
373 return null;
374 }
375 return AsyncResult.getOrElse(
376 appAtomRegistry.get(
377 gitEnvironment.pullRequestResolution({
378 environmentId: target.environmentId,
379 input: { cwd: target.cwd, reference: target.reference },
380 }),
381 ),
382 () => null,
383 );
384}
385
386export function usePullRequestResolutionState(target: PullRequestResolutionTarget) {
387 const query = useEnvironmentQuery(
388 target.environmentId !== null && target.cwd !== null && target.reference !== null
389 ? gitEnvironment.pullRequestResolution({
390 environmentId: target.environmentId,
391 input: {
392 cwd: target.cwd,
393 reference: target.reference,
394 },
395 })
396 : null,
397 );
398 const cached = readCachedPullRequestResolution(target);
399
400 return {
401 data: query.data ?? cached,
402 error: query.error,
403 isPending: query.isPending && cached === null,
404 isFetching: query.isPending,
405 refresh: query.refresh,
406 };
407}