import { useMemo } from "react"; import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"; import { useOptionalAppStore } from "@/components/state-provider"; import { resolveFindingAnchor } from "@/lib/review/findings"; import { hashDiff } from "@/components/review/types"; import type { TaskReviewFinding } from "@/lib/types/review"; import type { AnnotationMetadata } from "./use-diff-annotation-renderer"; import { useTranslation } from "react-i18next"; /** * Key for the message shown on a finding whose line moved but whose anchor text * survived. Stored as a key, not resolved copy: a module-level `t()` would * freeze at the boot locale. */ const RELOCATED_REASON_KEY = "diff:theDiffChangedAfterThisReviewRelocated"; export type ReviewFindingAnnotationsResult = { /** Findings that could be anchored to a current line in this file. */ annotations: DiffLineAnnotation[]; /** * Findings for this file that could not be anchored — the diff moved and the * anchor text is gone. The caller renders these above the diff so a stale * finding is neither dropped nor placed against unrelated code. */ unanchored: TaskReviewFinding[]; }; const EMPTY_RESULT: ReviewFindingAnnotationsResult = { annotations: [], unanchored: [] }; /** * Builds inline annotations for the review findings belonging to one file. * * Findings are matched by the same composite `\x00` key the rest of * the Review panel uses, so in a multi-repository task a finding renders only * inside its own repository's file. */ export function useReviewFindingAnnotations(opts: { filePath: string; repo?: string; diff?: string; }): ReviewFindingAnnotationsResult { const { filePath, repo, diff } = opts; const { t } = useTranslation(); // The diff reaching the viewer is already normalized (see normalizeDiffContent // in components/review/types.ts), so hashing it here yields the same value the // backend stamped on the finding and the same one computeReviewSets uses for // review-mark staleness. const diffHash = useMemo(() => (diff ? hashDiff(diff) : undefined), [diff]); const activeTaskId = useOptionalAppStore((s) => s.tasks.activeTaskId, null); const findings = useOptionalAppStore( (s) => (activeTaskId ? s.taskReview.findingsByTaskId[activeTaskId] : undefined), undefined, ); return useMemo(() => { if (!findings?.length) return EMPTY_RESULT; const forFile = findings.filter( (f) => f.file_path === filePath && (f.repository_name || undefined) === (repo || undefined), ); if (forFile.length === 0) return EMPTY_RESULT; const annotations: DiffLineAnnotation[] = []; const unanchored: TaskReviewFinding[] = []; for (const finding of forFile) { const anchor = resolveFindingAnchor(finding, diff, diffHash); if (!anchor) { unanchored.push(finding); continue; } annotations.push({ side: (finding.side === "deletions" ? "deletions" : "additions") as AnnotationSide, lineNumber: Math.max(anchor.startLine, anchor.endLine), metadata: { type: "review-finding" as const, finding, findingStaleReason: anchor.relocated ? t(RELOCATED_REASON_KEY) : undefined, }, }); } return { annotations, unanchored }; }, [findings, filePath, repo, diff, diffHash, t]); }