Files
playpen/web/src/lib/anchor.ts
T

132 lines
5.7 KiB
TypeScript

// 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<string>; // paths in the change set
lines: Set<string>; // 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<string>();
const lines = new Set<string>();
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<string, string> {
const map: Record<string, string> = {};
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;
}