1import * as Dialog from "../../components/ui/Dialog";
2import { useOpenPullRequest } from "../../native/useOpenPullRequest";
3import type { AppBarMenu } from "../../components/ui/AppBar.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 { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
19
20import { useEnvironmentScope } from "../../state/session";
21import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
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 const { shortLabel } = sourceControlClients.get(
66 gitStatus.sourceControlProvider?.kind,
67 ).changeRequest;
68 parts.push(`${shortLabel} #${gitStatus.pr.number}`);
69 }
70
71 return parts.join(" · ");
72}
73
74type QuickActionIcon =
75 | "arrow.down.circle"
76 | "arrow.up.right.circle"
77 | "checkmark.circle"
78 | "arrow.up.circle";
79
80export type ThreadGitMenuProps = {
81 readonly environmentId: EnvironmentId | string;
82 readonly threadId: ThreadId | string;
83 readonly currentBranch: string | null;
84 readonly hasThread?: boolean;
85 readonly gitStatus: VcsStatusResult | null;
86 readonly gitOperationLabel: string | null;
87 readonly onOpenFilesInspector?: () => void;
88 readonly onOpenGitInspector?: () => void;
89 readonly onMergeBack?: () => void;
90 readonly onPull: () => Promise<void>;
91 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
92};
93
94export type ThreadGitControlsProps = ThreadGitMenuProps & {
95 readonly canOpenTerminal: boolean;
96 readonly canOperateTerminal: boolean;
97 readonly canOpenFiles: boolean;
98 readonly projectScripts: ReadonlyArray<ProjectScript>;
99 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
100 readonly onOpenTerminal: (terminalId?: string | null) => void;
101 readonly onOpenNewTerminal: () => void;
102 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
103};
104
105function useThreadGitControlModel(props: ThreadGitMenuProps) {
106 const navigation = useNavigation();
107 const openPullRequest = useOpenPullRequest();
108 const environmentId = props.environmentId;
109 const canWriteSourceControl = useEnvironmentScope(
110 environmentId ? EnvironmentId.make(String(environmentId)) : null,
111 AuthSourceControlWriteScope,
112 );
113 const threadId = props.threadId;
114 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
115
116 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
117 const busy = gitOperationLabel !== null;
118 const isRepo = gitStatus?.isRepo ?? true;
119 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
120 const isDefaultRef = gitStatus?.isDefaultRef ?? false;
121
122 const quickAction = (() => {
123 if (!isRepo) {
124 return {
125 label: "Git unavailable",
126 disabled: true,
127 kind: "show_hint" as const,
128 hint: "This workspace is not a git repository.",
129 };
130 }
131 const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote);
132 return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action")
133 ? {
134 ...action,
135 disabled: true,
136 hint: "This connection cannot change source control.",
137 }
138 : action;
139 })();
140
141 const quickActionHint = quickAction.disabled
142 ? (quickAction.hint ?? "This action is unavailable.")
143 : null;
144
145 const quickActionIcon: QuickActionIcon = (() => {
146 if (quickAction.kind === "run_pull") return "arrow.down.circle";
147 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
148 if (quickAction.kind === "run_action") {
149 if (quickAction.action === "commit") return "checkmark.circle";
150 if (quickAction.action === "push" || quickAction.action === "commit_push")
151 return "arrow.up.circle";
152 }
153 return "arrow.up.right.circle";
154 })();
155
156 const openExistingPr = async () => {
157 const { shortLabel, singular } = sourceControlClients.get(
158 gitStatus?.sourceControlProvider?.kind,
159 ).changeRequest;
160 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
161 if (!prUrl) {
162 Dialog.alert(`No open ${shortLabel}`, `This branch does not have an open ${singular}.`);
163 return;
164 }
165 if (!(await openPullRequest(prUrl))) {
166 Dialog.alert(`Unable to open ${shortLabel}`, `The ${singular} could not be opened.`);
167 }
168 };
169
170 const runActionWithPrompt = async (input: GitActionRequestInput) => {
171 if (!canWriteSourceControl) return;
172 const confirmableAction =
173 input.action === "push" ||
174 input.action === "create_pr" ||
175 input.action === "commit_push" ||
176 input.action === "commit_push_pr"
177 ? input.action
178 : null;
179 const branchName = gitStatus?.refName;
180 if (
181 branchName &&
182 confirmableAction &&
183 !input.featureBranch &&
184 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
185 ) {
186 if (props.hasThread === false) {
187 Dialog.alert(
188 "Choose a feature branch",
189 "Switch branches before publishing changes from this draft.",
190 );
191 return;
192 }
193 navigation.navigate("GitConfirm", {
194 environmentId: String(environmentId),
195 threadId: String(threadId),
196 confirmAction: confirmableAction,
197 branchName,
198 includesCommit: String(input.action === "commit_push" || input.action === "commit_push_pr"),
199 });
200 return;
201 }
202
203 await onRunAction(input);
204 };
205
206 const runQuickAction = async () => {
207 if (quickAction.disabled) return;
208 if (quickAction.kind === "open_pr") {
209 await openExistingPr();
210 return;
211 }
212 if (quickAction.kind === "run_pull") {
213 await onPull();
214 return;
215 }
216 if (quickAction.kind === "run_action" && quickAction.action) {
217 await runActionWithPrompt({ action: quickAction.action });
218 }
219 };
220
221 const openReview = () => {
222 navigation.navigate("ThreadReview", {
223 environmentId: EnvironmentId.make(String(environmentId)),
224 threadId: ThreadId.make(String(threadId)),
225 });
226 };
227
228 const openGitInspector = () => {
229 if (props.onOpenGitInspector) {
230 props.onOpenGitInspector();
231 return;
232 }
233 navigation.navigate("GitOverview", {
234 environmentId: String(environmentId),
235 threadId: String(threadId),
236 });
237 };
238
239 return {
240 currentBranchLabel,
241 isRepo,
242 openGitInspector,
243 openReview,
244 quickAction,
245 quickActionHint,
246 quickActionIcon,
247 runQuickAction,
248 };
249}
250
251export function threadTerminalMenu(props: ThreadGitControlsProps): AppBarMenu | null {
252 if (!props.canOpenTerminal) return null;
253 return {
254 title: "Terminal",
255 icon: "terminal",
256 separateBackground: false,
257 items: [
258 ...(props.projectScripts.length > 0
259 ? props.projectScripts.map((script) => ({
260 id: `script-${script.id}`,
261 title: projectScriptMenuLabel(script),
262 icon: projectScriptMenuIcon(script.icon),
263 subtitle: script.command,
264 disabled: !props.canOperateTerminal,
265 onPress: () => void props.onRunProjectScript(script),
266 }))
267 : [
268 {
269 id: "script-none",
270 title: "No project scripts",
271 icon: "play",
272 subtitle: "This project has no saved scripts yet",
273 disabled: true,
274 onPress: () => {},
275 },
276 ]),
277 ...props.terminalSessions.map((session) => ({
278 id: `terminal-${session.terminalId}`,
279 title: session.displayLabel,
280 icon: "terminal",
281 subtitle: [
282 getTerminalStatusLabel({
283 status: session.status,
284 hasRunningSubprocess: session.hasRunningSubprocess,
285 }),
286 basename(session.cwd),
287 ]
288 .filter(Boolean)
289 .join(" · "),
290 onPress: () => props.onOpenTerminal(session.terminalId),
291 })),
292 {
293 id: "terminal-new",
294 title: "Open new terminal",
295 icon: "plus",
296 subtitle: "Start another shell for this thread",
297 disabled: !props.canOperateTerminal,
298 onPress: props.onOpenNewTerminal,
299 },
300 ],
301 };
302}
303
304/** The thread's git menu, shared by the thread bar and Review. */
305export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): AppBarMenu | null {
306 const model = useThreadGitControlModel(props);
307 return {
308 title: "Git controls",
309 icon: "point.topleft.down.curvedto.point.bottomright.up",
310 separateBackground: false,
311 items: [
312 {
313 id: "git-status",
314 title: compactMenuBranchLabel(model.currentBranchLabel),
315 icon: "point.topleft.down.curvedto.point.bottomright.up",
316 disabled: true,
317 subtitle: compactMenuStatus(props.gitStatus),
318 onPress: () => {},
319 },
320 {
321 id: "git-quick-action",
322 title: model.quickAction.label,
323 icon: model.quickActionIcon,
324 disabled: model.quickAction.disabled,
325 subtitle: model.quickActionHint ?? undefined,
326 onPress: () => {
327 void model.runQuickAction();
328 },
329 },
330 {
331 id: "git-review",
332 title: "Review changes",
333 icon: "text.bubble",
334 disabled: !model.isRepo || props.hasThread === false,
335 subtitle:
336 props.hasThread === false
337 ? "Available after the first message"
338 : "Turn diffs and worktree changes",
339 onPress: model.openReview,
340 },
341 ...(props.onMergeBack
342 ? [
343 {
344 id: "git-merge-back",
345 title: "Merge back to source",
346 icon: PullRequestGlyph.merged,
347 subtitle: "Bring this thread's latest turn into its source",
348 onPress: props.onMergeBack,
349 },
350 ]
351 : []),
352 {
353 id: "git-more",
354 title: "More",
355 icon: "ellipsis",
356 disabled: props.hasThread === false,
357 subtitle:
358 props.hasThread === false
359 ? "Available after the first message"
360 : "Commit, files, branches",
361 onPress: model.openGitInspector,
362 },
363 ],
364 };
365}