Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.3.0",
"@mediapipe/tasks-vision": "^0.10.35",
"@pierre/diffs": "1.3.5",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
Expand Down
182 changes: 182 additions & 0 deletions desktop/src/features/projects/lib/projectDiffAnnotations.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
annotationToBuzzAnchor,
buildFileDiffAnnotations,
buzzSideToDiffSide,
diffSideToBuzzSide,
focusedAnchorToSelectedRange,
groupCommentsByAnchor,
isRenderablePatch,
patchBodyLineCount,
} from "./projectDiffAnnotations.ts";

const OLD_ANCHOR = { line: 3, path: "src/a.ts", side: "old" };
const NEW_ANCHOR = { line: 5, path: "src/a.ts", side: "new" };

function comment(id, anchor) {
return {
id,
anchor,
createdAt: 1,
author: "author",
content: "note",
tags: [],
};
}

test("maps Buzz old/new anchor sides to Pierre deletions/additions and back", () => {
assert.equal(buzzSideToDiffSide("old"), "deletions");
assert.equal(buzzSideToDiffSide("new"), "additions");
assert.equal(diffSideToBuzzSide("deletions"), "old");
assert.equal(diffSideToBuzzSide("additions"), "new");
});

test("groups comments by path/side/line and excludes other files", () => {
const comments = [
comment("1", OLD_ANCHOR),
comment("2", OLD_ANCHOR),
comment("3", NEW_ANCHOR),
comment("4", { line: 9, path: "src/other.ts", side: "new" }),
comment("5", { line: 3, path: "src/a.ts", side: "new" }),
];
const groups = groupCommentsByAnchor("src/a.ts", comments);
assert.deepEqual(
[...groups.keys()].sort(),
["new:3", "old:3", "new:5"].sort(),
);
assert.equal(groups.get("old:3")?.length, 2);
assert.equal(groups.get("new:5")?.length, 1);
assert.equal(groups.get("new:3")?.length, 1);
});

test("builds annotations with exact anchor metadata and stable ordering", () => {
// Same line 7: new-side comment supplied first, old-side supplied second —
// the deletion-before-addition tie-break must still win on equal lines.
const SAME_LINE_NEW = { line: 7, path: "src/a.ts", side: "new" };
const SAME_LINE_OLD = { line: 7, path: "src/a.ts", side: "old" };
const comments = [
comment("1", NEW_ANCHOR),
comment("2", SAME_LINE_NEW),
comment("3", SAME_LINE_OLD),
comment("4", OLD_ANCHOR),
];
const annotations = buildFileDiffAnnotations("src/a.ts", comments, null);
// Sorted by line ascending, then deletions before additions on equal lines.
assert.deepEqual(
annotations.map((a) => [a.side, a.lineNumber]),
[
["deletions", 3],
["additions", 5],
["deletions", 7],
["additions", 7],
],
);
assert.equal(annotations[0].metadata.anchor.path, "src/a.ts");
assert.equal(annotations[0].metadata.comments.length, 1);
assert.equal(annotations[1].metadata.focused, false);
// The same-line group keeps both comments, old side first despite the
// reversed input order.
assert.equal(annotations[2].metadata.anchor.side, "old");
assert.equal(annotations[3].metadata.anchor.side, "new");
assert.equal(annotations[2].metadata.comments.length, 1);
assert.equal(annotations[3].metadata.comments.length, 1);
});

test("includes the focused anchor even with no comments and marks it focused", () => {
const annotations = buildFileDiffAnnotations("src/a.ts", [], NEW_ANCHOR);
assert.equal(annotations.length, 1);
assert.equal(annotations[0].side, "additions");
assert.equal(annotations[0].lineNumber, 5);
assert.equal(annotations[0].metadata.focused, true);
assert.deepEqual(annotations[0].metadata.comments, []);
});

test("does not add a focused annotation for another file", () => {
const annotations = buildFileDiffAnnotations("src/a.ts", [], {
line: 1,
path: "src/b.ts",
side: "new",
});
assert.equal(annotations.length, 0);
});

test("marks the focused flag on an existing comment group", () => {
const comments = [comment("1", NEW_ANCHOR)];
const annotations = buildFileDiffAnnotations(
"src/a.ts",
comments,
NEW_ANCHOR,
);
assert.equal(annotations.length, 1);
assert.equal(annotations[0].metadata.focused, true);
assert.equal(annotations[0].metadata.comments.length, 1);
});

