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; readonly sourceControlProvider: NonNullable; } /** 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, 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 ; } /** 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) => 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 = ( <> {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); return ( event.stopPropagation()} onClick={(event) => { event.preventDefault(); event.stopPropagation(); onOpenStack(); }} /> ) : ( event.stopPropagation()} onClick={onOpenPullRequest} /> ) } > {content} {label} ); } /** * 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; }) { const lines = useMemo( () => pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))), [pullRequests], ); if (lines.length === 0) return null; return (
    {lines.map((line) => { const snapshot = line.link.snapshot; const presentation = snapshot === null ? null : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); return (
  • {presentation ? ( ) : ( )} #{line.link.number} {snapshot?.title ?? line.link.repository} {line.stack ? ( {line.stack.kind === "native" ? "stack" : "chain"} ยท {line.stack.size} ) : null}
  • ); })}
); } /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ const PR_STATE_COLOR_CLASS: Record = { 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["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): string { if (pr.state === "open" && pr.isDraft === true) return "Draft"; return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable, 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, "state"> & { readonly isDraft?: boolean | undefined; readonly className?: string | undefined; }) { const presentation = resolvePullRequestState({ state, isDraft }); return ; } export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( {status.tooltipLead} ); } export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): 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; }) { 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} ); } export function ThreadStatusLabel({ status, compact = false, }: { status: ThreadStatusPill; compact?: boolean; }) { if (compact) { return ( } > {status.label} ); } return ( } > {status.label} {status.label} ); } /** * 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, }, }); const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const pendingLink = pr === null && supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; if (!prStatus && !threadStatus && !pendingLink) { return null; } return ( {prStatus && pr ? ( } > ) : null} {pendingLink ? ( ) : null} {threadStatus ? : null} ); } /** * Non-interactive trailing status icons for a thread row in compact contexts * like the command palette. Shows a terminal-running indicator and a remote * environment indicator, matching the sidebar's trailing indicators. */ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); // No primary (the hosted app) means every thread is remote, and the machine // glyph is what tells the environments apart. const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { return null; } return ( {terminalStatus ? ( } > {terminalStatus.label} ) : null} {isRemoteThread ? ( } > {threadEnvironmentLabel} ) : null} ); }