import type { AnnotationSide, CodeViewDiffItem, CodeViewItem, DiffLineAnnotation, FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; import type { CodeViewHandle } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useState, type ReactNode, type Ref } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { fnv1a32, resolveFileDiffPath, resolveFileDiffPreviousPath } from "~/lib/diffRendering"; import { buildDiffReviewComment, restoreDiffReviewCommentRange, type ReviewCommentContext, } from "~/reviewCommentContext"; import { nextFileCommentId } from "../files/fileCommentAnnotations"; import { DiffCommentAnnotation } from "./DiffCommentAnnotation"; import { StyledDiffCodeView, type StyledDiffCodeViewOptions } from "./StyledDiffCodeView"; interface DiffCommentAnnotationEntry { id: string; kind: "draft" | "comment"; range: SelectedLineRange; rangeLabel: string; text: string; } interface DiffCommentAnnotationGroup { entries: DiffCommentAnnotationEntry[]; } type DiffCommentLineAnnotation = DiffLineAnnotation; export type AnnotatableCodeViewHandle = CodeViewHandle; const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; function annotationSide(range: SelectedLineRange): AnnotationSide { return (range.endSide ?? range.side) === "deletions" ? "deletions" : "additions"; } function appendAnnotationEntry( annotations: ReadonlyArray, range: SelectedLineRange, entry: DiffCommentAnnotationEntry, ): DiffCommentLineAnnotation[] { const side = annotationSide(range); const annotationIndex = annotations.findIndex( (annotation) => annotation.side === side && annotation.lineNumber === range.end, ); if (annotationIndex < 0) { return [ ...annotations, { side, lineNumber: range.end, metadata: { entries: [entry] }, }, ]; } return annotations.map((annotation, index) => index === annotationIndex ? { ...annotation, metadata: { entries: [...annotation.metadata.entries, entry] }, } : annotation, ); } interface AnnotatableCodeViewProps { codeViewKey: string; files: ReadonlyArray<{ fileDiff: FileDiffMetadata; filePath: string; fileKey: string; fileVersion: number; collapsed: boolean; }>; pendingFiles: ReadonlyArray; sectionId: string; sectionTitle: string; composerDraftTarget: ScopedThreadRef | DraftId; options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; renderCodeViewFooter?: () => ReactNode; unsafeCSSExtra?: string; renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, fileKey: string, collapsed: boolean, ) => ReactNode; onRevealSearchMatch: (fileKey: string) => void; } interface DiffSelectionContext { item: CodeViewItem; } export function AnnotatableCodeView({ codeViewKey, files, pendingFiles, sectionId, sectionTitle, composerDraftTarget, options, viewerRef, className, renderCodeViewFooter, unsafeCSSExtra, renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, onRevealSearchMatch, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); const reviewComments = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, ); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; } | null>(null); const [draft, setDraft] = useState<{ fileKey: string; filePath: string; previousFilePath: string; annotation: DiffCommentLineAnnotation; } | null>(null); const [draftText, setDraftText] = useState(""); const filesByKey = new Map(files.map((file) => [file.fileKey, file])); if ( draft !== null && !filesByKey.has(draft.fileKey) && // Lazy placeholders have provisional change types, so match both paths instead. !pendingFiles.some( (file) => resolveFileDiffPath(file) === draft.filePath && resolveFileDiffPreviousPath(file) === draft.previousFilePath, ) ) { setDraft(null); setDraftText(""); setSelectedLines(null); } const items: CodeViewDiffItem[] = files.map( ({ fileDiff, filePath, fileKey, fileVersion, collapsed }) => { const persisted = reviewComments .filter( (comment) => comment.sectionId === sectionId && comment.filePath === filePath && (comment.fenceLanguage ?? "diff") === "diff", ) .reduce((annotations, comment) => { const range = restoreDiffReviewCommentRange(fileDiff, comment); if (!range) return annotations; return appendAnnotationEntry(annotations, range, { id: comment.id, kind: "comment", range, rangeLabel: comment.rangeLabel, text: comment.text, }); }, []); const annotations = draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; return { id: fileKey, type: "diff", fileDiff, annotations, collapsed, version: fnv1a32( `${fileVersion}:${collapsed ? "1" : "0"}:${annotations .flatMap((annotation) => annotation.metadata.entries.map( (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, ), ) .join(":")}`, ), }; }, ); const removeEntry = (entryId: string) => { setSelectedLines(null); if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) { setDraft(null); setDraftText(""); } else { removeReviewComment(composerDraftTarget, entryId); } }; const submitEntry = (entryId: string, text: string) => { const entry = draft?.annotation.metadata.entries.find((candidate) => candidate.id === entryId); const file = draft ? filesByKey.get(draft.fileKey) : undefined; if (!entry || !file) return; const comment = buildDiffReviewComment({ id: entry.id, sectionId, sectionTitle, filePath: file.filePath, fileDiff: file.fileDiff, range: entry.range, text, }); if (comment) addReviewComment(composerDraftTarget, comment); setSelectedLines(null); setDraft(null); setDraftText(""); }; const beginComment = (range: SelectedLineRange | null, context: DiffSelectionContext) => { if (!range) return; const item = context.item; if (item.type !== "diff") return; const id = nextFileCommentId(); const filePath = resolveFileDiffPath(item.fileDiff); const comment = buildDiffReviewComment({ id, sectionId, sectionTitle, filePath, fileDiff: item.fileDiff, range, text: "", }); if (!comment) return; setDraftText(""); setDraft({ fileKey: item.id, filePath, previousFilePath: resolveFileDiffPreviousPath(item.fileDiff), annotation: { side: annotationSide(range), lineNumber: range.end, metadata: { entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }], }, }, }); }; return ( key={codeViewKey} {...(viewerRef ? { viewerRef } : {})} {...(className ? { className } : {})} {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})} {...(renderHeaderMetadata ? { renderHeaderMetadata: (item: CodeViewItem) => item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null, } : {})} {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})} items={items} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)} options={{ ...options, enableGutterUtility: draft === null, enableLineSelection: draft === null, onGutterUtilityClick: beginComment, }} renderHeaderFilenameSuffix={(item) => item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null } renderHeaderPrefix={(item) => item.type === "diff" ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true) : null } renderAnnotation={(...[annotation]) => { const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft"); return (
{annotation.metadata.entries.map((entry) => ( removeEntry(entry.id)} onComment={(text) => submitEntry(entry.id, text)} onDelete={() => removeEntry(entry.id)} /> ))}
); }} /> ); }