604 lines · 20.1 KB · TypeScript
RawDownload
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";import {  resolveEnvironmentMachineKind,  type EnvironmentId,  type ThreadLinkedPullRequest,  type ThreadPullRequestLink,  type VcsStatusResult,} from "@t3tools/contracts";import {  resolveThreadCurrentPullRequestLink,  resolveThreadPullRequestChains,  visibleThreadPullRequests,  type ThreadPullRequestBadge,} from "@t3tools/shared/threadPullRequests";import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";import { useMemo, type MouseEvent } from "react";import { buttonVariants, InlineButton } from "./ui/button";import { cn } from "../lib/utils";import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";import { parseChangeRequestUrl } from "../lib/openPullRequestLink";import { useEnvironmentQuery } from "../state/query";import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";import { useThreadRunningTerminalIds } from "../state/terminalSessions";import { useUiStateStore } from "../uiStateStore";import { resolveChangeRequestPresentation } from "../sourceControlPresentation";import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";import type { SidebarThreadSummary } from "../types";import { formatWorktreePathForDisplay } from "../worktreeCleanup";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { pullRequestListLines } from "./pullRequest/pullRequestListLines";import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; export interface PrStatusIndicator {  label: string;  colorClass: string;  tooltip: string;  tooltipLead: string;  tooltipTitle: string;  url: string;} export interface TerminalStatusIndicator {  label: "Terminal process running";  colorClass: string;  pulse: boolean;} export type ThreadPr = VcsStatusResult["pr"]; export interface LinkedThreadPullRequestStatus {  readonly pr: NonNullable<ThreadPr>;  readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;} /** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */export function useLinkedThreadPullRequest(  environmentId: EnvironmentId | null,  linkedPullRequest: ThreadLinkedPullRequest | null | undefined,  enabled = true,  pullRequests?: ReadonlyArray<ThreadPullRequestLink>,  branchPullRequest?: ThreadLinkedPullRequest | null,): LinkedThreadPullRequestStatus | null {  const supportsLinks = useSupportsMultiplePullRequests(environmentId);  const current = useMemo(    () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null),    [pullRequests, supportsLinks],  );  const fallback =    current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;  const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host;  const reference =    fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) };  const queried = useEnvironmentQuery(    !enabled || environmentId === null || reference === null      ? null      : linkedPullRequestDetailAtom({ environmentId, input: reference }),  ).data;  const detail = useSharedPullRequestSummary(environmentId, reference, queried);   return useMemo(() => {    if (current !== null) return linkedPullRequestSnapshotStatus(current);    return detail === null      ? null      : {          pr: pullRequestDetailToVcsStatus(detail),          sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" },        };  }, [current, detail]);} export function linkedPullRequestSnapshotStatus(  link: ThreadPullRequestLink,): LinkedThreadPullRequestStatus | null {  const snapshot = link.snapshot;  if (snapshot === null) return null;  const kind = link.url.includes("/-/merge_requests/")    ? "gitlab"    : link.url.includes("/pullrequest/")      ? "azure-devops"      : link.url.includes("/pull-requests/")        ? "bitbucket"        : "github";  return {    pr: {      number: link.number,      url: link.url,      title: snapshot.title,      state: snapshot.state,      isDraft: snapshot.isDraft,      headRef: snapshot.headBranch,      baseRef: snapshot.baseBranch,      ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),    },    sourceControlProvider: { kind, name: kind, baseUrl: "" },  };} export {  resolveThreadPullRequestBadge,  type ThreadPullRequestBadge,} from "@t3tools/shared/threadPullRequests"; /** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */function ThreadPullRequestBadgeIcon({  icon,  className,}: {  icon: "stack" | "pull-request";  className?: string | undefined;}) {  const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon;  return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;} /** The complete linked-PR control shared by the sidebar and composer footer. */export function ThreadPullRequestBadgeControl({  variant,  badge,  number,  url,  status,  onOpenStack,  onOpenPullRequest,}: {  variant: "underline" | "ghost";  badge: ThreadPullRequestBadge | null;  number?: number | undefined;  url?: string | undefined;  status: PrStatusIndicator | null;  onOpenStack: () => void;  onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;}) {  const isStack = badge?.kind === "stack";  const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;  if (!isStack && (number === undefined || url === undefined)) return null;  const label = isStack    ? `Stack of ${badge.layers} pull requests, ${badge.state}`    : `${status?.tooltip ?? `PR #${number}, status pending`}${        badge?.kind === "pull-request" && badge.others > 0          ? `, and ${badge.others} more linked; overall ${badge.state}`          : ""      }`;  const className = cn(    variant === "ghost"      ? buttonVariants({ variant: "ghost", size: "xs" })      : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring",    "text-xs tabular-nums",    variant === "ghost" &&      "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",    badge !== null && (isStack || linkedCount !== null)      ? PR_STATE_COLOR_CLASS[badge.state]      : (status?.colorClass ?? "text-muted-foreground"),  );  const content = (    <>      <ThreadPullRequestBadgeIcon icon={badge?.kind ?? "pull-request"} />      {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number}    </>  );  return (    <Tooltip>      <TooltipTrigger        render={          isStack ? (            <InlineButton              className={className}              aria-label={label}              onPointerDown={(event) => event.stopPropagation()}              onClick={(event) => {                event.preventDefault();                event.stopPropagation();                onOpenStack();              }}            />          ) : (            <a              href={url}              target="_blank"              rel="noopener noreferrer"              className={className}              aria-label={label}              onPointerDown={(event) => event.stopPropagation()}              onClick={onOpenPullRequest}            />          )        }      >        {content}      </TooltipTrigger>      <TooltipPopup side="top">{label}</TooltipPopup>    </Tooltip>  );} /** * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, * so the hover answers "what is in here" without opening the surface. */export function ThreadPullRequestsMiniList({  pullRequests,}: {  pullRequests: ReadonlyArray<ThreadPullRequestLink>;}) {  const lines = useMemo(    () =>      pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))),    [pullRequests],  );  if (lines.length === 0) return null;  return (    <ul className="flex flex-col gap-1">      {lines.map((line) => {        const snapshot = line.link.snapshot;        const presentation =          snapshot === null            ? null            : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });        return (          <li            key={`${line.link.host}/${line.link.repository}#${line.link.number}`}            className="flex min-w-0 items-center gap-2"            // Capped like the panel: past a few layers the indent only repeats "still in the            // stack", and sixteen of them would walk the titles off the popover.            style={{ paddingLeft: `${Math.min(line.depth, 3) * 0.75}rem` }}          >            {presentation ? (              <presentation.Icon                aria-hidden                className={cn("size-3 shrink-0", presentation.toneClassName)}              />            ) : (              <GitPullRequestArrowIcon                aria-hidden                className="size-3 shrink-0 stroke-muted-foreground"              />            )}            <span className="shrink-0 font-mono tabular-nums">#{line.link.number}</span>            <span className="min-w-0 truncate text-foreground/75">              {snapshot?.title ?? line.link.repository}            </span>            {line.stack ? (              <span className="ml-auto shrink-0 pl-1 text-[10px]">                {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size}              </span>            ) : null}          </li>        );      })}    </ul>  );} /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */const PR_STATE_COLOR_CLASS: Record<ThreadPullRequestBadge["state"], string> = {  open: "text-emerald-600 dark:text-emerald-300/90",  merged: "text-violet-600 dark:text-violet-300/90",  closed: "text-red-600 dark:text-red-300/90",  draft: "text-zinc-500 dark:text-zinc-400/80",}; export function settledPrHoverColorClass(  state: NonNullable<ThreadPr>["state"],  isDraft = false,): string {  switch (state) {    case "open":      if (isDraft) {        return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";      }      return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";    case "merged":      return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";    case "closed":      return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";  }} export function prStatusIndicator(  pr: ThreadPr,  provider: VcsStatusResult["sourceControlProvider"] | null | undefined,): PrStatusIndicator | null {  function formatPrState(pr: NonNullable<ThreadPr>): string {    if (pr.state === "open" && pr.isDraft === true) return "Draft";    return pr.state.charAt(0).toUpperCase() + pr.state.slice(1);  }   function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {    return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`;  }  if (!pr) return null;  const presentation = resolveChangeRequestPresentation(provider);   const tooltipLead = formatPrStatusLead(pr, presentation.shortName);  const tooltip = `${tooltipLead}: ${pr.title}`;   if (pr.state === "open") {    const isDraft = pr.isDraft === true;    return {      label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`,      colorClass: isDraft        ? "text-zinc-500 dark:text-zinc-400/80"        : "text-emerald-600 dark:text-emerald-300/90",      tooltip,      tooltipLead,      tooltipTitle: pr.title,      url: pr.url,    };  }  if (pr.state === "closed") {    return {      label: `${presentation.shortName} closed`,      colorClass: "text-red-600 dark:text-red-300/90",      tooltip,      tooltipLead,      tooltipTitle: pr.title,      url: pr.url,    };  }  if (pr.state === "merged") {    return {      label: `${presentation.shortName} merged`,      colorClass: "text-violet-600 dark:text-violet-300/90",      tooltip,      tooltipLead,      tooltipTitle: pr.title,      url: pr.url,    };  }  return null;} export function ChangeRequestStatusIcon({  state,  isDraft = false,  className,}: Pick<NonNullable<ThreadPr>, "state"> & {  readonly isDraft?: boolean | undefined;  readonly className?: string | undefined;}) {  const presentation = resolvePullRequestState({ state, isDraft });  return <presentation.Icon className={className} />;} export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) {  return (    <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap">      <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span>      <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" />      <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span>    </span>  );} export function terminalStatusFromRunningIds(  runningTerminalIds: ReadonlyArray<string>,): TerminalStatusIndicator | null {  if (runningTerminalIds.length === 0) {    return null;  }  return {    label: "Terminal process running",    colorClass: "text-teal-600 dark:text-teal-300/90",    pulse: true,  };} export function ThreadWorktreeIndicator({  thread,}: {  thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">;}) {  const worktreePath = thread.worktreePath?.trim();  if (!worktreePath) {    return null;  }   const displayPath = formatWorktreePathForDisplay(worktreePath);  const tooltip = thread.branch    ? `Worktree: ${displayPath} (${thread.branch})`    : `Worktree: ${displayPath}`;   return (    <Tooltip>      <TooltipTrigger        render={          <span            role="img"            aria-label={tooltip}            data-testid={`thread-worktree-${thread.id}`}            className="inline-flex items-center justify-center"          />        }      >        <FolderGit2Icon className="size-3 text-muted-foreground/40" />      </TooltipTrigger>      <TooltipPopup side="top">{tooltip}</TooltipPopup>    </Tooltip>  );} export function ThreadStatusLabel({  status,  compact = false,}: {  status: ThreadStatusPill;  compact?: boolean;}) {  if (compact) {    return (      <Tooltip>        <TooltipTrigger          render={            <span              aria-label={status.label}              className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}            />          }        >          <span            className={`size-[9px] rounded-full ${status.dotClass} ${              status.pulse ? "animate-status-pulse" : ""            }`}          />        </TooltipTrigger>        <TooltipPopup side="top">{status.label}</TooltipPopup>      </Tooltip>    );  }   return (    <Tooltip>      <TooltipTrigger        render={          <span            aria-label={status.label}            className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}          />        }      >        <span          className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${            status.pulse ? "animate-status-pulse" : ""          }`}        />        <span className="hidden md:inline">{status.label}</span>      </TooltipTrigger>      <TooltipPopup side="top">{status.label}</TooltipPopup>    </Tooltip>  );} /** * Non-interactive leading status icons for a thread row in compact contexts * like the command palette. Shows the change request state icon (if present) and the * thread status dot, matching the sidebar's leading indicators. */export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {  const threadRef = scopeThreadRef(thread.environmentId, thread.id);  const lastVisitedAt = useUiStateStore(    (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],  );  const pullRequest = useLinkedThreadPullRequest(    thread.environmentId,    thread.linkedPullRequest,    true,    thread.pullRequests,    thread.branchPullRequest,  );  const pr = pullRequest?.pr ?? null;  const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);  const threadStatus = resolveThreadStatusPill({    thread: {      ...thread,      lastVisitedAt,    },  }); 

Lines 1–500 of 604.