1import type { EnvironmentId, PullRequestRef, PullRequestReviewGuide } from "@t3tools/contracts";
2import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
3
4import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents";
5import { isEditableFocused } from "~/lib/editableFocus";
6import { pullRequestEnvironment } from "~/state/pullRequests";
7import { useAtomCommand } from "~/state/use-atom-command";
8
9import { isCommandPaletteOpen } from "~/commandPaletteBus";
10
11import { GuideRail } from "./GuideRail";
12import { GuideStop } from "./GuideStop";
13import type { ExcerptCommenting } from "./GuideExcerpt";
14import {
15 guideProgressKey,
16 guideRunKey,
17 useGuideProgress,
18 useGuideRunStore,
19 type GuideProgress,
20} from "./guideRunStore";
21import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour";
22import { 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 */
31export 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}