Skip to content
Source
Sign inSign up

BranchToolbar.logic.ts

328 lines10.6 KB

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;
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;
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;
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;
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 );
80​
81// Hysteresis prevents labels and icons from flapping at the width boundary.
82const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16;
83​
84export function resolveContextStripLabelsCompact(input: {
85 compact: boolean;
86 neededWidth: number;
87 availableWidth: number;
88}): boolean {
89 return input.compact
90 ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX
91 : input.neededWidth > input.availableWidth;
93​
94export function resolveEnvModeLabel(mode: EnvMode): string {
95 return mode === "worktree" ? "New worktree" : "Current checkout";
97​
98export const WORKTREE_SUBMODULES_LABELS: Record<WorktreeSubmodules, string> = {
99 recursive: "Recursive",
100 "top-level": "Top level only",
101 none: "Skip",
102};
103​
104export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
105 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
107​
108export function resolveLockedWorkspaceLabel(
109 activeWorktreePath: string | null,
110 effectiveEnvMode: EnvMode,
111): string {
112 if (activeWorktreePath) return activeWorktreePath.split(/[\\/]/).findLast(Boolean) ?? "Worktree";
113 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";
115​
116export interface PreviousWorktreeSeed {
117 branch: string | null;
118 worktreePath: string;
120​
121export function resolvePreviousWorktreeSeed(input: {
122 threads: ReadonlyArray<{
123 branch: string | null;
124 worktreePath: string | null;
125 updatedAt: string;
126 archivedAt?: string | null;
127 }>;
128 currentWorktreePath: string | null;
129}): PreviousWorktreeSeed | null {
130 let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null;
131 for (const thread of input.threads) {
132 if (
133 !thread.worktreePath ||
134 thread.worktreePath === input.currentWorktreePath ||
135 (thread.archivedAt ?? null) !== null
136 ) {
137 continue;
138 }
139 const updatedAt = toSortableTimestamp(thread.updatedAt);
140 if (updatedAt === null) {
141 continue;
142 }
143 if (latest === null || updatedAt > latest.updatedAt) {
144 latest = {
145 branch: thread.branch,
146 worktreePath: thread.worktreePath,
147 updatedAt,
148 };
149 }
150 }
151 return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath };
153​
154export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string {
155 return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree";
157​
158export function resolveEffectiveEnvMode(input: {
159 activeWorktreePath: string | null;
160 hasServerThread: boolean;
161 draftThreadEnvMode: EnvMode | undefined;
162 preparingWorktree?: boolean;
163}): EnvMode {
164 const { activeWorktreePath, hasServerThread, draftThreadEnvMode, preparingWorktree } = input;
165 if (!hasServerThread) {
166 if (activeWorktreePath) {
167 return "local";
168 }
169 return draftThreadEnvMode === "worktree" ? "worktree" : "local";
170 }
171 return activeWorktreePath || preparingWorktree ? "worktree" : "local";
173​
174export function resolveDraftEnvModeAfterBranchChange(input: {
175 nextWorktreePath: string | null;
176 currentWorktreePath: string | null;
177 effectiveEnvMode: EnvMode;
178}): EnvMode {
179 const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input;
180 if (nextWorktreePath) {
181 return "worktree";
182 }
183 if (effectiveEnvMode === "worktree" && !currentWorktreePath) {
184 return "worktree";
185 }
186 return "local";
188​
189export function resolveBranchToolbarValue(input: {
190 envMode: EnvMode;
191 activeWorktreePath: string | null;
192 activeThreadBranch: string | null;
193 currentGitBranch: string | null;
194}): string | null {
195 const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
196 if (envMode === "worktree" && !activeWorktreePath) {
197 return activeThreadBranch ?? currentGitBranch;
198 }
199 return currentGitBranch ?? activeThreadBranch;
201​
202export function resolveBranchTriggerLabel(input: {
203 activeWorktreePath: string | null;
204 effectiveEnvMode: EnvMode;
205 resolvedActiveBranch: string | null;
206 resolvedActiveBranchIsRemote: boolean | null;
207 startFromOrigin: boolean;
208}): string {
209 const {
210 activeWorktreePath,
211 effectiveEnvMode,
212 resolvedActiveBranch,
213 resolvedActiveBranchIsRemote,
214 startFromOrigin,
215 } = input;
216 if (!resolvedActiveBranch) {
217 return "Select ref";
218 }
219 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
220 // Only the server can determine a local branch's configured remote.
221 return startFromOrigin && resolvedActiveBranchIsRemote === false
222 ? `From ${resolvedActiveBranch} (remote)`
223 : `From ${resolvedActiveBranch}`;
224 }
225 return resolvedActiveBranch;
227​
228export function resolveBranchToolbarPrBranch(input: {
229 activeThreadBranch: string | null;
230 resolvedActiveBranch: string | null;
231}): string | null {
232 return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null;
234​
235export function resolveLocalCheckoutBranchMismatch(input: {
236 effectiveEnvMode: EnvMode;
237 activeWorktreePath: string | null;
238 activeThreadBranch: string | null;
239 currentGitBranch: string | null;
240}): { threadBranch: string; currentBranch: string } | null {
241 const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
242 if (effectiveEnvMode !== "local" || activeWorktreePath !== null) {
243 return null;
244 }
245 if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) {
246 return null;
247 }
248 return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch };
250​
251export function resolveBranchSelectionTarget(input: {
252 activeProjectCwd: string;
253 activeProjectCheckoutRoot?: string | undefined;
254 activeWorktreePath: string | null;
255 refName: Pick<VcsRef, "isDefault" | "worktreePath">;
256}): {
257 checkoutCwd: string;
258 nextWorktreePath: string | null;
259 reuseExistingWorktree: boolean;
260} {
261 const { activeProjectCwd, activeWorktreePath, refName } = input;
262​
263 if (refName.worktreePath) {
264 // Until the project's checkout root loads, a checkout containing the project counts as its own.
265 const isProjectCheckout =
266 input.activeProjectCheckoutRoot === undefined
267 ? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null
268 : workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === ".";
269 return {
270 checkoutCwd: refName.worktreePath,
271 nextWorktreePath: isProjectCheckout ? null : refName.worktreePath,
272 reuseExistingWorktree: true,
273 };
274 }
275​
276 const nextWorktreePath =
277 activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath;
278​
279 return {
280 checkoutCwd: nextWorktreePath ?? activeProjectCwd,
281 nextWorktreePath,
282 reuseExistingWorktree: false,
283 };
285​
286export function isWorktreeChangeBlocked(input: {
287 runtime: ThreadStructureRuntime | null;
288 currentWorktreePath: string | null;
289 nextWorktreePath: string | null;
290}): boolean {
291 return (
292 input.nextWorktreePath !== input.currentWorktreePath &&
293 deriveCanInterruptRunningThread(true, input.runtime)
294 );
296​
297export function shouldIncludeBranchPickerItem(input: {
298 itemValue: string;
299 normalizedQuery: string;
300 createBranchItemValue: string | null;
301 checkoutPullRequestItemValue: string | null;
302}): boolean {
303 const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
304​
305 if (normalizedQuery.length === 0) {
306 return true;
307 }
308​
309 if (createBranchItemValue && itemValue === createBranchItemValue) {
310 return true;
311 }
312​
313 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
314 return true;
315 }
316​
317 const lowerItemValue = itemValue.toLowerCase();
318 if (lowerItemValue.includes(normalizedQuery)) {
319 return true;
320 }
321​
322 const sanitizedQuery = sanitizeNewRefName(normalizedQuery);
323 return (
324 sanitizedQuery.length > 0 &&
325 sanitizedQuery !== normalizedQuery &&
326 lowerItemValue.includes(sanitizedQuery)
327 );