Skip to content

Commit c8a3428

Browse files
aldevvbenvinegar
andauthored
fix(core): use header counts for hunkLineRange so context lines are in range (#244)
Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent f0ac68b commit c8a3428

4 files changed

Lines changed: 106 additions & 20 deletions

File tree

src/core/liveComments.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,37 @@ describe("live comment helpers", () => {
115115
expect(range.newRange[0]).toBeLessThanOrEqual(1);
116116
expect(range.newRange[1]).toBeGreaterThanOrEqual(2);
117117
});
118+
119+
// Regression: a hunk with one addition surrounded by lots of context used to report
120+
// newRange = [start, start] (additions-only), so a comment anchored past the leading
121+
// context fell outside the hunk's range, annotationOverlapsHunk returned false, and
122+
// the hunk silently disappeared from getAnnotatedHunkIndices / annotated-cursor lists.
123+
// Fix: hunkLineRange uses additionCount/deletionCount (header total, includes context)
124+
// instead of additionLines/deletionLines (just '+' / '-' counts).
125+
test("includes context lines when one hunk has many context rows around few changes", () => {
126+
// 12 leading + 1 added + many trailing context lines on the new side.
127+
const beforeLines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`);
128+
const afterLines = [...beforeLines.slice(0, 12), "INSERTED", ...beforeLines.slice(12)];
129+
130+
const file = createTestDiffFile({
131+
before: lines(...beforeLines),
132+
after: lines(...afterLines),
133+
context: 100,
134+
id: "file:context-heavy",
135+
path: "src/sparse.ts",
136+
previousPath: "src/sparse.ts",
137+
});
138+
139+
expect(file.metadata.hunks.length).toBe(1);
140+
const hunk = file.metadata.hunks[0]!;
141+
expect(hunk.additionLines).toBe(1);
142+
143+
const range = hunkLineRange(hunk);
144+
145+
// The range must cover the inserted line at position 13 — additions-only bounds
146+
// would put newEnd at additionStart and miss it.
147+
expect(range.newRange[1]).toBeGreaterThanOrEqual(13);
148+
expect(findHunkIndexForLine(file, "new", 13)).toBe(0);
149+
expect(findHunkIndexForLine(file, "new", 20)).toBe(0);
150+
});
118151
});

src/core/liveComments.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,25 @@ export interface ResolvedCommentTarget {
88
line: number;
99
}
1010

11-
/** Compute the inclusive old/new line spans touched by one hunk. */
11+
/**
12+
* Compute the inclusive old/new line spans for the visible extent of a hunk.
13+
*
14+
* Use the per-side `*Count` from the hunk header (`-X,count` / `+X,count`),
15+
* which includes both context and changed lines, not the `*Lines` count which
16+
* is only the `+` / `-` lines. Comments anchored at a context-region line
17+
* (e.g. resolved by `firstCommentTargetForHunk` walking past leading context)
18+
* fall outside the additions-only range and silently disappear from
19+
* `getAnnotatedHunkIndices` / `findHunkIndexForLine` if those use the wrong
20+
* extent.
21+
*/
1222
export function hunkLineRange(hunk: Hunk) {
1323
const newEnd = Math.max(
1424
hunk.additionStart,
15-
hunk.additionStart + Math.max(hunk.additionLines, 1) - 1,
25+
hunk.additionStart + Math.max(hunk.additionCount, 1) - 1,
1626
);
1727
const oldEnd = Math.max(
1828
hunk.deletionStart,
19-
hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1,
29+
hunk.deletionStart + Math.max(hunk.deletionCount, 1) - 1,
2030
);
2131

2232
return {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
3+
import { buildLiveComment, resolveCommentTarget } from "../../core/liveComments";
4+
import { getAnnotatedHunkIndices, getSelectedAnnotations } from "./agentAnnotations";
5+
6+
function createContextHeavyHunkFile() {
7+
const beforeLines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`);
8+
const afterLines = [...beforeLines.slice(0, 12), "INSERTED", ...beforeLines.slice(12)];
9+
10+
return createTestDiffFile({
11+
before: lines(...beforeLines),
12+
after: lines(...afterLines),
13+
context: 100,
14+
id: "file:context-heavy-annotation",
15+
path: "src/sparse.ts",
16+
previousPath: "src/sparse.ts",
17+
});
18+
}
19+
20+
describe("agent annotations", () => {
21+
test("keeps hunk-number comments visible when anchored after leading context", () => {
22+
const file = createContextHeavyHunkFile();
23+
const hunk = file.metadata.hunks[0]!;
24+
25+
const target = resolveCommentTarget(file, {
26+
filePath: file.path,
27+
hunkIndex: 0,
28+
summary: "Explain inserted line",
29+
rationale: "The daemon resolves hunk-number comments to the first change row.",
30+
});
31+
32+
expect(target).toMatchObject({ hunkIndex: 0, side: "new", line: 13 });
33+
expect(hunk.additionLines).toBe(1);
34+
expect(hunk.additionCount).toBeGreaterThan(target.line - hunk.additionStart + 1);
35+
36+
const comment = buildLiveComment(
37+
{
38+
filePath: file.path,
39+
side: target.side,
40+
line: target.line,
41+
summary: "Explain inserted line",
42+
rationale: "The daemon resolves hunk-number comments to the first change row.",
43+
},
44+
"comment-1",
45+
"2026-03-22T00:00:00.000Z",
46+
target.hunkIndex,
47+
);
48+
const annotatedFile = {
49+
...file,
50+
agent: {
51+
path: file.path,
52+
annotations: [comment],
53+
},
54+
};
55+
56+
expect([...getAnnotatedHunkIndices(annotatedFile)]).toEqual([0]);
57+
expect(getSelectedAnnotations(annotatedFile, hunk)).toEqual([comment]);
58+
});
59+
});

src/ui/lib/agentAnnotations.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Hunk } from "@pierre/diffs";
22
import type { AgentAnnotation, DiffFile } from "../../core/types";
3+
import { hunkLineRange } from "../../core/liveComments";
34
import { fileLabel } from "./files";
45

56
export interface VisibleAgentNote {
@@ -17,23 +18,6 @@ function overlap(rangeA: [number, number], rangeB: [number, number]) {
1718
return rangeA[0] <= rangeB[1] && rangeB[0] <= rangeA[1];
1819
}
1920

20-
/** Compute the old/new line ranges covered by a hunk, including single-line edge cases. */
21-
function hunkLineRange(hunk: Hunk) {
22-
const newEnd = Math.max(
23-
hunk.additionStart,
24-
hunk.additionStart + Math.max(hunk.additionLines, 1) - 1,
25-
);
26-
const oldEnd = Math.max(
27-
hunk.deletionStart,
28-
hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1,
29-
);
30-
31-
return {
32-
oldRange: [hunk.deletionStart, oldEnd] as [number, number],
33-
newRange: [hunk.additionStart, newEnd] as [number, number],
34-
};
35-
}
36-
3721
/** Check whether an annotation belongs to the visible span of a hunk. */
3822
function annotationOverlapsHunk(annotation: AgentAnnotation, hunk: Hunk) {
3923
const hunkRange = hunkLineRange(hunk);

0 commit comments

Comments
 (0)