diff --git a/CHANGELOG.md b/CHANGELOG.md index fad618b20..d516ab6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **A thread in Conversation shows the lines it is about, not the file.** GitHub + sends a review comment with the whole hunk it sits in, which on a file the + branch adds is the entire file — so a remark about ten lines arrived under a + hundred and twenty, and finding the ones it meant was a scroll. The snippet is + now cut to the commented span (with a little context above a single-line one), + its lines carry the diff viewer's own green and red, and the file reference in + the header reads the whole range: `utils.java:65-74`. - **A refused merge says which kind of refusal it was.** gh writes one sentence for two very different failures and puts the difference in its tail; lich read only the opening and claimed conflicts either way, sending you to look for diff --git a/frontend/src/components/pulls/ReviewThread.tsx b/frontend/src/components/pulls/ReviewThread.tsx index 515347bee..78ef8aeed 100644 --- a/frontend/src/components/pulls/ReviewThread.tsx +++ b/frontend/src/components/pulls/ReviewThread.tsx @@ -13,9 +13,11 @@ import { Markdown } from "@/components/Markdown" import { Button } from "@/components/ui/button" import { IconAction } from "@/components/common/IconAction" import type { DraftReviewComment, ReviewThread as Thread } from "@/lib/api-types" +import { formatLineRef } from "@/lib/git/diff" import { cn, errorText } from "@/lib/utils" import { Byline } from "./Byline" import { CommentBox } from "./CommentBox" +import { ThreadHunk } from "./ThreadHunk" // What a thread card can do to the thread it renders. Both actions are the // screen's, not this component's: it knows how to ask, never which pull request @@ -37,6 +39,14 @@ interface ReviewThreadProps { className?: string } +/** The lines a thread covers, as a file reference reads them: "74" or "65-74". */ +function lineRef(thread: Thread): string { + return formatLineRef({ + start: thread.startLine > 0 ? thread.startLine : thread.line, + end: thread.line, + }) +} + // One review thread: what was said, in order, with a way to answer it and a way // to close it. Rendered twice over — inside the diff, in the gap the widget // opens under the line, and again in the Conversation tab for the threads no @@ -118,7 +128,7 @@ export function ReviewThread({ {standalone ? ( {thread.path} - {thread.line > 0 && `:${thread.line}`} + {thread.line > 0 && `:${lineRef(thread)}`} ) : ( Line {thread.line} @@ -142,10 +152,10 @@ export function ReviewThread({ - {open && standalone && last?.diffHunk && ( -
-          {last.diffHunk}
-        
+ {/* The hunk of the comment the thread was filed on: a reply carries one + too, but the thread's line numbers are written against the first. */} + {open && standalone && comments[0]?.diffHunk && ( + )} {open && ( diff --git a/frontend/src/components/pulls/ThreadHunk.tsx b/frontend/src/components/pulls/ThreadHunk.tsx new file mode 100644 index 000000000..ef30f4c1d --- /dev/null +++ b/frontend/src/components/pulls/ThreadHunk.tsx @@ -0,0 +1,50 @@ +import type { ReviewThread } from "@/lib/api-types" +import { gutterNumber } from "@/lib/git/diff" +import { threadHunk } from "@/lib/pulls/thread-hunk" +import { cn } from "@/lib/utils" + +interface ThreadHunkProps { + /** GitHub's own hunk, as it travels with the comment. */ + diffHunk: string + thread: ReviewThread +} + +// The lines a review thread was filed on, coloured and numbered the way the +// diff viewer colours them. The diff renders them itself where the thread is +// anchored; this is for the Conversation tab, where the file is not on screen. +export function ThreadHunk({ diffHunk, thread }: ThreadHunkProps) { + const lines = threadHunk(diffHunk, thread) + if (lines.length === 0) { + return null + } + + return ( +
+ {/* w-max so a line wider than the card carries its tint the whole scrolled + width instead of stopping at the viewport edge. */} +
+ {lines.map((line) => ( +
+ + {gutterNumber(line)} + + {line.text} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f6a61c5d6..e7b719008 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -150,28 +150,29 @@ } } -/* Diff viewer (CodeMirror line + gutter decorations). Classic emerald/red for +/* Diff colours, worn by the CodeMirror viewer's line and gutter decorations and + by the review-thread snippets that render outside it. Classic emerald/red for add/del — the footer's counters keep the app's sky/pink identity. The 3px strip lives on the gutter cell (leftmost edge) as an inset box-shadow, which doesn't shift content the way border-left would. */ -.cm-diff-add { +.diff-add { background-color: color-mix(in oklab, var(--color-emerald-500) 12%, transparent); } -.cm-diff-del { +.diff-del { background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); } -.dark .cm-diff-add { +.dark .diff-add { background-color: color-mix(in oklab, var(--color-emerald-400) 14%, transparent); } -.dark .cm-diff-del { +.dark .diff-del { background-color: color-mix(in oklab, var(--color-red-400) 12%, transparent); } -.cm-diff-gutter-add { +.diff-gutter-add { background-color: color-mix(in oklab, var(--color-emerald-500) 18%, transparent); box-shadow: inset 3px 0 0 0 var(--color-emerald-500); } -.cm-diff-gutter-del { +.diff-gutter-del { background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent); box-shadow: inset 3px 0 0 0 var(--color-red-500); } diff --git a/frontend/src/lib/codemirror.ts b/frontend/src/lib/codemirror.ts index 207f67d06..c56e3ceb4 100644 --- a/frontend/src/lib/codemirror.ts +++ b/frontend/src/lib/codemirror.ts @@ -117,8 +117,8 @@ export function loadLanguage(view: EditorView, filename: string, isAlive: () => } const lineClasses: Partial> = { - add: Decoration.line({ class: "cm-diff-add" }), - del: Decoration.line({ class: "cm-diff-del" }), + add: Decoration.line({ class: "diff-add" }), + del: Decoration.line({ class: "diff-del" }), meta: Decoration.line({ class: "cm-diff-sep" }), } @@ -131,8 +131,8 @@ class LineClassMarker extends GutterMarker { } const gutterMarkers: Partial> = { - add: new LineClassMarker("cm-diff-gutter-add"), - del: new LineClassMarker("cm-diff-gutter-del"), + add: new LineClassMarker("diff-gutter-add"), + del: new LineClassMarker("diff-gutter-del"), } // buildLineDecorations colors added/deleted lines and hunk separators, in the diff --git a/frontend/src/lib/pulls/thread-hunk.test.ts b/frontend/src/lib/pulls/thread-hunk.test.ts new file mode 100644 index 000000000..c43fb76f8 --- /dev/null +++ b/frontend/src/lib/pulls/thread-hunk.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { threadHunk } from "./thread-hunk" + +// A hunk shaped like the one an added file produces: every line new, numbered +// from the header, ending on the line the comment was filed on. +function addedFile(upTo: number): string { + const lines = ["@@ -0,0 +1,120 @@"] + for (let n = 1; n <= upTo; n += 1) { + lines.push(`+line ${n}`) + } + return lines.join("\n") +} + +describe("threadHunk", () => { + it("cuts a multi-line thread down to the span it covers", () => { + const lines = threadHunk(addedFile(74), { line: 74, startLine: 65, side: "RIGHT" }) + expect(lines.map((l) => l.newLine)).toEqual([65, 66, 67, 68, 69, 70, 71, 72, 73, 74]) + expect(lines[0].text).toBe("+line 65") + }) + + it("keeps a little context above a single-line thread", () => { + const lines = threadHunk(addedFile(74), { line: 74, startLine: 0, side: "RIGHT" }) + expect(lines.map((l) => l.newLine)).toEqual([71, 72, 73, 74]) + }) + + it("falls back to the hunk's tail when the anchor moved off it", () => { + // A re-anchored thread: line 900 is nowhere in the hunk, but the hunk still + // ends on the line it was filed on. + const lines = threadHunk(addedFile(74), { line: 900, startLine: 0, side: "RIGHT" }) + expect(lines.map((l) => l.newLine)).toEqual([71, 72, 73, 74]) + }) + + it("caps a span nobody wants to scroll", () => { + const lines = threadHunk(addedFile(74), { line: 74, startLine: 2, side: "RIGHT" }) + expect(lines).toHaveLength(24) + expect(lines[lines.length - 1].newLine).toBe(74) + }) + + it("numbers a deleted line on the side it was deleted from", () => { + const hunk = ["@@ -57,4 +57,3 @@", " kept", "-gone", " after", "+added"].join("\n") + const lines = threadHunk(hunk, { line: 58, startLine: 0, side: "LEFT" }) + expect(lines.map((l) => [l.kind, l.oldLine, l.newLine])).toEqual([ + ["context", 57, 57], + ["del", 58, null], + ["context", 59, 58], + ["add", null, 59], + ]) + }) + + it("survives a hunk it cannot parse", () => { + expect(threadHunk("", { line: 12, startLine: 0, side: "RIGHT" })).toEqual([]) + }) +}) diff --git a/frontend/src/lib/pulls/thread-hunk.ts b/frontend/src/lib/pulls/thread-hunk.ts new file mode 100644 index 000000000..6b9c23164 --- /dev/null +++ b/frontend/src/lib/pulls/thread-hunk.ts @@ -0,0 +1,40 @@ +import { type DiffLine, type DiffSide, parseDiff } from "@/lib/git/diff" + +// GitHub's diffHunk is the whole hunk up to the line the comment was filed on — +// on a file the branch adds, that is the entire file. What the thread is about +// is the tail of it, so the snippet is cut down to the commented span. + +/** Lines of context kept above a thread that covers a single line. */ +const CONTEXT = 3 + +/** The span a thread covers: what a ReviewThread carries about its anchor. */ +export interface ThreadAnchor { + line: number + startLine: number + side: string +} + +/** How many lines a snippet shows at most, whatever the anchor says — a stray + * span must not put the whole file back on screen. */ +const MAX_LINES = 24 + +// threadHunk parses one GitHub diff hunk and returns the lines the thread was +// filed on, numbered. An anchor whose numbers the hunk does not hold (a thread +// GitHub re-anchored onto a rewritten file) falls back to the hunk's last lines: +// the hunk always ends on the commented line, so its tail is the span even when +// the numbers have moved. +export function threadHunk(diffHunk: string, anchor: ThreadAnchor): DiffLine[] { + // parseDiff wants a file header above the hunk; the path is never read back. + const [file] = parseDiff(`diff --git a/hunk b/hunk\n${diffHunk}`) + const lines = file?.hunks[0]?.lines ?? [] + const side: DiffSide = anchor.side === "LEFT" ? "LEFT" : "RIGHT" + const first = anchor.startLine > 0 ? anchor.startLine : anchor.line + const at = lines.findIndex((line) => { + const number = side === "LEFT" ? line.oldLine : line.newLine + return number !== null && number >= first + }) + // A span states its own first line; a single-line thread reads better with a + // few lines above it than alone. + const from = at === -1 ? lines.length - 1 - CONTEXT : at - (anchor.startLine > 0 ? 0 : CONTEXT) + return lines.slice(Math.max(0, from, lines.length - MAX_LINES)) +} diff --git a/internal/project/prthreads.go b/internal/project/prthreads.go index c5825d169..c0e304e23 100644 --- a/internal/project/prthreads.go +++ b/internal/project/prthreads.go @@ -20,7 +20,7 @@ const prConversationQuery = `query($owner:String!,$repo:String!,$number:Int!){ reviews(last:50){nodes{author{login} state body submittedAt}} comments(last:50){nodes{author{login} body createdAt}} reviewThreads(last:100){nodes{ - id isResolved isOutdated path line originalLine startLine diffSide + id isResolved isOutdated path line originalLine startLine originalStartLine diffSide comments(first:50){nodes{databaseId author{login} body createdAt diffHunk}} }} } @@ -135,15 +135,16 @@ type ghIssueComment struct { } type ghReviewThread struct { - ID string `json:"id"` - IsResolved bool `json:"isResolved"` - IsOutdated bool `json:"isOutdated"` - Path string `json:"path"` - Line *int `json:"line"` - OriginalLine *int `json:"originalLine"` - StartLine *int `json:"startLine"` - DiffSide string `json:"diffSide"` - Comments struct { + ID string `json:"id"` + IsResolved bool `json:"isResolved"` + IsOutdated bool `json:"isOutdated"` + Path string `json:"path"` + Line *int `json:"line"` + OriginalLine *int `json:"originalLine"` + StartLine *int `json:"startLine"` + OriginalStartLine *int `json:"originalStartLine"` + DiffSide string `json:"diffSide"` + Comments struct { Nodes []ghThreadComment `json:"nodes"` } `json:"comments"` } @@ -234,7 +235,7 @@ func toThreads(nodes []ghReviewThread) []PRReviewThread { ID: t.ID, Path: t.Path, Line: threadLine(t), - StartLine: deref(t.StartLine), + StartLine: threadStartLine(t), Side: t.DiffSide, IsResolved: t.IsResolved, IsOutdated: t.IsOutdated, @@ -255,6 +256,15 @@ func threadLine(t ghReviewThread) int { return deref(t.OriginalLine) } +// threadStartLine is the same fallback for the range's first line: an outdated +// thread keeps the original, which is the number its diff hunk is written in. +func threadStartLine(t ghReviewThread) int { + if t.StartLine != nil { + return *t.StartLine + } + return deref(t.OriginalStartLine) +} + func deref(v *int) int { if v == nil { return 0 diff --git a/internal/project/prthreads_test.go b/internal/project/prthreads_test.go index de94b0c90..939c15765 100644 --- a/internal/project/prthreads_test.go +++ b/internal/project/prthreads_test.go @@ -28,6 +28,11 @@ const conversationPayload = `{"data":{"repository":{"pullRequest":{ "line":null,"originalLine":18,"startLine":null,"diffSide":"RIGHT", "comments":{"nodes":[ {"databaseId":900003,"author":null,"body":"gone account","createdAt":"2026-07-30T08:00:00Z","diffHunk":"@@ -16,3 +16,5 @@"} + ]}}, + {"id":"PRRT_kw3","isResolved":false,"isOutdated":true,"path":"internal/store/store.go", + "line":null,"originalLine":74,"startLine":null,"originalStartLine":65,"diffSide":"RIGHT", + "comments":{"nodes":[ + {"databaseId":900004,"author":{"login":"reviewer"},"body":"this range","createdAt":"2026-07-30T09:30:00Z","diffHunk":"@@ -60,0 +60,15 @@"} ]}} ]} }}}}` @@ -65,8 +70,8 @@ func TestParseConversation(t *testing.T) { }) t.Run("an anchored thread keeps its line span and both ids", func(t *testing.T) { - if len(convo.Threads) != 2 { - t.Fatalf("threads = %d, want 2", len(convo.Threads)) + if len(convo.Threads) != 3 { + t.Fatalf("threads = %d, want 3", len(convo.Threads)) } anchored := convo.Threads[0] if anchored.ID != "PRRT_kw1" || anchored.Line != 61 || anchored.StartLine != 59 { @@ -100,6 +105,15 @@ func TestParseConversation(t *testing.T) { } }) + t.Run("an outdated range keeps its original first line", func(t *testing.T) { + // Without it the span collapses to one line, and the snippet the + // Conversation tab cuts from the hunk loses the lines it was filed on. + ranged := convo.Threads[2] + if ranged.StartLine != 65 || ranged.Line != 74 { + t.Errorf("span = %d–%d, want the original 65–74", ranged.StartLine, ranged.Line) + } + }) + t.Run("a deleted account leaves an empty login, not a crash", func(t *testing.T) { if got := convo.Threads[1].Comments[0].Author; got != "" { t.Errorf("author = %q, want empty", got) @@ -137,7 +151,7 @@ func TestPullRequestConversationFlow(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if convo == nil || len(convo.Threads) != 2 { + if convo == nil || len(convo.Threads) != 3 { t.Fatalf("wrong conversation: %+v", convo) } want := []string{