| 1 | import { |
| 2 | scopeProjectRef, |
| 3 | scopedThreadKey, |
| 4 | scopeThreadRef, |
| 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 7 | |
| 8 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 9 | import { |
| 10 | resolveEnvironmentMachineKind, |
| 11 | type EnvironmentId, |
| 12 | type ScopedThreadRef, |
| 13 | type ThreadLinkedPullRequest, |
| 14 | type ThreadPullRequestLink, |
| 15 | type VcsStatusResult, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import { Atom } from "effect/reactivity"; |
| 18 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 19 | import { useCallback, useMemo } from "react"; |
| 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 23 | import { useProject } from "../state/entities"; |
| 24 | import { |
| 25 | resolveThreadCurrentPullRequestLink, |
| 26 | resolveThreadPullRequestChains, |
| 27 | visibleThreadPullRequests, |
| 28 | type ThreadPullRequestBadge, |
| 29 | } from "@t3tools/shared/threadPullRequests"; |
| 30 | import { useRender } from "@base-ui/react/use-render"; |
| 31 | import { |
| 32 | type ReactNode, |
| 33 | type AnimationEvent, |
| 34 | type MouseEvent, |
| 35 | type ReactElement, |
| 36 | type ComponentProps, |
| 37 | } from "react"; |
| 38 | import { cn } from "../lib/utils"; |
| 39 | import { revealThreadTerminal } from "../lib/revealThreadTerminal"; |
| 40 | |
| 41 | import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; |
| 42 | import { useEnvironmentQuery } from "../state/query"; |
| 43 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; |
| 44 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 45 | import { vcsEnvironment } from "../state/vcs"; |
| 46 | import { useUiStateStore } from "../uiStateStore"; |
| 47 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 48 | import { |
| 49 | pullRequestMatchesSearchQuery, |
| 50 | resolveThreadLastVisitedAt, |
| 51 | resolveThreadStatusPill, |
| 52 | type ThreadStatusPill, |
| 53 | useRetainedValue, |
| 54 | useSidebarRowSubscriptionLease, |
| 55 | } from "./Sidebar.logic"; |
| 56 | |
| 57 | import type { SidebarThreadSummary } from "../types"; |
| 58 | import { formatWorktreePathForDisplay } from "../worktreeCleanup"; |
| 59 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 60 | import { pullRequestListLines } from "./pullRequest/pullRequestListLines"; |
| 61 | import { |
| 62 | PULL_REQUEST_STATE_PRESENTATION, |
| 63 | PullRequestGlyph, |
| 64 | type PullRequestGlyphIcon, |
| 65 | } from "./pullRequest/pullRequestIcons"; |
| 66 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 67 | |
| 68 | export interface PrStatusIndicator { |
| 69 | label: string; |
| 70 | colorClass: string; |
| 71 | Icon: PullRequestGlyphIcon; |
| 72 | tooltip: string; |
| 73 | tooltipLead: string; |
| 74 | tooltipTitle: string; |
| 75 | url: string; |
| 76 | } |
| 77 | |
| 78 | export interface TerminalStatusIndicator { |
| 79 | label: "Terminal process running"; |
| 80 | colorClass: string; |
| 81 | pulse: boolean; |
| 82 | } |
| 83 | |
| 84 | export type ThreadPr = VcsStatusResult["pr"]; |
| 85 | |
| 86 | export interface LinkedThreadPullRequestStatus { |
| 87 | readonly pr: NonNullable<ThreadPr>; |
| 88 | readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; |
| 89 | } |
| 90 | |
| 91 | /** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ |
| 92 | export function useLinkedThreadPullRequest( |
| 93 | environmentId: EnvironmentId | null, |
| 94 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 95 | enabled = true, |
| 96 | pullRequests?: ReadonlyArray<ThreadPullRequestLink>, |
| 97 | branchPullRequest?: ThreadLinkedPullRequest | null, |
| 98 | ): LinkedThreadPullRequestStatus | null { |
| 99 | const supportsLinks = useSupportsMultiplePullRequests(environmentId); |
| 100 | const current = useMemo( |
| 101 | () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null), |
| 102 | [pullRequests, supportsLinks], |
| 103 | ); |
| 104 | const fallback = |
| 105 | current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; |
| 106 | // Stable per link: the shared summary effect keys on this object, and a sidebar row must not |
| 107 | // touch the cache on every render. |
| 108 | const reference = useMemo(() => { |
| 109 | if (fallback == null) return null; |
| 110 | const host = parseChangeRequestUrl(fallback.url)?.host; |
| 111 | return { ...fallback, ...(host === undefined ? {} : { host }) }; |
| 112 | }, [fallback]); |
| 113 | const queried = useEnvironmentQuery( |
| 114 | !enabled || environmentId === null || reference === null |
| 115 | ? null |
| 116 | : linkedPullRequestDetailAtom({ environmentId, input: reference }), |
| 117 | ); |
| 118 | const detail = useSharedPullRequestSummary( |
| 119 | environmentId, |
| 120 | reference, |
| 121 | queried.data, |
| 122 | queried.dataUpdatedAt, |
| 123 | ); |
| 124 | |
| 125 | return useMemo(() => { |
| 126 | if (current !== null) return linkedPullRequestSnapshotStatus(current); |
| 127 | return detail === null |
| 128 | ? null |
| 129 | : { |
| 130 | pr: pullRequestDetailToVcsStatus(detail), |
| 131 | sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, |
| 132 | }; |
| 133 | }, [current, detail]); |
| 134 | } |
| 135 | |
| 136 | export function linkedPullRequestSnapshotStatus( |
| 137 | link: ThreadPullRequestLink, |
| 138 | ): LinkedThreadPullRequestStatus | null { |
| 139 | const snapshot = link.snapshot; |
| 140 | if (snapshot === null) return null; |
| 141 | // A link records no host kind, so read it from the URL's change request path. |
| 142 | const kind = ( |
| 143 | sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined) |
| 144 | ).kind; |
| 145 | return { |
| 146 | pr: { |
| 147 | number: link.number, |
| 148 | url: link.url, |
| 149 | title: snapshot.title, |
| 150 | state: snapshot.state, |
| 151 | isDraft: snapshot.isDraft, |
| 152 | headRef: snapshot.headBranch, |
| 153 | baseRef: snapshot.baseBranch, |
| 154 | ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), |
| 155 | }, |
| 156 | sourceControlProvider: { kind, name: kind, baseUrl: "" }, |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | export { |
| 161 | resolveThreadPullRequestBadge, |
| 162 | type ThreadPullRequestBadge, |
| 163 | } from "@t3tools/shared/threadPullRequests"; |
| 164 | |
| 165 | export interface ThreadPullRequestBadgePresentation { |
| 166 | readonly Icon: PullRequestGlyphIcon; |
| 167 | readonly toneClassName: string; |
| 168 | readonly label: string; |
| 169 | readonly text: string | number; |
| 170 | } |
| 171 | |
| 172 | /** Resolve the complete badge appearance before rendering it in the sidebar or composer. */ |
| 173 | export function resolveThreadPullRequestBadgePresentation({ |
| 174 | badge, |
| 175 | number, |
| 176 | url, |
| 177 | status, |
| 178 | }: { |
| 179 | readonly badge: ThreadPullRequestBadge | null; |
| 180 | readonly number?: number | undefined; |
| 181 | readonly url?: string | undefined; |
| 182 | readonly status: PrStatusIndicator | null; |
| 183 | }): ThreadPullRequestBadgePresentation | null { |
| 184 | // The badge already folds every visible link into one state, draft included, so both the |
| 185 | // stack and the linked count index the shared table directly rather than the single-PR resolver. |
| 186 | if (badge?.kind === "stack") { |
| 187 | const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state]; |
| 188 | return { |
| 189 | Icon: PullRequestGlyph.stack, |
| 190 | toneClassName: aggregate.toneClassName, |
| 191 | label: `Stack of ${badge.layers} pull requests, ${aggregate.label.toLowerCase()}`, |
| 192 | text: badge.layers, |
| 193 | }; |
| 194 | } |
| 195 | if (number === undefined || url === undefined) return null; |
| 196 | |
| 197 | const tooltip = status?.tooltip ?? `PR #${number}, status pending`; |
| 198 | if (badge?.kind === "pull-request" && badge.others > 0) { |
| 199 | // Unrelated links fold into one state, so a count of merged PRs reads as merged. |
| 200 | const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state]; |
| 201 | return { |
| 202 | Icon: aggregate.Icon, |
| 203 | toneClassName: aggregate.toneClassName, |
| 204 | label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`, |
| 205 | text: `+${badge.others + 1}`, |
| 206 | }; |
| 207 | } |
| 208 | return { |
| 209 | Icon: status?.Icon ?? PullRequestGlyph.pullRequest, |
| 210 | toneClassName: status?.colorClass ?? "text-muted-foreground", |
| 211 | label: tooltip, |
| 212 | text: number, |
| 213 | }; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: |
| 218 | * the state glyph and number at the meta size, in the state's color. The caller owns the control |
| 219 | * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the |
| 220 | * composer), and the badge fills in the behavior: a single PR is a link to it, while a stack or |
| 221 | * several linked PRs is a button that opens the thread's pull requests tab. |
| 222 | */ |
| 223 | export function ThreadPullRequestBadgeControl({ |
| 224 | render, |
| 225 | badge, |
| 226 | pullRequests, |
| 227 | number, |
| 228 | url, |
| 229 | status, |
| 230 | onOpenList, |
| 231 | onOpenPullRequest, |
| 232 | }: { |
| 233 | render: ReactElement<{ render?: useRender.RenderProp }>; |
| 234 | badge: ThreadPullRequestBadge | null; |
| 235 | pullRequests: ReadonlyArray<ThreadPullRequestLink>; |
| 236 | number?: number | undefined; |
| 237 | url?: string | undefined; |
| 238 | status: PrStatusIndicator | null; |
| 239 | onOpenList: () => void; |
| 240 | onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void; |
| 241 | }) { |
| 242 | const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); |
| 243 | if (presentation === null) return null; |
| 244 | return ( |
| 245 | <PullRequestBadge |
| 246 | render={render} |
| 247 | presentation={presentation} |
| 248 | opensList={badge !== null && (badge.kind === "stack" || badge.others > 0)} |
| 249 | url={url} |
| 250 | number={number} |
| 251 | status={status} |
| 252 | pullRequests={pullRequests} |
| 253 | onOpenList={onOpenList} |
| 254 | onOpenPullRequest={onOpenPullRequest} |
| 255 | /> |
| 256 | ); |
| 257 | } |
| 258 | |
| 259 | function PullRequestBadge({ |
| 260 | render, |
| 261 | presentation, |
| 262 | opensList, |
| 263 | url, |
| 264 | number, |
| 265 | status, |
| 266 | pullRequests, |
| 267 | onOpenList, |
| 268 | onOpenPullRequest, |
| 269 | }: { |
| 270 | render: ReactElement<{ render?: useRender.RenderProp }>; |
| 271 | presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>; |
| 272 | opensList: boolean; |
| 273 | url: string | undefined; |
| 274 | number: number | undefined; |
| 275 | status: PrStatusIndicator | null; |
| 276 | pullRequests: ReadonlyArray<ThreadPullRequestLink>; |
| 277 | onOpenList: () => void; |
| 278 | onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void; |
| 279 | }) { |
| 280 | const onClick = opensList |
| 281 | ? (event: MouseEvent<HTMLElement>) => { |
| 282 | event.preventDefault(); |
| 283 | event.stopPropagation(); |
| 284 | onOpenList(); |
| 285 | } |
| 286 | : onOpenPullRequest; |
| 287 | const element = opensList ? ( |
| 288 | <button type="button" /> |
| 289 | ) : ( |
| 290 | <a href={url} target="_blank" rel="noopener noreferrer" /> |
| 291 | ); |
| 292 | // The caller's control (InlineButton, ComposerControl) renders as the link or stack button |
| 293 | // through its own render prop; useRender merges the badge's behavior into it. |
| 294 | const control = useRender({ |
| 295 | render, |
| 296 | props: { |
| 297 | render: element, |
| 298 | "aria-label": presentation.label, |
| 299 | onPointerDown: (event: MouseEvent<HTMLElement>) => event.stopPropagation(), |
| 300 | onClick, |
| 301 | }, |
| 302 | }); |
| 303 | return ( |
| 304 | <Tooltip key={opensList ? "list" : "link"}> |
| 305 | <TooltipTrigger render={control}> |
| 306 | <span |
| 307 | className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)} |
| 308 | > |
| 309 | <presentation.Icon aria-hidden className="size-3 shrink-0" /> |
| 310 | {/* An element, not bare text: bare text takes its line box from the control, which |
| 311 | inherits the row's size, so beside a text-sm title it sat below the other meta. */} |
| 312 | <span>{presentation.text}</span> |
| 313 | </span> |
| 314 | </TooltipTrigger> |
| 315 | <TooltipPopup |
| 316 | side="top" |
| 317 | sideOffset={0} |
| 318 | variant="glass" |
| 319 | className="pointer-events-auto w-80 max-w-[calc(100vw-2rem)] text-left whitespace-normal" |
| 320 | > |
| 321 | {/* The viewport pads 8px inline and 4px block; pull the rows out so their hover |
| 322 | highlight sits 4px from every edge. */} |
| 323 | <div className="-mx-1"> |
| 324 | {visibleThreadPullRequests(pullRequests).length > 0 ? ( |
| 325 | <ThreadPullRequestsMiniList |
| 326 | pullRequests={pullRequests} |
| 327 | onOpenPullRequest={onOpenPullRequest} |
| 328 | /> |
| 329 | ) : number !== undefined && url !== undefined ? ( |
| 330 | <ul className="flex flex-col gap-1"> |
| 331 | <ThreadPullRequestMiniListItem |
| 332 | number={number} |
| 333 | url={url} |
| 334 | title={status?.tooltipTitle ?? presentation.label} |
| 335 | presentation={presentation} |
| 336 | onOpenPullRequest={onOpenPullRequest} |
| 337 | /> |
| 338 | </ul> |
| 339 | ) : null} |
| 340 | </div> |
| 341 | </TooltipPopup> |
| 342 | </Tooltip> |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, |
| 348 | * so the hover answers "what is in here" without opening the surface. |
| 349 | */ |
| 350 | export function ThreadPullRequestsMiniList({ |
| 351 | pullRequests, |
| 352 | onOpenPullRequest, |
| 353 | }: { |
| 354 | pullRequests: ReadonlyArray<ThreadPullRequestLink>; |
| 355 | onOpenPullRequest?: (event: MouseEvent<HTMLAnchorElement>, url: string) => void; |
| 356 | }) { |
| 357 | const lines = useMemo( |
| 358 | () => |
| 359 | pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))), |
| 360 | [pullRequests], |
| 361 | ); |
| 362 | if (lines.length === 0) return null; |
| 363 | return ( |
| 364 | <ul className="flex flex-col gap-1"> |
| 365 | {lines.map((line) => { |
| 366 | const snapshot = line.link.snapshot; |
| 367 | const presentation = |
| 368 | snapshot === null |
| 369 | ? null |
| 370 | : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); |
| 371 | return ( |
| 372 | <ThreadPullRequestMiniListItem |
| 373 | key={`${line.link.host}/${line.link.repository}#${line.link.number}`} |
| 374 | number={line.link.number} |
| 375 | url={line.link.url} |
| 376 | title={snapshot?.title ?? line.link.repository} |
| 377 | presentation={presentation} |
| 378 | depth={line.depth} |
| 379 | onOpenPullRequest={onOpenPullRequest} |
| 380 | > |
| 381 | {line.stack ? ( |
| 382 | <span className="ml-auto shrink-0 pl-1 text-3xs"> |
| 383 | {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size} |
| 384 | </span> |
| 385 | ) : null} |
| 386 | </ThreadPullRequestMiniListItem> |
| 387 | ); |
| 388 | })} |
| 389 | </ul> |
| 390 | ); |
| 391 | } |
| 392 | |
| 393 | function ThreadPullRequestMiniListItem({ |
| 394 | number, |
| 395 | url, |
| 396 | title, |
| 397 | presentation, |
| 398 | depth = 0, |
| 399 | onOpenPullRequest, |
| 400 | children, |
| 401 | }: { |
| 402 | number: number; |
| 403 | url: string; |
| 404 | title: string; |
| 405 | presentation: Pick<ThreadPullRequestBadgePresentation, "Icon" | "toneClassName"> | null; |
| 406 | depth?: number; |
| 407 | onOpenPullRequest?: ((event: MouseEvent<HTMLAnchorElement>, url: string) => void) | undefined; |
| 408 | children?: ReactNode; |
| 409 | }) { |
| 410 | const Icon = presentation?.Icon ?? PullRequestGlyph.pullRequest; |
| 411 | const content = ( |
| 412 | <> |
| 413 | <Icon |
| 414 | aria-hidden |
| 415 | className={cn("size-3 shrink-0", presentation?.toneClassName ?? "stroke-muted-foreground")} |
| 416 | /> |
| 417 | <span className="shrink-0 font-mono tabular-nums">#{number}</span> |
| 418 | <span className="min-w-0 truncate text-foreground/75">{title}</span> |
| 419 | {children} |
| 420 | </> |
| 421 | ); |
| 422 | return ( |
| 423 | <li style={{ paddingLeft: `${Math.min(depth, 3) * 0.75}rem` }}> |
| 424 | {onOpenPullRequest ? ( |
| 425 | <a |
| 426 | href={url} |
| 427 | target="_blank" |
| 428 | rel="noopener noreferrer" |
| 429 | className="flex min-w-0 items-center gap-2 rounded-sm px-1 py-1 hover:bg-accent focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-ring" |
| 430 | onPointerDown={(event) => event.stopPropagation()} |
| 431 | onClick={(event) => onOpenPullRequest(event, url)} |
| 432 | > |
| 433 | {content} |
| 434 | </a> |
| 435 | ) : ( |
| 436 | <div className="flex min-w-0 items-center gap-2">{content}</div> |
| 437 | )} |
| 438 | </li> |
| 439 | ); |
| 440 | } |
| 441 | |
| 442 | export function prStatusIndicator( |
| 443 | pr: ThreadPr, |
| 444 | provider: VcsStatusResult["sourceControlProvider"] | null | undefined, |
| 445 | ): PrStatusIndicator | null { |
| 446 | if (!pr) return null; |
| 447 | const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest; |
| 448 | const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true }); |
| 449 | |
| 450 | const tooltipLead = `${shortLabel} #${pr.number} - ${state.label}`; |
| 451 | return { |
| 452 | label: `${shortLabel} ${state.label.toLowerCase()}`, |
| 453 | colorClass: state.toneClassName, |
| 454 | Icon: state.Icon, |
| 455 | tooltip: `${tooltipLead}: ${pr.title}`, |
| 456 | tooltipLead, |
| 457 | tooltipTitle: pr.title, |
| 458 | url: pr.url, |
| 459 | }; |
| 460 | } |
| 461 | |
| 462 | export function ChangeRequestStatusIcon({ |
| 463 | state, |
| 464 | isDraft = false, |
| 465 | className, |
| 466 | }: Pick<NonNullable<ThreadPr>, "state"> & { |
| 467 | readonly isDraft?: boolean | undefined; |
| 468 | readonly className?: string | undefined; |
| 469 | }) { |
| 470 | const presentation = resolvePullRequestState({ state, isDraft }); |
| 471 | return <presentation.Icon className={className} />; |
| 472 | } |
| 473 | |
| 474 | export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { |
| 475 | return ( |
| 476 | <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap"> |
| 477 | <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span> |
| 478 | <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" /> |
| 479 | <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span> |
| 480 | </span> |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | export function resolveThreadPr(input: { |
| 485 | threadBranch: string | null; |
| 486 | gitStatus: VcsStatusResult | null; |
| 487 | }): ThreadPr | null { |
| 488 | const { threadBranch, gitStatus } = input; |
| 489 | if (gitStatus === null) { |
| 490 | return null; |
| 491 | } |
| 492 | |
| 493 | if (threadBranch === null || gitStatus.refName !== threadBranch) { |
| 494 | return null; |
| 495 | } |
| 496 | |
| 497 | return gitStatus.pr ?? null; |
| 498 | } |
| 499 | |
| 500 | /** |
| 501 | * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement |
| 502 | * partitions move them, so terminal PR metadata must live above the row. |
| 503 | */ |
| 504 | export interface ThreadChangeRequestSnapshot { |
| 505 | readonly branch: string; |
| 506 | readonly pr: NonNullable<ThreadPr>; |
| 507 | readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; |
| 508 | readonly linkedPullRequest?: ThreadLinkedPullRequest; |
| 509 | } |
| 510 | |
| 511 | export const threadChangeRequestSnapshotsAtom = Atom.make< |
| 512 | ReadonlyMap<string, ThreadChangeRequestSnapshot> |
| 513 | >(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); |
| 514 | |
| 515 | function isTerminalChangeRequestState( |
| 516 | state: NonNullable<ThreadPr>["state"], |
| 517 | ): state is "merged" | "closed" { |
| 518 | return state === "merged" || state === "closed"; |
| 519 | } |
| 520 | |
| 521 | function sourceControlProvidersEqual( |
| 522 | left: VcsStatusResult["sourceControlProvider"] | undefined, |
| 523 | right: VcsStatusResult["sourceControlProvider"] | undefined, |
| 524 | ): boolean { |
| 525 | if (left === right) return true; |
| 526 | if (left == null || right == null) return left == null && right == null; |
| 527 | return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; |
| 528 | } |
| 529 | |
| 530 | function linkedPullRequestsEqual( |
| 531 | left: ThreadLinkedPullRequest | null | undefined, |
| 532 | right: ThreadLinkedPullRequest | null | undefined, |
| 533 | ): boolean { |
| 534 | if (left == null || right == null) return left == null && right == null; |
| 535 | return ( |
| 536 | left.projectId === right.projectId && |
| 537 | left.repository === right.repository && |
| 538 | left.number === right.number && |
| 539 | left.url === right.url |
| 540 | ); |
| 541 | } |
| 542 | |
| 543 | export function threadChangeRequestSnapshotsEqual( |
| 544 | left: ThreadChangeRequestSnapshot, |
| 545 | right: ThreadChangeRequestSnapshot, |
| 546 | ): boolean { |
| 547 | return ( |
| 548 | left.branch === right.branch && |
| 549 | left.pr.number === right.pr.number && |
| 550 | left.pr.title === right.pr.title && |
| 551 | left.pr.url === right.pr.url && |
| 552 | left.pr.baseRef === right.pr.baseRef && |
| 553 | left.pr.headRef === right.pr.headRef && |
| 554 | left.pr.state === right.pr.state && |
| 555 | left.pr.isDraft === right.pr.isDraft && |
| 556 | (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && |
| 557 | sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && |
| 558 | linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) |
| 559 | ); |
| 560 | } |
| 561 | |
| 562 | export function setThreadChangeRequestSnapshot( |
| 563 | threadKey: string, |
| 564 | snapshot: ThreadChangeRequestSnapshot | null, |
| 565 | ): void { |
| 566 | appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { |
| 567 | const existing = current.get(threadKey); |
| 568 | if (snapshot === null) { |
| 569 | if (existing === undefined) return [false, current]; |
| 570 | const next = new Map(current); |
| 571 | next.delete(threadKey); |
| 572 | return [true, next]; |
| 573 | } |
| 574 | if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { |
| 575 | return [false, current]; |
| 576 | } |
| 577 | const next = new Map(current); |
| 578 | next.set(threadKey, snapshot); |
| 579 | return [true, next]; |
| 580 | }); |
| 581 | } |
| 582 | |
| 583 | /** |
| 584 | * Authoritative snapshot update from live VCS status. |
| 585 | * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone |
| 586 | * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear |
| 587 | * - snapshot: matching branch reports a PR — store/replace |
| 588 | */ |
| 589 | export function nextThreadChangeRequestSnapshot(input: { |
| 590 | threadBranch: string | null; |
| 591 | gitStatus: VcsStatusResult | null; |
| 592 | snapshot: ThreadChangeRequestSnapshot | null | undefined; |
| 593 | retainTerminalOnBranchMismatch: boolean; |
| 594 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; |
| 595 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; |
| 596 | }): ThreadChangeRequestSnapshot | null | undefined { |
| 597 | const { |
| 598 | threadBranch, |
| 599 | gitStatus, |
| 600 | snapshot, |
| 601 | retainTerminalOnBranchMismatch, |
| 602 | linkedPullRequest, |
| 603 | linkedPullRequestStatus, |
| 604 | } = input; |
| 605 | if (linkedPullRequest != null) { |
| 606 | if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { |
| 607 | return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) |
| 608 | ? undefined |
| 609 | : null; |
| 610 | } |
| 611 | return { |
| 612 | branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, |
| 613 | pr: linkedPullRequestStatus.pr, |
| 614 | sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, |
| 615 | linkedPullRequest, |
| 616 | }; |
| 617 | } |
| 618 | if (gitStatus === null) { |
| 619 | return snapshot?.linkedPullRequest === undefined ? undefined : null; |
| 620 | } |
| 621 | if (threadBranch === null) { |
| 622 | return null; |
| 623 | } |
| 624 | if (gitStatus.refName !== threadBranch) { |
| 625 | return retainTerminalOnBranchMismatch && |
| 626 | snapshot != null && |
| 627 | snapshot.linkedPullRequest === undefined && |
| 628 | isTerminalChangeRequestState(snapshot.pr.state) |
| 629 | ? undefined |
| 630 | : null; |
| 631 | } |
| 632 | if (gitStatus.pr == null) { |
| 633 | if ( |
| 634 | retainTerminalOnBranchMismatch && |
| 635 | snapshot != null && |
| 636 | snapshot.linkedPullRequest === undefined && |
| 637 | isTerminalChangeRequestState(snapshot.pr.state) |
| 638 | ) { |
| 639 | return undefined; |
| 640 | } |
| 641 | return null; |
| 642 | } |
| 643 | return { |
| 644 | branch: threadBranch, |
| 645 | pr: gitStatus.pr, |
| 646 | sourceControlProvider: gitStatus.sourceControlProvider, |
| 647 | }; |
| 648 | } |
| 649 | |
| 650 | /** |
| 651 | * Live PR when the checkout matches the thread branch; otherwise, for local |
| 652 | * checkouts only, a cached merged/closed PR for the thread. Local thread |
| 653 | * metadata follows the shared checkout, so the cached branch intentionally |
| 654 | * survives that metadata changing to the newly checked-out branch. Open PRs |
| 655 | * are retained only while live status is absent and the branch still matches. |
| 656 | */ |
| 657 | export function resolveDisplayedThreadPr(input: { |
| 658 | threadBranch: string | null; |
| 659 | gitStatus: VcsStatusResult | null; |
| 660 | snapshot: ThreadChangeRequestSnapshot | null | undefined; |
| 661 | retainTerminalOnBranchMismatch: boolean; |
| 662 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; |
| 663 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; |
| 664 | }): ThreadPr | null { |
| 665 | const { |
| 666 | threadBranch, |
| 667 | gitStatus, |
| 668 | snapshot, |
| 669 | retainTerminalOnBranchMismatch, |
| 670 | linkedPullRequest, |
| 671 | linkedPullRequestStatus, |
| 672 | } = input; |
| 673 | if (linkedPullRequest != null) { |
| 674 | return ( |
| 675 | linkedPullRequestStatus?.pr ?? |
| 676 | (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) |
| 677 | ? (snapshot?.pr ?? null) |
| 678 | : null) |
| 679 | ); |
| 680 | } |
| 681 | if ( |
| 682 | threadBranch !== null && |
| 683 | gitStatus !== null && |
| 684 | gitStatus.refName === threadBranch && |
| 685 | gitStatus.pr != null |
| 686 | ) { |
| 687 | return gitStatus.pr; |
| 688 | } |
| 689 | |
| 690 | if ( |
| 691 | gitStatus === null && |
| 692 | threadBranch !== null && |
| 693 | snapshot?.branch === threadBranch && |
| 694 | snapshot.linkedPullRequest === undefined |
| 695 | ) { |
| 696 | return snapshot.pr; |
| 697 | } |
| 698 | |
| 699 | if ( |
| 700 | threadBranch !== null && |
| 701 | retainTerminalOnBranchMismatch && |
| 702 | snapshot != null && |
| 703 | snapshot.linkedPullRequest === undefined && |
| 704 | isTerminalChangeRequestState(snapshot.pr.state) |
| 705 | ) { |
| 706 | return snapshot.pr; |
| 707 | } |
| 708 | |
| 709 | return null; |
| 710 | } |
| 711 | |
| 712 | export function resolveDisplayedThreadPrProvider(input: { |
| 713 | threadBranch: string | null; |
| 714 | gitStatus: VcsStatusResult | null; |
| 715 | snapshot: ThreadChangeRequestSnapshot | null | undefined; |
| 716 | retainTerminalOnBranchMismatch: boolean; |
| 717 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; |
| 718 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; |
| 719 | }): VcsStatusResult["sourceControlProvider"] | undefined { |
| 720 | const { |
| 721 | threadBranch, |
| 722 | gitStatus, |
| 723 | snapshot, |
| 724 | retainTerminalOnBranchMismatch, |
| 725 | linkedPullRequest, |
| 726 | linkedPullRequestStatus, |
| 727 | } = input; |
| 728 | if (linkedPullRequest != null) { |
| 729 | return ( |
| 730 | linkedPullRequestStatus?.sourceControlProvider ?? |
| 731 | (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) |
| 732 | ? snapshot?.sourceControlProvider |
| 733 | : undefined) |
| 734 | ); |
| 735 | } |
| 736 | if ( |
| 737 | threadBranch !== null && |
| 738 | gitStatus !== null && |
| 739 | gitStatus.refName === threadBranch && |
| 740 | gitStatus.pr != null |
| 741 | ) { |
| 742 | return gitStatus.sourceControlProvider; |
| 743 | } |
| 744 | |
| 745 | if ( |
| 746 | gitStatus === null && |
| 747 | threadBranch !== null && |
| 748 | snapshot?.branch === threadBranch && |
| 749 | snapshot.linkedPullRequest === undefined |
| 750 | ) { |
| 751 | return snapshot.sourceControlProvider; |
| 752 | } |
| 753 | |
| 754 | if ( |
| 755 | threadBranch !== null && |
| 756 | retainTerminalOnBranchMismatch && |
| 757 | snapshot != null && |
| 758 | snapshot.linkedPullRequest === undefined && |
| 759 | isTerminalChangeRequestState(snapshot.pr.state) |
| 760 | ) { |
| 761 | return snapshot.sourceControlProvider; |
| 762 | } |
| 763 | |
| 764 | return undefined; |
| 765 | } |
| 766 | |
| 767 | export function terminalStatusFromRunningIds( |
| 768 | runningTerminalIds: ReadonlyArray<string>, |
| 769 | ): TerminalStatusIndicator | null { |
| 770 | if (runningTerminalIds.length === 0) { |
| 771 | return null; |
| 772 | } |
| 773 | return { |
| 774 | label: "Terminal process running", |
| 775 | colorClass: "text-teal-600 dark:text-teal-300/90", |
| 776 | pulse: true, |
| 777 | }; |
| 778 | } |
| 779 | |
| 780 | /** Align newly started pulses with the document clock without a timer or frame loop. */ |
| 781 | export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) { |
| 782 | if (event.animationName !== "status-pulse") return; |
| 783 | |
| 784 | for (const animation of event.currentTarget.getAnimations()) { |
| 785 | if ("animationName" in animation && animation.animationName === "status-pulse") { |
| 786 | animation.startTime = 0; |
| 787 | } |
| 788 | } |
| 789 | } |
| 790 | |
| 791 | export function ThreadTerminalStatusButton({ |
| 792 | threadRef, |
| 793 | terminalIds, |
| 794 | onThreadActivate, |
| 795 | }: { |
| 796 | threadRef: ScopedThreadRef; |
| 797 | terminalIds: readonly string[]; |
| 798 | onThreadActivate: (threadRef: ScopedThreadRef) => void; |
| 799 | }) { |
| 800 | const terminalId = terminalIds[0]; |
| 801 | if (terminalId === undefined) return null; |
| 802 | const status = terminalStatusFromRunningIds(terminalIds); |
| 803 | const label = |
| 804 | terminalIds.length === 1 |
| 805 | ? "1 terminal process running. Open terminal" |
| 806 | : `${terminalIds.length} terminal processes running. Open terminal`; |
| 807 | return ( |
| 808 | <Tooltip> |
| 809 | <TooltipTrigger |
| 810 | render={ |
| 811 | <button |
| 812 | type="button" |
| 813 | aria-label={label} |
| 814 | data-testid={`sidebar-terminal-status-${threadRef.threadId}`} |
| 815 | className={cn( |
| 816 | "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none hover:opacity-80 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", |
| 817 | status?.colorClass, |
| 818 | )} |
| 819 | onPointerDown={(event) => event.stopPropagation()} |
| 820 | onKeyDown={(event) => event.stopPropagation()} |
| 821 | onClick={(event) => { |
| 822 | event.preventDefault(); |
| 823 | event.stopPropagation(); |
| 824 | revealThreadTerminal(threadRef, terminalId); |
| 825 | onThreadActivate(threadRef); |
| 826 | }} |
| 827 | /> |
| 828 | } |
| 829 | > |
| 830 | <TerminalIcon |
| 831 | className="size-3.5 motion-safe:animate-status-pulse" |
| 832 | onAnimationStart={synchronizeTerminalPulse} |
| 833 | /> |
| 834 | </TooltipTrigger> |
| 835 | <TooltipPopup side="top">{label}</TooltipPopup> |
| 836 | </Tooltip> |
| 837 | ); |
| 838 | } |
| 839 | |
| 840 | export function ThreadWorktreeIndicator({ |
| 841 | thread, |
| 842 | }: { |
| 843 | thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">; |
| 844 | }) { |
| 845 | const worktreePath = thread.worktreePath?.trim(); |
| 846 | if (!worktreePath) { |
| 847 | return null; |
| 848 | } |
| 849 | |
| 850 | const displayPath = formatWorktreePathForDisplay(worktreePath); |
| 851 | const tooltip = thread.branch |
| 852 | ? `Worktree: ${displayPath} (${thread.branch})` |
| 853 | : `Worktree: ${displayPath}`; |
| 854 | |
| 855 | return ( |
| 856 | <Tooltip> |
| 857 | <TooltipTrigger |
| 858 | render={ |
| 859 | <span |
| 860 | role="img" |
| 861 | aria-label={tooltip} |
| 862 | data-testid={`thread-worktree-${thread.id}`} |
| 863 | className="inline-flex items-center justify-center" |
| 864 | /> |
| 865 | } |
| 866 | > |
| 867 | <FolderGit2Icon className="size-3 text-muted-foreground/40" /> |
| 868 | </TooltipTrigger> |
| 869 | <TooltipPopup side="top">{tooltip}</TooltipPopup> |
| 870 | </Tooltip> |
| 871 | ); |
| 872 | } |
| 873 | |
| 874 | export function ThreadStatusLabel({ |
| 875 | status, |
| 876 | compact = false, |
| 877 | }: { |
| 878 | status: ThreadStatusPill; |
| 879 | compact?: boolean; |
| 880 | }) { |
| 881 | if (compact) { |
| 882 | return ( |
| 883 | <Tooltip> |
| 884 | <TooltipTrigger |
| 885 | render={ |
| 886 | <span |
| 887 | role="img" |
| 888 | aria-label={status.label} |
| 889 | className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`} |
| 890 | /> |
| 891 | } |
| 892 | > |
| 893 | <span |
| 894 | className={`size-[9px] rounded-full ${status.dotClass} ${ |
| 895 | status.pulse ? "animate-status-pulse" : "" |
| 896 | }`} |
| 897 | /> |
| 898 | </TooltipTrigger> |
| 899 | <TooltipPopup side="top">{status.label}</TooltipPopup> |
| 900 | </Tooltip> |
| 901 | ); |
| 902 | } |
| 903 | |
| 904 | return ( |
| 905 | <Tooltip> |
| 906 | <TooltipTrigger |
| 907 | render={ |
| 908 | <span |
| 909 | role="img" |
| 910 | aria-label={status.label} |
| 911 | className={`inline-flex items-center gap-1 text-3xs ${status.colorClass}`} |
| 912 | /> |
| 913 | } |
| 914 | > |
| 915 | <span |
| 916 | className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ |
| 917 | status.pulse ? "animate-status-pulse" : "" |
| 918 | }`} |
| 919 | /> |
| 920 | <span className="hidden md:inline">{status.label}</span> |
| 921 | </TooltipTrigger> |
| 922 | <TooltipPopup side="top">{status.label}</TooltipPopup> |
| 923 | </Tooltip> |
| 924 | ); |
| 925 | } |
| 926 | |
| 927 | /** |
| 928 | * Non-interactive leading status icons for a thread row in compact contexts |
| 929 | * like the command palette. Shows the change request state icon (if present) and the |
| 930 | * thread status dot, matching the sidebar's leading indicators. |
| 931 | */ |
| 932 | export function ThreadRowLeadingStatus({ |
| 933 | thread, |
| 934 | snapshot, |
| 935 | }: { |
| 936 | thread: SidebarThreadSummary; |
| 937 | snapshot?: ThreadChangeRequestSnapshot | undefined; |
| 938 | }) { |
| 939 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); |
| 940 | // Observe the containing title even when this thread has no badge yet. |
| 941 | const statusRef = useCallback( |
| 942 | (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null), |
| 943 | [rowRef], |
| 944 | ); |
| 945 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 946 | const localLastVisitedAt = useUiStateStore( |
| 947 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| 948 | ); |
| 949 | const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); |
| 950 | const threadProject = useProject( |
| 951 | useMemo( |
| 952 | () => scopeProjectRef(thread.environmentId, thread.projectId), |
| 953 | [thread.environmentId, thread.projectId], |
| 954 | ), |
| 955 | ); |
| 956 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; |
| 957 | const gitCwd = thread.worktreePath ?? threadProjectCwd; |
| 958 | const linkedPullRequest = useLinkedThreadPullRequest( |
| 959 | thread.environmentId, |
| 960 | thread.linkedPullRequest, |
| 961 | leaseLiveStatus, |
| 962 | ); |
| 963 | const gitStatus = useEnvironmentQuery( |
| 964 | leaseLiveStatus && |
| 965 | thread.linkedPullRequest == null && |
| 966 | (thread.branch != null || thread.worktreePath !== null) && |
| 967 | gitCwd !== null |
| 968 | ? vcsEnvironment.status({ |
| 969 | environmentId: thread.environmentId, |
| 970 | input: { cwd: gitCwd, includeRemote: false }, |
| 971 | }) |
| 972 | : null, |
| 973 | ); |
| 974 | const visibleGitStatus = useRetainedValue( |
| 975 | JSON.stringify([thread.environmentId, gitCwd]), |
| 976 | gitStatus.data, |
| 977 | ); |
| 978 | const displayedPrInput = { |
| 979 | threadBranch: thread.branch, |
| 980 | gitStatus: visibleGitStatus, |
| 981 | snapshot, |
| 982 | retainTerminalOnBranchMismatch: thread.worktreePath === null, |
| 983 | linkedPullRequest: thread.linkedPullRequest, |
| 984 | linkedPullRequestStatus: linkedPullRequest, |
| 985 | }; |
| 986 | const pr = resolveDisplayedThreadPr(displayedPrInput); |
| 987 | const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); |
| 988 | const threadStatus = resolveThreadStatusPill({ |
| 989 | thread: { |
| 990 | ...thread, |
| 991 | lastVisitedAt, |
| 992 | }, |
| 993 | }); |
| 994 | |
| 995 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); |
| 996 | const pendingLink = |
| 997 | pr === null && supportsMultiplePullRequests |
| 998 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) |
| 999 | : null; |
| 1000 | |
| 1001 | return ( |
| 1002 | <span |
| 1003 | ref={statusRef} |
| 1004 | className={ |
| 1005 | prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents" |
| 1006 | } |
| 1007 | > |
| 1008 | {prStatus && pr ? ( |
| 1009 | <Tooltip> |
| 1010 | <TooltipTrigger |
| 1011 | render={ |
| 1012 | <span |
| 1013 | aria-label={prStatus.tooltip} |
| 1014 | className={`inline-flex items-center justify-center ${prStatus.colorClass}`} |
| 1015 | /> |
| 1016 | } |
| 1017 | > |
| 1018 | <ChangeRequestStatusIcon state={pr.state} isDraft={pr.isDraft} className="size-3" /> |
| 1019 | </TooltipTrigger> |
| 1020 | <TooltipPopup side="top"> |
| 1021 | <PrStatusTooltipContent status={prStatus} /> |
| 1022 | </TooltipPopup> |
| 1023 | </Tooltip> |
| 1024 | ) : null} |
| 1025 | {pendingLink ? ( |
| 1026 | <PullRequestGlyph.pullRequest |
| 1027 | className="size-3 text-muted-foreground" |
| 1028 | aria-label={`PR #${pendingLink.number}, status pending`} |
| 1029 | /> |
| 1030 | ) : null} |
| 1031 | {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null} |
| 1032 | </span> |
| 1033 | ); |
| 1034 | } |
| 1035 | |
| 1036 | /** |
| 1037 | * Non-interactive trailing status icons for a thread row in compact contexts |
| 1038 | * like the command palette. Shows a terminal-running indicator and a remote |
| 1039 | * environment indicator, matching the sidebar's trailing indicators. |
| 1040 | */ |
| 1041 | export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { |
| 1042 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1043 | environmentId: thread.environmentId, |
| 1044 | threadId: thread.id, |
| 1045 | }); |
| 1046 | const environment = useEnvironment(thread.environmentId); |
| 1047 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1048 | // No primary (the hosted app) means every thread is remote, and the machine |
| 1049 | // glyph is what tells the environments apart. |
| 1050 | const isRemoteThread = thread.environmentId !== primaryEnvironmentId; |
| 1051 | const remoteEnvLabel = environment?.label ?? null; |
| 1052 | const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; |
| 1053 | const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); |
| 1054 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 1055 | |
| 1056 | if (!terminalStatus && !isRemoteThread) { |
| 1057 | return null; |
| 1058 | } |
| 1059 | |
| 1060 | return ( |
| 1061 | <span className="inline-flex shrink-0 items-center gap-1.5"> |
| 1062 | {terminalStatus ? ( |
| 1063 | <Tooltip> |
| 1064 | <TooltipTrigger |
| 1065 | render={ |
| 1066 | <span |
| 1067 | role="img" |
| 1068 | aria-label={terminalStatus.label} |
| 1069 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} |
| 1070 | /> |
| 1071 | } |
| 1072 | > |
| 1073 | <TerminalIcon |
| 1074 | className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`} |
| 1075 | onAnimationStart={synchronizeTerminalPulse} |
| 1076 | /> |
| 1077 | </TooltipTrigger> |
| 1078 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 1079 | </Tooltip> |
| 1080 | ) : null} |
| 1081 | {isRemoteThread ? ( |
| 1082 | <Tooltip> |
| 1083 | <TooltipTrigger |
| 1084 | render={ |
| 1085 | <span |
| 1086 | aria-label={threadEnvironmentLabel ?? "Remote"} |
| 1087 | className="inline-flex items-center justify-center" |
| 1088 | /> |
| 1089 | } |
| 1090 | > |
| 1091 | <EnvironmentMachineIcon |
| 1092 | kind={remoteMachine} |
| 1093 | className="size-3 text-muted-foreground/60" |
| 1094 | /> |
| 1095 | </TooltipTrigger> |
| 1096 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> |
| 1097 | </Tooltip> |
| 1098 | ) : null} |
| 1099 | </span> |
| 1100 | ); |
| 1101 | } |
| 1102 | |
| 1103 | /** Number presentation for compact thread search results. */ |
| 1104 | function ThreadPullRequestNumber({ |
| 1105 | number, |
| 1106 | className, |
| 1107 | ...props |
| 1108 | }: ComponentProps<"span"> & { number: number }) { |
| 1109 | return ( |
| 1110 | <span {...props} className={cn("shrink-0 text-xs tabular-nums", className)}> |
| 1111 | #{number} |
| 1112 | </span> |
| 1113 | ); |
| 1114 | } |
| 1115 | |
| 1116 | function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"], isDraft = false): string { |
| 1117 | switch (state) { |
| 1118 | case "open": |
| 1119 | if (isDraft) { |
| 1120 | return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; |
| 1121 | } |
| 1122 | return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; |
| 1123 | case "merged": |
| 1124 | return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; |
| 1125 | case "closed": |
| 1126 | return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90"; |
| 1127 | } |
| 1128 | } |
| 1129 | |
| 1130 | export function ThreadSearchPullRequestNumber({ |
| 1131 | thread, |
| 1132 | query, |
| 1133 | enabled = true, |
| 1134 | settled = false, |
| 1135 | }: { |
| 1136 | thread: SidebarThreadSummary; |
| 1137 | query: string; |
| 1138 | enabled?: boolean; |
| 1139 | settled?: boolean; |
| 1140 | }) { |
| 1141 | const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => |
| 1142 | pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), |
| 1143 | ); |
| 1144 | const fallbackCandidates = |
| 1145 | thread.pullRequests.length === 0 |
| 1146 | ? [thread.linkedPullRequest, thread.branchPullRequest] |
| 1147 | : [thread.branchPullRequest]; |
| 1148 | const legacyReference = matchedLink |
| 1149 | ? undefined |
| 1150 | : fallbackCandidates.find( |
| 1151 | (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), |
| 1152 | ); |
| 1153 | const legacyStatus = useLinkedThreadPullRequest( |
| 1154 | thread.environmentId, |
| 1155 | legacyReference, |
| 1156 | enabled, |
| 1157 | undefined, |
| 1158 | legacyReference, |
| 1159 | ); |
| 1160 | const reference = matchedLink ?? legacyReference; |
| 1161 | const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; |
| 1162 | const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); |
| 1163 | return reference ? ( |
| 1164 | <ThreadPullRequestNumber |
| 1165 | number={reference.number} |
| 1166 | className={ |
| 1167 | settled |
| 1168 | ? cn( |
| 1169 | "text-secondary-label transition-colors", |
| 1170 | linked?.pr && settledPrHoverColorClass(linked.pr.state, linked.pr.isDraft), |
| 1171 | ) |
| 1172 | : (status?.colorClass ?? "text-secondary-label") |
| 1173 | } |
| 1174 | aria-label={status?.tooltip ?? `Pull request #${reference.number}`} |
| 1175 | /> |
| 1176 | ) : null; |
| 1177 | } |