test("annotation metadata round-trips to the exact Buzz anchor", () => {
const annotations = buildFileDiffAnnotations(
"src/a.ts",
[comment("1", OLD_ANCHOR)],
null,
);
const recovered = annotationToBuzzAnchor(annotations[0]);
assert.deepEqual(recovered, OLD_ANCHOR);
});

test("maps a focused anchor to a single-line Pierre selected range", () => {
assert.deepEqual(focusedAnchorToSelectedRange(null), null);
assert.deepEqual(focusedAnchorToSelectedRange(undefined), null);
assert.deepEqual(focusedAnchorToSelectedRange(OLD_ANCHOR), {
start: 3,
end: 3,
side: "deletions",
});
assert.deepEqual(focusedAnchorToSelectedRange(NEW_ANCHOR), {
start: 5,
end: 5,
side: "additions",
});
});

test("detects renderable patches (at least one valid hunk header)", () => {
assert.equal(isRenderablePatch(""), false);
assert.equal(isRenderablePatch(" \n\n"), false);
assert.equal(isRenderablePatch("this is not a patch at all"), false);
assert.equal(
isRenderablePatch(
"diff --git a/x b/x\nindex 1..2 100644\nBinary files differ\n",
),
false,
);
// A fake `@@ ` line without numeric old/new ranges is not a hunk header.
assert.equal(isRenderablePatch("@@ not a hunk header\n"), false);
assert.equal(
isRenderablePatch("@@ -abc +def @@\nno numeric ranges\n"),
false,
);
assert.equal(isRenderablePatch("@@ -1,2 +1,2 @@\n-old\n+new\n"), true);
// Hunk headers may omit the line counts on either side.
assert.equal(isRenderablePatch("@@ -1 +1 @@\n-old\n+new\n"), true);
assert.equal(isRenderablePatch("@@ -1 +2,3 @@\n context\n"), true);
// A trailing section/function label after the closing @@ is valid.
assert.equal(
isRenderablePatch("@@ -12,3 +12,4 @@ function renderDiff()\n-old\n+new\n"),
true,
);
});

test("counts patch body lines like the previous renderer", () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"index 1..2 100644",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1,2 +1,2 @@",
"-old",
"+new",
" context",
].join("\n");
assert.equal(patchBodyLineCount(patch), 4);
assert.equal(patchBodyLineCount(""), 0);
});
207 changes: 207 additions & 0 deletions desktop/src/features/projects/lib/projectDiffAnnotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";

import type {
ProjectPullRequestComment,
ProjectPullRequestCommentAnchor,
} from "@/features/projects/projectPullRequests.mjs";

/**
* Buzz-owned annotation metadata carried inside a Pierre
* {@link DiffLineAnnotation}. It keeps the exact Buzz anchor and its comment
* group so `renderAnnotation` can rebuild the existing inline thread/form
* without re-deriving state from the parsed patch.
*/
export type ProjectDiffAnnotationMetadata = {
/** The exact Buzz anchor this annotation was built from. */
anchor: ProjectPullRequestCommentAnchor;
/** Comments already grouped to this path/side/line, in stable order. */
comments: ProjectPullRequestComment[];
/** True when this annotation is the active focused line. */
focused: boolean;
};

/** Pierre diff sides, mirroring {@link DiffLineAnnotation.side}. */
export type ProjectDiffSide = "deletions" | "additions";

/**
* Map a Buzz anchor side to the Pierre diff side.
* Buzz `old` anchors live on the deletion side; Buzz `new` anchors live on
* the addition side.
*/
export function buzzSideToDiffSide(
side: ProjectPullRequestCommentAnchor["side"],
): ProjectDiffSide {
return side === "old" ? "deletions" : "additions";
}

/**
* Map a Pierre diff side back to the Buzz anchor side.
* Inverse of {@link buzzSideToDiffSide}.
*/
export function diffSideToBuzzSide(side: ProjectDiffSide): "old" | "new" {
return side === "deletions" ? "old" : "new";
}

/** Stable per-file grouping key for a comment anchor. */
function anchorGroupKey(anchor: ProjectPullRequestCommentAnchor) {
return `${anchor.side}:${anchor.line}`;
}

/**
* Group a file's inline comments by path/side/line. The returned map order is
* first-seen key order (input order determines group order); the stable,
* sorted ordering callers need is produced by {@link buildFileDiffAnnotations}
* when it renders the final annotation list. Within each group, the original
* comment order is preserved. Comments whose anchor points at another file
* are excluded.
*/
export function groupCommentsByAnchor(
path: string,
comments: ProjectPullRequestComment[],
): Map<string, ProjectPullRequestComment[]> {
const groups = new Map<string, ProjectPullRequestComment[]>();
for (const comment of comments) {
const anchor = comment.anchor;
if (!anchor || anchor.path !== path) continue;
const key = anchorGroupKey(anchor);
const group = groups.get(key);
if (group) group.push(comment);
else groups.set(key, [comment]);
}
return groups;
}

