541 lines · 20.2 KB · TypeScript
RawDownload
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";import { ComposerSurface } from "./chat/ComposerSurface"; 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.    <span className="inline-flex shrink-0 items-center gap-0.5">      <EnvironmentIcon className="size-3 shrink-0 mx-0!" />      <WorkspaceIcon className="size-3 shrink-0 mx-0!" />    </span>  ) : (    <WorkspaceIcon className="size-3 shrink-0" />  );  const triggerContent = (    <>      {icon}      <span className="min-w-0 truncate">        {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}      </span>    </>  );   if (isLocked) {    return (      <span className="inline-flex h-7 min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 md:hidden">        {triggerContent}      </span>    );  }   return (    <Menu>      <MenuTrigger        render={<Button variant="ghost" size="xs" />}        className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"      >        {triggerContent}        <ChevronDownIcon className="size-3 shrink-0 opacity-50" />      </MenuTrigger>      <MenuPopup align="start" side="top" className="w-64">        {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? (          <>            <MenuGroup>              <MenuGroupLabel>Run on</MenuGroupLabel>              <MenuRadioGroup                value={environmentId}                onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}              >                {availableEnvironments.map((env) => {                  const Icon = env.isPrimary ? MonitorIcon : CloudIcon;                  return (                    <MenuRadioItem                      key={env.environmentId}                      disabled={envLocked}                      value={env.environmentId}                    >                      <span className="flex min-w-0 items-center gap-1.5">                        <Icon className="size-3" />                        <span className="min-w-0 truncate">{env.label}</span>                      </span>                    </MenuRadioItem>                  );                })}              </MenuRadioGroup>            </MenuGroup>            <MenuSeparator />          </>        ) : null}        <MenuGroup>          <MenuGroupLabel>Workspace</MenuGroupLabel>          <MenuRadioGroup            value={effectiveEnvMode}            onValueChange={(value) => {              if (value === "previous-worktree") {                onUsePreviousWorktree();                return;              }              onEnvModeChange(value as EnvMode);            }}          >            <MenuRadioItem disabled={envModeLocked} value="local">              <span className="flex min-w-0 items-center gap-1.5">                {activeWorktreePath ? (                  <FolderGitIcon className="size-3" />                ) : (                  <FolderIcon className="size-3" />                )}                <span className="min-w-0 truncate">                  {resolveCurrentWorkspaceLabel(activeWorktreePath)}                </span>              </span>            </MenuRadioItem>            <MenuRadioItem disabled={envModeLocked} value="worktree">              <span className="flex min-w-0 items-center gap-1.5">                <FolderGit2Icon className="size-3" />                <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span>              </span>            </MenuRadioItem>            {previousWorktreeLabel ? (              <MenuRadioItem disabled={envModeLocked} value="previous-worktree">                <span className="flex min-w-0 items-center gap-1.5">                  <HistoryIcon className="size-3" />                  <span className="min-w-0 truncate">{previousWorktreeLabel}</span>                </span>              </MenuRadioItem>            ) : null}          </MenuRadioGroup>        </MenuGroup>      </MenuPopup>    </Menu>  );}); /** * 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<Map<HTMLElement, DOMRect> | null>(null);  const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>());  // 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)) continue;      const width = contentWidth(child);      if (width <= 1) continue;      needed += width;      groups += 1;    }    needed += stripGap * Math.max(0, groups - 1);    for (const label of current.querySelectorAll<HTMLElement>("[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<HTMLElement>("*")) {        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<HTMLElement>(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.  useLayoutEffect(() => {    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<HTMLDivElement | null>(null);  const labelsOverflow = useLabelsOverflow(stripElement);   if (!hasActiveThread || !activeProject) return null;   return (    <ComposerSurface.ContextStrip      ref={setStripElement}      data-compact={labelsOverflow ? "" : undefined}    >      {isMobile && showGitControls ? (        <MobileRunContextSelector          envLocked={envLocked}          envModeLocked={envModeLocked}          environmentId={environmentId}          availableEnvironments={availableEnvironments}          showEnvironmentPicker={showEnvironmentPicker}          showEnvironmentIndicator={showEnvironmentIndicator}          onEnvironmentChange={onEnvironmentChange}          effectiveEnvMode={effectiveEnvMode}          activeWorktreePath={activeWorktreePath}          onEnvModeChange={onEnvModeChange}          previousWorktreeLabel={previousWorktreeLabel}          onUsePreviousWorktree={onUsePreviousWorktree}        />      ) : (        <div className="flex min-w-10 flex-1 items-center gap-1">          {showEnvironmentIndicator && availableEnvironments && (            <>              <BranchToolbarEnvironmentSelector                envLocked={envLocked}                environmentId={environmentId}                availableEnvironments={availableEnvironments}                {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}              />

Lines 1–500 of 541.