1import type {
2 EnvironmentId,
3 EnvironmentMachineKind,
4 VcsRef,
5 ProjectId,
6 WorktreeSubmodules,
7} from "@t3tools/contracts";
8import type { ThreadStructureRuntime } from "@t3tools/client-runtime/state/shell";
9import * as Schema from "effect/Schema";
10import { sanitizeNewRefName } from "@t3tools/shared/git";
11import { workspaceRelativeFilePath } from "@t3tools/shared/path";
12import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort";
13import { deriveCanInterruptRunningThread } from "../session-logic";
14export { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git";
15
16export interface EnvironmentOption {
17 environmentId: EnvironmentId;
18 projectId: ProjectId | null;
19 label: string;
20 isPrimary: boolean;
21 machine: EnvironmentMachineKind;
22}
23
24/** "Add to another machine" stays muted until hovered: easy to find, quieter than the machines. */
25export const ADD_TO_MACHINE_LABEL_CLASS =
26 "flex min-w-0 items-center gap-1.5 opacity-55 in-data-highlighted:opacity-100 in-data-popup-open:opacity-100";
27
28export const EnvMode = Schema.Literals(["local", "worktree"]);
29export type EnvMode = typeof EnvMode.Type;
30
31const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]);
32
33function normalizeDisplayLabel(value: string | null | undefined): string | null {
34 const trimmed = value?.trim();
35 return trimmed && trimmed.length > 0 ? trimmed : null;
36}
37
38export function resolveEnvironmentOptionLabel(input: {
39 isPrimary: boolean;
40 environmentId: EnvironmentId;
41 runtimeLabel?: string | null;
42 savedLabel?: string | null;
43}): string {
44 const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel);
45 const savedLabel = normalizeDisplayLabel(input.savedLabel);
46
47 if (input.isPrimary) {
48 const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => {
49 if (!label) return false;
50 return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase());
51 });
52 return preferredLocalLabel ?? "This device";
53 }
54
55 return runtimeLabel ?? savedLabel ?? input.environmentId;
56}
57
58export function shouldShowEnvironmentIndicator(input: {
59 activeEnvironment: Pick<EnvironmentOption, "isPrimary"> | null;
60 canPickEnvironment: boolean;
61}): boolean {
62 if (input.canPickEnvironment) return true;
63 return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary;
64}
65
66export function shouldShowComposerContextStrip(input: {
67 isDraftHeroState: boolean;
68 persistInActiveThreads: boolean;
69 hasActiveProject: boolean;
70 isGitRepo: boolean;
71 showEnvironmentIndicator: boolean;
72}): boolean {
73 // A new thread always shows the strip, since it names the permissions the first send uses.
74 return (
75 input.hasActiveProject &&
76 (input.isDraftHeroState ||
77 (input.persistInActiveThreads && (input.isGitRepo || input.showEnvironmentIndicator)))
78 );
79}
80
81// Hysteresis prevents labels and icons from flapping at the width boundary.
82const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16;
83
84export type ContextStripCompaction = "none" | "labels" | "branch";
85
86export function resolveContextStripCompaction(input: {
87 current: ContextStripCompaction;
88 neededWidth: number;
89 availableWidth: number;
90 labelsSavedWidth: number;
91}): ContextStripCompaction {
92 const fits = (width: number, expanding: boolean) =>
93 width <= input.availableWidth - (expanding ? CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX : 0);
94 if (fits(input.neededWidth, input.current !== "none")) return "none";
95 if (fits(input.neededWidth - input.labelsSavedWidth, input.current === "branch")) {
96 return "labels";
97 }
98 return "branch";
99}
100
101export function resolveEnvModeLabel(mode: EnvMode): string {
102 return mode === "worktree" ? "New worktree" : "Current checkout";
103}
104
105export const WORKTREE_SUBMODULES_LABELS: Record<WorktreeSubmodules, string> = {
106 recursive: "Recursive",
107 "top-level": "Top level only",
108 none: "Skip",
109};
110
111export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
112 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
113}
114
115export function resolveLockedWorkspaceLabel(
116 activeWorktreePath: string | null,
117 effectiveEnvMode: EnvMode,
118): string {
119 if (activeWorktreePath) return activeWorktreePath.split(/[\\/]/).findLast(Boolean) ?? "Worktree";
120 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";
121}
122
123export interface PreviousWorktreeSeed {
124 branch: string | null;
125 worktreePath: string;
126 /** When a thread last touched the worktree, for the picker's "2h ago". */
127 updatedAt: string;
128}
129
130export function resolvePreviousWorktreeSeed(input: {
131 threads: ReadonlyArray<{
132 branch: string | null;
133 worktreePath: string | null;
134 updatedAt: string;
135 archivedAt?: string | null;
136 }>;
137 currentWorktreePath: string | null;
138}): PreviousWorktreeSeed | null {
139 let latest: {
140 branch: string | null;
141 worktreePath: string;
142 updatedAt: number;
143 updatedAtIso: string;
144 } | null = null;
145 for (const thread of input.threads) {
146 if (
147 !thread.worktreePath ||
148 thread.worktreePath === input.currentWorktreePath ||
149 (thread.archivedAt ?? null) !== null
150 ) {
151 continue;
152 }
153 const updatedAt = toSortableTimestamp(thread.updatedAt);
154 if (updatedAt === null) {
155 continue;
156 }
157 if (latest === null || updatedAt > latest.updatedAt) {
158 latest = {
159 branch: thread.branch,
160 worktreePath: thread.worktreePath,
161 updatedAt,
162 updatedAtIso: thread.updatedAt,
163 };
164 }
165 }
166 return latest === null
167 ? null
168 : { branch: latest.branch, worktreePath: latest.worktreePath, updatedAt: latest.updatedAtIso };
169}
170
171export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string {
172 return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree";
173}
174
175export function resolveEffectiveEnvMode(input: {
176 activeWorktreePath: string | null;
177 hasServerThread: boolean;
178 draftThreadEnvMode: EnvMode | undefined;
179 preparingWorktree?: boolean;
180}): EnvMode {
181 const { activeWorktreePath, hasServerThread, draftThreadEnvMode, preparingWorktree } = input;
182 if (!hasServerThread) {
183 if (activeWorktreePath) {
184 return "local";
185 }
186 return draftThreadEnvMode === "worktree" ? "worktree" : "local";
187 }
188 return activeWorktreePath || preparingWorktree ? "worktree" : "local";
189}
190
191export function resolveDraftEnvModeAfterBranchChange(input: {
192 nextWorktreePath: string | null;
193 currentWorktreePath: string | null;
194 effectiveEnvMode: EnvMode;
195}): EnvMode {
196 const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input;
197 if (nextWorktreePath) {
198 return "worktree";
199 }
200 if (effectiveEnvMode === "worktree" && !currentWorktreePath) {
201 return "worktree";
202 }
203 return "local";
204}
205
206export function resolveBranchToolbarValue(input: {
207 envMode: EnvMode;
208 activeWorktreePath: string | null;
209 activeThreadBranch: string | null;
210 currentGitBranch: string | null;
211}): string | null {
212 const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
213 if (envMode === "worktree" && !activeWorktreePath) {
214 return activeThreadBranch ?? currentGitBranch;
215 }
216 return currentGitBranch ?? activeThreadBranch;
217}
218
219export function resolveBranchTriggerLabel(input: {
220 activeWorktreePath: string | null;
221 effectiveEnvMode: EnvMode;
222 resolvedActiveBranch: string | null;
223 resolvedActiveBranchIsRemote: boolean | null;
224 startFromOrigin: boolean;
225}): string {
226 const {
227 activeWorktreePath,
228 effectiveEnvMode,
229 resolvedActiveBranch,
230 resolvedActiveBranchIsRemote,
231 startFromOrigin,
232 } = input;
233 if (!resolvedActiveBranch) {
234 return "Select ref";
235 }
236 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
237 // Only the server can determine a local branch's configured remote.
238 return startFromOrigin && resolvedActiveBranchIsRemote === false
239 ? `From ${resolvedActiveBranch} (remote)`
240 : `From ${resolvedActiveBranch}`;
241 }
242 return resolvedActiveBranch;
243}
244
245export function resolveBranchToolbarPrBranch(input: {
246 activeThreadBranch: string | null;
247 resolvedActiveBranch: string | null;
248}): string | null {
249 return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null;
250}
251
252export function resolveLocalCheckoutBranchMismatch(input: {
253 effectiveEnvMode: EnvMode;
254 activeWorktreePath: string | null;
255 activeThreadBranch: string | null;
256 currentGitBranch: string | null;
257}): { threadBranch: string; currentBranch: string } | null {
258 const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
259 if (effectiveEnvMode !== "local" || activeWorktreePath !== null) {
260 return null;
261 }
262 if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) {
263 return null;
264 }
265 return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch };
266}
267
268export function resolveBranchSelectionTarget(input: {
269 activeProjectCwd: string;
270 activeProjectCheckoutRoot?: string | undefined;
271 activeWorktreePath: string | null;
272 refName: Pick<VcsRef, "isDefault" | "worktreePath">;
273}): {
274 checkoutCwd: string;
275 nextWorktreePath: string | null;
276 reuseExistingWorktree: boolean;
277} {
278 const { activeProjectCwd, activeWorktreePath, refName } = input;
279
280 if (refName.worktreePath) {
281 // Until the project's checkout root loads, a checkout containing the project counts as its own.
282 const isProjectCheckout =
283 input.activeProjectCheckoutRoot === undefined
284 ? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null
285 : workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === ".";
286 return {
287 checkoutCwd: refName.worktreePath,
288 nextWorktreePath: isProjectCheckout ? null : refName.worktreePath,
289 reuseExistingWorktree: true,
290 };
291 }
292
293 const nextWorktreePath =
294 activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath;
295
296 return {
297 checkoutCwd: nextWorktreePath ?? activeProjectCwd,
298 nextWorktreePath,
299 reuseExistingWorktree: false,
300 };
301}
302
303export function isWorktreeChangeBlocked(input: {
304 runtime: ThreadStructureRuntime | null;
305 currentWorktreePath: string | null;
306 nextWorktreePath: string | null;
307}): boolean {
308 return (
309 input.nextWorktreePath !== input.currentWorktreePath &&
310 deriveCanInterruptRunningThread(true, input.runtime)
311 );
312}
313
314export function shouldIncludeBranchPickerItem(input: {
315 itemValue: string;
316 normalizedQuery: string;
317 createBranchItemValue: string | null;
318 checkoutPullRequestItemValue: string | null;
319}): boolean {
320 const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
321
322 if (normalizedQuery.length === 0) {
323 return true;
324 }
325
326 if (createBranchItemValue && itemValue === createBranchItemValue) {
327 return true;
328 }
329
330 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
331 return true;
332 }
333
334 const lowerItemValue = itemValue.toLowerCase();
335 if (lowerItemValue.includes(normalizedQuery)) {
336 return true;
337 }
338
339 const sanitizedQuery = sanitizeNewRefName(normalizedQuery);
340 return (
341 sanitizedQuery.length > 0 &&
342 sanitizedQuery !== normalizedQuery &&
343 lowerItemValue.includes(sanitizedQuery)
344 );
345}
346
347/** Ref names for the picker: local branches first, then remote refs, each in server order. */
348export function orderRefNamesByGroup(refs: ReadonlyArray<VcsRef>): string[] {
349 return [
350 ...refs.filter((ref) => !ref.isRemote).map((ref) => ref.name),
351 ...refs.filter((ref) => ref.isRemote).map((ref) => ref.name),
352 ];
353}
354
355/**
356 * The section label each group's first visible row carries, keyed by item value. Items whose
357 * group is null (create, checkout-by-reference) get no label and do not start a group.
358 */
359export function groupLabelsForPickerItems(
360 items: ReadonlyArray<string>,
361 groupOf: (item: string) => string | null,
362): ReadonlyMap<string, string> {
363 const labels = new Map<string, string>();
364 let previousGroup: string | null = null;
365 for (const item of items) {
366 const group = groupOf(item);
367 if (group !== null && group !== previousGroup) labels.set(item, group);
368 previousGroup = group;
369 }
370 return labels;
371}