1938 lines · 85.3 KB · TypeScript
RawDownload
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.