293 lines · 9.7 KB · TypeScript
RawDownload
import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";import * as Schema from "effect/Schema";import { toSortableTimestamp } from "../lib/threadSort";export {  dedupeRemoteBranchesWithLocalMatches,  deriveLocalBranchNameFromRemoteRef,} from "@t3tools/shared/git"; export interface EnvironmentOption {  environmentId: EnvironmentId;  projectId: ProjectId;  label: string;  isPrimary: boolean;} 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;} // A remote (non-primary) environment is always surfaced, even when it is the// only environment available: with a single connected machine there is nothing// to pick, but the user still needs to see where the project runs.export function shouldShowEnvironmentIndicator(input: {  activeEnvironment: Pick<EnvironmentOption, "isPrimary"> | null;  canPickEnvironment: boolean;}): boolean {  if (input.canPickEnvironment) return true;  return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary;} export function shouldShowComposerContextStrip(input: {  hasActiveProject: boolean;  isGitRepo: boolean;  showEnvironmentIndicator: boolean;}): boolean {  return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator);} export function resolveEnvModeLabel(mode: EnvMode): string {  return mode === "worktree" ? "New worktree" : "Current checkout";} export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {  return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");} export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {  return activeWorktreePath ? "Worktree" : "Local checkout";} export interface PreviousWorktreeSeed {  branch: string | null;  worktreePath: string;} // The most recently touched worktree in the project that the composer isn't// already pointing at. Backs the "Previous worktree" entry in the workspace// selector so a follow-up thread can hop back into the worktree you just// worked in without hunting for its branch. Archived threads don't compete —// the rest of the UI hides them, so their worktrees shouldn't resurface here.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;}): EnvMode {  const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input;  if (!hasServerThread) {    if (activeWorktreePath) {      return "local";    }    return draftThreadEnvMode === "worktree" ? "worktree" : "local";  }  return activeWorktreePath ? "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 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) {    const baseRef =      startFromOrigin && resolvedActiveBranchIsRemote === false        ? `origin/${resolvedActiveBranch}`        : resolvedActiveBranch;    return `From ${baseRef}`;  }  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<VcsRef, "isDefault" | "worktreePath">;}): {  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,  };} // Git rejects ASCII space and the ASCII control characters (tab, newline and// friends) in ref names, so the picker's "Create new ref" entry can only fail// for a typed name like "new branch". Replacing runs of those with a dash makes// the name usable without reimplementing check-ref-format: names invalid for// other reasons still surface the git error. Only the whitespace git actually// rejects is replaced — git accepts U+00A0 and friends, and rewriting those// would silently create a ref the user never asked for. Case and existing// dashes are left alone, since ref names are case sensitive and consecutive// dashes are valid.export function sanitizeNewRefName(rawName: string): string {  return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-");} 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;  }   // A query containing whitespace can only ever match a ref under its sanitized  // name, because that is the name such a ref would have been created with.  // Without this, typing "new branch" hides an existing "new-branch".  const sanitizedQuery = sanitizeNewRefName(normalizedQuery);  return (    sanitizedQuery.length > 0 &&    sanitizedQuery !== normalizedQuery &&    lowerItemValue.includes(sanitizedQuery)  );}