755 lines · 27.2 KB · TypeScript
RawDownload
import type { ContextMenuItem, PreviewSessionSnapshot, PullRequestState } from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import {  Bot,  FileDiff,  Files,  GitPullRequest,  Globe2,  Plus,  TerminalSquare,  X,} from "lucide-react";import {  type KeyboardEvent as ReactKeyboardEvent,  type MouseEvent as ReactMouseEvent,  type ReactElement,  type ReactNode,  useCallback,  useEffect,  useRef,  useState,} from "react"; import { isElectron } from "~/env";import type { RightPanelSurface } from "~/rightPanelStore";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { Kbd } from "~/components/ui/kbd";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";import { ScrollArea } from "~/components/ui/scroll-area";import { faviconUrlForOrigin } from "~/lib/favicon";import { useTheme } from "~/hooks/useTheme";import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps {  mode: PreviewPanelMode;  maximized?: boolean;  /** Forwarded to PreviewPanelShell so this surface persists its own width. */  widthStorageKey?: string;  /** Forwarded to PreviewPanelShell as the initial width before a user resize. */  defaultWidth?: number;  layoutControls?: ReactNode;  surfaces: readonly RightPanelSurface[];  activeSurfaceId: string | null;  pendingSurfaceIds: ReadonlySet<string>;  previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;  terminalLabelsById: ReadonlyMap<string, string>;  onActivate: (surface: RightPanelSurface) => void;  onCloseSurface: (surface: RightPanelSurface) => void;  onCloseOtherSurfaces: (surface: RightPanelSurface) => void;  onCloseSurfacesToRight: (surface: RightPanelSurface) => void;  onCloseAllSurfaces: () => void;  onCopyFilePath: (relativePath: string) => void;  onAddBrowser: () => void;  onAddTerminal: () => void;  onAddDiff: () => void;  onAddFiles: () => void;  onAddPullRequest: () => void;  onAddAgents: () => void;  browserAvailable: boolean;  terminalAvailable: boolean;  diffAvailable: boolean;  filesAvailable: boolean;  pullRequestAvailable: boolean;  agentsAvailable: boolean;  pullRequestStatuses?: Readonly<Record<string, PullRequestTabStatus>>;  /** Running + waiting subagents; badges the Agents card in the empty state. */  liveAgentCount: number;  children: ReactNode;} export interface PullRequestTabStatus {  projectId: string;  repository: string;  number: number;  state: PullRequestState;  isDraft: boolean;} const SURFACE_DISABLED_REASONS = {  browser: "Browser previews are only available in the T3 Code desktop app.",  terminal: "Terminal surfaces are only available from a project thread.",  files: "Files are only available when a project is open.",  diff: "Diff is only available for server threads in Git repositories.",  pullRequest: "This thread's branch has no pull request yet.",  agents: "Agents are only available from a thread.",} as const; /** Overlays that must win over the launcher's letter shortcuts. */const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [  '[data-slot="dialog-popup"]',  '[data-slot="alert-dialog-popup"]',  '[data-slot="command-dialog-popup"]',  '[data-slot="menu-popup"]',  '[data-slot="select-popup"]',  '[data-slot="popover-popup"]',  '[data-slot="combobox-popup"]',  '[data-slot="autocomplete-popup"]',].join(","); /** One-line unavailability hints for the empty-state cards. */const SURFACE_UNAVAILABLE_HINTS = {  browser: "Only available in the desktop app.",  terminal: "Available when a project is open.",  files: "Available when a project is open.",  diff: "Available for Git repositories.",  pullRequest: "No pull request on this branch yet.",  agents: "Available from a thread.",} as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {  return (    <Tooltip>      <TooltipTrigger render={props.trigger} />      <TooltipPopup side="top">{props.reason}</TooltipPopup>    </Tooltip>  );} function SurfaceMenuItem(props: {  available: boolean;  disabledReason?: string;  onClick: () => void;  children: ReactNode;}) {  const item = (    <MenuItem      className={!props.available ? "data-disabled:pointer-events-auto" : undefined}      onClick={props.onClick}      disabled={!props.available}    >      {props.children}    </MenuItem>  );  if (props.available || !props.disabledReason) return item;  return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;} /** * Card launcher shown when the right panel has no surfaces. Keyboard-first * without palette chrome: a surface's letter opens it directly from anywhere * outside a typing context, and arrows plus Enter work while the launcher is * focused. The highlight only appears on hover or arrow use. Unavailable * surfaces stay visible with a one-line reason. */function RightPanelEmptyState(props: {  onAddBrowser: () => void;  onAddTerminal: () => void;  onAddDiff: () => void;  onAddFiles: () => void;  onAddPullRequest: () => void;  onAddAgents: () => void;  browserAvailable: boolean;  terminalAvailable: boolean;  diffAvailable: boolean;  filesAvailable: boolean;  pullRequestAvailable: boolean;  agentsAvailable: boolean;  liveAgentCount: number;}) {  // -1 means no highlight: it only appears on hover or arrow use.  const [highlight, setHighlight] = useState(-1);   const actions = [    {      label: "Browser",      description: "Open a local app or URL.",      icon: Globe2,      shortcut: "B",      available: props.browserAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,      onClick: props.onAddBrowser,      badgeCount: 0,    },    {      label: "Terminal",      description: "Start a shell in this workspace.",      icon: TerminalSquare,      shortcut: "T",      available: props.terminalAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,      onClick: props.onAddTerminal,      badgeCount: 0,    },    {      label: "Files",      description: "Browse and read workspace files.",      icon: Files,      shortcut: "F",      available: props.filesAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.files,      onClick: props.onAddFiles,      badgeCount: 0,    },    {      label: "Diff",      description: "Review changes in this thread.",      icon: FileDiff,      shortcut: "D",      available: props.diffAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,      onClick: props.onAddDiff,      badgeCount: 0,    },    {      label: "Pull request",      description: "Open this branch's pull request.",      icon: GitPullRequest,      shortcut: "P",      available: props.pullRequestAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,      onClick: props.onAddPullRequest,      badgeCount: 0,    },    {      label: "Agents",      description: "Follow subagents and workflows.",      icon: Bot,      shortcut: "A",      available: props.agentsAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.agents,      onClick: props.onAddAgents,      badgeCount: props.liveAgentCount,    },  ] as const;   type SurfaceAction = (typeof actions)[number];   const availableActions = actions.filter((action) => action.available);  const highlightIndex =    availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1);   // Letter shortcuts work while the launcher is visible, not only while it  // is focused; focus moves around too easily (stray clicks) to carry them.  // Capture phase so app-level key handlers cannot swallow the event first;  // typing contexts and already-handled events are left alone.  const shortcutActionsRef = useRef(availableActions);  useEffect(() => {    shortcutActionsRef.current = availableActions;  });  useEffect(() => {    const handler = (event: KeyboardEvent) => {      if (event.defaultPrevented || event.isComposing) return;      if (event.metaKey || event.ctrlKey || event.altKey) return;      if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;      const target = event.target;      if (target instanceof HTMLElement) {        if (target.closest("input, textarea, select")) return;        // An empty contenteditable (the chat composer at rest) does not        // count as typing; letters only become text once a draft exists.        const editable = target.isContentEditable ? target : target.closest("[contenteditable]");        if (editable && (editable.textContent ?? "").trim().length > 0) return;      }      const action = shortcutActionsRef.current.find(        (candidate) => candidate.shortcut.toLowerCase() === event.key.toLowerCase(),      );      if (!action) return;      event.preventDefault();      event.stopPropagation();      action.onClick();    };    window.addEventListener("keydown", handler, true);    return () => window.removeEventListener("keydown", handler, true);  }, []);   const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;    if (availableActions.length === 0) return;    if (event.key === "ArrowDown" || event.key === "ArrowRight") {      event.preventDefault();      setHighlight((highlightIndex + 1) % availableActions.length);      return;    }    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {      event.preventDefault();      setHighlight(        highlightIndex === -1          ? availableActions.length - 1          : (highlightIndex - 1 + availableActions.length) % availableActions.length,      );      return;    }    if (event.key === "Enter") {      // A focused card button owns its own activation; only open from the      // highlight when the container itself has focus.      if (event.target instanceof HTMLElement && event.target.closest("button")) return;      const action = availableActions[highlightIndex];      if (!action) return;      event.preventDefault();      action.onClick();    }  };   // Stable identity so React only runs this callback ref on mount/unmount;  // an inline arrow would re-attach and re-focus on every render.  const focusOnMount = useCallback((node: HTMLDivElement | null) => {    node?.focus();  }, []);   const isHighlighted = (action: SurfaceAction) =>    highlightIndex !== -1 && availableActions[highlightIndex] === action;   const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => {    const Icon = action.icon;    return (      <span className="relative inline-flex shrink-0">        <Icon className={iconClassName} />        {action.badgeCount > 0 ? (          <span            aria-hidden            className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"          >            {action.badgeCount}          </span>        ) : null}      </span>    );  };   const cardShellClass =    "rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5";  const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20";   return (    <div      ref={focusOnMount}      tabIndex={0}      onKeyDown={handleKeyDown}      aria-label="Open a surface"      data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}      className={cn(        "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",        // The panel topbar sits above this container; matching bottom padding        // keeps the cards centered against the full panel, not the leftover.        "pb-[calc(var(--workspace-topbar-height)+--spacing(6))]",      )}    >      <div className="relative w-full max-w-lg">        <div className="absolute inset-x-0 bottom-full mb-5 text-center">          <h3 className="font-medium text-foreground text-sm">Open a surface</h3>          <p className="mt-1 text-muted-foreground text-xs">            Choose what to show in the right panel.          </p>        </div>        <div className="grid grid-cols-2 gap-2">          {actions.map((action) =>            action.available ? (              <button                key={action.label}                type="button"                onClick={action.onClick}                onMouseEnter={() => setHighlight(availableActions.indexOf(action))}                onMouseLeave={() =>                  setHighlight((current) =>                    current === availableActions.indexOf(action) ? -1 : current,                  )                }                className={cn(                  "relative flex w-full cursor-pointer flex-col items-start p-4 text-left transition hover:border-border hover:bg-accent/60",                  cardShellClass,                  isHighlighted(action) && highlightedCardClass,                )}              >                <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>                <span className="flex items-center gap-2 pe-8">                  {actionIcon(action)}                  <span className="font-medium text-sm">{action.label}</span>                </span>                <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">                  {action.description}                </span>              </button>            ) : (              <div                key={action.label}                className={cn(                  "relative flex w-full flex-col items-start p-4 opacity-40",                  cardShellClass,                )}              >                <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>                <span className="flex items-center gap-2 pe-8">                  {actionIcon(action)}                  <span className="font-medium text-sm">{action.label}</span>                </span>                <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">                  {action.disabledReason}                </span>              </div>            ),          )}        </div>      </div>    </div>  );} function surfaceTitle(  surface: RightPanelSurface,  sessions: Readonly<Record<string, PreviewSessionSnapshot>>,  terminalLabelsById: ReadonlyMap<string, string>,): string {  switch (surface.kind) {    case "diff":      return "Diff";    case "files":      return "Files";    case "file":      return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1);    case "terminal":      return (        terminalLabelsById.get(surface.activeTerminalId) ??        getTerminalLabel(surface.activeTerminalId)      );    case "pull-request":      return `#${surface.number}`;    case "agents":      return "Agents";    case "preview": {      const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;      if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";      if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title;      try {        return new URL(snapshot.navStatus.url).host || "Browser";      } catch {        return "Browser";      }    }  }} function PreviewFavicon({ url }: { url: string | null }) {  const faviconUrl = faviconUrlForOrigin(url, 32);  const [failedUrl, setFailedUrl] = useState<string | null>(null);  if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />;  return (    <img      src={faviconUrl}      alt=""      aria-hidden      draggable={false}      className="size-3 shrink-0 rounded-sm"      onError={() => setFailedUrl(faviconUrl)}    />  );} function SurfaceIcon({  surface,  sessions,  theme,  pullRequestStatuses,}: {  surface: RightPanelSurface;  sessions: Readonly<Record<string, PreviewSessionSnapshot>>;  theme: "light" | "dark";  pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined;}) {  switch (surface.kind) {    case "preview": {      const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;      const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;      return <PreviewFavicon url={url} />;    }    case "diff":      return <FileDiff className="size-3 shrink-0" />;    case "files":      return <Files className="size-3 shrink-0" />;    case "file":      return (        <PierreEntryIcon          pathValue={surface.relativePath}          kind="file"          theme={theme}          className="size-3"        />      );    case "terminal":      return <TerminalSquare className="size-3 shrink-0" />;    case "pull-request": {      const status = pullRequestStatuses?.[surface.id] ?? null;      const toneClassName =        status?.state === "merged"          ? "text-violet-600 dark:text-violet-300/90"          : status?.state === "closed"            ? "text-red-600 dark:text-red-300/90"            : status?.isDraft              ? "text-zinc-500 dark:text-zinc-400/80"              : status?.state === "open"                ? "text-emerald-600 dark:text-emerald-300/90"                : "text-muted-foreground";      return <GitPullRequest className={cn("size-3 shrink-0", toneClassName)} />;    }    case "agents":

Lines 1–500 of 755.