293 lines · 10.5 KB · TypeScript
RawDownload
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";import type { EnvironmentId, ThreadId } from "@t3tools/contracts";import {  ChevronDownIcon,  CloudIcon,  FolderGit2Icon,  FolderGitIcon,  FolderIcon,  MonitorIcon,} from "lucide-react";import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore";import { useProject, useThread } from "../state/entities";import { useIsMobile } from "../hooks/useMediaQuery";import {  type EnvMode,  type EnvironmentOption,  resolveCurrentWorkspaceLabel,  resolveEnvModeLabel,  resolveEffectiveEnvMode,  resolveLockedWorkspaceLabel,} 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;  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;  onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;  effectiveEnvMode: EnvMode;  activeWorktreePath: string | null;  onEnvModeChange: (mode: EnvMode) => void;} const MobileRunContextSelector = memo(function MobileRunContextSelector({  envLocked,  envModeLocked,  environmentId,  availableEnvironments,  showEnvironmentPicker,  onEnvironmentChange,  effectiveEnvMode,  activeWorktreePath,  onEnvModeChange,}: 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 = showEnvironmentPicker ? (    // 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">        {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}      </span>    </>  );   if (isLocked) {    return (      <span className="inline-flex 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 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) => 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>          </MenuRadioGroup>        </MenuGroup>      </MenuPopup>    </Menu>  );}); export const BranchToolbar = memo(function BranchToolbar({  environmentId,  threadId,  draftId,  onEnvModeChange,  effectiveEnvModeOverride,  activeThreadBranchOverride,  onActiveThreadBranchOverrideChange,  startFromOrigin,  onStartFromOriginChange,  envLocked,  onCheckoutPullRequestRequest,  onComposerFocusRequest,  availableEnvironments,  onEnvironmentChange,}: BranchToolbarProps) {  const threadRef = useMemo(    () => scopeThreadRef(environmentId, threadId),    [environmentId, threadId],  );  const serverThread = useThread(threadRef);  const draftThread = useComposerDraftStore((store) =>    draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),  );  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);   const showEnvironmentPicker = Boolean(    availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,  );  const isMobile = useIsMobile();   if (!hasActiveThread || !activeProject) return null;   return (    <div className="mx-auto flex w-full max-w-208 items-center gap-2 px-2.5 pb-3 pt-1 sm:px-3">      {isMobile ? (        <MobileRunContextSelector          envLocked={envLocked}          envModeLocked={envModeLocked}          environmentId={environmentId}          availableEnvironments={availableEnvironments}          showEnvironmentPicker={showEnvironmentPicker}          onEnvironmentChange={onEnvironmentChange}          effectiveEnvMode={effectiveEnvMode}          activeWorktreePath={activeWorktreePath}          onEnvModeChange={onEnvModeChange}        />      ) : (        <div className="flex min-w-0 shrink-0 items-center gap-1">          {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && (            <>              <BranchToolbarEnvironmentSelector                envLocked={envLocked}                environmentId={environmentId}                availableEnvironments={availableEnvironments}                onEnvironmentChange={onEnvironmentChange}              />              <Separator orientation="vertical" className="mx-0.5 h-3.5!" />            </>          )}          <BranchToolbarEnvModeSelector            envLocked={envModeLocked}            effectiveEnvMode={effectiveEnvMode}            activeWorktreePath={activeWorktreePath}            onEnvModeChange={onEnvModeChange}          />        </div>      )}       <BranchToolbarBranchSelector        className="min-w-0 flex-1 justify-end md:ml-auto md:flex-none"        environmentId={environmentId}        threadId={threadId}        {...(draftId ? { draftId } : {})}        envLocked={envLocked}        {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}        {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}        {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}        startFromOrigin={startFromOrigin}        onStartFromOriginChange={onStartFromOriginChange}        {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}        {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}      />    </div>  );});