1import type {
2 AnnotationSide,
3 CodeViewDiffItem,
4 CodeViewItem,
5 DiffLineAnnotation,
6 FileDiffMetadata,
7 SelectedLineRange,
8} from "@pierre/diffs";
9import type { CodeViewHandle } from "@pierre/diffs/react";
10import type { ScopedThreadRef } from "@t3tools/contracts";
11import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
12
13import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
14import { fnv1a32, resolveFileDiffPath } from "~/lib/diffRendering";
15import {
16 buildDiffReviewComment,
17 restoreDiffReviewCommentRange,
18 type ReviewCommentContext,
19} from "~/reviewCommentContext";
20
21import { nextFileCommentId } from "../files/fileCommentAnnotations";
22import { DiffCommentAnnotation } from "./DiffCommentAnnotation";
23import { StyledDiffCodeView, type StyledDiffCodeViewOptions } from "./StyledDiffCodeView";
24
25interface DiffCommentAnnotationEntry {
26 id: string;
27 kind: "draft" | "comment";
28 range: SelectedLineRange;
29 rangeLabel: string;
30 text: string;
31}
32
33interface DiffCommentAnnotationGroup {
34 entries: DiffCommentAnnotationEntry[];
35}
36
37type DiffCommentLineAnnotation = DiffLineAnnotation<DiffCommentAnnotationGroup>;
38export type AnnotatableCodeViewHandle = CodeViewHandle<DiffCommentAnnotationGroup, undefined>;
39const EMPTY_REVIEW_COMMENTS: ReadonlyArray<ReviewCommentContext> = [];
40
41function annotationSide(range: SelectedLineRange): AnnotationSide {
42 return (range.endSide ?? range.side) === "deletions" ? "deletions" : "additions";
43}
44
45function appendAnnotationEntry(
46 annotations: ReadonlyArray<DiffCommentLineAnnotation>,
47 range: SelectedLineRange,
48 entry: DiffCommentAnnotationEntry,
49): DiffCommentLineAnnotation[] {
50 const side = annotationSide(range);
51 const annotationIndex = annotations.findIndex(
52 (annotation) => annotation.side === side && annotation.lineNumber === range.end,
53 );
54 if (annotationIndex < 0) {
55 return [
56 ...annotations,
57 {
58 side,
59 lineNumber: range.end,
60 metadata: { entries: [entry] },
61 },
62 ];
63 }
64 return annotations.map((annotation, index) =>
65 index === annotationIndex
66 ? {
67 ...annotation,
68 metadata: { entries: [...annotation.metadata.entries, entry] },
69 }
70 : annotation,
71 );
72}
73
74interface AnnotatableCodeViewProps {
75 codeViewKey: string;
76 files: ReadonlyArray<{
77 fileDiff: FileDiffMetadata;
78 filePath: string;
79 fileKey: string;
80 fileVersion: number;
81 collapsed: boolean;
82 }>;
83 sectionId: string;
84 sectionTitle: string;
85 composerDraftTarget: ScopedThreadRef | DraftId;
86 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
87 viewerRef?: Ref<AnnotatableCodeViewHandle>;
88 className?: string;
89 renderCodeViewHeader?: () => ReactNode;
90 renderCodeViewFooter?: () => ReactNode;
91 unsafeCSSExtra?: string;
92 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
93 renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode;
94 renderHeaderPrefix: (
95 fileDiff: FileDiffMetadata,
96 fileKey: string,
97 collapsed: boolean,
98 ) => ReactNode;
99 /** Unfold a collapsed file that holds the find match being navigated to. */
100 onRevealSearchMatch: (fileKey: string) => void;
101}
102
103interface DiffSelectionContext {
104 item: CodeViewItem<DiffCommentAnnotationGroup>;
105}
106
107export function AnnotatableCodeView({
108 codeViewKey,
109 files,
110 sectionId,
111 sectionTitle,
112 composerDraftTarget,
113 options,
114 viewerRef,
115 className,
116 renderCodeViewHeader,
117 renderCodeViewFooter,
118 unsafeCSSExtra,
119 renderHeaderMetadata,
120 renderHeaderFilenameSuffix,
121 renderHeaderPrefix,
122 onRevealSearchMatch,
123}: AnnotatableCodeViewProps) {
124 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
125 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
126 const reviewComments = useComposerDraftStore(
127 (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS,
128 );
129 const [selectedLines, setSelectedLines] = useState<{
130 id: string;
131 range: SelectedLineRange;
132 } | null>(null);
133 const [draft, setDraft] = useState<{
134 fileKey: string;
135 annotation: DiffCommentLineAnnotation;
136 } | null>(null);
137 const [draftText, setDraftText] = useState("");
138
139 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);
140 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(
141 () =>
142 files.map(({ fileDiff, filePath, fileKey, fileVersion, collapsed }) => {
143 const persisted = reviewComments
144 .filter(
145 (comment) =>
146 comment.sectionId === sectionId &&
147 comment.filePath === filePath &&
148 (comment.fenceLanguage ?? "diff") === "diff",
149 )
150 .reduce<DiffCommentLineAnnotation[]>((annotations, comment) => {
151 const range = restoreDiffReviewCommentRange(fileDiff, comment);
152 if (!range) return annotations;
153 return appendAnnotationEntry(annotations, range, {
154 id: comment.id,
155 kind: "comment",
156 range,
157 rangeLabel: comment.rangeLabel,
158 text: comment.text,
159 });
160 }, []);
161 const annotations =
162 draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted;
163 return {
164 id: fileKey,
165 type: "diff",
166 fileDiff,
167 annotations,
168 collapsed,
169 version: fnv1a32(
170 `${fileVersion}:${collapsed ? "1" : "0"}:${annotations
171 .flatMap((annotation) =>
172 annotation.metadata.entries.map(
173 (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`,
174 ),
175 )
176 .join(":")}`,
177 ),
178 };
179 }),
180 [draft, files, reviewComments, sectionId],
181 );
182
183 const removeEntry = useCallback(
184 (entryId: string) => {
185 setSelectedLines(null);
186 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
187 setDraft(null);
188 setDraftText("");
189 } else {
190 removeReviewComment(composerDraftTarget, entryId);
191 }
192 },
193 [composerDraftTarget, draft, removeReviewComment],
194 );
195
196 const submitEntry = useCallback(
197 (entryId: string, text: string) => {
198 const entry = draft?.annotation.metadata.entries.find(
199 (candidate) => candidate.id === entryId,
200 );
201 const file = draft ? filesByKey.get(draft.fileKey) : undefined;
202 if (!entry || !file) return;
203 const comment = buildDiffReviewComment({
204 id: entry.id,
205 sectionId,
206 sectionTitle,
207 filePath: file.filePath,
208 fileDiff: file.fileDiff,
209 range: entry.range,
210 text,
211 });
212 if (comment) addReviewComment(composerDraftTarget, comment);
213 setSelectedLines(null);
214 setDraft(null);
215 setDraftText("");
216 },
217 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],
218 );
219
220 const beginComment = useCallback(
221 (range: SelectedLineRange | null, context: DiffSelectionContext) => {
222 if (!range) return;
223 const item = context.item;
224 if (item.type !== "diff") return;
225 // Read from the item, not the file list, so this callback keeps its identity as
226 // patches arrive; the viewer re-applies its options whenever it changes.
227 const id = nextFileCommentId();
228 const comment = buildDiffReviewComment({
229 id,
230 sectionId,
231 sectionTitle,
232 filePath: resolveFileDiffPath(item.fileDiff),
233 fileDiff: item.fileDiff,
234 range,
235 text: "",
236 });
237 if (!comment) return;
238 setDraftText("");
239 setDraft({
240 fileKey: item.id,
241 annotation: {
242 side: annotationSide(range),
243 lineNumber: range.end,
244 metadata: {
245 entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }],
246 },
247 },
248 });
249 },
250 [sectionId, sectionTitle],
251 );
252
253 const hasOpenComment = draft !== null;
254 return (
255 <StyledDiffCodeView<DiffCommentAnnotationGroup>
256 key={codeViewKey}
257 {...(viewerRef ? { viewerRef } : {})}
258 {...(className ? { className } : {})}
259 {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})}
260 {...(renderHeaderMetadata
261 ? {
262 renderHeaderMetadata: (item: CodeViewItem<DiffCommentAnnotationGroup>) =>
263 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,
264 }
265 : {})}
266 {...(renderCodeViewHeader ? { renderCodeViewHeader } : {})}
267 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}
268 items={items}
269 selectedLines={selectedLines}
270 onSelectedLinesChange={setSelectedLines}
271 onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)}
272 options={{
273 ...options,
274 enableGutterUtility: !hasOpenComment,
275 enableLineSelection: !hasOpenComment,
276 onGutterUtilityClick: beginComment,
277 }}
278 renderHeaderFilenameSuffix={(item) =>
279 item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null
280 }
281 renderHeaderPrefix={(item) =>
282 item.type === "diff"
283 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
284 : null
285 }
286 renderAnnotation={(annotation) => {
287 const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft");
288 return (
289 <div
290 className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"}
291 >
292 {annotation.metadata.entries.map((entry) => (
293 <DiffCommentAnnotation
294 key={entry.id}
295 kind={entry.kind}
296 rangeLabel={entry.rangeLabel}
297 text={entry.kind === "draft" ? draftText : entry.text}
298 onTextChange={setDraftText}
299 onCancel={() => removeEntry(entry.id)}
300 onComment={(text) => submitEntry(entry.id, text)}
301 onDelete={() => removeEntry(entry.id)}
302 />
303 ))}
304 </div>
305 );
306 }}
307 />
308 );
309}