1import { useCallback, useEffect, useEffectEvent, useMemo } from "react";
2
3import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
5import {
6 type GitActionRequestInput,
7 type VcsActionOperation,
8 type VcsRef,
9} from "@t3tools/client-runtime/state/vcs";
10import {
11 AuthOrchestrationOperateScope,
12 AuthSourceControlWriteScope,
13 EnvironmentAuthorizationError,
14 type GitRunStackedActionResult,
15} from "@t3tools/contracts";
16import {
17 dedupeRemoteBranchesWithLocalMatches,
18 sanitizeFeatureBranchName,
19} from "@t3tools/shared/git";
20import * as Cause from "effect/Cause";
21import { AsyncResult } from "effect/reactivity";
22
23import { useBranches } from "../state/queries";
24import { threadEnvironment } from "../state/threads";
25import { vcsActionManager, vcsEnvironment } from "../state/vcs";
26import { uuidv4 } from "../lib/uuid";
27import { appAtomRegistry } from "./atom-registry";
28import { readEnvironmentScope, useEnvironmentScope } from "./session";
29import { setPendingConnectionError } from "./use-remote-environment-registry";
30import { useAtomCommand } from "./use-atom-command";
31import { showGitActionResult } from "./use-vcs-action-state";
32import { useThreadSelection } from "./use-thread-selection";
33import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
34
35export function useSelectedThreadGitActions() {
36 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
37 reportFailure: false,
38 });
39 const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
40 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
41 const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false });
42 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
43 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
44 const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } =
45 useThreadSelection();
46 const canWriteSourceControl = useEnvironmentScope(
47 selectedThread?.environmentId ?? null,
48 AuthSourceControlWriteScope,
49 );
50 const canOperateThread = useEnvironmentScope(
51 selectedThread?.environmentId ?? null,
52 AuthOrchestrationOperateScope,
53 );
54 const canChangeThreadBranch = canWriteSourceControl && canOperateThread;
55 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
56 const runStackedAction = useAtomCommand(
57 vcsActionManager.runStackedAction({
58 environmentId: selectedThread?.environmentId ?? null,
59 cwd: selectedThreadCwd,
60 }),
61 { reportFailure: false },
62 );
63
64 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
65 const branchTarget = useMemo(
66 () => ({
67 environmentId: selectedThread?.environmentId ?? null,
68 cwd: selectedThreadGitRootCwd,
69 query: null,
70 }),
71 [selectedThread?.environmentId, selectedThreadGitRootCwd],
72 );
73 const branchState = useBranches(branchTarget);
74 const updateThreadGitContext = useCallback(
75 async (
76 thread: NonNullable<typeof selectedThread>,
77 nextState: {
78 readonly branch?: string | null;
79 readonly worktreePath?: string | null;
80 },
81 ) => {
82 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) {
83 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
84 Cause.fail(
85 new EnvironmentAuthorizationError({
86 requiredScope: AuthOrchestrationOperateScope,
87 message: "This connection cannot update the thread's branch.",
88 }),
89 ),
90 );
91 }
92 return updateThreadMetadata({
93 environmentId: thread.environmentId,
94 input: {
95 threadId: thread.id,
96 ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
97 ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
98 },
99 });
100 },
101 [updateThreadMetadata],
102 );
103
104 const refreshSelectedThreadGitStatus = useCallback(
105 async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => {
106 if (!selectedThread || !selectedThreadProject) {
107 return null;
108 }
109
110 const cwd = options?.cwd ?? selectedThreadCwd;
111 if (!cwd) {
112 return null;
113 }
114
115 const target = { environmentId: selectedThread.environmentId, cwd };
116 const execute = () =>
117 refreshStatus({
118 environmentId: selectedThread.environmentId,
119 input: { cwd },
120 });
121 const result = options?.quiet
122 ? await execute()
123 : await vcsActionManager.track(
124 appAtomRegistry,
125 target,
126 {
127 operation: "refresh_status",
128 label: "Refreshing source control status",
129 },
130 execute,
131 );
132 if (AsyncResult.isFailure(result)) {
133 const error = Cause.squash(result.cause);
134 const message = error instanceof Error ? error.message : "Failed to refresh git status.";
135 setPendingConnectionError(message);
136 return null;
137 }
138 setPendingConnectionError(null);
139 return result.value;
140 },
141 [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject],
142 );
143
144 // Shell updates replace the selected thread object many times per second while a
145 // turn streams, so key the refresh on primitives. The server publishes status after
146 // each turn finishes; this only seeds status when the selection or cwd changes, and
147 // again on reconnect because the server's cached status can miss changes made while
148 // the app was away.
149 const selectedEnvironmentId = selectedThread?.environmentId ?? null;
150 const selectedThreadId = selectedThread?.id ?? null;
151 const hasSelectedThreadProject = selectedThreadProject !== null;
152 const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected";
153 const refreshOnSelection = useEffectEvent(() => {
154 void refreshSelectedThreadGitStatus({ quiet: true });
155 });
156 useEffect(() => {
157 if (
158 selectedEnvironmentId === null ||
159 selectedThreadId === null ||
160 !hasSelectedThreadProject ||
161 selectedThreadCwd === null ||
162 !isEnvironmentConnected
163 ) {
164 return;
165 }
166 refreshOnSelection();
167 }, [
168 selectedEnvironmentId,
169 selectedThreadId,
170 hasSelectedThreadProject,
171 selectedThreadCwd,
172 isEnvironmentConnected,
173 ]);
174
175 const runSelectedThreadGitMutation = useCallback(
176 async <T, E>(
177 operation: VcsActionOperation,
178 label: string,
179 execute: (input: {
180 readonly thread: EnvironmentThreadShell;
181 readonly project: EnvironmentProject;
182 readonly cwd: string;
183 }) => Promise<AtomCommandResult<T, E>>,
184 options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean },
185 ): Promise<T | null> => {
186 if (
187 !selectedThread ||
188 !selectedThreadProject ||
189 !selectedThreadCwd ||
190 !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) ||
191 (options?.changesThreadBranch === true &&
192 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope))
193 ) {
194 return null;
195 }
196
197 const target = {
198 environmentId: selectedThread.environmentId,
199 cwd: selectedThreadCwd,
200 };
201 setPendingConnectionError(null);
202 const run = () =>
203 execute({
204 thread: selectedThread,
205 project: selectedThreadProject,
206 cwd: selectedThreadCwd,
207 });
208 const result =
209 options?.managedExternally === true
210 ? await run()
211 : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run);
212 if (AsyncResult.isFailure(result)) {
213 const error = Cause.squash(result.cause);
214 const message = error instanceof Error ? error.message : "Git action failed.";
215 setPendingConnectionError(message);
216 showGitActionResult({ type: "error", title: "Git action failed", description: message });
217 return null;
218 }
219 return result.value;
220 },
221 [selectedThread, selectedThreadCwd, selectedThreadProject],
222 );
223
224 const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<VcsRef>> => {
225 branchState.refresh();
226 return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter(
227 (branch) => !branch.isRemote,
228 );
229 }, [branchState]);
230
231 const syncSelectedThreadBranchState = useCallback(
232 async (input: {
233 readonly thread: EnvironmentThreadShell;
234 readonly cwd: string;
235 readonly nextThreadState?: {
236 readonly branch?: string | null;
237 readonly worktreePath?: string | null;
238 };
239 }): Promise<AtomCommandResult<void, unknown>> => {
240 // The Git mutation already landed; refresh what the worktree shows even
241 // when the thread metadata update is denied, so the sheet does not keep
242 // displaying the previous branch.
243 const updateResult = input.nextThreadState
244 ? await updateThreadGitContext(input.thread, input.nextThreadState)
245 : AsyncResult.success(undefined);
246 branchState.refresh();
247 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
248 return AsyncResult.isFailure(updateResult)
249 ? AsyncResult.failure(updateResult.cause)
250 : AsyncResult.success(undefined);
251 },
252 [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext],
253 );
254
255 const onCheckoutSelectedThreadBranch = useCallback(
256 async (branch: string) => {
257 return runSelectedThreadGitMutation(
258 "switch_ref",
259 "Switching branch",
260 async ({ thread, cwd }) => {
261 const result = await switchRef({
262 environmentId: thread.environmentId,
263 input: { cwd, refName: branch },
264 });
265 if (AsyncResult.isFailure(result)) {
266 return result;
267 }
268 const syncResult = await syncSelectedThreadBranchState({
269 thread,
270 cwd,
271 nextThreadState: {
272 branch: result.value.refName ?? thread.branch,
273 worktreePath: selectedThreadWorktreePath,
274 },
275 });
276 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
277 },
278 { changesThreadBranch: true },
279 );
280 },
281 [
282 runSelectedThreadGitMutation,
283 selectedThreadWorktreePath,
284 syncSelectedThreadBranchState,
285 switchRef,
286 ],
287 );
288
289 const onCreateSelectedThreadBranch = useCallback(
290 async (branch: string) => {
291 return runSelectedThreadGitMutation(
292 "create_ref",
293 "Creating branch",
294 async ({ thread, cwd }) => {
295 const result = await createRef({
296 environmentId: thread.environmentId,
297 input: { cwd, refName: branch, switchRef: true },
298 });
299 if (AsyncResult.isFailure(result)) {
300 return result;
301 }
302 const syncResult = await syncSelectedThreadBranchState({
303 thread,
304 cwd,
305 nextThreadState: {
306 branch: result.value.refName ?? thread.branch,
307 worktreePath: selectedThreadWorktreePath,
308 },
309 });
310 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
311 },
312 { changesThreadBranch: true },
313 );
314 },
315 [
316 runSelectedThreadGitMutation,
317 selectedThreadWorktreePath,
318 syncSelectedThreadBranchState,
319 createRef,
320 ],
321 );
322
323 const onCreateSelectedThreadWorktree = useCallback(
324 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
325 return runSelectedThreadGitMutation(
326 "create_worktree",
327 "Creating worktree",
328 async ({ thread, project }) => {
329 const result = await createWorktree({
330 environmentId: thread.environmentId,
331 input: {
332 cwd: project.workspaceRoot,
333 refName: nextWorktree.baseBranch,
334 newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
335 path: null,
336 },
337 });
338 if (AsyncResult.isFailure(result)) {
339 return result;
340 }
341 const syncResult = await syncSelectedThreadBranchState({
342 thread,
343 cwd: result.value.worktree.path,
344 nextThreadState: {
345 branch: result.value.worktree.refName,
346 worktreePath: result.value.worktree.path,
347 },
348 });
349 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
350 },
351 { changesThreadBranch: true },
352 );
353 },
354 [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState],
355 );
356
357 const onPullSelectedThreadBranch = useCallback(async () => {
358 await runSelectedThreadGitMutation(
359 "pull",
360 "Pulling latest changes",
361 async ({ thread, cwd }) => {
362 const result = await pull({
363 environmentId: thread.environmentId,
364 input: { cwd },
365 });
366 if (AsyncResult.isFailure(result)) {
367 return result;
368 }
369 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
370 showGitActionResult({
371 type: "success",
372 title:
373 result.value.status === "skipped_up_to_date"
374 ? "Already up to date"
375 : `Pulled latest on ${result.value.refName}`,
376 });
377 return result;
378 },
379 );
380 }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
381
382 const onRunSelectedThreadGitAction = useCallback(
383 async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => {
384 const actionId = uuidv4();
385 return await runSelectedThreadGitMutation(
386 "run_change_request",
387 "Running source control action",
388 async ({ thread, cwd }) => {
389 const result = await runStackedAction({
390 actionId,
391 action: input.action,
392 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
393 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
394 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
395 // A pull request the action opens is linked to the thread it ran beside.
396 threadId: thread.id,
397 });
398 if (AsyncResult.isFailure(result)) {
399 return result;
400 }
401
402 if (result.value.branch.status === "created" && result.value.branch.name) {
403 const syncResult = await syncSelectedThreadBranchState({
404 thread,
405 cwd,
406 nextThreadState: {
407 branch: result.value.branch.name,
408 worktreePath: selectedThreadWorktreePath,
409 },
410 });
411 if (AsyncResult.isFailure(syncResult)) {
412 return AsyncResult.failure(syncResult.cause);
413 }
414 } else {
415 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
416 }
417 showGitActionResult({
418 type: "success",
419 title: result.value.toast.title,
420 description: result.value.toast.description,
421 prUrl:
422 result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined,
423 });
424 return result;
425 },
426 { managedExternally: true, changesThreadBranch: input.featureBranch === true },
427 );
428 },
429 [
430 runStackedAction,
431 refreshSelectedThreadGitStatus,
432 runSelectedThreadGitMutation,
433 selectedThreadWorktreePath,
434 syncSelectedThreadBranchState,
435 ],
436 );
437
438 return {
439 canWriteSourceControl,
440 canChangeThreadBranch,
441 refreshSelectedThreadGitStatus,
442 refreshSelectedThreadBranches,
443 onCheckoutSelectedThreadBranch,
444 onCreateSelectedThreadBranch,
445 onCreateSelectedThreadWorktree,
446 onPullSelectedThreadBranch,
447 onRunSelectedThreadGitAction,
448 };
449}