1import type {
2 EnvironmentId,
3 EnvironmentMachineKind,
4 VcsRef,
5 ProjectId,
6 WorktreeSubmodules,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import { sanitizeNewRefName } from "@t3tools/shared/git";
10import { toSortableTimestamp } from "../lib/threadSort";
11export {
12 dedupeRemoteBranchesWithLocalMatches,
13 deriveLocalBranchNameFromRemoteRef,
14 sanitizeNewRefName,
15} from "@t3tools/shared/git";
16
17export interface EnvironmentOption {
18 environmentId: EnvironmentId;
19 /** Null when the machine's "No project" folder is not created yet. */
20 projectId: ProjectId | null;
21 label: string;
22 isPrimary: boolean;
23 machine: EnvironmentMachineKind;
24}
25
26export const EnvMode = Schema.Literals(["local", "worktree"]);
27export type EnvMode = typeof EnvMode.Type;
28
29const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]);
30
31function normalizeDisplayLabel(value: string | null | undefined): string | null {
32 const trimmed = value?.trim();
33 return trimmed && trimmed.length > 0 ? trimmed : null;
34}
35
36export function resolveEnvironmentOptionLabel(input: {
37 isPrimary: boolean;
38 environmentId: EnvironmentId;
39 runtimeLabel?: string | null;
40 savedLabel?: string | null;
41}): string {
42 const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel);
43 const savedLabel = normalizeDisplayLabel(input.savedLabel);
44
45 if (input.isPrimary) {
46 const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => {
47 if (!label) return false;
48 return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase());
49 });
50 return preferredLocalLabel ?? "This device";
51 }
52
53 return runtimeLabel ?? savedLabel ?? input.environmentId;
54}
55
56// A remote (non-primary) environment is always surfaced, even when it is the
57// only environment available: with a single connected machine there is nothing
58// to pick, but the user still needs to see where the project runs.
59export function shouldShowEnvironmentIndicator(input: {
60 activeEnvironment: Pick<EnvironmentOption, "isPrimary"> | null;
61 canPickEnvironment: boolean;
62}): boolean {
63 if (input.canPickEnvironment) return true;
64 return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary;
65}
66
67export function shouldShowComposerContextStrip(input: {
68 isDraftHeroState: boolean;
69 persistInActiveThreads: boolean;
70 hasActiveProject: boolean;
71 isGitRepo: boolean;
72 showEnvironmentIndicator: boolean;
73 /** A collapsed composer's controls currently fit in their measured strip host. */
74 hostsRestingComposerControls: boolean;
75}): boolean {
76 return (
77 input.hasActiveProject &&
78 (input.isDraftHeroState || input.persistInActiveThreads) &&
79 (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls)
80 );
81}
82
83// Labels collapse to icons when the strip's content no longer fits. A small
84// hysteresis on the way back out keeps the boundary from flapping.
85const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16;
86
87export function resolveContextStripLabelsCompact(input: {
88 compact: boolean;
89 neededWidth: number;
90 availableWidth: number;
91}): boolean {
92 return input.compact
93 ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX
94 : input.neededWidth > input.availableWidth;
95}
96
97export function resolveEnvModeLabel(mode: EnvMode): string {
98 return mode === "worktree" ? "New worktree" : "Current checkout";
99}
100
101export const WORKTREE_SUBMODULES_LABELS: Record<WorktreeSubmodules, string> = {
102 recursive: "Recursive",
103 "top-level": "Top level only",
104 none: "Skip",
105};
106
107export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
108 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
109}
110
111// A locked thread in worktree mode with no path is still creating its
112// worktree, so it reads as a new worktree rather than the project checkout.
113export function resolveLockedWorkspaceLabel(
114 activeWorktreePath: string | null,
115 effectiveEnvMode: EnvMode,
116): string {
117 if (activeWorktreePath) return "Worktree";
118 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";
119}
120
121export interface PreviousWorktreeSeed {
122 branch: string | null;
123 worktreePath: string;
124}
125
126// The most recently touched worktree in the project that the composer isn't
127// already pointing at. Backs the "Previous worktree" entry in the workspace
128// selector so a follow-up thread can hop back into the worktree you just
129// worked in without hunting for its branch. Archived threads don't compete —
130// the rest of the UI hides them, so their worktrees shouldn't resurface here.
131export function resolvePreviousWorktreeSeed(input: {
132 threads: ReadonlyArray<{
133 branch: string | null;
134 worktreePath: string | null;
135 updatedAt: string;
136 archivedAt?: string | null;
137 }>;
138 currentWorktreePath: string | null;
139}): PreviousWorktreeSeed | null {
140 let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null;
141 for (const thread of input.threads) {
142 if (
143 !thread.worktreePath ||
144 thread.worktreePath === input.currentWorktreePath ||
145 (thread.archivedAt ?? null) !== null
146 ) {
147 continue;
148 }
149 const updatedAt = toSortableTimestamp(thread.updatedAt);
150 if (updatedAt === null) {
151 continue;
152 }
153 if (latest === null || updatedAt > latest.updatedAt) {
154 latest = {
155 branch: thread.branch,
156 worktreePath: thread.worktreePath,
157 updatedAt,
158 };
159 }
160 }
161 return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath };
162}
163
164export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string {
165 return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree";
166}
167
168export function resolveEffectiveEnvMode(input: {
169 activeWorktreePath: string | null;
170 hasServerThread: boolean;
171 draftThreadEnvMode: EnvMode | undefined;
172 /**
173 * The server is still creating this thread's worktree. The thread exists
174 * from the start of that setup but gets its worktree path only at the end.
175 */
176 preparingWorktree?: boolean;
177}): EnvMode {
178 const { activeWorktreePath, hasServerThread, draftThreadEnvMode, preparingWorktree } = input;
179 if (!hasServerThread) {
180 if (activeWorktreePath) {
181 return "local";
182 }
183 return draftThreadEnvMode === "worktree" ? "worktree" : "local";
184 }
185 return activeWorktreePath || preparingWorktree ? "worktree" : "local";
186}
187
188export function resolveDraftEnvModeAfterBranchChange(input: {
189 nextWorktreePath: string | null;
190 currentWorktreePath: string | null;
191 effectiveEnvMode: EnvMode;
192}): EnvMode {
193 const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input;
194 if (nextWorktreePath) {
195 return "worktree";
196 }
197 if (effectiveEnvMode === "worktree" && !currentWorktreePath) {
198 return "worktree";
199 }
200 return "local";
201}
202
203export function resolveBranchToolbarValue(input: {
204 envMode: EnvMode;
205 activeWorktreePath: string | null;
206 activeThreadBranch: string | null;
207 currentGitBranch: string | null;
208}): string | null {
209 const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
210 if (envMode === "worktree" && !activeWorktreePath) {
211 return activeThreadBranch ?? currentGitBranch;
212 }
213 return currentGitBranch ?? activeThreadBranch;
214}
215
216export function resolveBranchTriggerLabel(input: {
217 activeWorktreePath: string | null;
218 effectiveEnvMode: EnvMode;
219 resolvedActiveBranch: string | null;
220 resolvedActiveBranchIsRemote: boolean | null;
221 startFromOrigin: boolean;
222}): string {
223 const {
224 activeWorktreePath,
225 effectiveEnvMode,
226 resolvedActiveBranch,
227 resolvedActiveBranchIsRemote,
228 startFromOrigin,
229 } = input;
230 if (!resolvedActiveBranch) {
231 return "Select ref";
232 }
233 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
234 const baseRef =
235 startFromOrigin && resolvedActiveBranchIsRemote === false
236 ? `origin/${resolvedActiveBranch}`
237 : resolvedActiveBranch;
238 return `From ${baseRef}`;
239 }
240 return resolvedActiveBranch;
241}
242
243export function resolveBranchToolbarPrBranch(input: {
244 activeThreadBranch: string | null;
245 resolvedActiveBranch: string | null;
246}): string | null {
247 return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null;
248}
249
250export function resolveLocalCheckoutBranchMismatch(input: {
251 effectiveEnvMode: EnvMode;
252 activeWorktreePath: string | null;
253 activeThreadBranch: string | null;
254 currentGitBranch: string | null;
255}): { threadBranch: string; currentBranch: string } | null {
256 const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
257 if (effectiveEnvMode !== "local" || activeWorktreePath !== null) {
258 return null;
259 }
260 if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) {
261 return null;
262 }
263 return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch };
264}
265
266export function resolveBranchSelectionTarget(input: {
267 activeProjectCwd: string;
268 activeWorktreePath: string | null;
269 refName: Pick<VcsRef, "isDefault" | "worktreePath">;
270}): {
271 checkoutCwd: string;
272 nextWorktreePath: string | null;
273 reuseExistingWorktree: boolean;
274} {
275 const { activeProjectCwd, activeWorktreePath, refName } = input;
276
277 if (refName.worktreePath) {
278 return {
279 checkoutCwd: refName.worktreePath,
280 nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath,
281 reuseExistingWorktree: true,
282 };
283 }
284
285 const nextWorktreePath =
286 activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath;
287
288 return {
289 checkoutCwd: nextWorktreePath ?? activeProjectCwd,
290 nextWorktreePath,
291 reuseExistingWorktree: false,
292 };
293}
294
295export function shouldIncludeBranchPickerItem(input: {
296 itemValue: string;
297 normalizedQuery: string;
298 createBranchItemValue: string | null;
299 checkoutPullRequestItemValue: string | null;
300}): boolean {
301 const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
302
303 if (normalizedQuery.length === 0) {
304 return true;
305 }
306
307 if (createBranchItemValue && itemValue === createBranchItemValue) {
308 return true;
309 }
310
311 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
312 return true;
313 }
314
315 const lowerItemValue = itemValue.toLowerCase();
316 if (lowerItemValue.includes(normalizedQuery)) {
317 return true;
318 }
319
320 // A query containing whitespace can only ever match a ref under its sanitized
321 // name, because that is the name such a ref would have been created with.
322 // Without this, typing "new branch" hides an existing "new-branch".
323 const sanitizedQuery = sanitizeNewRefName(normalizedQuery);
324 return (
325 sanitizedQuery.length > 0 &&
326 sanitizedQuery !== normalizedQuery &&
327 lowerItemValue.includes(sanitizedQuery)
328 );
329}