1import { useOpenPullRequest } from "../../native/useOpenPullRequest";
2import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";
3import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";
4import {
5 AuthSourceControlWriteScope,
6 EnvironmentId,
7 type GitRunStackedActionResult,
8 type ProjectScript,
9 ThreadId,
10 type VcsStatusResult,
11} from "@t3tools/contracts";
12import {
13 type GitActionRequestInput,
14 requiresDefaultBranchConfirmation,
15 resolveQuickAction,
16} from "@t3tools/client-runtime/state/vcs";
17import { useNavigation } from "@react-navigation/native";
18import { NativeHeaderToolbar } from "../../native/StackHeader";
19import { useCallback, useMemo } from "react";
20import { Alert } from "react-native";
21import { useEnvironmentScope } from "../../state/session";
22import {
23 basename,
24 getTerminalStatusLabel,
25 projectScriptMenuIcon,
26 type TerminalMenuSession,
27} from "../terminal/terminalMenu";
28import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
29
30function truncateMiddle(value: string, maxLength: number): string {
31 if (value.length <= maxLength) {
32 return value;
33 }
34
35 const headLength = Math.ceil((maxLength - 1) / 2);
36 const tailLength = Math.floor((maxLength - 1) / 2);
37 return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`;
38}
39
40function compactMenuBranchLabel(branch: string): string {
41 return truncateMiddle(branch, 24);
42}
43
44function compactMenuStatus(gitStatus: VcsStatusResult | null): string {
45 if (!gitStatus) {
46 return "Checking status";
47 }
48 if (!gitStatus.isRepo) {
49 return "Not a repo";
50 }
51
52 const parts: string[] = [];
53 if (gitStatus.hasWorkingTreeChanges) {
54 parts.push(`${gitStatus.workingTree.files.length} changed`);
55 } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) {
56 parts.push("Clean");
57 }
58 if (gitStatus.aheadCount > 0) {
59 parts.push(`${gitStatus.aheadCount} ahead`);
60 }
61 if (gitStatus.behindCount > 0) {
62 parts.push(`${gitStatus.behindCount} behind`);
63 }
64 if (gitStatus.pr?.state === "open") {
65 parts.push(`PR #${gitStatus.pr.number}`);
66 }
67
68 return parts.join(" · ");
69}
70
71type HeaderItem = Record<string, unknown>;
72type HeaderItems = HeaderItem[];
73type ThreadGitHeaderActionItems = {
74 readonly terminal: HeaderItem;
75 readonly files: HeaderItem;
76 readonly git: HeaderItem;
77};
78type QuickActionIcon =
79 | "arrow.down.circle"
80 | "arrow.up.right.circle"
81 | "checkmark.circle"
82 | "arrow.up.circle";
83
84/** The subset of git-control wiring the standalone git menu needs. */
85export type ThreadGitMenuProps = {
86 readonly environmentId: EnvironmentId | string;
87 readonly threadId: ThreadId | string;
88 readonly currentBranch: string | null;
89 readonly gitStatus: VcsStatusResult | null;
90 readonly gitOperationLabel: string | null;
91 readonly onOpenFilesInspector?: () => void;
92 readonly onOpenGitInspector?: () => void;
93 /** Present only on a thread whose work can be merged into the one it came from. */
94 readonly onMergeBack?: () => void;
95 readonly onPull: () => Promise<void>;
96 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
97};
98
99type ThreadGitControlsProps = ThreadGitMenuProps & {
100 readonly auxiliaryPaneControl?: {
101 readonly accessibilityLabel: string;
102 readonly onPress: () => void;
103 };
104 readonly canOpenTerminal: boolean;
105 readonly canOperateTerminal: boolean;
106 readonly canOpenFiles: boolean;
107 readonly projectScripts: ReadonlyArray<ProjectScript>;
108 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
109 readonly showActionControls?: boolean;
110 readonly showDirectFileControl?: boolean;
111 readonly onOpenTerminal: (terminalId?: string | null) => void;
112 readonly onOpenNewTerminal: () => void;
113 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
114};
115
116function useThreadGitControlModel(props: ThreadGitMenuProps) {
117 const navigation = useNavigation();
118 const openPullRequest = useOpenPullRequest();
119 const environmentId = props.environmentId;
120 const canWriteSourceControl = useEnvironmentScope(
121 environmentId ? EnvironmentId.make(String(environmentId)) : null,
122 AuthSourceControlWriteScope,
123 );
124 const threadId = props.threadId;
125 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
126
127 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
128 const busy = gitOperationLabel !== null;
129 const isRepo = gitStatus?.isRepo ?? true;
130 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
131 const isDefaultRef = gitStatus?.isDefaultRef ?? false;
132
133 const quickAction = useMemo(() => {
134 if (!isRepo) {
135 return {
136 label: "Git unavailable",
137 disabled: true,
138 kind: "show_hint" as const,
139 hint: "This workspace is not a git repository.",
140 };
141 }
142 const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote);
143 return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action")
144 ? {
145 ...action,
146 disabled: true,
147 hint: "This connection cannot change source control.",
148 }
149 : action;
150 }, [busy, canWriteSourceControl, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo]);
151
152 const quickActionHint = quickAction.disabled
153 ? (quickAction.hint ?? "This action is unavailable.")
154 : null;
155
156 const quickActionIcon: QuickActionIcon = (() => {
157 if (quickAction.kind === "run_pull") return "arrow.down.circle";
158 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
159 if (quickAction.kind === "run_action") {
160 if (quickAction.action === "commit") return "checkmark.circle";
161 if (quickAction.action === "push" || quickAction.action === "commit_push")
162 return "arrow.up.circle";
163 }
164 return "arrow.up.right.circle";
165 })();
166
167 const openExistingPr = useCallback(async () => {
168 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
169 if (!prUrl) {
170 Alert.alert("No open PR", "This branch does not have an open pull request.");
171 return;
172 }
173 if (!(await openPullRequest(prUrl))) {
174 Alert.alert("Unable to open PR", "The pull request could not be opened.");
175 }
176 }, [gitStatus, openPullRequest]);
177
178 const runActionWithPrompt = useCallback(
179 async (input: GitActionRequestInput) => {
180 if (!canWriteSourceControl) return;
181 const confirmableAction =
182 input.action === "push" ||
183 input.action === "create_pr" ||
184 input.action === "commit_push" ||
185 input.action === "commit_push_pr"
186 ? input.action
187 : null;
188 const branchName = gitStatus?.refName;
189 if (
190 branchName &&
191 confirmableAction &&
192 !input.featureBranch &&
193 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
194 ) {
195 navigation.navigate("GitConfirm", {
196 environmentId: String(environmentId),
197 threadId: String(threadId),
198 confirmAction: confirmableAction,
199 branchName,
200 includesCommit: String(
201 input.action === "commit_push" || input.action === "commit_push_pr",
202 ),
203 });
204 return;
205 }
206
207 await onRunAction(input);
208 },
209 [
210 canWriteSourceControl,
211 environmentId,
212 gitStatus,
213 isDefaultRef,
214 onRunAction,
215 navigation,
216 threadId,
217 ],
218 );
219
220 const runQuickAction = useCallback(async () => {
221 if (quickAction.disabled) return;
222 if (quickAction.kind === "open_pr") {
223 await openExistingPr();
224 return;
225 }
226 if (quickAction.kind === "run_pull") {
227 await onPull();
228 return;
229 }
230 if (quickAction.kind === "run_action" && quickAction.action) {
231 await runActionWithPrompt({ action: quickAction.action });
232 }
233 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
234
235 const openFiles = useCallback(() => {
236 if (props.onOpenFilesInspector) {
237 props.onOpenFilesInspector();
238 return;
239 }
240 navigation.navigate("ThreadFiles", {
241 environmentId: String(environmentId),
242 threadId: String(threadId),
243 });
244 }, [environmentId, props.onOpenFilesInspector, navigation, threadId]);
245
246 const openReview = useCallback(() => {
247 navigation.navigate("ThreadReview", {
248 environmentId: EnvironmentId.make(String(environmentId)),
249 threadId: ThreadId.make(String(threadId)),
250 });
251 }, [environmentId, navigation, threadId]);
252
253 const openGitInspector = useCallback(() => {
254 if (props.onOpenGitInspector) {
255 props.onOpenGitInspector();
256 return;
257 }
258 navigation.navigate("GitOverview", {
259 environmentId: String(environmentId),
260 threadId: String(threadId),
261 });
262 }, [environmentId, props.onOpenGitInspector, navigation, threadId]);
263
264 return {
265 currentBranchLabel,
266 isRepo,
267 openFiles,
268 openGitInspector,
269 openReview,
270 quickAction,
271 quickActionHint,
272 quickActionIcon,
273 runQuickAction,
274 };
275}
276
277function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems {
278 const model = useThreadGitControlModel(props);
279
280 return useMemo(
281 () => ({
282 terminal: {
283 accessibilityLabel: "Open terminal",
284 disabled: !props.canOpenTerminal,
285 icon: { name: "terminal", type: "sfSymbol" },
286 identifier: "thread-right-terminal",
287 label: "Terminal",
288 menu: {
289 items: [
290 ...props.projectScripts.map((script) => ({
291 description: script.command,
292 disabled: !props.canOperateTerminal,
293 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
294 label: projectScriptMenuLabel(script),
295 onPress: () => void props.onRunProjectScript(script),
296 type: "action" as const,
297 })),
298 ...(props.projectScripts.length === 0
299 ? [
300 {
301 description: "This project has no saved scripts yet",
302 disabled: true,
303 icon: { name: "play", type: "sfSymbol" as const },
304 label: "No project scripts",
305 onPress: () => {},
306 type: "action" as const,
307 },
308 ]
309 : []),
310 ...props.terminalSessions.map((session) => ({
311 description: [
312 getTerminalStatusLabel({
313 status: session.status,
314 hasRunningSubprocess: session.hasRunningSubprocess,
315 }),
316 basename(session.cwd),
317 ]
318 .filter(Boolean)
319 .join(" · "),
320 icon: { name: "terminal", type: "sfSymbol" as const },
321 label: session.displayLabel,
322 onPress: () => props.onOpenTerminal(session.terminalId),
323 type: "action" as const,
324 })),
325 {
326 description: "Start another shell for this thread",
327 disabled: !props.canOperateTerminal,
328 icon: { name: "plus", type: "sfSymbol" },
329 label: "Open new terminal",
330 onPress: props.onOpenNewTerminal,
331 type: "action",
332 },
333 ],
334 title: "Terminal",
335 },
336 sharesBackground: true,
337 type: "menu",
338 variant: "plain",
339 },
340 files: {
341 accessibilityLabel: "Open files",
342 disabled: !props.canOpenFiles,
343 icon: { name: "folder", type: "sfSymbol" },
344 identifier: "thread-right-files",
345 label: "Files",
346 onPress: model.openFiles,
347 sharesBackground: true,
348 type: "button",
349 variant: "plain",
350 },
351 git: {
352 accessibilityLabel: "Git actions",
353 icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" },
354 identifier: "thread-right-git",
355 label: "Git",
356 menu: {
357 items: [
358 {
359 description: compactMenuStatus(props.gitStatus),
360 disabled: true,
361 icon: {
362 name: "point.topleft.down.curvedto.point.bottomright.up",
363 type: "sfSymbol",
364 },
365 label: compactMenuBranchLabel(model.currentBranchLabel),
366 onPress: (): void => {},
367 type: "action",
368 },
369 {
370 description: model.quickActionHint ?? undefined,
371 disabled: model.quickAction.disabled,
372 icon: { name: model.quickActionIcon, type: "sfSymbol" },
373 label: model.quickAction.label,
374 onPress: (): void => void model.runQuickAction(),
375 type: "action",
376 },
377 {
378 description: "Turn diffs and worktree changes",
379 disabled: !model.isRepo,
380 icon: { name: "text.bubble", type: "sfSymbol" },
381 label: "Review changes",
382 onPress: model.openReview,
383 type: "action",
384 },
385 ...(props.onMergeBack
386 ? [
387 {
388 description: "Bring this thread's latest turn into its source",
389 icon: { name: "arrow.triangle.merge", type: "sfSymbol" as const },
390 label: "Merge back to source",
391 onPress: props.onMergeBack,
392 type: "action" as const,
393 },
394 ]
395 : []),
396 {
397 description: "Commit, files, branches",
398 icon: { name: "ellipsis", type: "sfSymbol" },
399 label: "More",
400 onPress: model.openGitInspector,
401 type: "action",
402 },
403 ],
404 title: "Git",
405 },
406 sharesBackground: true,
407 type: "menu",
408 variant: "plain",
409 },
410 }),
411 [
412 model.currentBranchLabel,
413 model.isRepo,
414 model.openFiles,
415 model.openGitInspector,
416 model.openReview,
417 model.quickAction.disabled,
418 model.quickAction.label,
419 model.quickActionHint,
420 model.quickActionIcon,
421 model.runQuickAction,
422 props.canOpenFiles,
423 props.canOpenTerminal,
424 props.canOperateTerminal,
425 props.gitStatus,
426 props.onMergeBack,
427 props.onOpenNewTerminal,
428 props.onOpenTerminal,
429 props.onRunProjectScript,
430 props.projectScripts,
431 props.terminalSessions,
432 ],
433 );
434}
435
436export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {
437 const actionItems = useThreadGitHeaderActionItems(props);
438 return useMemo(
439 () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems,
440 [actionItems],
441 );
442}
443
444export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems {
445 const actionItems = useThreadGitHeaderActionItems(props);
446 return useMemo(
447 () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems,
448 [actionItems],
449 );
450}
451
452export function ThreadGitControls(props: ThreadGitControlsProps) {
453 const model = useThreadGitControlModel(props);
454 const showActionControls = props.showActionControls ?? true;
455
456 if (!showActionControls) {
457 return null;
458 }
459
460 return (
461 <NativeHeaderToolbar placement="right">
462 {showActionControls && props.auxiliaryPaneControl ? (
463 <NativeHeaderToolbar.Button
464 accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
465 icon="sidebar.right"
466 onPress={props.auxiliaryPaneControl.onPress}
467 separateBackground
468 />
469 ) : null}
470 {showActionControls ? (
471 <NativeHeaderToolbar.Menu
472 accessibilityLabel="Open terminal"
473 icon="terminal"
474 disabled={!props.canOpenTerminal}
475 separateBackground
476 >
477 {props.projectScripts.length > 0 ? (
478 props.projectScripts.map((script) => (
479 <NativeHeaderToolbar.MenuAction
480 key={script.id}
481 icon={projectScriptMenuIcon(script.icon)}
482 disabled={!props.canOperateTerminal}
483 onPress={() => void props.onRunProjectScript(script)}
484 subtitle={script.command}
485 >
486 <NativeHeaderToolbar.Label>
487 {projectScriptMenuLabel(script)}
488 </NativeHeaderToolbar.Label>
489 </NativeHeaderToolbar.MenuAction>
490 ))
491 ) : (
492 <NativeHeaderToolbar.MenuAction
493 icon="play"
494 disabled
495 onPress={() => {}}
496 subtitle="This project has no saved scripts yet"
497 >
498 <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label>
499 </NativeHeaderToolbar.MenuAction>
500 )}
501 {props.terminalSessions.map((session) => (
502 <NativeHeaderToolbar.MenuAction
503 key={session.terminalId}
504 icon="terminal"
505 onPress={() => props.onOpenTerminal(session.terminalId)}
506 subtitle={[
507 getTerminalStatusLabel({
508 status: session.status,
509 hasRunningSubprocess: session.hasRunningSubprocess,
510 }),
511 basename(session.cwd),
512 ]
513 .filter(Boolean)
514 .join(" · ")}
515 >
516 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
517 </NativeHeaderToolbar.MenuAction>
518 ))}
519 <NativeHeaderToolbar.MenuAction
520 icon="plus"
521 disabled={!props.canOperateTerminal}
522 onPress={props.onOpenNewTerminal}
523 subtitle="Start another shell for this thread"
524 >
525 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
526 </NativeHeaderToolbar.MenuAction>
527 </NativeHeaderToolbar.Menu>
528 ) : null}
529 {showActionControls && props.showDirectFileControl ? (
530 <NativeHeaderToolbar.Button
531 accessibilityLabel="Open files"
532 disabled={!props.canOpenFiles}
533 icon="folder"
534 onPress={model.openFiles}
535 separateBackground
536 />
537 ) : null}
538 {showActionControls ? createNativeHeaderMenu(threadGitMenuDefinition(props, model)) : null}
539 </NativeHeaderToolbar>
540 );
541}
542
543/**
544 * The standalone git actions menu (branch status, quick commit/push action,
545 * review, more). Rendered inside a NativeHeaderToolbar by both the thread
546 * chat header and the review screen's toolbar.
547 */
548export function ThreadGitMenu(props: ThreadGitMenuProps) {
549 const menu = useThreadGitMenuDefinition(props);
550 return menu ? createNativeHeaderMenu(menu) : null;
551}
552
553/** Returns menu data because native toolbars serialize direct items rather than rendering component children. */
554export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): ScreenHeaderMenu | null {
555 return threadGitMenuDefinition(props, useThreadGitControlModel(props));
556}
557
558function threadGitMenuDefinition(
559 props: ThreadGitMenuProps,
560 model: ReturnType<typeof useThreadGitControlModel>,
561): ScreenHeaderMenu {
562 return {
563 title: "Git controls",
564 icon: "point.topleft.down.curvedto.point.bottomright.up",
565 separateBackground: false,
566 items: [
567 {
568 id: "git-status",
569 title: compactMenuBranchLabel(model.currentBranchLabel),
570 icon: "point.topleft.down.curvedto.point.bottomright.up",
571 disabled: true,
572 subtitle: compactMenuStatus(props.gitStatus),
573 onPress: () => {},
574 },
575 {
576 id: "git-quick-action",
577 title: model.quickAction.label,
578 icon: model.quickActionIcon,
579 disabled: model.quickAction.disabled,
580 subtitle: model.quickActionHint ?? undefined,
581 onPress: () => {
582 void model.runQuickAction();
583 },
584 },
585 {
586 id: "git-review",
587 title: "Review changes",
588 icon: "text.bubble",
589 disabled: !model.isRepo,
590 subtitle: "Turn diffs and worktree changes",
591 onPress: model.openReview,
592 },
593 {
594 id: "git-more",
595 title: "More",
596 icon: "ellipsis",
597 subtitle: "Commit, files, branches",
598 onPress: model.openGitInspector,
599 },
600 ],
601 };
602}