import { RefreshIcon } from "~/components/ui/refresh-icon";import { Spinner } from "~/components/ui/spinner";import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";import type { EnvironmentId, ProjectId, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListInput, PullRequestListResult, PullRequestListState, SourceControlProviderKind,} from "@t3tools/contracts";import { useAtomValue } from "@effect/atom-react";import { createFileRoute, useNavigate } from "@tanstack/react-router";import { ArrowDownUpIcon, CalendarArrowDownIcon, CalendarArrowUpIcon, ChevronDownIcon, ClockIcon, EyeIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, LayersIcon, ListChecksIcon, PenLineIcon, Maximize2Icon, Minimize2Icon, SearchIcon,} from "lucide-react";import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode, type RefObject,} from "react"; import { filterPullRequestsByInvolvement, findScopedProject, collectPullRequestListFacets, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, parsePullRequestQuery, narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, pullRequestDiffStatKey, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, sortPullRequestGroups, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, pullRequestStatsRefreshBatches, pullRequestStatsRequestBatches, retainVisiblePullRequestStatsBatches, type EnvironmentPullRequestEntry, type MergedPullRequestList, type PullRequestDiffStats, type PullRequestStatsBatch, type PullRequestStatsPolicy, type PullRequestStatsScope, type PullRequestPartitionsSnapshot,} from "../components/pullRequest/pullRequestList.logic";import { pullRequestListPreferences, type PullRequestListPreferencePatch, type PullRequestListPreferences, type PullRequestListSort, writePullRequestListPreferences,} from "../components/pullRequest/pullRequestListPreferences";import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic";import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";import { PullRequestFiltersMenu, PullRequestFilterOptionIcon, PullRequestSearchInput, pullRequestHostLabel, pullRequestProjectKey, type PullRequestExpectedHost, type PullRequestFilterOption,} from "../components/pullRequest/PullRequestListFilters";import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState";import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts";import { PullRequestRow, type PullRequestRowTarget,} from "../components/pullRequest/PullRequestRow";import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs";import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator,} from "../components/WorkspaceBreadcrumb";import { WorkspacePageContainer } from "../components/WorkspacePageContainer";import { WorkspacePageHeader } from "../components/WorkspacePageHeader";import { isCommandPaletteOpen } from "../commandPaletteBus";import { isElectron } from "../env";import { resolveShortcutCommand } from "../keybindings";import { isTerminalFocused } from "../lib/terminalFocus";import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";import { Button } from "../components/ui/button";import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";import { SidebarInset } from "../components/ui/sidebar";import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";import { useLiveRefresh } from "../hooks/useLiveRefresh";import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";import { writeTextToClipboard } from "../hooks/useCopyToClipboard";import { toastManager } from "../components/ui/toast";import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";import { PULL_REQUESTS_PANEL_REF, pullRequestSurfaceId, selectActiveRightPanelSurface, selectSelectedRightPanelSurface, selectThreadRightPanelState, useRightPanelStore, type PullRequestSurface,} from "../rightPanelStore";import { useDebouncedValue } from "../state/queries";import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";import { useEnvironments } from "../state/environments";import { pullRequestEnvironment, usePullRequestList, usePullRequestListStats, usePullRequestTurnRefreshes, type EnvironmentQueryTarget,} from "../state/pullRequests";import { useAtomCommand } from "../state/use-atom-command";import { cn } from "~/lib/utils";import { primaryServerKeybindingsAtom } from "~/state/server";import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; export interface PullRequestsSearch extends PullRequestListPreferences { /** * Narrows the list to one server. Absent means every connected one, which is the default the * page has now — so a link written before servers could be chosen still opens the whole list. */ readonly environmentId?: EnvironmentId; /** Scopes the list. Separate from the selection so one cannot silently change the other. */ readonly projectId?: ProjectId; /** * Narrows the list to one host, named as the host itself: two GitHub installs are two * accounts, and their shared provider kind cannot tell them apart. Absent means every host. */ readonly host?: string; readonly repository?: string; readonly number?: number; readonly selectedProjectId?: ProjectId; /** Host of the open review; changing tabs must not narrow the list host filter. */ readonly selectedHost?: string; /** * Which server the selected pull request was read from. A project id only names a project on * its own server, so this is what tells two servers holding one project apart. Optional: a * link without it still opens, resolved by project id alone where that is unambiguous. */ readonly selectedEnvironmentId?: EnvironmentId;} // The state filters wear the same glyphs the rows do, so the two read as one vocabulary.const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, { value: "reviewing", label: "Reviewing", Icon: EyeIcon }, { value: "authored", label: "Authored", Icon: PenLineIcon },] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestInvolvement>>; const STATE_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, { value: "open", label: "Open", Icon: GitPullRequestIcon }, { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon }, { value: "merged", label: "Merged", Icon: GitMergeIcon },] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>; const SORT_OPTIONS = [ { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, { value: "updated", label: "Recently updated", Icon: ClockIcon }, { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon },] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListSort>>; /** Long enough that a keystroke does not become a request, short enough to feel answered. */const SEARCH_DEBOUNCE_MS = 250;/** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */const MATCHED_ELSEWHERE_SCORE = 10;/** * One whole page from the host and no more: every provider asks for one row beyond the page as * its "is there more" probe, and GitHub serves a hundred per request — so asking for ninety-nine * costs one round trip where a hundred costs two. */const PAGE_SIZE = 99;/** The largest page the listing accepts; past it the request is refused outright. */const MAX_PAGE_SIZE = 500;/** Stable empty map so the memos below do not see a new object on every render. */const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};/** Stable so a read that is not wanted right now does not re-key on every render. */const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];const EMPTY_PREVIEW_SESSIONS = {};const EMPTY_PREVIEW_DESKTOP_STATE = {};const EMPTY_TERMINAL_LABELS = new Map<string, string>();const EMPTY_PENDING_SURFACES = new Set<string>();const MAX_SEARCH_LABEL_CANDIDATES = 100; const pullRequestListEntryId = (target: Parameters<typeof pullRequestSurfaceId>[0]) => pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( 0, MAX_SEARCH_LABEL_CANDIDATES, ); const labels: Array<string> = []; const seen = new Set<string>(); for (const rawValue of values) { if (typeof rawValue !== "string") continue; const value = rawValue.trim().slice(0, 200); const key = value.toLowerCase(); if (value.length === 0 || seen.has(key)) continue; labels.push(value); seen.add(key); if (labels.length === 10) break; } return labels.length === 0 ? {} : { labels };} export const Route = createFileRoute("/_chat/pull-requests")({ validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ involvement: raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open", ...(SORT_OPTIONS.some((option) => option.value === raw.sort) ? { sort: raw.sort as PullRequestListSort } : {}), ...(typeof raw.repository === "string" && raw.repository ? { repository: raw.repository.slice(0, 200) } : {}), ...(typeof raw.number === "number" && Number.isInteger(raw.number) && raw.number > 0 ? { number: raw.number } : {}), ...(typeof raw.projectId === "string" && raw.projectId ? { projectId: raw.projectId as ProjectId } : {}), ...(typeof raw.environmentId === "string" && raw.environmentId ? { environmentId: raw.environmentId as EnvironmentId } : {}), ...(typeof raw.host === "string" && raw.host ? { host: raw.host.slice(0, 200) } : {}), ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId ? { selectedProjectId: raw.selectedProjectId as ProjectId } : {}), ...(typeof raw.selectedHost === "string" && raw.selectedHost ? { selectedHost: raw.selectedHost.slice(0, 200) } : {}), ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } : {}), ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}), ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}), ...(raw.review === "approved" || raw.review === "changes-requested" || raw.review === "review-required" || raw.review === "none" ? { review: raw.review } : {}), ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}), ...(typeof raw.author === "string" && raw.author.trim() ? { author: raw.author.trim().slice(0, 200) } : {}), ...pullRequestSearchLabels(raw.labels), }), component: PullRequestsRouteView,}); function PullRequestsRouteView() { const search = Route.useSearch(); const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; const navigate = useNavigate({ from: Route.fullPath }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); // Every connected environment that has said it can list pull requests. Sorted, so the query // keys, the scope key and the stored snapshot all read the same whichever order the // connections happened to come up in. const capableEnvironments = useMemo( () => environments .filter( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, ) .toSorted((left, right) => left.environmentId.localeCompare(right.environmentId)), [environments], ); // The server the URL asks for, kept only while it is one the page could read: a link naming a // server this workspace no longer has falls back to all of them rather than to nothing. const scopedEnvironmentId = capableEnvironments.find((environment) => environment.environmentId === search.environmentId) ?.environmentId ?? null; // Every server this workspace has ever heard of, connecting or not — wider than // `capableEnvironments`, which only holds the ones ready to answer. const knownEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); const environmentIds = useMemo( () => capableEnvironments .filter( (environment) => scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId, ) .map((environment) => environment.environmentId), [capableEnvironments, scopedEnvironmentId], ); const environmentKey = useMemo( () => pullRequestEnvironmentSetKey(environmentIds), [environmentIds], ); // An environment may still be connecting, or may predate this feature. Until at least one has // reported, an empty set means "not known yet" rather than "no environment can", and the page // waits rather than telling a reader to upgrade a server that has not spoken. const capabilityKnown = environments.some((environment) => environment.serverConfig !== null); const pullRequestsSupported = environmentIds.length > 0; const allProjects = useProjects(); // Whether the workspace has said what it holds yet. Until it has, an empty project list is // "not loaded" rather than "none", and telling a reader to add a project they already have is // the one wrong answer the empty state can give. const projectsKnown = useAllEnvironmentShellsBootstrapped(); // Only the projects the page can actually read: one on an environment that cannot list pull // requests could neither be listed nor acted on. const projects = useMemo( () => allProjects.filter((project) => environmentIds.includes(project.environmentId)), [allProjects, environmentIds], ); const environmentLabels = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); // The scope the URL asks for, once the environments have had their say about whether it exists. const scopedProjectId = useMemo( () => resolveProjectScope(search.projectId, projects, projectsKnown), [projects, projectsKnown, search.projectId], ); const scopedProject = useMemo( () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), [projects, scopedEnvironmentId, scopedProjectId], ); const scopedProjects = useMemo( () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), [environmentLabels, projects, scopedProject], ); // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. const selectedHost = search.selectedHost ?? search.host; const projectIdForRepository = useMemo(() => { const repository = search.repository?.toLowerCase(); if (repository === undefined) return undefined; const identity = projects.find( (project) => project.repositoryIdentity?.owner && project.repositoryIdentity.name && `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() === repository && // The same `owner/name` can exist on two hosts. Without this the first match wins, and // a link that named its host opens the pull request from the other one. (selectedHost === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, ) === selectedHost.toLowerCase()), ); return identity?.id; }, [projects, selectedHost, search.repository]); // The selection is resolved the same way the scope is: an id no connected environment has can // never be read here, and one that arrived before the projects did is not yet wrong. const linkedProjectId = useMemo( () => resolveProjectScope(search.selectedProjectId, projects, projectsKnown), [projects, projectsKnown, search.selectedProjectId], ); // The scope filter stands in as a last resort: a link can carry `projectId` with a repository // whose identity the inference above cannot match, and refusing to open it because of the // weaker signal would ignore the stronger one the URL spelled out. const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId; // Which server the selection belongs to. Named by the URL where a link had one to give; // otherwise the project id decides, and only where exactly one server has that project. // A named server still connecting is kept named rather than falling back: falling back while // it is merely not ready yet would resolve the project against whatever other server has // answered, which can be the wrong one where two servers share a project id. Only a name this // workspace has never heard of falls back to the scope above, the same way that scope treats a // stale name — a saved link naming a server that has since gone would otherwise open nothing, // even where the project id it also carries names exactly one remaining server. const selectedEnvironmentId = resolveSelectedEnvironmentId( search.selectedEnvironmentId, knownEnvironmentIds, scopedEnvironmentId, ); const selectedProject = useMemo( () => findScopedProject(projects, selectedEnvironmentId, selectedProjectId), [projects, selectedEnvironmentId, selectedProjectId], ); // One panel for the page rather than one per server: the surfaces carry the server they were // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab // strip survives a capable server disconnecting or losing the pull-requests capability. const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, rightPanelRef), ); const selectedRightPanelSurface = useRightPanelStore((state) => selectSelectedRightPanelSurface(state.byThreadKey, rightPanelRef), ); const selectedPullRequestSurface = selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null; const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const rightPanelPresenceValue = useMemo( () => ({ activeSurface: selectedPullRequestSurface, surfaces: rightPanelState.surfaces, }), [rightPanelState.surfaces, selectedPullRequestSurface], ); const rightPanelPresence = usePanelPresence( rightPanelState.isOpen && selectedPullRequestSurface !== null, rightPanelPresenceValue, panelAnimationsActive, rightPanelRef?.threadId ?? null, panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; const renderedPullRequestSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; // The open tab names its own server; a link that arrived before any tab was opened names it // through the project it selected. const panelEnvironmentId = (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ?? selectedProject?.environmentId ?? null; const updateSearch = useCallback( (patch: { [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; }) => void navigate({ // Rebuilt rather than spread so a cleared field leaves the URL instead of // lingering as an explicit `undefined`. search: (previous: PullRequestsSearch): PullRequestsSearch => { const next = { ...previous, ...patch }; return { involvement: next.involvement ?? previous.involvement, state: next.state ?? previous.state, ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), ...(next.repository ? { repository: next.repository } : {}), ...(next.number ? { number: next.number } : {}), ...(next.projectId ? { projectId: next.projectId } : {}), ...(next.environmentId ? { environmentId: next.environmentId } : {}), ...(next.host ? { host: next.host } : {}), ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), ...(next.selectedEnvironmentId ? { selectedEnvironmentId: next.selectedEnvironmentId } : {}), ...(next.q ? { q: next.q } : {}), ...(next.draft ? { draft: next.draft } : {}), ...(next.review ? { review: next.review } : {}), ...(next.checks ? { checks: next.checks } : {}), ...(next.author ? { author: next.author } : {}), ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), }; }, replace: true, }), [navigate], );Lines 1–500 of 2408. Load more
import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListInput, PullRequestListResult, PullRequestListState, SourceControlProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { ArrowDownUpIcon, CalendarArrowDownIcon, CalendarArrowUpIcon, ChevronDownIcon, ClockIcon, EyeIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, LayersIcon, ListChecksIcon, PenLineIcon, Maximize2Icon, Minimize2Icon, SearchIcon, } from "lucide-react"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode, type RefObject, } from "react"; import { filterPullRequestsByInvolvement, findScopedProject, collectPullRequestListFacets, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, parsePullRequestQuery, narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, pullRequestDiffStatKey, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, sortPullRequestGroups, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, pullRequestStatsRefreshBatches, pullRequestStatsRequestBatches, retainVisiblePullRequestStatsBatches, type EnvironmentPullRequestEntry, type MergedPullRequestList, type PullRequestDiffStats, type PullRequestStatsBatch, type PullRequestStatsPolicy, type PullRequestStatsScope, type PullRequestPartitionsSnapshot, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, type PullRequestListPreferencePatch, type PullRequestListPreferences, type PullRequestListSort, writePullRequestListPreferences, } from "../components/pullRequest/pullRequestListPreferences"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, PullRequestFilterOptionIcon, PullRequestSearchInput, pullRequestHostLabel, pullRequestProjectKey, type PullRequestExpectedHost, type PullRequestFilterOption, } from "../components/pullRequest/PullRequestListFilters"; import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; import { PullRequestRow, type PullRequestRowTarget, } from "../components/pullRequest/PullRequestRow"; import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../components/WorkspacePageContainer"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; import { resolveShortcutCommand } from "../keybindings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { toastManager } from "../components/ui/toast"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { PULL_REQUESTS_PANEL_REF, pullRequestSurfaceId, selectActiveRightPanelSurface, selectSelectedRightPanelSurface, selectThreadRightPanelState, useRightPanelStore, type PullRequestSurface, } from "../rightPanelStore"; import { useDebouncedValue } from "../state/queries"; import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; import { useEnvironments } from "../state/environments"; import { pullRequestEnvironment, usePullRequestList, usePullRequestListStats, usePullRequestTurnRefreshes, type EnvironmentQueryTarget, } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; export interface PullRequestsSearch extends PullRequestListPreferences { /** * Narrows the list to one server. Absent means every connected one, which is the default the * page has now — so a link written before servers could be chosen still opens the whole list. */ readonly environmentId?: EnvironmentId; /** Scopes the list. Separate from the selection so one cannot silently change the other. */ readonly projectId?: ProjectId; /** * Narrows the list to one host, named as the host itself: two GitHub installs are two * accounts, and their shared provider kind cannot tell them apart. Absent means every host. */ readonly host?: string; readonly repository?: string; readonly number?: number; readonly selectedProjectId?: ProjectId; /** Host of the open review; changing tabs must not narrow the list host filter. */ readonly selectedHost?: string; /** * Which server the selected pull request was read from. A project id only names a project on * its own server, so this is what tells two servers holding one project apart. Optional: a * link without it still opens, resolved by project id alone where that is unambiguous. */ readonly selectedEnvironmentId?: EnvironmentId; } // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, { value: "reviewing", label: "Reviewing", Icon: EyeIcon }, { value: "authored", label: "Authored", Icon: PenLineIcon }, ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestInvolvement>>; const STATE_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, { value: "open", label: "Open", Icon: GitPullRequestIcon }, { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon }, { value: "merged", label: "Merged", Icon: GitMergeIcon }, ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>; const SORT_OPTIONS = [ { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, { value: "updated", label: "Recently updated", Icon: ClockIcon }, { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon }, ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListSort>>; /** Long enough that a keystroke does not become a request, short enough to feel answered. */ const SEARCH_DEBOUNCE_MS = 250; /** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */ const MATCHED_ELSEWHERE_SCORE = 10; /** * One whole page from the host and no more: every provider asks for one row beyond the page as * its "is there more" probe, and GitHub serves a hundred per request — so asking for ninety-nine * costs one round trip where a hundred costs two. */ const PAGE_SIZE = 99; /** The largest page the listing accepts; past it the request is refused outright. */ const MAX_PAGE_SIZE = 500; /** Stable empty map so the memos below do not see a new object on every render. */ const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; /** Stable so a read that is not wanted right now does not re-key on every render. */ const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map<string, string>(); const EMPTY_PENDING_SURFACES = new Set<string>(); const MAX_SEARCH_LABEL_CANDIDATES = 100; const pullRequestListEntryId = (target: Parameters<typeof pullRequestSurfaceId>[0]) => pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( 0, MAX_SEARCH_LABEL_CANDIDATES, ); const labels: Array<string> = []; const seen = new Set<string>(); for (const rawValue of values) { if (typeof rawValue !== "string") continue; const value = rawValue.trim().slice(0, 200); const key = value.toLowerCase(); if (value.length === 0 || seen.has(key)) continue; labels.push(value); seen.add(key); if (labels.length === 10) break; } return labels.length === 0 ? {} : { labels }; } export const Route = createFileRoute("/_chat/pull-requests")({ validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ involvement: raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open", ...(SORT_OPTIONS.some((option) => option.value === raw.sort) ? { sort: raw.sort as PullRequestListSort } : {}), ...(typeof raw.repository === "string" && raw.repository ? { repository: raw.repository.slice(0, 200) } : {}), ...(typeof raw.number === "number" && Number.isInteger(raw.number) && raw.number > 0 ? { number: raw.number } : {}), ...(typeof raw.projectId === "string" && raw.projectId ? { projectId: raw.projectId as ProjectId } : {}), ...(typeof raw.environmentId === "string" && raw.environmentId ? { environmentId: raw.environmentId as EnvironmentId } : {}), ...(typeof raw.host === "string" && raw.host ? { host: raw.host.slice(0, 200) } : {}), ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId ? { selectedProjectId: raw.selectedProjectId as ProjectId } : {}), ...(typeof raw.selectedHost === "string" && raw.selectedHost ? { selectedHost: raw.selectedHost.slice(0, 200) } : {}), ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } : {}), ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}), ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}), ...(raw.review === "approved" || raw.review === "changes-requested" || raw.review === "review-required" || raw.review === "none" ? { review: raw.review } : {}), ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}), ...(typeof raw.author === "string" && raw.author.trim() ? { author: raw.author.trim().slice(0, 200) } : {}), ...pullRequestSearchLabels(raw.labels), }), component: PullRequestsRouteView, }); function PullRequestsRouteView() { const search = Route.useSearch(); const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; const navigate = useNavigate({ from: Route.fullPath }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); // Every connected environment that has said it can list pull requests. Sorted, so the query // keys, the scope key and the stored snapshot all read the same whichever order the // connections happened to come up in. const capableEnvironments = useMemo( () => environments .filter( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, ) .toSorted((left, right) => left.environmentId.localeCompare(right.environmentId)), [environments], ); // The server the URL asks for, kept only while it is one the page could read: a link naming a // server this workspace no longer has falls back to all of them rather than to nothing. const scopedEnvironmentId = capableEnvironments.find((environment) => environment.environmentId === search.environmentId) ?.environmentId ?? null; // Every server this workspace has ever heard of, connecting or not — wider than // `capableEnvironments`, which only holds the ones ready to answer. const knownEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); const environmentIds = useMemo( () => capableEnvironments .filter( (environment) => scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId, ) .map((environment) => environment.environmentId), [capableEnvironments, scopedEnvironmentId], ); const environmentKey = useMemo( () => pullRequestEnvironmentSetKey(environmentIds), [environmentIds], ); // An environment may still be connecting, or may predate this feature. Until at least one has // reported, an empty set means "not known yet" rather than "no environment can", and the page // waits rather than telling a reader to upgrade a server that has not spoken. const capabilityKnown = environments.some((environment) => environment.serverConfig !== null); const pullRequestsSupported = environmentIds.length > 0; const allProjects = useProjects(); // Whether the workspace has said what it holds yet. Until it has, an empty project list is // "not loaded" rather than "none", and telling a reader to add a project they already have is // the one wrong answer the empty state can give. const projectsKnown = useAllEnvironmentShellsBootstrapped(); // Only the projects the page can actually read: one on an environment that cannot list pull // requests could neither be listed nor acted on. const projects = useMemo( () => allProjects.filter((project) => environmentIds.includes(project.environmentId)), [allProjects, environmentIds], ); const environmentLabels = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); // The scope the URL asks for, once the environments have had their say about whether it exists. const scopedProjectId = useMemo( () => resolveProjectScope(search.projectId, projects, projectsKnown), [projects, projectsKnown, search.projectId], ); const scopedProject = useMemo( () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), [projects, scopedEnvironmentId, scopedProjectId], ); const scopedProjects = useMemo( () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), [environmentLabels, projects, scopedProject], ); // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. const selectedHost = search.selectedHost ?? search.host; const projectIdForRepository = useMemo(() => { const repository = search.repository?.toLowerCase(); if (repository === undefined) return undefined; const identity = projects.find( (project) => project.repositoryIdentity?.owner && project.repositoryIdentity.name && `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() === repository && // The same `owner/name` can exist on two hosts. Without this the first match wins, and // a link that named its host opens the pull request from the other one. (selectedHost === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, ) === selectedHost.toLowerCase()), ); return identity?.id; }, [projects, selectedHost, search.repository]); // The selection is resolved the same way the scope is: an id no connected environment has can // never be read here, and one that arrived before the projects did is not yet wrong. const linkedProjectId = useMemo( () => resolveProjectScope(search.selectedProjectId, projects, projectsKnown), [projects, projectsKnown, search.selectedProjectId], ); // The scope filter stands in as a last resort: a link can carry `projectId` with a repository // whose identity the inference above cannot match, and refusing to open it because of the // weaker signal would ignore the stronger one the URL spelled out. const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId; // Which server the selection belongs to. Named by the URL where a link had one to give; // otherwise the project id decides, and only where exactly one server has that project. // A named server still connecting is kept named rather than falling back: falling back while // it is merely not ready yet would resolve the project against whatever other server has // answered, which can be the wrong one where two servers share a project id. Only a name this // workspace has never heard of falls back to the scope above, the same way that scope treats a // stale name — a saved link naming a server that has since gone would otherwise open nothing, // even where the project id it also carries names exactly one remaining server. const selectedEnvironmentId = resolveSelectedEnvironmentId( search.selectedEnvironmentId, knownEnvironmentIds, scopedEnvironmentId, ); const selectedProject = useMemo( () => findScopedProject(projects, selectedEnvironmentId, selectedProjectId), [projects, selectedEnvironmentId, selectedProjectId], ); // One panel for the page rather than one per server: the surfaces carry the server they were // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab // strip survives a capable server disconnecting or losing the pull-requests capability. const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, rightPanelRef), ); const selectedRightPanelSurface = useRightPanelStore((state) => selectSelectedRightPanelSurface(state.byThreadKey, rightPanelRef), ); const selectedPullRequestSurface = selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null; const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const rightPanelPresenceValue = useMemo( () => ({ activeSurface: selectedPullRequestSurface, surfaces: rightPanelState.surfaces, }), [rightPanelState.surfaces, selectedPullRequestSurface], ); const rightPanelPresence = usePanelPresence( rightPanelState.isOpen && selectedPullRequestSurface !== null, rightPanelPresenceValue, panelAnimationsActive, rightPanelRef?.threadId ?? null, panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; const renderedPullRequestSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; // The open tab names its own server; a link that arrived before any tab was opened names it // through the project it selected. const panelEnvironmentId = (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ?? selectedProject?.environmentId ?? null; const updateSearch = useCallback( (patch: { [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; }) => void navigate({ // Rebuilt rather than spread so a cleared field leaves the URL instead of // lingering as an explicit `undefined`. search: (previous: PullRequestsSearch): PullRequestsSearch => { const next = { ...previous, ...patch }; return { involvement: next.involvement ?? previous.involvement, state: next.state ?? previous.state, ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), ...(next.repository ? { repository: next.repository } : {}), ...(next.number ? { number: next.number } : {}), ...(next.projectId ? { projectId: next.projectId } : {}), ...(next.environmentId ? { environmentId: next.environmentId } : {}), ...(next.host ? { host: next.host } : {}), ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), ...(next.selectedEnvironmentId ? { selectedEnvironmentId: next.selectedEnvironmentId } : {}), ...(next.q ? { q: next.q } : {}), ...(next.draft ? { draft: next.draft } : {}), ...(next.review ? { review: next.review } : {}), ...(next.checks ? { checks: next.checks } : {}), ...(next.author ? { author: next.author } : {}), ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), }; }, replace: true, }), [navigate], );