1414 lines · 61.8 KB · TypeScript
RawDownload
import { scopeProjectRef } from "@t3tools/client-runtime/environment";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import type {  EnvironmentId,  PullRequestAction,  PullRequestMergeMethod,  PullRequestRef,  PullRequestState,} from "@t3tools/contracts";import {  ArrowDownUpIcon,  ArrowLeftIcon,  ArrowUpRightIcon,  BookOpenIcon,  CircleDotIcon,  ChevronDownIcon,  FileDiffIcon,  FolderGit2Icon,  GitBranchIcon,  GitCommitHorizontalIcon,  GitMergeIcon,  GitPullRequestClosedIcon,  GitPullRequestDraftIcon,  GitPullRequestIcon,  HammerIcon,  MessageCircleQuestionIcon,  MessageSquareIcon,  LinkIcon,  MoreHorizontalIcon,  PanelRightIcon,  RefreshCwIcon,  TriangleAlertIcon,} from "lucide-react";import {  lazy,  Suspense,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { useNewThreadHandler } from "~/hooks/useHandleNewThread";import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import type { ReviewCommentContext } from "~/reviewCommentContext";import { useEnvironmentQuery } from "~/state/query";import { useLiveRefresh } from "~/hooks/useLiveRefresh";import { pullRequestEnvironment } from "~/state/pullRequests";import { useAtomCommand } from "~/state/use-atom-command";import { formatRelativeTimeLabel } from "~/timestampFormat"; import {  AlertDialog,  AlertDialogClose,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogPopup,  AlertDialogTitle,} from "../ui/alert-dialog";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import {  Menu,  MenuItem,  MenuPopup,  MenuRadioGroup,  MenuRadioItem,  MenuSeparator,  MenuTrigger,} from "../ui/menu";import { toastManager } from "../ui/toast";import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";import { DiffPanelLoadingState } from "../DiffPanelShell";import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab";import { PullRequestSummaryTab } from "./PullRequestSummaryTab";import { PullRequestTimelineTab } from "./PullRequestTimelineTab";import {  buildAskAboutLinesHandoff,  buildAskAboutPullRequestHandoff,  buildExplainPullRequestHandoff,  buildFixFindingHandoff,  buildFixFindingsHandoff,  buildResolveConflictsPrompt,  handoffPrompt,  handoffReviewComments,  pullRequestFindingKey,  readableFailure,  type PullRequestFinding,} from "./pullRequestDetail.logic";import {  PullRequestActorLabel,  PullRequestDiffStat,  PullRequestMetaLine,  resolvePullRequestState,  summarizePullRequestChecks,} from "./pullRequestPresentation"; type DetailTab = "summary" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {  merge: "Pull request merged",  ready: "Marked ready for review",  draft: "Converted to draft",  close: "Pull request closed",  reopen: "Pull request reopened",}; /** Said as the thing that did not happen, rather than as the operation that returned an error. */const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {  merge: "Could not merge this pull request",  ready: "Could not mark this ready for review",  draft: "Could not convert this to a draft",  close: "Could not close this pull request",  reopen: "Could not reopen this pull request",}; /** What to try, for the times the host says only that it refused. */const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {  merge:    "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting.",  ready: "The host refused it. Check that you have write access to this repository.",  draft: "The host refused it. Check that you have write access to this repository.",  close: "The host refused it. Check that you have write access, or that you opened it.",  reopen:    "The host refused it. Check that you have write access, and that the branch still exists.",}; /** Named for the host rather than "externally": the point is where you will land. */const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {  github: "Open on GitHub",  gitlab: "Open on GitLab",  bitbucket: "Open on Bitbucket",  "azure-devops": "Open on Azure DevOps",}; const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [  { value: "summary", label: "Summary" },  { value: "timeline", label: "Timeline" },  { value: "code", label: "Code" },]; // The diff viewer pulls in its worker pool, so it stays out of the bundle until Code is opened.// Named rather than inlined so the panel can also call it itself, to start the download before// anyone has clicked the tab.const loadCodeTab = () => import("./PullRequestCodeTab");const PullRequestCodeTab = lazy(loadCodeTab); /** * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it * is closed by the time the next one opens. It is how a prompt the reader has since edited is told * apart from the one they were handed: only the sentence still exactly as written may be replaced. */const lastHandoffPromptByDraft = new Map<DraftId, string>(); export function PullRequestDetailPanel({  environmentId,  reference,  refreshToken: forcedRefreshToken = 0,  onActed,  onClose,  onStateChange,  context = "page",  chromeVariant = "full",}: {  environmentId: EnvironmentId;  reference: PullRequestRef;  /**   * Bumped by whatever holds the panel when a reader asks for everything on screen to be read   * again. The panel owns its own reads, so the page cannot refresh them for it — it says when,   * and this says it.   */  refreshToken?: number;  /**   * An action changed this pull request on the host, so a list showing it is now out of date.   * Told rather than assumed: only the page knows whether it is showing one.   */  onActed?: () => void;  /** Page-owned detail columns use this to clear the selected pull request. */  onClose?: () => void;  /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */  onStateChange?: (status: {    projectId: string;    repository: string;    number: number;    state: PullRequestState;    isDraft: boolean;  }) => void;  /**   * Beside a thread, the checkout affordance disappears: the panel is showing that thread's   * own pull request, so the branch is already under the reader's feet — and checking it out   * again is at best a no-op and at worst git refusing a branch two checkouts.   */  context?: "page" | "thread";  /**   * How the metadata above the content behaves: `full` keeps every row pinned; `collapse`   * folds the whole of it into the top row once the active tab scrolls, and unfolds at the   * top — the chrome spends its height on what is being read.   */  chromeVariant?: "full" | "collapse";}) {  const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;  const [tab, setTab] = useState<DetailTab>("summary");  const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");  const [codeCommitScope, setCodeCommitScope] = useState<{    readonly pullRequestKey: string;    readonly oid: string | null;  }>(() => ({ pullRequestKey, oid: null }));  const selectedCodeCommitOid =    codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null;  const selectCodeCommit = (oid: string | null) => {    setCodeCommitScope({ pullRequestKey, oid });  };  const openCommit = (oid: string) => {    selectCodeCommit(oid);    setTab("code");  };  // Every tab the reader has opened stays mounted behind the active one. The diff viewer  // always needed this (it virtualizes against its own scroll position); the trace showed the  // summary needs it too — a large description re-parses its whole markdown on every return  // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content  // out of the tab order and the accessibility tree.  const [mountedTabs, setMountedTabs] = useState<ReadonlySet<DetailTab>>(    () => new Set<DetailTab>(["summary"]),  );  useEffect(() => {    setMountedTabs((previous) =>      previous.has(tab) ? previous : new Set<DetailTab>(previous).add(tab),    );  }, [tab]);  const [chromeCondensed, setChromeCondensed] = useState(false);  // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll  // events, so the capture handler always writes the active tab's entry — and a tab switch  // reads the destination's memory instead of inheriting the tab being left. A tab too short  // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome  // it has no scrollbar to reopen.  const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});  useEffect(() => {    setChromeCondensed(chromeStateByTab.current[tab] ?? false);  }, [tab]);  const condensed = chromeVariant === "collapse" && chromeCondensed;  // Collapsing removes the fold's height from the chrome, which would otherwise hand that  // height to the scrollport and leap the content up by it mid-scroll. The cure is exact  // compensation: collapse only once the reader has scrolled at least the fold's height,  // then give that height back to `scrollTop` before the next paint — the content under  // their eyes does not move, and the collapse itself is the only thing that changes.  const scrollerRef = useRef<HTMLElement | null>(null);  const foldRef = useRef<HTMLDivElement | null>(null);  // The condensed chrome's second row opens as the fold closes, so the height the scrollport  // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight`  // through a zero track reads its natural height in either state.  const condensedRowRef = useRef<HTMLDivElement | null>(null);  const compensationRef = useRef<number | null>(null);  useLayoutEffect(() => {    if (compensationRef.current === null) return;    const scroller = scrollerRef.current;    const delta = compensationRef.current;    compensationRef.current = null;    if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);  }, [condensed]);  const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");  const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null);  // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself  // alone. One at a time whatever the key: they all check the same pull request out.  const [handoff, setHandoff] = useState<string | null>(null);  const { copyToClipboard: copyBranchToClipboard, isCopied: isBranchCopied } = useCopyToClipboard({    target: "branch name",    timeout: 1600,  });   // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be  // clicked, so a reader who does click it lands on a chunk already in the module cache.  useEffect(() => {    void loadCodeTab();  }, []);   const detailQuery = useEnvironmentQuery(    pullRequestEnvironment.detail({ environmentId, input: reference }),  );  const activityQuery = useEnvironmentQuery(    pullRequestEnvironment.activity({ environmentId, input: reference }),  );  // Detail and diff are independent server reads, so the diff for the default view (no commit,  // no cursor) is started here too rather than waiting for the Code tab to mount. This is one  // extra cached read per opened pull request even for readers who never open the tab, but it  // turns the tab's first paint from a cold request into a cache hit.  const _diffWarmUpQuery = useEnvironmentQuery(    pullRequestEnvironment.diff({ environmentId, input: { ...reference } }),  );  const coreDetail = detailQuery.data;  const activity = activityQuery.data;  const detail = useMemo(    () =>      coreDetail === null        ? null        : {            ...coreDetail,            author: activity?.author ?? coreDetail.author,            reviewers: activity?.reviewers ?? coreDetail.reviewers,            comments: activity?.comments ?? [],            commentCount: activity?.commentCount ?? 0,            commentsTruncated: activity?.commentsTruncated ?? false,            reviewThreads: activity?.reviewThreads ?? [],            commits: activity?.commits ?? [],          },    [activity, coreDetail],  );  const activityPending = activityQuery.isPending && activity === null;  const activityError = activity === null ? activityQuery.error : null;  const refreshDetail = useCallback(() => {    detailQuery.refresh();    activityQuery.refresh();  }, [activityQuery.refresh, detailQuery.refresh]);  useEffect(() => {    if (!detail) return;    onStateChange?.({      projectId: detail.projectId,      repository: detail.repository,      number: detail.number,      state: detail.state,      isDraft: detail.isDraft,    });  }, [detail, onStateChange]);  // A pull request changes while it is open in front of somebody — a push lands, a check  // finishes, a review arrives — so the panel reads it again on the way back to the window and  // while a reader sits on it. Keyed by the pull request rather than by the panel, because this  // one panel shows a different pull request every time it is opened.  useLiveRefresh(refreshDetail, {    key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`,  });  // The button, on the other hand, goes around the server's cache rather than through it: it is  // the answer for a reader who can see that what they are looking at is behind. The  // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run  // and at worst answer from it.  const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });  const [refreshToken, setRefreshToken] = useState(0);  const refreshFromHost = useCallback(async () => {    await invalidate({ environmentId, input: { reference } });    refreshDetail();    setRefreshToken((token) => token + 1);  }, [environmentId, invalidate, reference, refreshDetail]);  // A refresh asked for by the page: the detail, and through the token below, the diff with it.  const appliedForcedToken = useRef(forcedRefreshToken);  useEffect(() => {    if (appliedForcedToken.current === forcedRefreshToken) return;    appliedForcedToken.current = forcedRefreshToken;    void refreshFromHost();  }, [forcedRefreshToken, refreshFromHost]);  const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });  const [actionPending, setActionPending] = useState(false);  const newThread = useNewThreadHandler();  const prepareThread = usePreparePullRequestThreadAction({    environmentId,    cwd: detail?.workspaceRoot ?? null,  });   const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {    if (actionPending) return;    setActionPending(true);    const result = await runAction({      environmentId,      input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) },    });    setActionPending(false);    if (result._tag === "Failure") {      // The host's own sentence, because it is the only thing that says why. A merge strategy a      // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps      // publishes no per-strategy availability to hide the control with — so "action failed"      // would leave the reader pressing the same button again.      const failure = squashAtomCommandFailure(result);      toastManager.add({        type: "error",        title: ACTION_FAILURE_LABELS[action],        description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),      });      return;    }    toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });    refreshDetail();    onActed?.();  };   type ThreadTask = {    prompt: string;    reviewComments?: ReadonlyArray<ReviewCommentContext>;  };   /**   * Opens a thread on this project and leaves the task in its composer for the reader to send.   *   * Nothing is checked out: asking a question is not a reason to move somebody's working tree or   * to make a worktree they did not ask for. The two hand-offs that do need the code call this   * after preparing it, so there is one path from "a task" to "a thread holding it".   */  const openThreadWithTask = async (    projectRef: ReturnType<typeof scopeProjectRef>,    task: ThreadTask | null,    opened?: { draftId: DraftId },  ): Promise<{ draftId: DraftId } | null> => {    const session =      opened ??      (await newThread(projectRef).then(        (result) => result,        () => null,      ));    if (session === null) return null;    const store = useComposerDraftStore.getState();    if (task === null) return session;    // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips    // both, rather than stacking a second one under the first. What the reader typed themselves    // survives — the composer they are handed is not always a fresh one, and a prompt they have    // since edited is theirs rather than the hand-off's.    const draft = store.getComposerDraft(session.draftId);    const existingComments = draft?.reviewComments ?? [];    const prompt = handoffPrompt(      {        prompt: draft?.prompt ?? "",        lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId),      },      task.prompt,    );    // Remember the hand-off's own contribution, not the merged prompt: only that sentence is    // this session's to take back next time, and the reader's text around it is not.    lastHandoffPromptByDraft.set(session.draftId, task.prompt);    store.setPrompt(session.draftId, prompt);    store.setReviewComments(      session.draftId,      handoffReviewComments(existingComments, task.reviewComments ?? []),    );    return session;  };   /** A question about the change, which needs a thread and nothing else. */  const startAsk = async (kind: string, task: ThreadTask) => {    if (!detail || handoff !== null) return;    setHandoff(kind);    const projectRef = scopeProjectRef(environmentId, detail.projectId);    const opened = await openThreadWithTask(projectRef, task);    setHandoff(null);    if (opened === null) {      toastManager.add({        type: "error",        title: "Could not open a thread",        description: "Try again from the project, or open a thread first.",      });      return;    }    toastManager.add({      type: "success",      title: "Asked in a thread",      // "Ask" leaves the composer empty on purpose, so saying the question is in it would send      // the reader looking for something that is not there. The chips are what landed.      description:        task.prompt.length > 0          ? "The question is in the composer — read it over, then send."          : "The pull request is in the composer — type your question, then send.",    });  };   // Every handoff works the same way: check the pull request out into its own worktree, open a  // thread there, and — when it carries a task — put that in the composer for the user to read  // before sending. Checking out is the whole point of the ones that carry nothing.  const startHandoff = async (    kind: string,    task: { prompt: string; reviewComments?: ReadonlyArray<ReviewCommentContext> } | null,    // A worktree leaves whatever is open alone, which is why it is the default. Checking out in    // the repository itself is what you want when the point is to run the thing where you    // already work — and it moves the branch under everything else that is open there.    mode: "worktree" | "local" = "worktree",  ) => {    if (!detail || handoff !== null) return;    setHandoff(kind);    // The menu closes on the press and takes its "Preparing..." label with it, so this is the    // only thing answering for the checkout. It carries no timeout of its own: a loading toast    // never expires, and an explicit one would survive the update and pin the result on screen.    const toastId = toastManager.add({      type: "loading",      title: "Preparing the pull request checkout...",    });    const projectRef = scopeProjectRef(environmentId, detail.projectId);    // The thread is opened before the checkout rather than after it, because the project's setup    // script only runs for a checkout that knows which thread it is for — and a worktree with no    // dependencies installed is not something anyone can test.    const opened = await newThread(projectRef).then(      (session) => session,      () => null,    );    if (opened === null) {      setHandoff(null);      // Without a thread there is nowhere for the checkout to belong: its setup script would not      // run and its task would have no composer to land in. Better to stop before touching the      // working tree than to prepare a worktree nobody asked for.

Lines 1–500 of 1414.