1import type {
2 AnnotationSide,
3 CodeViewDiffItem,
4 CodeViewItem,
5 DiffLineAnnotation,
6 FileDiffMetadata,
7 SelectedLineRange,
8} from "@pierre/diffs";
9import type { CodeViewHandle, CodeViewProps } from "@pierre/diffs/react";
10import type { EnvironmentId, ProjectId, ScopedThreadRef } from "@t3tools/contracts";
11import { useState, type ReactNode, type Ref } from "react";
12
13import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
14import { fnv1a32, resolveFileDiffPath, resolveFileDiffPreviousPath } 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 environmentId?: EnvironmentId | undefined;
76 projectId?: ProjectId | null | undefined;
77 codeViewKey: string;
78 files: ReadonlyArray<{
79 fileDiff: FileDiffMetadata;
80 filePath: string;
81 fileKey: string;
82 fileVersion: number;
83 collapsed: boolean;
84 }>;
85 pendingFiles: ReadonlyArray<FileDiffMetadata>;
86 sectionId: string;
87 sectionTitle: string;
88 composerDraftTarget: ScopedThreadRef | DraftId;
89 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
90 viewerRef?: Ref<AnnotatableCodeViewHandle>;
91 onScroll?: CodeViewProps<DiffCommentAnnotationGroup, undefined>["onScroll"];
92 className?: string;
93 renderCodeViewFooter?: () => ReactNode;
94 unsafeCSSExtra?: string;
95 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
96 renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode;
97 renderHeaderPrefix: (
98 fileDiff: FileDiffMetadata,
99 fileKey: string,
100 collapsed: boolean,
101 ) => ReactNode;
102 onRevealSearchMatch: (fileKey: string) => void;
103}
104
105interface DiffSelectionContext {
106 item: CodeViewItem<DiffCommentAnnotationGroup>;
107}
108
109export function AnnotatableCodeView({
110 environmentId,
111 projectId,
112 codeViewKey,
113 files,
114 pendingFiles,
115 sectionId,
116 sectionTitle,
117 composerDraftTarget,
118 options,
119 viewerRef,
120 onScroll,
121 className,
122 renderCodeViewFooter,
123 unsafeCSSExtra,
124 renderHeaderMetadata,
125 renderHeaderFilenameSuffix,
126 renderHeaderPrefix,
127 onRevealSearchMatch,
128}: AnnotatableCodeViewProps) {
129 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
130 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
131 const reviewComments = useComposerDraftStore(
132 (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS,
133 );
134 const [selectedLines, setSelectedLines] = useState<{
135 id: string;
136 range: SelectedLineRange;
137 } | null>(null);
138 const [draft, setDraft] = useState<{
139 fileKey: string;
140 filePath: string;
141 previousFilePath: string;
142 annotation: DiffCommentLineAnnotation;
143 } | null>(null);
144 const [draftText, setDraftText] = useState("");
145
146 const filesByKey = new Map(files.map((file) => [file.fileKey, file]));
147 if (
148 draft !== null &&
149 !filesByKey.has(draft.fileKey) &&
150 // Lazy placeholders have provisional change types, so match both paths instead.
151 !pendingFiles.some(
152 (file) =>
153 resolveFileDiffPath(file) === draft.filePath &&
154 resolveFileDiffPreviousPath(file) === draft.previousFilePath,
155 )
156 ) {
157 setDraft(null);
158 setDraftText("");
159 setSelectedLines(null);
160 }
161 const items: CodeViewDiffItem<DiffCommentAnnotationGroup>[] = files.map(
162 ({ fileDiff, filePath, fileKey, fileVersion, collapsed }) => {
163 const persisted = reviewComments
164 .filter(
165 (comment) =>
166 comment.sectionId === sectionId &&
167 comment.filePath === filePath &&
168 (comment.fenceLanguage ?? "diff") === "diff",
169 )
170 .reduce<DiffCommentLineAnnotation[]>((annotations, comment) => {
171 const range = restoreDiffReviewCommentRange(fileDiff, comment);
172 if (!range) return annotations;
173 return appendAnnotationEntry(annotations, range, {
174 id: comment.id,
175 kind: "comment",
176 range,
177 rangeLabel: comment.rangeLabel,
178 text: comment.text,
179 });
180 }, []);
181 const annotations = draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted;
182 return {
183 id: fileKey,
184 type: "diff",
185 fileDiff,
186 annotations,
187 collapsed,
188 version: fnv1a32(
189 `${fileVersion}:${collapsed ? "1" : "0"}:${annotations
190 .flatMap((annotation) =>
191 annotation.metadata.entries.map(
192 (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`,
193 ),
194 )
195 .join(":")}`,
196 ),
197 };
198 },
199 );
200
201 const removeEntry = (entryId: string) => {
202 setSelectedLines(null);
203 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
204 setDraft(null);
205 setDraftText("");
206 } else {
207 removeReviewComment(composerDraftTarget, entryId);
208 }
209 };
210
211 const submitEntry = (entryId: string, text: string) => {
212 const entry = draft?.annotation.metadata.entries.find((candidate) => candidate.id === entryId);
213 const file = draft ? filesByKey.get(draft.fileKey) : undefined;
214 if (!entry || !file) return;
215 const comment = buildDiffReviewComment({
216 id: entry.id,
217 sectionId,
218 sectionTitle,
219 filePath: file.filePath,
220 fileDiff: file.fileDiff,
221 range: entry.range,
222 text,
223 });
224 if (comment) addReviewComment(composerDraftTarget, comment);
225 setSelectedLines(null);
226 setDraft(null);
227 setDraftText("");
228 };
229
230 const beginComment = (range: SelectedLineRange | null, context: DiffSelectionContext) => {
231 if (!range) return;
232 const item = context.item;
233 if (item.type !== "diff") return;
234 const id = nextFileCommentId();
235 const filePath = resolveFileDiffPath(item.fileDiff);
236 const comment = buildDiffReviewComment({
237 id,
238 sectionId,
239 sectionTitle,
240 filePath,
241 fileDiff: item.fileDiff,
242 range,
243 text: "",
244 });
245 if (!comment) return;
246 setDraftText("");
247 setDraft({
248 fileKey: item.id,
249 filePath,
250 previousFilePath: resolveFileDiffPreviousPath(item.fileDiff),
251 annotation: {
252 side: annotationSide(range),
253 lineNumber: range.end,
254 metadata: {
255 entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }],
256 },
257 },
258 });
259 };
260
261 return (
262 <StyledDiffCodeView<DiffCommentAnnotationGroup>
263 key={codeViewKey}
264 {...(viewerRef ? { viewerRef } : {})}
265 {...(onScroll ? { onScroll } : {})}
266 {...(className ? { className } : {})}
267 {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})}
268 {...(renderHeaderMetadata
269 ? {
270 renderHeaderMetadata: (item: CodeViewItem<DiffCommentAnnotationGroup>) =>
271 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,
272 }
273 : {})}
274 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}
275 items={items}
276 selectedLines={selectedLines}
277 onSelectedLinesChange={setSelectedLines}
278 options={{
279 ...options,
280 enableGutterUtility: draft === null,
281 enableLineSelection: draft === null,
282 onGutterUtilityClick: beginComment,
283 onRevealSearchMatch: (item) => onRevealSearchMatch(item.id),
284 }}
285 renderHeaderFilenameSuffix={(item) =>
286 item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null
287 }
288 renderHeaderPrefix={(item) =>
289 item.type === "diff"
290 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
291 : null
292 }
293 renderAnnotation={(...[annotation]) => {
294 const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft");
295 return (
296 <div
297 className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"}
298 >
299 {annotation.metadata.entries.map((entry) => (
300 <DiffCommentAnnotation
301 key={entry.id}
302 environmentId={environmentId}
303 projectId={projectId}
304 ownerKey={JSON.stringify([
305 environmentId,
306 composerDraftTarget,
307 codeViewKey,
308 entry.id,
309 ])}
310 kind={entry.kind}
311 rangeLabel={entry.rangeLabel}
312 text={entry.kind === "draft" ? draftText : entry.text}
313 onTextChange={setDraftText}
314 onCancel={() => removeEntry(entry.id)}
315 onComment={(text) => submitEntry(entry.id, text)}
316 onDelete={() => removeEntry(entry.id)}
317 />
318 ))}
319 </div>
320 );
321 }}
322 />
323 );
324}