// Deciding whether a comment still points at real code. // // Comments outlive the diff they were written against: the base ref moves, the // working tree gets committed, the code under a thread gets rewritten. The store // hands back every comment in the repository, so this module answers the one // question the UI needs — can a comment be placed in the diff currently on // screen? The ones that can't are flagged outdated and shown apart, never // dropped: a comment the user typed is review content, and losing it silently // because the code moved is the worst thing this tool could do. import { getChangeKey, type ChangeData, type FileData } from 'react-diff-view'; import type { Comment, DiffContext, Side } from '../types'; // filePath returns the path comments are anchored to (new path, or old for // deletes). export function filePath(file: FileData): string { return file.type === 'delete' ? file.oldPath : file.newPath; } // anchorLine is the diff line a line-comment hangs off (its end line). export function anchorLine(c: Comment): number { return c.endLine || c.line; } // lineFor returns the line number a change occupies on the given side, or null // if the change has no line on that side (e.g. an insert has no old line). export function lineFor(change: ChangeData, side: Side): number | null { if (side === 'new') { if (change.type === 'insert') return change.lineNumber; if (change.type === 'normal') return change.newLineNumber; return null; } if (change.type === 'delete') return change.lineNumber; if (change.type === 'normal') return change.oldLineNumber; return null; } // lineKey identifies one line of one file. NUL-separated because NUL cannot // occur in a path, so no path can spell another file's key. function lineKey(path: string, side: Side, line: number): string { return `${path}\u0000${side}:${line}`; } // DiffAnchors is everything the diff on screen offers to hang a comment on. export interface DiffAnchors { files: Set; // paths in the change set lines: Set; // lineKey() for every line the diff carries ctx: DiffContext; // the selection this diff was produced from } // buildAnchors indexes a parsed diff. It reads each file's original hunks, not // the expanded ones — what a reviewer has unfolded is a view preference and // shouldn't change whether a comment counts as current. export function buildAnchors(files: FileData[], ctx: DiffContext): DiffAnchors { const paths = new Set(); const lines = new Set(); for (const file of files) { const path = filePath(file); paths.add(path); for (const hunk of file.hunks) { for (const change of hunk.changes) { const nl = lineFor(change, 'new'); const ol = lineFor(change, 'old'); if (nl != null) lines.add(lineKey(path, 'new', nl)); if (ol != null) lines.add(lineKey(path, 'old', ol)); } } } return { files: paths, lines, ctx }; } export function sameCtx(a: DiffContext, b: DiffContext): boolean { return ( a.base === b.base && a.uncommitted === b.uncommitted && (a.commit ?? '') === (b.commit ?? '') ); } // isOutdated reports that a comment's anchor is missing from the diff on screen: // its file has left the change set, or the line it hangs off is no longer part // of the diff. Pass anchors=null while a diff is still loading — with nothing to // compare against, nothing is outdated. export function isOutdated(c: Comment, anchors: DiffAnchors | null): boolean { if (!anchors) return false; // A review-level comment is anchored to the change set as a whole, which is // whatever is on screen. It is never outdated. if (c.level === 'review') return false; if (!anchors.files.has(c.file)) return true; if (c.level === 'file') return false; // A single commit's diff numbers lines in that commit's revision of the file, // so the same number means something else in another commit — and something // else again in the full diff, where the file is at the tip of the branch. That // goes for both sides, unlike the base-ref case below: in a commit diff neither // side is the working file. if ((c.context.commit ?? '') !== (anchors.ctx.commit ?? '')) return true; // Old-side line numbers are positions in the *base* revision, so they only // mean anything against the base they were written against; against a // different base the same number is a different line. New-side numbers are // positions in the working file and stay valid as the base moves. if (c.side === 'old' && !sameCtx(c.context, anchors.ctx)) return true; return !anchors.lines.has(lineKey(c.file, c.side, anchorLine(c))); } // changeKeyIndex maps "side:line" -> react-diff-view change key for one file's // hunks, so threads and composers can be attached as line widgets. Built from // the hunks actually being rendered (expansion included), unlike buildAnchors. // // `newOnly` is for the since-viewed diff, whose old side is a snapshot of the // file held in this browser rather than the base ref. Those line numbers are // real, but they number a revision no comment was ever written against, so they // must not be offered as anchors — indexing them would hang a thread meant for // base line 40 off whatever line 40 of the snapshot happens to be. export function changeKeyIndex( hunks: readonly { changes: ChangeData[] }[], newOnly = false, ): Record { const map: Record = {}; for (const hunk of hunks) { for (const change of hunk.changes) { const key = getChangeKey(change); const nl = lineFor(change, 'new'); if (nl != null) map[`new:${nl}`] = key; if (newOnly) continue; const ol = lineFor(change, 'old'); if (ol != null) map[`old:${ol}`] = key; } } return map; }