/**
* Build the Pierre line annotations for one file from its comments and the
* active focused anchor.
*
* Every comment group becomes one annotation carrying the exact Buzz anchor
* and its comments. The focused anchor is always included, even when it has
* no comments, so focused-line scroll/focus has a stable annotation slot to
* resolve inside the rendered diff. Results are ordered deterministically by
* (line, side) so annotation slots are stable across renders.
*/
export function buildFileDiffAnnotations(
path: string,
comments: ProjectPullRequestComment[],
focusedAnchor: ProjectPullRequestCommentAnchor | null | undefined,
): DiffLineAnnotation<ProjectDiffAnnotationMetadata>[] {
const groups = groupCommentsByAnchor(path, comments);
const seen = new Set<string>();
const annotations: DiffLineAnnotation<ProjectDiffAnnotationMetadata>[] = [];

for (const [key, group] of groups) {
seen.add(key);
const anchor = group[0].anchor;
if (!anchor) continue;
annotations.push({
side: buzzSideToDiffSide(anchor.side),
lineNumber: anchor.line,
metadata: {
anchor,
comments: group,
focused: anchorsEqual(anchor, focusedAnchor),
},
});
}

if (
focusedAnchor &&
focusedAnchor.path === path &&
!seen.has(anchorGroupKey(focusedAnchor))
) {
annotations.push({
side: buzzSideToDiffSide(focusedAnchor.side),
lineNumber: focusedAnchor.line,
metadata: {
anchor: focusedAnchor,
comments: [],
focused: true,
},
});
}

return annotations.sort(
(left, right) =>
left.lineNumber - right.lineNumber ||
sideOrder(left.side) - sideOrder(right.side),
);
}

function sideOrder(side: ProjectDiffSide) {
return side === "deletions" ? 0 : 1;
}

/**
* Recover the exact Buzz anchor from a Pierre annotation's metadata.
* Inverse of the mapping performed by {@link buildFileDiffAnnotations}.
*/
export function annotationToBuzzAnchor(
annotation: DiffLineAnnotation<ProjectDiffAnnotationMetadata>,
): ProjectPullRequestCommentAnchor {
return annotation.metadata.anchor;
}

/**
* Map the focused Buzz anchor to the Pierre controlled selected-line range
* (single line). `null` when there is no focused anchor.
*/
export function focusedAnchorToSelectedRange(
focusedAnchor: ProjectPullRequestCommentAnchor | null | undefined,
): SelectedLineRange | null {
if (!focusedAnchor) return null;
return {
start: focusedAnchor.line,
end: focusedAnchor.line,
side: buzzSideToDiffSide(focusedAnchor.side),
};
}

/** Structural equality for Buzz comment anchors. */
export function anchorsEqual(
left: ProjectPullRequestCommentAnchor | null | undefined,
right: ProjectPullRequestCommentAnchor | null | undefined,
) {
return Boolean(
left &&
right &&
left.line === right.line &&
left.path === right.path &&
left.side === right.side,
);
}

/**
* True when a raw patch contains at least one syntactically valid unified
* hunk header and can be handed to Pierre for rendering. A hunk header must
* carry numeric old/new ranges (`@@ -<old>[,<count>] +<new>[,<count>] @@`),
* the same boundary the previous hand-written parser recognized; an optional
* trailing section/function label after the closing `@@` (e.g. `@@ -12,3
* +12,4 @@ function renderDiff()`) is allowed. Covers empty/whitespace
* patches, binary-only diffs ("Binary files differ"), and malformed payloads
* whose `@@` lines are not real hunk headers — all of which should fall back
* to the friendly "no textual diff" state.
*/
export function isRenderablePatch(patch: string): boolean {
return /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/m.test(patch);
}

/**
* Count the rendered body lines of a patch for the truncation banner,
* matching the previous hand-written renderer's count: every non-metadata
* line (after stripping `diff --git`, `index`, `---`, `+++` headers) becomes
* one visible row.
*/
export function patchBodyLineCount(patch: string): number {
if (!patch.trim()) return 0;
return patch
.trimEnd()
.split("\n")
.filter(
(line) =>
!line.startsWith("diff --git ") &&
!line.startsWith("index ") &&
!line.startsWith("--- ") &&
!line.startsWith("+++ "),
).length;
}
Loading