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