import { scopeThreadRef } from "@t3tools/client-runtime/environment";import { pullRequestHostOf, ThreadId } from "@t3tools/contracts";import type { EnvironmentId, ProjectId, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListInput, PullRequestListResult, PullRequestListState, SourceControlProviderKind,} from "@t3tools/contracts";import { createFileRoute, useNavigate } from "@tanstack/react-router";import { ChevronDownIcon, EyeIcon, MonitorIcon, ServerIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, LayersIcon, PenLineIcon, LoaderIcon, RefreshCwIcon, SearchIcon,} from "lucide-react";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { filterPullRequestsByInvolvement, findScopedProject, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, parsePullRequestQuery, narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, type EnvironmentPullRequestEntry, type MergedPullRequestList, type PullRequestDiffStats, type PullRequestPartitionsSnapshot,} from "../components/pullRequest/pullRequestList.logic";import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";import { PullRequestFiltersMenu, 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 } from "../components/pullRequest/PullRequestRow";import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";import { RightPanelTabs, type PullRequestTabStatus } from "../components/RightPanelTabs";import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator,} from "../components/WorkspaceBreadcrumb";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 { selectActiveRightPanelSurface, selectSelectedRightPanelSurface, selectThreadRightPanelState, updatePullRequestTabStatus, 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, type EnvironmentQueryTarget,} from "../state/pullRequests";import { useAtomCommand } from "../state/use-atom-command";import { cn } from "~/lib/utils";import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; readonly state: PullRequestListState; /** * 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; /** * 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; readonly q?: string; /** * The narrowings beyond state and involvement, each absent when that group is unfiltered. Flat * in the URL because a link is read and edited by hand; folded into one record for the listing. */ readonly draft?: "only" | "hide"; readonly review?: NonNullable<PullRequestListFilters["review"]>; readonly checks?: NonNullable<PullRequestListFilters["checks"]>;} // 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>>; /** 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"] = {};/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel");/** * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each * surface already carries the server it was read from), so its store key must not move when a * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so * this string can never collide with one. */const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId;/** 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>(); 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", ...(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.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 } : {}), }), component: PullRequestsRouteView,}); function PullRequestsRouteView() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); 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], ); const scopedProjects = useMemo(() => { // Two machines can hold the same repository, so a title the workspace carries twice is told // apart by the environment it lives on rather than left as two identical rows. const titleCounts = new Map<string, number>(); for (const project of projects) { titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); } return projects .map((project) => ({ id: project.id, environmentId: project.environmentId, title: (titleCounts.get(project.title) ?? 0) > 1 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` : project.title, workspaceRoot: project.workspaceRoot, })) .toSorted((left, right) => left.title.localeCompare(right.title)); }, [environmentLabels, projects]); // 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], ); // 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 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. (search.host === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, ) === search.host.toLowerCase()), ); return identity?.id; }, [projects, search.host, 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 = useMemo( () => capableEnvironments.length === 0 ? null : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID), [capableEnvironments.length], ); 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; // 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 = (activePullRequestSurface?.environmentId as EnvironmentId | undefined) ?? selectedProject?.environmentId ?? null; const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< Record<string, PullRequestTabStatus> >({}); // Keyed by the surface the panel is showing rather than by a key rebuilt from the status: a // surface opened from this page carries the environment its row was listed under, and a key // assembled from the pull request alone would never name that surface back. const activePullRequestSurfaceId = activePullRequestSurface?.id; const handlePullRequestTabStatusChange = useCallback( (status: PullRequestTabStatus) => { const id = activePullRequestSurfaceId; if (id === undefined) return; setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); }, [activePullRequestSurfaceId], ); 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.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.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 } : {}), }; }, replace: true, }), [navigate], ); // Changing what the list contains must not leave a selection from the previous view open. // The project filter is untouched: it is the user's scope, not part of the selection. const clearedSelection = { repository: undefined, number: undefined, selectedProjectId: undefined, selectedEnvironmentId: undefined, }; const updateListScope = (patch: { [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; }) => { if (rightPanelRef !== null) { // Hide the old selection while retaining peer PR tabs for parallel reviews. useRightPanelStore.getState().close(rightPanelRef); } updateSearch({ ...patch, ...clearedSelection }); }; // Searching asks the hosts, which takes a round trip, so the text is held for a moment before // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is // late but the page is not. const typedQuery = (search.q ?? "").trim(); const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); const querySettled = typedQuery === sentQuery; // What was typed, split into the qualifiers the hosts can act on and the words that are left. // The URL keeps the line as it was written; only the request sees the two halves. const typedParsed = useMemo(() => parsePullRequestQuery(typedQuery), [typedQuery]); const sentParsed = useMemo(() => parsePullRequestQuery(sentQuery), [sentQuery]); // One record for the hosts, rebuilt only when the URL's own fields change so the listing // inputs stay identical between renders. const menuFilters = useMemo( (): PullRequestListFilters => ({ ...(search.draft ? { draft: search.draft } : {}), ...(search.review ? { review: search.review } : {}), ...(search.checks ? { checks: search.checks } : {}), }), [search.checks, search.draft, search.review], ); const menuFiltered = Object.keys(menuFilters).length > 0; // A typed qualifier wins over the menu's own answer for the same thing, since it is the more // recent word on it; labels only ever come from the query, so there is nothing to overrule. const filters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }), [menuFilters, sentParsed.filters], ); const hasFilters = Object.keys(filters).length > 0; /** The same narrowings the moment they are typed, for the rows already on screen. */ const localFilters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...typedParsed.filters }), [menuFilters, typedParsed.filters], ); const hasLocalFilters = Object.keys(localFilters).length > 0; // Scoping to a project scopes to the server that owns it, saving every other server a read // that could only answer with nothing. Where an id is ambiguous — two servers holding the // same project id, with no server named — only the servers that actually hold that id are // asked: a server without it may still hold an unrelated project of its own under the same // string, and asking it would return that project's rows rather than an honest empty answer. const queryEnvironmentIds = useMemo( () => resolveQueryEnvironmentIds( environmentIds, projects, scopedProject,Lines 1–500 of 1938. Load more
import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestHostOf, ThreadId } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListInput, PullRequestListResult, PullRequestListState, SourceControlProviderKind, } from "@t3tools/contracts"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { ChevronDownIcon, EyeIcon, MonitorIcon, ServerIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon, LayersIcon, PenLineIcon, LoaderIcon, RefreshCwIcon, SearchIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { filterPullRequestsByInvolvement, findScopedProject, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, parsePullRequestQuery, narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, type EnvironmentPullRequestEntry, type MergedPullRequestList, type PullRequestDiffStats, type PullRequestPartitionsSnapshot, } from "../components/pullRequest/pullRequestList.logic"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, 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 } from "../components/pullRequest/PullRequestRow"; import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatus } from "../components/RightPanelTabs"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; 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 { selectActiveRightPanelSurface, selectSelectedRightPanelSurface, selectThreadRightPanelState, updatePullRequestTabStatus, 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, type EnvironmentQueryTarget, } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; readonly state: PullRequestListState; /** * 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; /** * 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; readonly q?: string; /** * The narrowings beyond state and involvement, each absent when that group is unfiltered. Flat * in the URL because a link is read and edited by hand; folded into one record for the listing. */ readonly draft?: "only" | "hide"; readonly review?: NonNullable<PullRequestListFilters["review"]>; readonly checks?: NonNullable<PullRequestListFilters["checks"]>; } // 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>>; /** 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"] = {}; /** The list owns one environment-scoped right panel rather than borrowing a real thread's. */ const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel"); /** * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each * surface already carries the server it was read from), so its store key must not move when a * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so * this string can never collide with one. */ const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId; /** 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>(); 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", ...(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.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 } : {}), }), component: PullRequestsRouteView, }); function PullRequestsRouteView() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); 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], ); const scopedProjects = useMemo(() => { // Two machines can hold the same repository, so a title the workspace carries twice is told // apart by the environment it lives on rather than left as two identical rows. const titleCounts = new Map<string, number>(); for (const project of projects) { titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); } return projects .map((project) => ({ id: project.id, environmentId: project.environmentId, title: (titleCounts.get(project.title) ?? 0) > 1 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` : project.title, workspaceRoot: project.workspaceRoot, })) .toSorted((left, right) => left.title.localeCompare(right.title)); }, [environmentLabels, projects]); // 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], ); // 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 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. (search.host === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, ) === search.host.toLowerCase()), ); return identity?.id; }, [projects, search.host, 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 = useMemo( () => capableEnvironments.length === 0 ? null : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID), [capableEnvironments.length], ); 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; // 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 = (activePullRequestSurface?.environmentId as EnvironmentId | undefined) ?? selectedProject?.environmentId ?? null; const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< Record<string, PullRequestTabStatus> >({}); // Keyed by the surface the panel is showing rather than by a key rebuilt from the status: a // surface opened from this page carries the environment its row was listed under, and a key // assembled from the pull request alone would never name that surface back. const activePullRequestSurfaceId = activePullRequestSurface?.id; const handlePullRequestTabStatusChange = useCallback( (status: PullRequestTabStatus) => { const id = activePullRequestSurfaceId; if (id === undefined) return; setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); }, [activePullRequestSurfaceId], ); 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.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.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 } : {}), }; }, replace: true, }), [navigate], ); // Changing what the list contains must not leave a selection from the previous view open. // The project filter is untouched: it is the user's scope, not part of the selection. const clearedSelection = { repository: undefined, number: undefined, selectedProjectId: undefined, selectedEnvironmentId: undefined, }; const updateListScope = (patch: { [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; }) => { if (rightPanelRef !== null) { // Hide the old selection while retaining peer PR tabs for parallel reviews. useRightPanelStore.getState().close(rightPanelRef); } updateSearch({ ...patch, ...clearedSelection }); }; // Searching asks the hosts, which takes a round trip, so the text is held for a moment before // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is // late but the page is not. const typedQuery = (search.q ?? "").trim(); const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); const querySettled = typedQuery === sentQuery; // What was typed, split into the qualifiers the hosts can act on and the words that are left. // The URL keeps the line as it was written; only the request sees the two halves. const typedParsed = useMemo(() => parsePullRequestQuery(typedQuery), [typedQuery]); const sentParsed = useMemo(() => parsePullRequestQuery(sentQuery), [sentQuery]); // One record for the hosts, rebuilt only when the URL's own fields change so the listing // inputs stay identical between renders. const menuFilters = useMemo( (): PullRequestListFilters => ({ ...(search.draft ? { draft: search.draft } : {}), ...(search.review ? { review: search.review } : {}), ...(search.checks ? { checks: search.checks } : {}), }), [search.checks, search.draft, search.review], ); const menuFiltered = Object.keys(menuFilters).length > 0; // A typed qualifier wins over the menu's own answer for the same thing, since it is the more // recent word on it; labels only ever come from the query, so there is nothing to overrule. const filters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }), [menuFilters, sentParsed.filters], ); const hasFilters = Object.keys(filters).length > 0; /** The same narrowings the moment they are typed, for the rows already on screen. */ const localFilters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...typedParsed.filters }), [menuFilters, typedParsed.filters], ); const hasLocalFilters = Object.keys(localFilters).length > 0; // Scoping to a project scopes to the server that owns it, saving every other server a read // that could only answer with nothing. Where an id is ambiguous — two servers holding the // same project id, with no server named — only the servers that actually hold that id are // asked: a server without it may still hold an unrelated project of its own under the same // string, and asking it would return that project's rows rather than an honest empty answer. const queryEnvironmentIds = useMemo( () => resolveQueryEnvironmentIds( environmentIds, projects, scopedProject,