import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, CloudIcon, FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon, MonitorIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, type EnvironmentOption, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; import { Menu, MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; } interface MobileRunContextSelectorProps { envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; previousWorktreeLabel: string | null; onUsePreviousWorktree: () => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ envLocked, envModeLocked, environmentId, availableEnvironments, showEnvironmentPicker, showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, onEnvModeChange, previousWorktreeLabel, onUsePreviousWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); const WorkspaceIcon = effectiveEnvMode === "worktree" ? FolderGit2Icon : activeWorktreePath ? FolderGitIcon : FolderIcon; const workspaceLabel = envModeLocked ? resolveLockedWorkspaceLabel(activeWorktreePath) : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. ) : ( ); const triggerContent = ( <> {icon} {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); if (isLocked) { return ( {triggerContent} ); } return ( } className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> Run on onEnvironmentChange(value as EnvironmentId)} > {availableEnvironments.map((env) => { const Icon = env.isPrimary ? MonitorIcon : CloudIcon; return ( {env.label} ); })} ) : null} Workspace { if (value === "previous-worktree") { onUsePreviousWorktree(); return; } onEnvModeChange(value as EnvMode); }} > {activeWorktreePath ? ( ) : ( )} {resolveCurrentWorkspaceLabel(activeWorktreePath)} {resolveEnvModeLabel("worktree")} {previousWorktreeLabel ? ( {previousWorktreeLabel} ) : null} ); }); /** * Collapse the strip's labels to icons only when the text no longer fits. * * Hidden labels stay measurable because their inner text keeps its natural * width while the outer layout box collapses. This lets every pass recompute * the expanded width without remembered values that could go stale or latch * the strip compact. A small hysteresis keeps the boundary from flapping. */ const COMPACT_EXPAND_HYSTERESIS_PX = 16; const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); const pendingControlRectsRef = useRef | null>(null); const controlAnimationsRef = useRef(new Map()); // A render-synced mirror instead of useEffectEvent: the compiler memoizes // the event callback, which left observers reading the first render's null // element forever. const stateRef = useRef({ element, overflows }); stateRef.current = { element, overflows }; const measure = useCallback(() => { const { element: current, overflows: compact } = stateRef.current; if (!current) return; const available = current.clientWidth; if (available === 0) return; // flex-1 stretches the groups to fill the strip, so their own boxes always // measure "full". Sum the laid-out content instead, skipping hidden form // artifacts and other out-of-flow nodes. const contentWidth = (parent: Element): number => { const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; let width = 0; let counted = 0; for (const child of parent.children) { if (!(child instanceof HTMLElement)) continue; if (child.offsetWidth <= 1) continue; const position = getComputedStyle(child).position; if (position === "absolute" || position === "fixed") continue; width += child.offsetWidth; counted += 1; } return width + gap * Math.max(0, counted - 1); }; const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; let needed = 0; let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; needed += contentWidth(child); groups += 1; } needed += stripGap * Math.max(0, groups - 1); for (const label of current.querySelectorAll("[data-composer-label]")) { // The clipping can happen below the marker (SelectValue truncates // internally), where the outer span's scrollWidth matches its clipped // box. The text's real width is the largest scrollWidth in the subtree. let textWidth = label.scrollWidth; for (const inner of label.querySelectorAll("*")) { textWidth = Math.max(textWidth, inner.scrollWidth); } if (compact) { // Compact: the label is squeezed to zero width but keeps reporting // the full width it would need when expanded. needed += textWidth; } else { // Expanded: the label is in flow; only the clipped remainder is // missing from the content sum. needed += Math.max(0, textWidth - label.clientWidth); } } const nextOverflows = compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available; if (nextOverflows !== compact) { pendingControlRectsRef.current = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( (control) => [control, control.getBoundingClientRect()], ), ); } setOverflows(nextOverflows); }, []); useLayoutEffect(() => { const previousRects = pendingControlRectsRef.current; if (!previousRects) return; pendingControlRectsRef.current = null; for (const animation of controlAnimationsRef.current.values()) { animation.cancel(); } controlAnimationsRef.current.clear(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; for (const [control, previousRect] of previousRects) { if (!control.isConnected) continue; const nextRect = control.getBoundingClientRect(); const deltaX = previousRect.left - nextRect.left; const deltaY = previousRect.top - nextRect.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; const animation = control.animate( [ { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, { transform: "translate3d(0, 0, 0)" }, ], { duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, easing: COMPOSER_CONTEXT_MOTION_EASING, fill: "backwards", }, ); controlAnimationsRef.current.set(control, animation); animation.addEventListener( "finish", () => { if (controlAnimationsRef.current.get(control) === animation) { controlAnimationsRef.current.delete(control); } }, { once: true }, ); } }, [overflows]); useEffect( () => () => { for (const animation of controlAnimationsRef.current.values()) { animation.cancel(); } }, [], ); // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. useEffect(() => { measure(); }); useEffect(() => { if (!element) return; const observer = new ResizeObserver(measure); observer.observe(element); document.fonts.addEventListener("loadingdone", measure); return () => { observer.disconnect(); document.fonts.removeEventListener("loadingdone", measure); }; }, [element, measure]); return overflows; } export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, availableEnvironments, onEnvironmentChange, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const activeProject = useProject(activeProjectRef); const hasActiveThread = serverThread !== null || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: serverThread !== null, draftThreadEnvMode: draftThread?.envMode, }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); // "Previous worktree" hops a draft into the most recently active worktree // of this project — the "keep going where I just was" follow-up flow. Only // drafts can hop; started server threads have their workspace pinned. const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; const projectRefsForWorktreeLookup = useMemo( () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), [canUsePreviousWorktree, activeProjectRef], ); const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); const previousWorktreeSeed = useMemo( () => canUsePreviousWorktree ? resolvePreviousWorktreeSeed({ threads: projectThreads, currentWorktreePath: activeWorktreePath, }) : null, [activeWorktreePath, canUsePreviousWorktree, projectThreads], ); const previousWorktreeLabel = previousWorktreeSeed ? resolvePreviousWorktreeLabel(previousWorktreeSeed) : null; const onUsePreviousWorktree = useCallback(() => { if (!previousWorktreeSeed || !activeProjectRef) return; // Same shape the branch selector writes when picking a branch that // already lives in a worktree: point the draft at the existing tree. setDraftThreadContext(draftId ?? threadRef, { branch: previousWorktreeSeed.branch, worktreePath: previousWorktreeSeed.worktreePath, envMode: "worktree", projectRef: activeProjectRef, }); }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); const activeEnvironmentOption = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return (
{isMobile && showGitControls ? ( ) : (
{showEnvironmentIndicator && availableEnvironments && ( <> {showGitControls ? ( ) : null} )} {showGitControls ? ( ) : null}
)} {showGitControls ? ( ) : null}
); });