import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId, WorktreeSubmodules, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; export { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git"; export interface EnvironmentOption { environmentId: EnvironmentId; projectId: ProjectId | null; label: string; isPrimary: boolean; machine: EnvironmentMachineKind; } /** "Add to another machine" stays muted until hovered: easy to find, quieter than the machines. */ export const ADD_TO_MACHINE_LABEL_CLASS = "flex min-w-0 items-center gap-1.5 opacity-55 in-data-highlighted:opacity-100 in-data-popup-open:opacity-100"; export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; } export function resolveEnvironmentOptionLabel(input: { isPrimary: boolean; environmentId: EnvironmentId; runtimeLabel?: string | null; savedLabel?: string | null; }): string { const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel); const savedLabel = normalizeDisplayLabel(input.savedLabel); if (input.isPrimary) { const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => { if (!label) return false; return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase()); }); return preferredLocalLabel ?? "This device"; } return runtimeLabel ?? savedLabel ?? input.environmentId; } export function shouldShowEnvironmentIndicator(input: { activeEnvironment: Pick | null; canPickEnvironment: boolean; }): boolean { if (input.canPickEnvironment) return true; return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } export function shouldShowComposerContextStrip(input: { isDraftHeroState: boolean; persistInActiveThreads: boolean; hasActiveProject: boolean; isGitRepo: boolean; showEnvironmentIndicator: boolean; }): boolean { // A new thread always shows the strip, since it names the permissions the first send uses. return ( input.hasActiveProject && (input.isDraftHeroState || (input.persistInActiveThreads && (input.isGitRepo || input.showEnvironmentIndicator))) ); } // Hysteresis prevents labels and icons from flapping at the width boundary. const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; export function resolveContextStripLabelsCompact(input: { compact: boolean; neededWidth: number; availableWidth: number; }): boolean { return input.compact ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX : input.neededWidth > input.availableWidth; } export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } export const WORKTREE_SUBMODULES_LABELS: Record = { recursive: "Recursive", "top-level": "Top level only", none: "Skip", }; export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } export function resolveLockedWorkspaceLabel( activeWorktreePath: string | null, effectiveEnvMode: EnvMode, ): string { if (activeWorktreePath) return "Worktree"; return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; } export interface PreviousWorktreeSeed { branch: string | null; worktreePath: string; } export function resolvePreviousWorktreeSeed(input: { threads: ReadonlyArray<{ branch: string | null; worktreePath: string | null; updatedAt: string; archivedAt?: string | null; }>; currentWorktreePath: string | null; }): PreviousWorktreeSeed | null { let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; for (const thread of input.threads) { if ( !thread.worktreePath || thread.worktreePath === input.currentWorktreePath || (thread.archivedAt ?? null) !== null ) { continue; } const updatedAt = toSortableTimestamp(thread.updatedAt); if (updatedAt === null) { continue; } if (latest === null || updatedAt > latest.updatedAt) { latest = { branch: thread.branch, worktreePath: thread.worktreePath, updatedAt, }; } } return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; } export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; } export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; preparingWorktree?: boolean; }): EnvMode { const { activeWorktreePath, hasServerThread, draftThreadEnvMode, preparingWorktree } = input; if (!hasServerThread) { if (activeWorktreePath) { return "local"; } return draftThreadEnvMode === "worktree" ? "worktree" : "local"; } return activeWorktreePath || preparingWorktree ? "worktree" : "local"; } export function resolveDraftEnvModeAfterBranchChange(input: { nextWorktreePath: string | null; currentWorktreePath: string | null; effectiveEnvMode: EnvMode; }): EnvMode { const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input; if (nextWorktreePath) { return "worktree"; } if (effectiveEnvMode === "worktree" && !currentWorktreePath) { return "worktree"; } return "local"; } export function resolveBranchToolbarValue(input: { envMode: EnvMode; activeWorktreePath: string | null; activeThreadBranch: string | null; currentGitBranch: string | null; }): string | null { const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; if (envMode === "worktree" && !activeWorktreePath) { return activeThreadBranch ?? currentGitBranch; } return currentGitBranch ?? activeThreadBranch; } export interface BranchHold { readonly branch: string | null; readonly replaces: string | null; readonly cwd: string; } /** * A finished checkout's branch stays on screen until status reports any change, because the status * stream and the RPC reply are not ordered and showing status first would flip back to the old branch. */ export function isBranchHoldCurrent( hold: BranchHold, current: { readonly canonicalBranch: string | null; readonly cwd: string | null }, ): boolean { return hold.cwd === current.cwd && hold.replaces === current.canonicalBranch; } export function resolveBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: EnvMode; resolvedActiveBranch: string | null; resolvedActiveBranchIsRemote: boolean | null; startFromOrigin: boolean; }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, } = input; if (!resolvedActiveBranch) { return "Select ref"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { // Only the server can determine a local branch's configured remote. return startFromOrigin && resolvedActiveBranchIsRemote === false ? `From ${resolvedActiveBranch} (remote)` : `From ${resolvedActiveBranch}`; } return resolvedActiveBranch; } export function resolveBranchToolbarPrBranch(input: { activeThreadBranch: string | null; resolvedActiveBranch: string | null; }): string | null { return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null; } export function resolveLocalCheckoutBranchMismatch(input: { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; activeThreadBranch: string | null; currentGitBranch: string | null; }): { threadBranch: string; currentBranch: string } | null { const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { return null; } if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { return null; } return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; refName: Pick; }): { checkoutCwd: string; nextWorktreePath: string | null; reuseExistingWorktree: boolean; } { const { activeProjectCwd, activeWorktreePath, refName } = input; if (refName.worktreePath) { return { checkoutCwd: refName.worktreePath, nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, reuseExistingWorktree: true, }; } const nextWorktreePath = activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath; return { checkoutCwd: nextWorktreePath ?? activeProjectCwd, nextWorktreePath, reuseExistingWorktree: false, }; } export function shouldIncludeBranchPickerItem(input: { itemValue: string; normalizedQuery: string; createBranchItemValue: string | null; checkoutPullRequestItemValue: string | null; }): boolean { const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input; if (normalizedQuery.length === 0) { return true; } if (createBranchItemValue && itemValue === createBranchItemValue) { return true; } if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return true; } const lowerItemValue = itemValue.toLowerCase(); if (lowerItemValue.includes(normalizedQuery)) { return true; } const sanitizedQuery = sanitizeNewRefName(normalizedQuery); return ( sanitizedQuery.length > 0 && sanitizedQuery !== normalizedQuery && lowerItemValue.includes(sanitizedQuery) ); }