| 1 | import type { EnvironmentId, PullRequestRef, PullRequestReviewGuide } from "@t3tools/contracts"; |
| 2 | import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; |
| 5 | import { isEditableFocused } from "~/lib/editableFocus"; |
| 6 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 7 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 8 | |
| 9 | import { isCommandPaletteOpen } from "~/commandPaletteBus"; |
| 10 | |
| 11 | import { GuideRail } from "./GuideRail"; |
| 12 | import { GuideStop } from "./GuideStop"; |
| 13 | import type { ExcerptCommenting } from "./GuideExcerpt"; |
| 14 | import { |
| 15 | guideProgressKey, |
| 16 | guideRunKey, |
| 17 | useGuideProgress, |
| 18 | useGuideRunStore, |
| 19 | type GuideProgress, |
| 20 | } from "./guideRunStore"; |
| 21 | import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; |
| 22 | import { useGuideDiffFiles } from "./useGuideDiffFiles"; |
| 23 | |
| 24 | /** |
| 25 | * The guide as a tour: a rail of stops beside the stop being read. Progress is kept per guide |
| 26 | * head for as long as the app is open, so the same guide in another panel opens where the reader |
| 27 | * is; a new head starts it over. What |
| 28 | * the reader says about the change goes into the review: line comments queue with the Code tab's, |
| 29 | * and the last stop opens the composer that sends them. |
| 30 | */ |
| 31 | export function GuideTour({ |
| 32 | environmentId, |
| 33 | reference, |
| 34 | guide, |
| 35 | banner, |
| 36 | active, |
| 37 | commenting, |
| 38 | onAsk, |
| 39 | onFinish, |
| 40 | onOpenFile, |
| 41 | }: { |
| 42 | environmentId: EnvironmentId; |
| 43 | reference: PullRequestRef; |
| 44 | guide: PullRequestReviewGuide; |
| 45 | /** The stale or failed notice, shown above whichever view is open. */ |
| 46 | banner: ReactNode; |
| 47 | /** Whether the tour is the tab on screen, which is when J and K step through it. */ |
| 48 | active: boolean; |
| 49 | commenting: ExcerptCommenting | null; |
| 50 | /** Opens an agent thread about a stop. Null where the reader cannot start one. */ |
| 51 | onAsk: ((stop: number, question: string) => void) | null; |
| 52 | onFinish: (() => void) | null; |
| 53 | onOpenFile: (path: string) => void; |
| 54 | }) { |
| 55 | const key = guideProgressKey(guideRunKey(environmentId, reference), guide.headSha); |
| 56 | const tour = useGuideProgress(key); |
| 57 | const update = (change: (current: GuideProgress) => Partial<GuideProgress>) => |
| 58 | useGuideRunStore.getState().setProgress(key, (current) => ({ ...current, ...change(current) })); |
| 59 | const [railOpen, setRailOpen] = useState(false); |
| 60 | |
| 61 | const stopCount = guide.stops.length; |
| 62 | const position = Math.max(Math.min(tour.position, stopCount - 1), 0); |
| 63 | const stop = guide.stops[position] ?? null; |
| 64 | |
| 65 | // Only the open stop's files are read: its excerpts, and the files it lists to open in place. |
| 66 | const wantedPaths = useMemo(() => (stop === null ? [] : stopFilePaths(stop)), [stop]); |
| 67 | const diff = useGuideDiffFiles({ |
| 68 | environmentId, |
| 69 | reference, |
| 70 | headSha: guide.headSha, |
| 71 | wantedPaths, |
| 72 | }); |
| 73 | // The same file reads the Code tab uses, so the bars between hunks load what lies between. |
| 74 | const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); |
| 75 | const loadDiffFiles = useMemo( |
| 76 | () => |
| 77 | createPullRequestDiffFileContentsLoader(getDiffFileContents, { |
| 78 | environmentId, |
| 79 | reference, |
| 80 | commit: null, |
| 81 | cacheKey: `pull-request-guide:${key}`, |
| 82 | }), |
| 83 | [environmentId, getDiffFileContents, key, reference], |
| 84 | ); |
| 85 | |
| 86 | // A new stop starts at its top rather than wherever the last one was scrolled to. |
| 87 | const scrollRef = useRef<HTMLDivElement>(null); |
| 88 | const goTo = (next: number) => { |
| 89 | // Leaving a stop for a later one is reading it; jumping back is not. |
| 90 | update((current) => ({ |
| 91 | position: next, |
| 92 | seen: next > position ? new Set(current.seen).add(position) : current.seen, |
| 93 | })); |
| 94 | setRailOpen(false); |
| 95 | scrollRef.current?.scrollTo({ top: 0 }); |
| 96 | }; |
| 97 | const step = (delta: 1 | -1) => goTo(stepTourPosition(position, delta, stopCount)); |
| 98 | |
| 99 | // J and K step through the tour whenever it is the tab on screen, wherever focus is, unless |
| 100 | // the reader is typing: clicking a stop in the rail leaves focus on a button outside any |
| 101 | // element that could hold a key handler of its own. |
| 102 | const stepFromKey = useEffectEvent((event: KeyboardEvent) => { |
| 103 | if (event.defaultPrevented || event.isComposing || isCommandPaletteOpen()) return; |
| 104 | if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; |
| 105 | if (event.key !== "j" && event.key !== "k") return; |
| 106 | if (isEditableFocused(event.target)) return; |
| 107 | event.preventDefault(); |
| 108 | step(event.key === "j" ? 1 : -1); |
| 109 | }); |
| 110 | useEffect(() => { |
| 111 | if (!active) return; |
| 112 | const onKeyDown = (event: KeyboardEvent) => stepFromKey(event); |
| 113 | document.addEventListener("keydown", onKeyDown); |
| 114 | return () => document.removeEventListener("keydown", onKeyDown); |
| 115 | }, [active]); |
| 116 | |
| 117 | return ( |
| 118 | // Three widths: one column with the rail folded; the rail beside the stop; and, on a |
| 119 | // maximized panel, the stop's notes in a column of their own (see GuideStop). |
| 120 | <div ref={scrollRef} className="@container/guide h-full overflow-y-auto scrollbar-gutter-both"> |
| 121 | <div className="mx-auto grid max-w-[100rem] gap-6 px-4 py-4 @min-[56rem]/guide:grid-cols-[16rem_minmax(0,1fr)] @min-[90rem]/guide:gap-8 @min-[90rem]/guide:px-6"> |
| 122 | {/* Beside the stop on a wide panel; narrow, the stop folds the same list open under its |
| 123 | header instead. */} |
| 124 | <aside className="sticky top-4 self-start @max-[56rem]/guide:hidden"> |
| 125 | <GuideRail stops={guide.stops} position={position} seen={tour.seen} onSelect={goTo} /> |
| 126 | </aside> |
| 127 | <main className="flex min-w-0 flex-col gap-4"> |
| 128 | {banner} |
| 129 | {diff.error !== null ? ( |
| 130 | <p className="text-xs text-muted-foreground"> |
| 131 | The diff could not be loaded, so excerpts are missing: {diff.error} |
| 132 | </p> |
| 133 | ) : null} |
| 134 | {stop === null ? ( |
| 135 | <p className="text-sm text-muted-foreground">This change has no files to tour.</p> |
| 136 | ) : ( |
| 137 | <GuideStop |
| 138 | key={position} |
| 139 | stop={stop} |
| 140 | index={position} |
| 141 | stopCount={stopCount} |
| 142 | railOpen={railOpen} |
| 143 | onRailOpenChange={setRailOpen} |
| 144 | stopList={ |
| 145 | <GuideRail |
| 146 | stops={guide.stops} |
| 147 | position={position} |
| 148 | seen={tour.seen} |
| 149 | onSelect={goTo} |
| 150 | /> |
| 151 | } |
| 152 | nextTitle={guide.stops[position + 1]?.title ?? null} |
| 153 | files={diff.files} |
| 154 | loadDiffFiles={loadDiffFiles} |
| 155 | diffLoading={diff.loading} |
| 156 | commenting={commenting} |
| 157 | onStep={step} |
| 158 | onAsk={onAsk === null ? null : (question) => onAsk(position, question)} |
| 159 | onFinish={ |
| 160 | onFinish === null |
| 161 | ? null |
| 162 | : () => { |
| 163 | update((current) => ({ seen: new Set(current.seen).add(position) })); |
| 164 | onFinish(); |
| 165 | } |
| 166 | } |
| 167 | onOpenFile={onOpenFile} |
| 168 | /> |
| 169 | )} |
| 170 | </main> |
| 171 | </div> |
| 172 | </div> |
| 173 | ); |
| 174 | } |