From 4164f559a1ffad1454e8dfd64a072f70e939264e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:32:41 +0100 Subject: [PATCH 1/8] feat: comment and reply from the pr overview, and wrap on word github does not thread conversation comments, so gp only offers a reply on a code thread box; ga answers everything else with a new comment on the pr. --- .demo/fake-sidecar/fixtures.go | 28 +++- CHANGELOG.md | 5 + README.md | 2 +- doc/differ.txt | 6 +- internal/github/comments.go | 15 ++ internal/github/dtos.go | 12 ++ internal/github/mutations.go | 10 ++ internal/github/results.go | 6 + internal/github/schema_validate_test.go | 1 + internal/handlers/handlers.go | 2 + internal/handlers/handlers_test.go | 50 +++++++ internal/handlers/mutate_comments.go | 21 +++ lua/differ/pr/client.lua | 10 ++ lua/differ/pr/overview.lua | 99 +++++++++++-- lua/differ/ui/overview.lua | 14 +- test/nvim/pr_nav_spec.lua | 178 ++++++++++++++++++++++++ test/unit/overview_spec.lua | 2 + 17 files changed, 438 insertions(+), 23 deletions(-) diff --git a/.demo/fake-sidecar/fixtures.go b/.demo/fake-sidecar/fixtures.go index da11015..b13ae42 100644 --- a/.demo/fake-sidecar/fixtures.go +++ b/.demo/fake-sidecar/fixtures.go @@ -44,10 +44,11 @@ var headBlobs = map[string]string{ type fixture struct { mu sync.Mutex threads []github.Thread - reviewID string // the active pending-review node id; "" = none - viewed map[string]string // path -> VIEWED/UNVIEWED - seq int // monotonic id source - now time.Time // process-start clock: timestamps are stamped relative to it + comments []github.TimelineComment // the pr-level conversation, appended to by PostIssueComment + reviewID string // the active pending-review node id; "" = none + viewed map[string]string // path -> VIEWED/UNVIEWED + seq int // monotonic id source + now time.Time // process-start clock: timestamps are stamped relative to it } func newFixture() *fixture { @@ -69,6 +70,11 @@ func newFixture() *fixture { CreatedAt: f.ago(3 * time.Hour), }}, }} + f.comments = []github.TimelineComment{{ + Author: other, + Body: "thanks for tackling this, dracula's been on the wishlist for a while.", + CreatedAt: f.ago(5 * time.Hour), + }} return f } @@ -161,10 +167,10 @@ func (f *fixture) GetThreads(_ context.Context, _, _ string, _ int) ([]github.Th } func (f *fixture) GetTimeline(_ context.Context, _, _ string, _ int) (*github.Timeline, error) { + f.mu.Lock() + defer f.mu.Unlock() return &github.Timeline{ - Comments: []github.TimelineComment{ - {Author: other, Body: "thanks for tackling this, dracula's been on the wishlist for a while.", CreatedAt: f.ago(5 * time.Hour)}, - }, + Comments: append([]github.TimelineComment(nil), f.comments...), Reviews: []github.ReviewSummary{ {Author: other, State: "COMMENTED", Body: "looks close. one inline question on the accent.", CreatedAt: f.ago(3 * time.Hour)}, }, @@ -268,6 +274,14 @@ func (f *fixture) PostComment(_ context.Context, _, _ string, _ int, in github.P return &github.PostComment{ID: commentID, ThreadID: threadID, ReviewID: f.reviewID}, nil } +func (f *fixture) PostIssueComment(_ context.Context, _, _ string, _ int, body string) (*github.PostIssueComment, error) { + f.mu.Lock() + defer f.mu.Unlock() + id := int64(9000 + f.nextID()) + f.comments = append(f.comments, github.TimelineComment{Author: viewer, Body: body, CreatedAt: f.ago(0)}) + return &github.PostIssueComment{ID: id}, nil +} + func (f *fixture) DeleteComment(_ context.Context, commentID string) error { f.mu.Lock() defer f.mu.Unlock() diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f774f..8921303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` on a thread box replies to that thread + ### Changed - The PR overview's `g?` cheatsheet now correctly says that `r` starts a draft review, whilst `e` only enters the review @@ -13,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - The diff drew a cursor line even with `cursorline` off and the PR overview drew none with it on; both now respect the user's config. Opening the PR checks float used to switch `cursorline` on for the rest of the session, it now doesn't +- Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box ## [0.1.41] — 2026-09-06 diff --git a/README.md b/README.md index 4b8e6ec..473816b 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command ### PR review -`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. +`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself and `gp` replies to the thread under the cursor, without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. | Command | Effect | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index 2e13bd5..45fbe1d 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -324,8 +324,10 @@ PR REVIEW *differ-usage-pr-review* page; no argument lists the repo’s PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps -straight to that comment’s file and line. The review keymaps (`ga` comment, -`gp` reply, `gr` resolve, `` viewed) are live once you’re in the diff. +straight to that comment’s file and line. `ga` comments on the PR itself and +`gp` replies to the thread under the cursor, without leaving the page. The +review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are +live once you’re in the diff. ----------------------------------------------------------------------- Command Effect diff --git a/internal/github/comments.go b/internal/github/comments.go index 2af37b6..c4df231 100644 --- a/internal/github/comments.go +++ b/internal/github/comments.go @@ -119,6 +119,21 @@ func (c *Client) postNewThread(ctx context.Context, owner, repo string, number i return pc, nil } +// PostIssueComment posts a PR-level conversation comment. github does not thread +// these, so a reply to one is just another comment; the caller supplies the quoting. +func (c *Client) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*PostIssueComment, error) { + prID, err := c.prNodeID(ctx, owner, repo, number) + if err != nil { + return nil, err + } + var out addIssueCommentGQL + vars := map[string]any{"subjectId": prID, "body": body} + if err := c.graphql(ctx, addIssueCommentMutation, vars, &out); err != nil { + return nil, err + } + return &PostIssueComment{ID: parseID(out.AddComment.CommentEdge.Node.FullDatabaseID)}, nil +} + // DeleteComment removes a single review comment by its node id (draft or published). func (c *Client) DeleteComment(ctx context.Context, commentID string) error { if err := c.graphql(ctx, deleteCommentMutation, map[string]any{"id": commentID}, nil); err != nil { diff --git a/internal/github/dtos.go b/internal/github/dtos.go index 08d794d..c21f0c4 100644 --- a/internal/github/dtos.go +++ b/internal/github/dtos.go @@ -293,6 +293,18 @@ type addReplyGQL struct { } `json:"addPullRequestReviewThreadReply"` } +// addIssueCommentGQL is the addComment response: the new conversation comment's +// numeric id. +type addIssueCommentGQL struct { + AddComment struct { + CommentEdge struct { + Node struct { + FullDatabaseID string `json:"fullDatabaseId"` + } `json:"node"` + } `json:"commentEdge"` + } `json:"addComment"` +} + // resolveThreadGQL is the (un)resolveReviewThread response; the mutation field is // aliased to result so resolve and unresolve share one shape. type resolveThreadGQL struct { diff --git a/internal/github/mutations.go b/internal/github/mutations.go index 12d238c..6054f39 100644 --- a/internal/github/mutations.go +++ b/internal/github/mutations.go @@ -97,6 +97,16 @@ mutation AddThreadReply($threadId: ID!, $reviewId: ID, $body: String!) { } }` +// addIssueCommentMutation posts a PR-level conversation comment. the subject is the PR +// node id; github has no threading on these, so a "reply" is another comment like any +// other. +const addIssueCommentMutation = ` +mutation AddIssueComment($subjectId: ID!, $body: String!) { + addComment(input: {subjectId: $subjectId, body: $body}) { + commentEdge { node { fullDatabaseId } } + } +}` + // resolveThreadMutation / unresolveThreadMutation toggle a thread's resolved state. // the mutation field is aliased to result so both decode into one shape. const resolveThreadMutation = ` diff --git a/internal/github/results.go b/internal/github/results.go index ac4107d..033e117 100644 --- a/internal/github/results.go +++ b/internal/github/results.go @@ -180,6 +180,12 @@ type PostComment struct { ReviewID string `json:"review_id,omitempty"` } +// PostIssueComment is the post_issue_comment result: the new conversation comment's +// numeric id. github does not thread these, so there is no thread to report. +type PostIssueComment struct { + ID int64 `json:"id"` +} + // ResolveThread is the resolve_thread result: the thread's resolved state after the // toggle, echoed from GitHub. type ResolveThread struct { diff --git a/internal/github/schema_validate_test.go b/internal/github/schema_validate_test.go index 255b019..6269048 100644 --- a/internal/github/schema_validate_test.go +++ b/internal/github/schema_validate_test.go @@ -32,6 +32,7 @@ var mutationConstants = []struct { {"addThreadMutation", addThreadMutation}, {"deleteCommentMutation", deleteCommentMutation}, {"addThreadReplyMutation", addThreadReplyMutation}, + {"addIssueCommentMutation", addIssueCommentMutation}, {"resolveThreadMutation", resolveThreadMutation}, {"unresolveThreadMutation", unresolveThreadMutation}, {"markFileViewedMutation", markFileViewedMutation}, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6386ff6..960a82b 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -35,6 +35,7 @@ type API interface { SubmitReview(ctx context.Context, reviewID, event, body string) (*github.SubmitReview, error) DiscardReview(ctx context.Context, reviewID string) error PostComment(ctx context.Context, owner, repo string, number int, in github.PostCommentInput) (*github.PostComment, error) + PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*github.PostIssueComment, error) DeleteComment(ctx context.Context, commentID string) error ResolveThread(ctx context.Context, threadID string, resolved bool) (*github.ResolveThread, error) SetFileViewed(ctx context.Context, owner, repo string, number int, path string, viewed bool) (*github.SetFileViewed, error) @@ -64,6 +65,7 @@ func NewRegistry(d Deps) Registry { "submit_review": d.submitReview, "discard_review": d.discardReview, "post_comment": d.postComment, + "post_issue_comment": d.postIssueComment, "delete_comment": d.deleteComment, "resolve_thread": d.resolveThread, "set_file_viewed": d.setFileViewed, diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 9853dba..bdbef27 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -28,6 +28,7 @@ type mockAPI struct { gotReviewID string gotEvent string gotComment github.PostCommentInput + gotBody string gotThreadID string gotCommentID string gotResolved bool @@ -121,6 +122,13 @@ func (m *mockAPI) PostComment(_ context.Context, _, _ string, number int, in git return &github.PostComment{ID: 555, ThreadID: "PRT_1"}, nil } +func (m *mockAPI) PostIssueComment(_ context.Context, _, _ string, number int, body string) (*github.PostIssueComment, error) { + m.called = true + m.gotNumber = number + m.gotBody = body + return &github.PostIssueComment{ID: 777}, nil +} + func (m *mockAPI) DeleteComment(_ context.Context, commentID string) error { m.called = true m.gotCommentID = commentID @@ -387,6 +395,48 @@ func TestPostCommentReplyRoutes(t *testing.T) { } } +func TestPostIssueCommentRoutes(t *testing.T) { + m := &mockAPI{headSHA: "abc123"} + res, err := deps(m).postIssueComment(context.Background(), json.RawMessage( + `{"owner":"o","repo":"r","number":3,"body":"ship it"}`, + )) + if err != nil { + t.Fatal(err) + } + if m.gotNumber != 3 || m.gotBody != "ship it" { + t.Errorf("params not forwarded: number=%d body=%q", m.gotNumber, m.gotBody) + } + if pc := res.(*github.PostIssueComment); pc.ID != 777 { + t.Errorf("result not forwarded: %+v", pc) + } + // no diff anchor, so nothing for a moved head to invalidate + if m.headCalled { + t.Error("a conversation comment must not pay for the head round-trip") + } +} + +func TestPostIssueCommentRequiresBody(t *testing.T) { + m := &mockAPI{} + _, err := deps(m).postIssueComment(context.Background(), json.RawMessage( + `{"owner":"o","repo":"r","number":3}`, + )) + wantBadRequest(t, err) + if m.called { + t.Error("GH must not be called without a body") + } +} + +func TestPostIssueCommentRequiresPR(t *testing.T) { + m := &mockAPI{} + _, err := deps(m).postIssueComment(context.Background(), json.RawMessage( + `{"owner":"o","repo":"r","body":"ship it"}`, + )) + wantBadRequest(t, err) + if m.called { + t.Error("GH must not be called without pr coords") + } +} + // ── the TOCTOU guard ──────────────────────────────────────────────────────────── // an anchored mutation pins expected_head; the handler resolves the live head and // rejects with conflict when it moved. an unpinned mutation skips the round-trip. diff --git a/internal/handlers/mutate_comments.go b/internal/handlers/mutate_comments.go index c5de879..62240ad 100644 --- a/internal/handlers/mutate_comments.go +++ b/internal/handlers/mutate_comments.go @@ -56,6 +56,27 @@ func (d Deps) postComment(ctx context.Context, params json.RawMessage) (any, err }) } +type postIssueCommentParams struct { + prParams + Body string `json:"body"` +} + +// postIssueComment posts a PR-level conversation comment. it carries no diff anchor, so +// neither the anchor validation nor the head guard applies. +func (d Deps) postIssueComment(ctx context.Context, params json.RawMessage) (any, error) { + var p postIssueCommentParams + if err := decode(params, &p); err != nil { + return nil, err + } + if err := requirePR(p.Owner, p.Repo, p.Number); err != nil { + return nil, err + } + if p.Body == "" { + return nil, protocol.NewError(protocol.CodeBadRequest, "body is required") + } + return d.GH.PostIssueComment(ctx, p.Owner, p.Repo, p.Number, p.Body) +} + type deleteCommentParams struct { prParams CommentID string `json:"comment_id"` // the comment's graphql node id diff --git a/lua/differ/pr/client.lua b/lua/differ/pr/client.lua index a6d8cdf..7c9cd78 100644 --- a/lua/differ/pr/client.lua +++ b/lua/differ/pr/client.lua @@ -151,6 +151,16 @@ function M.post_comment(pr, args, cb) sidecar.request("post_comment", with_pr(pr, args), cb) end +-- post_issue_comment result: {id}. a PR-level conversation comment, posted +-- immediately: it carries no diff anchor, so it never joins a review draft and there is +-- no head to guard against +---@param pr { owner: string, repo: string, number: integer } +---@param body string +---@param cb fun(err: table|nil, result: any) +function M.post_issue_comment(pr, body, cb) + sidecar.request("post_issue_comment", with_pr(pr, { body = body }), cb) +end + -- submit_review result: {id}. finalises the draft as one batch with an event. args: -- review_id/event (APPROVE|REQUEST_CHANGES|COMMENT)/body, expected_head? ---@param pr { owner: string, repo: string, number: integer } diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index 0d98e04..ba43e61 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -1,9 +1,9 @@ --- the PR overview home: a read-only pre-review page with the PR summary + a --- minimal timeline (conversation comments, submitted review verdicts, and code --- threads). it is a step *before* the review proper — no file panel, just a dedicated --- page filling the session tab. e enters the review (builds the panel + diff), r enters --- and also starts a github draft review, on a thread row enters at that thread's --- file/line, q backs into an in-progress review. the pure layout lives in +-- the PR overview home: a pre-review page with the PR summary + a minimal timeline +-- (conversation comments, submitted review verdicts, and code threads) that ga and gp +-- write back to. it is a step *before* the review proper — no file panel, just a +-- dedicated page filling the session tab. e enters the review (builds the panel + +-- diff), r enters and also starts a github draft review, on a thread row enters at +-- that thread's file/line, q backs into an in-progress review. the pure layout lives in -- ui/overview.lua; this owns the vim surface (buffer, window, extmarks, -- fetches). get_timeline is a round-trip and threads are ensured (shared, PR-wide, -- also feeding the header count) — the meta comes from session.pr_meta (enriched in @@ -127,7 +127,8 @@ end -- acts on a stale session. e enters the review (panel + diff), r enters and also starts -- a github draft review, q backs into the review when one is open, gx opens the PR url. -- on a thread row enters the review at that thread's file/line; elsewhere it opens --- the url. ]t/[t hop between thread boxes; g? floats the keymap cheatsheet +-- the url. ga comments on the PR and gp replies into the thread under the cursor, both +-- mirroring the diff's keys. ]t/[t hop between thread boxes; g? floats the cheatsheet ---@param b integer local function set_keymaps(b) local function live() @@ -210,14 +211,91 @@ local function set_keymaps(b) require("differ.ui.help").show({ " e / r enter review / enter + start a draft review (thread row: at its file)", " thread row: jump into the review here, else open the PR url", + " ga / gp comment on the PR / reply to the thread under the cursor", " ]t / [t next / previous thread", " gx open the PR in the browser", " q back into the review (when one is in progress)", " g? this help", }, { title = " Differ: overview " }) end + -- compose a body and hand it to `send`, then re-open the page so the new comment + -- renders from github rather than being patched in locally. the stashed cursor keeps + -- the reading position across the rebuild (render consumes it as a one-shot) + ---@param s table + ---@param spec { title: string, done: string, send: fun(body: string, cb: fun(err: table|nil)) } + local function compose(s, spec) + stash_cursor(s) + require("differ.ui.compose").open({ + title = spec.title, + anchor_win = s.overview_win, + on_submit = function(body) + if body == "" then + return require("differ.pr").notify("empty comment discarded") + end + spec.send(body, function(err) + if not require("differ.pr.guard").owns(s) then + return -- session torn down (or replaced) while the post was in flight + end + if err then + return require("differ.pr").notify_err(err) + end + require("differ.pr").notify(spec.done) + M.open(s) + end) + end, + }) + end + -- ga: a PR-level conversation comment. github has no draft for these, so it posts + -- immediately even mid-review, and there is no diff anchor for the head to shift under + local function comment() + local s = live() + if not s then + return + end + compose(s, { + title = "Comment on the PR (posts immediately)", + done = "comment posted", + send = function(body, cb) + client.post_issue_comment(s.pr, body, cb) + end, + }) + end + -- gp: reply into the thread under the cursor, joining the draft when a review is in + -- progress (like the diff's gp). github doesn't thread conversation comments, so off + -- a thread row there is nothing to reply to and ga is the way to answer + local function reply() + local s = live() + if not s then + return + end + local a = anchor_at_cursor() + if not (a and a.thread_id) then + return require("differ.pr").notify("no thread here to reply to; ga comments on the PR") + end + local draft = s.review_id and s.review_id ~= "" + compose(s, { + title = draft and "Reply (draft)" or "Reply (posts immediately)", + done = draft and "reply added to your review draft" or "reply posted", + send = function(body, cb) + local args = { in_reply_to = a.thread_id, body = body } + if draft then + args.review_id = s.review_id + end + client.post_comment(s.pr, args, function(err, res) + if not err then + -- the page re-reads threads on open, and the list is cached per + -- TTL window, so without this the reply lands invisibly + require("differ.pr.threads").invalidate(s) + end + cb(err, res) + end) + end, + }) + end local opts = { buffer = b, nowait = true, silent = true } vim.keymap.set("n", "gx", open_url, opts) + vim.keymap.set("n", "ga", comment, opts) + vim.keymap.set("n", "gp", reply, opts) vim.keymap.set("n", "", select_or_url, opts) vim.keymap.set("n", "e", function() enter(false) @@ -245,7 +323,7 @@ local function set_keymaps(b) end -- the page's window-local chrome: a clean reading surface (no diff gutter), markdown --- conceal on, wrapping for long body lines +-- conceal on, word-wrapping for long body lines ---@param win integer local function setup_window(win) local set_wo = require("differ.util.win").set_local @@ -257,6 +335,11 @@ local function setup_window(win) -- than inherit whatever local cursorline it was left with set_wo(win, "cursorline", vim.go.cursorline) set_wo(win, "wrap", true) + -- break at a word rather than mid-token, and hang the continuation past the thread + -- box's "│ " spine so a wrapped comment body stays inside its box + set_wo(win, "linebreak", true) + set_wo(win, "breakindent", true) + set_wo(win, "breakindentopt", "shift:2") set_wo(win, "conceallevel", 2) set_wo(win, "list", false) end diff --git a/lua/differ/ui/overview.lua b/lua/differ/ui/overview.lua index 77a8a00..8546e97 100644 --- a/lua/differ/ui/overview.lua +++ b/lua/differ/ui/overview.lua @@ -2,9 +2,10 @@ -- out, no vim state, so it's unit-tested like ui/thread.lua. the timeline merges -- comments, review verdicts and code threads and sorts by created_at; relative time is -- injected (opts.reltime) to keep the builder deterministic. a thread item carries its --- code anchor (path/side/line) and `anchors` records the row span of each, so the vim --- layer can jump from a timeline row into the review. scope guard: comments, verdicts --- and threads only — no reactions, labels, assignees, or events +-- code anchor (path/side/line) plus its node id, and `anchors` records the row span of +-- each, so the vim layer can jump from a timeline row into the review, or reply into the +-- thread. scope guard: comments, verdicts and threads only — no reactions, labels, +-- assignees, or events local M = {} @@ -127,6 +128,7 @@ local function timeline(tl) local first = (t.comments or {})[1] or {} items[#items + 1] = { kind = "thread", + thread_id = t.thread_id, author = first.author, body = first.body, ts = first.created_at, @@ -161,8 +163,9 @@ local function verdict_of(item) end -- build the overview buffer content. `anchors` maps each thread item to its 1-based --- row span ({ row_start, row_end, path, side, line }), so anywhere in the section --- can jump to the code anchor. a highlight is { row, col_start, col_end, hl }, 0-based +-- row span ({ row_start, row_end, thread_id, path, side, line }), so anywhere in +-- the section can jump to the code anchor, and reply into the thread. a highlight is +-- { row, col_start, col_end, hl }, 0-based ---@param data { meta: table, checks: table|nil, unresolved: integer, total_threads: integer, timeline: table } ---@param opts { reltime?: fun(ts: string): string }|nil ---@return { lines: string[], highlights: table[], anchors: table[], hunks: table[] } @@ -316,6 +319,7 @@ function M.build(data, opts) anchors[#anchors + 1] = { row_start = row_start, row_end = #lines, + thread_id = item.thread_id, path = item.path, side = item.side, line = item.line, diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 913468e..7c7361d 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -987,3 +987,181 @@ describe("stacked-row thread gestures pick a thread rather than assuming the fir assert.are.equal("gid1", deleted.node_id) end) end) + +-- the page's own comment gestures: ga opens a PR-level conversation comment, gp +-- replies into the thread box under the cursor. both compose in the shared split and +-- re-open the page on success, so the new comment renders from github +describe("pr overview commenting", function() + local restore_all = {} + local function track(restore) + restore_all[#restore_all + 1] = restore + end + + after_each(function() + for i = #restore_all, 1, -1 do + restore_all[i]() + end + restore_all = {} + if pr.current_session() then + pr.end_session() + end + end) + + -- like stub_sidecar, but records every (method, params) so a test can assert what + -- the gesture put on the wire + ---@param responses table + ---@return table sent + local function record_sidecar(responses) + local sent = {} + local real = sidecar.request + ---@diagnostic disable-next-line: duplicate-set-field + sidecar.request = function(method, params, cb) + sent[#sent + 1] = { method = method, params = params } + local r = responses[method] + vim.schedule(function() + cb(r and r.err or nil, r and r.result or {}) + end) + end + track(function() + sidecar.request = real + end) + return sent + end + + -- the first request for `method`, or nil + ---@param sent table[] + ---@param method string + ---@return table|nil + local function sent_params(sent, method) + for _, req in ipairs(sent) do + if req.method == method then + return req.params + end + end + return nil + end + + -- the compose split's scratch buffer, which open() leaves current + ---@return integer + local function compose_buf() + local buf = vim.api.nvim_get_current_buf() + -- the page is markdown/nofile too, so identity is what separates them + assert.are_not.equal(overview_buf(), buf) + assert.are.equal("markdown", vim.bo[buf].filetype) + return buf + end + + -- type `body` into the open compose split and submit it + ---@param body string + local function submit(body) + local buf = compose_buf() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true })) + assert.is_true(fire(buf, "differ: submit")) + end + + -- a live page with the default one-thread fixture, recording what the gestures send + ---@return integer bufnr, table sent + local function page() + track(open_overview(default_responses())) + return overview_buf(), record_sidecar(default_responses()) + end + + it("ga posts a conversation comment on the PR", function() + local buf, sent = page() + + assert.is_true(fire_lhs(buf, "ga")) + submit("ship it") + + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_issue_comment") ~= nil + end)) + local params = sent_params(sent, "post_issue_comment") + assert.are.equal("ship it", params.body) + assert.are.equal(7, params.number) + -- a conversation comment has no diff anchor, so nothing pins the head + assert.is_nil(params.expected_head) + end) + + it("ga re-opens the page so the posted comment renders from github", function() + local buf = page() + local before = vim.api.nvim_buf_get_changedtick(buf) + + assert.is_true(fire_lhs(buf, "ga")) + submit("ship it") + + wait_overview(before) + end) + + it("an empty body posts nothing", function() + local buf, sent = page() + + assert.is_true(fire_lhs(buf, "ga")) + submit("") + + assert.is_true(vim.wait(200, function() + return #_G.notifs > 0 and _G.notifs[#_G.notifs].msg == "differ: empty comment discarded" + end)) + assert.is_nil(sent_params(sent, "post_issue_comment")) + end) + + it("gp on a thread row replies into that thread", function() + local buf, sent = page() + local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) + assert.is_truthy(row) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gp")) + submit("done") + + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_comment") ~= nil + end)) + local params = sent_params(sent, "post_comment") + assert.are.equal("th_1", params.in_reply_to) + assert.are.equal("done", params.body) + -- a reply targets a thread node id, so no anchor and no head guard + assert.is_nil(params.path) + assert.is_nil(params.expected_head) + end) + + it("gp off a thread row says so and composes nothing", function() + local buf, sent = page() + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { 1, 0 }) + + assert.is_true(fire_lhs(buf, "gp")) + + assert.are.equal( + "differ: no thread here to reply to; ga comments on the PR", + _G.notifs[#_G.notifs].msg + ) + assert.are.equal(buf, vim.api.nvim_get_current_buf()) -- no compose split opened + assert.is_nil(sent_params(sent, "post_comment")) + end) + + it("a reply drops the cached thread list so the refreshed page carries it", function() + local buf, sent = page() + local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gp")) + submit("done") + + -- the list is memoised per TTL window, so without the invalidation the + -- re-opened page would serve the pre-reply threads + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "get_threads") ~= nil + end)) + end) + + it("the page word-wraps rather than breaking mid-token", function() + page() + local win = pr.current_session().overview_win + + assert.is_true(vim.wo[win].wrap) + assert.is_true(vim.wo[win].linebreak) + -- the continuation hangs past the thread box's "│ " spine + assert.is_true(vim.wo[win].breakindent) + assert.are.equal("shift:2", vim.wo[win].breakindentopt) + assert.is_false(vim.wo[win].list) -- linebreak wants list off + end) +end) diff --git a/test/unit/overview_spec.lua b/test/unit/overview_spec.lua index e2b31d0..5456659 100644 --- a/test/unit/overview_spec.lua +++ b/test/unit/overview_spec.lua @@ -213,6 +213,7 @@ end) describe("ui.overview.build (thread sections + anchors)", function() local function thread_data(over) local t = extend({ + thread_id = "PRRT_1", path = "lua/differ/init.lua", side = "RIGHT", line = 12, @@ -266,6 +267,7 @@ describe("ui.overview.build (thread sections + anchors)", function() local built = build(thread_data()) assert.are.equal(1, #built.anchors) local a = built.anchors[1] + assert.are.equal("PRRT_1", a.thread_id) -- the reply target gp posts into assert.are.equal("lua/differ/init.lua", a.path) assert.are.equal("RIGHT", a.side) assert.are.equal(12, a.line) From 0fd4f225ee6df69460bc57b4962dedacdd742b71 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:02:01 +0100 Subject: [PATCH 2/8] feat: expand a thread's replies on the pr overview, and fix the wrap indent the reply count now means one thing in both views: replies you cannot see. the wrap indent moves to breakindentopt=list:-1 so it applies per line, since shift:2 indented plain prose that has no spine to clear. --- CHANGELOG.md | 2 + README.md | 2 +- doc/differ.txt | 9 +++-- lua/differ/pr/overview.lua | 51 ++++++++++++++++++++++--- lua/differ/ui/overview.lua | 41 ++++++++++++++++++-- lua/differ/ui/thread.lua | 11 ++---- test/nvim/pr_nav_spec.lua | 76 +++++++++++++++++++++++++++++++++++-- test/unit/overview_spec.lua | 38 ++++++++++++++++++- test/unit/thread_spec.lua | 14 ++++--- 9 files changed, 214 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8921303..5416baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` on a thread box replies to that thread +- `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review ### Changed @@ -18,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The diff drew a cursor line even with `cursorline` off and the PR overview drew none with it on; both now respect the user's config. Opening the PR checks float used to switch `cursorline` on for the rest of the session, it now doesn't - Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box +- A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed ## [0.1.41] — 2026-09-06 diff --git a/README.md b/README.md index 473816b..a9452af 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command ### PR review -`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself and `gp` replies to the thread under the cursor, without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. +`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` replies to the thread under the cursor and `gc` shows or hides that thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. | Command | Effect | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index 45fbe1d..55537a7 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -324,10 +324,11 @@ PR REVIEW *differ-usage-pr-review* page; no argument lists the repo’s PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps -straight to that comment’s file and line. `ga` comments on the PR itself and -`gp` replies to the thread under the cursor, without leaving the page. The -review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are -live once you’re in the diff. +straight to that comment’s file and line. `ga` comments on the PR itself, +`gp` replies to the thread under the cursor and `gc` shows or hides that +thread’s replies, all without leaving the page. The review keymaps (`ga` +comment, `gp` reply, `gr` resolve, `` viewed) are live once you’re in +the diff. ----------------------------------------------------------------------- Command Effect diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index ba43e61..811ece2 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -20,6 +20,13 @@ local function namespace() return ns end +-- what a wrapped row indents past, under breakindentopt=list:-1: a thread box's spine, +-- so a wrapped body stays inside its box, then a markdown list marker, so a wrapped +-- bullet hangs under its own text. plain prose matches neither and wraps flush left +local WRAP_INDENT_PAT = "^" + .. vim.trim(ui.SPINE) + .. "\\s*\\|^\\s*[-*+]\\s\\+\\|^\\s*\\d\\+[\\]:.)}\\t ]\\s*" + local GUARD = "differ.pr.overview.guard" -- the page's scratch-buffer name, owned here; the review's on_repurpose asks @@ -32,6 +39,14 @@ local BUFNAME = "differ://overview" local buf = nil local anchors = nil +-- thread node id -> true for each box showing its replies. lives with the page buffer +-- (teardown clears it), since it's a reading state, not something the session owns +local expanded = {} + +-- the data the page last rendered, so a change to how it reads (gc) rebuilds without +-- re-fetching. cleared with the buffer +local last = nil + -- a timestamp -> the display string, honouring the configured relative/absolute mode ---@return fun(ts: string): string local function time_formatter() @@ -97,7 +112,7 @@ function M.teardown() if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end - buf, anchors = nil, nil + buf, anchors, expanded, last = nil, nil, {}, nil end -- end the session when the page window is closed: pre-review that's the only exit (q @@ -212,6 +227,7 @@ local function set_keymaps(b) " e / r enter review / enter + start a draft review (thread row: at its file)", " thread row: jump into the review here, else open the PR url", " ga / gp comment on the PR / reply to the thread under the cursor", + " gc show / hide the replies of the thread under the cursor", " ]t / [t next / previous thread", " gx open the PR in the browser", " q back into the review (when one is in progress)", @@ -292,7 +308,20 @@ local function set_keymaps(b) end, }) end + -- gc: show or hide the replies of the thread box under the cursor, matching the + -- diff's collapse key. the page is rebuilt from what it already holds, so no fetch + local function toggle_replies() + local s = live() + local a = anchor_at_cursor() + if not (s and a and a.thread_id) then + return require("differ.pr").notify("no thread here to expand") + end + expanded[a.thread_id] = not expanded[a.thread_id] or nil + stash_cursor(s) + M.repaint(s) + end local opts = { buffer = b, nowait = true, silent = true } + vim.keymap.set("n", "gc", toggle_replies, opts) vim.keymap.set("n", "gx", open_url, opts) vim.keymap.set("n", "ga", comment, opts) vim.keymap.set("n", "gp", reply, opts) @@ -335,11 +364,12 @@ local function setup_window(win) -- than inherit whatever local cursorline it was left with set_wo(win, "cursorline", vim.go.cursorline) set_wo(win, "wrap", true) - -- break at a word rather than mid-token, and hang the continuation past the thread - -- box's "│ " spine so a wrapped comment body stays inside its box + -- break at a word rather than mid-token, and indent a continuation per line rather + -- than per window (WRAP_INDENT_PAT), so a box body clears its spine while plain + -- prose still wraps flush left set_wo(win, "linebreak", true) set_wo(win, "breakindent", true) - set_wo(win, "breakindentopt", "shift:2") + set_wo(win, "breakindentopt", "list:-1") set_wo(win, "conceallevel", 2) set_wo(win, "list", false) end @@ -353,6 +383,7 @@ local function paint(built) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = "hide" vim.bo[buf].filetype = "markdown" + vim.bo[buf].formatlistpat = WRAP_INDENT_PAT pcall(vim.api.nvim_buf_set_name, buf, BUFNAME) set_keymaps(buf) end @@ -409,6 +440,7 @@ end ---@param timeline table -- get_timeline result { comments, reviews } ---@param checks table|nil local function render(session, timeline, checks) + last = { timeline = timeline, checks = checks } local meta = session.pr_meta or {} local unresolved, total = thread_counts(session.threads) local built = ui.build({ @@ -429,7 +461,7 @@ local function render(session, timeline, checks) reviews = timeline.reviews, threads = session.threads, -- ensured by open; nil degrades to none }, - }, { reltime = time_formatter() }) + }, { reltime = time_formatter(), expanded = expanded }) local win = target_window(session) if not (win and vim.api.nvim_win_is_valid(win)) then @@ -451,6 +483,15 @@ local function render(session, timeline, checks) arm_guard(session, win) end +-- rebuild and repaint from the data the page last rendered, for a change that is only +-- how it reads (gc). a page that has never rendered has nothing to redraw +---@param session table +function M.repaint(session) + if last then + render(session, last.timeline, last.checks) + end +end + -- M.open(session): fetch the timeline and the checks, ensure threads (shared with the -- diff overlay; feeds the header count + the timeline's thread sections), render the -- page. guards the session is still live at every async hop (it can be torn down diff --git a/lua/differ/ui/overview.lua b/lua/differ/ui/overview.lua index 8546e97..c93aed4 100644 --- a/lua/differ/ui/overview.lua +++ b/lua/differ/ui/overview.lua @@ -51,6 +51,10 @@ local SPINE = "│ " local BOT = "└─ " local HUNK_INDENT = " " +-- exported so the page can build the wrap-indent pattern that keeps a wrapped body row +-- clear of the spine rather than printing over it +M.SPINE = SPINE + -- a thread's diff-hunk items: the tail of the hunk (it ends at the commented line), -- capped to MAX_HUNK and keeping the @@ header + a ⋯ elision marker when truncated, so -- a code comment reads with its diff like github's. a code item carries its line tint @@ -138,7 +142,9 @@ local function timeline(tl) outdated = t.outdated == true, -- vim.NIL-safe diff_hunk = first.diff_hunk, -- the root comment's, rendered under the header resolved = t.resolved == true, -- vim.NIL-safe + comments = t.comments or {}, -- the whole list; only rendered when expanded replies = math.max(0, #(t.comments or {}) - 1), + comments_truncated = t.comments_truncated == true, -- vim.NIL-safe } end end @@ -167,13 +173,14 @@ end -- the section can jump to the code anchor, and reply into the thread. a highlight is -- { row, col_start, col_end, hl }, 0-based ---@param data { meta: table, checks: table|nil, unresolved: integer, total_threads: integer, timeline: table } ----@param opts { reltime?: fun(ts: string): string }|nil +---@param opts { expanded?: table, reltime?: fun(ts: string): string }|nil ---@return { lines: string[], highlights: table[], anchors: table[], hunks: table[] } function M.build(data, opts) opts = opts or {} local reltime = opts.reltime or function(ts) return ts or "" end + local expanded_ids = opts.expanded or {} local meta = data.meta or {} local lines, highlights = {}, {} @@ -255,6 +262,7 @@ function M.build(data, opts) ---@param item table local function render_thread(item) local row_start = #lines + 1 + local expanded = item.thread_id ~= nil and expanded_ids[item.thread_id] == true local header = { { TOP, "differOverviewMeta" }, { "@" .. (item.author or "?"), "differOverviewAuthor" }, @@ -300,16 +308,41 @@ function M.build(data, opts) if #hunk.lines > 0 and item.path then hunks[#hunks + 1] = hunk end + -- the root comment's body on spine rows + local function push_body(body) + for _, line in ipairs(split_lines(body)) do + push({ { SPINE, "differOverviewMeta" }, { line, "differOverviewBody" } }) + end + end if item.body and item.body ~= "" then if #items > 0 then push({ { "│", "differOverviewMeta" } }) -- breathing row after the code end - for _, line in ipairs(split_lines(item.body)) do - push({ { SPINE, "differOverviewMeta" }, { line, "differOverviewBody" } }) + push_body(item.body) + end + -- expanded: each reply under its own spine sub-header, mirroring the diff + -- overlay's shape, so the same thread reads the same way in both places + if expanded then + for i = 2, #(item.comments or {}) do + local c = item.comments[i] + push({ { "│", "differOverviewMeta" } }) + push({ + { SPINE, "differOverviewMeta" }, + { "@" .. (c.author or "?"), "differOverviewAuthor" }, + { " · " .. reltime(c.created_at or ""), "differOverviewMeta" }, + }) + push_body(c.body or "") end end + -- the footer's reply count means one thing only: replies you can't see. once + -- they're on screen it says nothing, since they speak for themselves local footer = { { BOT, "differOverviewMeta" } } - if item.replies and item.replies > 0 then + if expanded then + if item.comments_truncated then + footer[#footer + 1] = + { "↳ showing the first " .. #item.comments, "differOverviewMeta" } + end + elseif item.replies and item.replies > 0 then footer[#footer + 1] = { ("↳ %d repl%s"):format(item.replies, item.replies == 1 and "y" or "ies"), "differOverviewMeta", diff --git a/lua/differ/ui/thread.lua b/lua/differ/ui/thread.lua index 7e139ef..721a2de 100644 --- a/lua/differ/ui/thread.lua +++ b/lua/differ/ui/thread.lua @@ -126,14 +126,11 @@ function M.build(thread, opts) append_body(comments[i], hl, rows) end - -- footer rule: reply count then the open/resolved state, in their own chunks so the - -- resolved tag rides its own green group while the rest stays meta-grey + -- footer rule: the open/resolved state, in its own chunk so the resolved tag rides + -- its own green group while the rest stays meta-grey. no reply count here: the + -- replies are rendered right above, and a count beside them reads as a there-is-more + -- affordance. the collapsed line is where the total belongs local footer = { { BOT, hl } } - local replies = math.max(0, #comments - 1) - if replies > 0 then - local n = replies == 1 and "1 reply" or replies .. " replies" - footer[#footer + 1] = { "↳ " .. n .. " · ", "differThreadMeta" } - end if thread.resolved then footer[#footer + 1] = { "✓ resolved", "differThreadResolvedTag" } else diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 7c7361d..62daef8 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -1153,15 +1153,85 @@ describe("pr overview commenting", function() end)) end) + it("gc shows and hides the replies of the thread under the cursor", function() + local responses = default_responses() + local threads = threads_result() + threads[1].comments[#threads[1].comments + 1] = { + id = "c2", + node_id = "gid2", + author = "author", + body = "fixed in the next push", + created_at = "2026-01-02T00:00:00Z", + } + responses.get_threads = { result = threads } + track(open_overview(responses)) + local buf = overview_buf() + + -- collapsed: only the root comment, with the count standing in for the rest + assert.is_truthy(row_containing(buf, "please fix")) + assert.is_nil(row_containing(buf, "fixed in the next push")) + assert.is_truthy(row_containing(buf, "↳ 1 reply")) + + local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + assert.is_true(fire_lhs(buf, "gc")) + + assert.is_truthy(row_containing(buf, "fixed in the next push")) + assert.is_truthy(row_containing(buf, "@author · ")) + -- the count only ever means replies you can't see + assert.is_nil(row_containing(buf, "↳ 1 reply")) + + assert.is_true(fire_lhs(buf, "gc")) + assert.is_nil(row_containing(buf, "fixed in the next push")) + assert.is_truthy(row_containing(buf, "↳ 1 reply")) + end) + + it("gc repaints from what the page holds, without refetching", function() + track(open_overview(default_responses())) + local buf = overview_buf() + local sent = record_sidecar(default_responses()) + local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gc")) + + assert.are.equal(0, #sent) + end) + + it("gc off a thread row says so", function() + track(open_overview(default_responses())) + local buf = overview_buf() + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { 1, 0 }) + + assert.is_true(fire_lhs(buf, "gc")) + + assert.are.equal("differ: no thread here to expand", _G.notifs[#_G.notifs].msg) + end) + it("the page word-wraps rather than breaking mid-token", function() page() local win = pr.current_session().overview_win assert.is_true(vim.wo[win].wrap) assert.is_true(vim.wo[win].linebreak) - -- the continuation hangs past the thread box's "│ " spine - assert.is_true(vim.wo[win].breakindent) - assert.are.equal("shift:2", vim.wo[win].breakindentopt) assert.is_false(vim.wo[win].list) -- linebreak wants list off + -- list:-1 indents a continuation per line, by whatever formatlistpat matches + assert.is_true(vim.wo[win].breakindent) + assert.are.equal("list:-1", vim.wo[win].breakindentopt) + end) + + it("a wrapped body clears the thread box spine, and plain prose doesn't indent", function() + page() + local buf = overview_buf() + local pat = vim.bo[buf].formatlistpat + local spine = require("differ.ui.overview").SPINE + + -- the wrap indent is measured off the spine the builder actually emits, so a + -- body row hangs clear of it while a flat comment stays flush left + assert.is_truthy(vim.fn.match(spine .. "a wrapped body", pat) == 0) + assert.are.equal(#spine, vim.fn.matchend(spine .. "a wrapped body", pat)) + assert.are.equal(-1, vim.fn.match("Section aggregate. Each header", pat)) + -- a markdown bullet still hangs under its own text + assert.are.equal(2, vim.fn.matchend("- renamed.txt is a list item", pat)) end) end) diff --git a/test/unit/overview_spec.lua b/test/unit/overview_spec.lua index 5456659..dcfbd74 100644 --- a/test/unit/overview_spec.lua +++ b/test/unit/overview_spec.lua @@ -1,11 +1,15 @@ local overview = require("differ.ui.overview") -- inject a deterministic reltime so the golden lines don't depend on the clock -local function build(data) +---@param data table +---@param opts table|nil -- the build opts the caller varies, over the fixed reltime +local function build(data, opts) + opts = opts or {} return overview.build(data, { reltime = function(ts) return ts end, + expanded = opts.expanded, }) end @@ -279,6 +283,38 @@ describe("ui.overview.build (thread sections + anchors)", function() assert.are.equal(row_of(built, "└─ ↳ 1 reply"), a.row_end) end) + it("hides the replies until the thread is expanded", function() + local built = build(thread_data()) + assert.is_truthy(row_of(built, "│ first line")) + assert.is_nil(row_of(built, "│ ack")) + assert.is_truthy(row_of(built, "└─ ↳ 1 reply")) + end) + + it("expanded, each reply gets its own spine sub-header and body", function() + local built = build(thread_data(), { expanded = { PRRT_1 = true } }) + assert.is_truthy(row_of(built, "│ first line")) + assert.is_truthy(row_of(built, "│ @a · 2026-01-02T00:00:00Z")) + assert.is_truthy(row_of(built, "│ ack")) + end) + + -- the count only ever means "replies you can't see", so it goes once they're shown + it("expanded, the footer drops the reply count", function() + local built = build(thread_data(), { expanded = { PRRT_1 = true } }) + assert.is_nil(row_of(built, "└─ ↳ 1 reply")) + assert.is_truthy(row_of(built, "└─ ")) -- the bare rule, count gone + end) + + it("expanded, a capped comment list says how many it is showing", function() + local built = + build(thread_data({ comments_truncated = true }), { expanded = { PRRT_1 = true } }) + assert.is_truthy(row_of(built, "└─ ↳ showing the first 2")) + end) + + it("the expanded set keys on the thread node id, not its position", function() + local built = build(thread_data(), { expanded = { PRRT_other = true } }) + assert.is_nil(row_of(built, "│ ack")) + end) + it("returns an empty anchor list without threads", function() local built = build({ meta = BASE_META, diff --git a/test/unit/thread_spec.lua b/test/unit/thread_spec.lua index 1321575..a69bd99 100644 --- a/test/unit/thread_spec.lua +++ b/test/unit/thread_spec.lua @@ -36,7 +36,7 @@ describe("ui.thread.build (expanded)", function() assert.are.equal("│", text(rows[3])) assert.are.equal("│ @bob · 2d ago", text(rows[4])) assert.are.equal("│ good catch, fixing it", text(rows[5])) - assert.are.equal("└─ ↳ 1 reply · open", text(rows[6])) + assert.are.equal("└─ open", text(rows[6])) end) it("colours the chrome with the open state group and meta separately", function() @@ -49,7 +49,9 @@ describe("ui.thread.build (expanded)", function() assert.are.same({ "needs a null check here", "differThreadBody" }, body[2]) end) - it("pluralises replies and shows the open tag", function() + -- the replies are rendered right above the footer, so a count beside them would + -- read as a there-is-more affordance. the collapsed line carries the total instead + it("the footer is the state alone, however many replies are on show", function() local t = { comments = { { author = "a", body = "x", created_at = "t" }, @@ -58,10 +60,12 @@ describe("ui.thread.build (expanded)", function() }, } local rows = build(t) - assert.are.equal("└─ ↳ 2 replies · open", text(rows[#rows])) + assert.are.equal("│ @c · t", text(rows[#rows - 2])) + assert.are.equal("│ z", text(rows[#rows - 1])) + assert.are.equal("└─ open", text(rows[#rows])) end) - it("a single comment has no reply count, just the open tag", function() + it("a single comment reads the same, just the open tag", function() local t = { comments = { { author = "a", body = "x", created_at = "t" } } } local rows = build(t) assert.are.equal("┌─ @a · t", text(rows[1])) @@ -83,7 +87,7 @@ describe("ui.thread.build (resolved + pending state)", function() local rows = build(t) assert.are.equal("┌─ @alice · 3d ago", text(rows[1])) -- state moved off the header assert.are.same({ "┌─ ", "differThreadResolved" }, rows[1][1]) - assert.are.equal("└─ ↳ 1 reply · ✓ resolved", text(rows[#rows])) + assert.are.equal("└─ ✓ resolved", text(rows[#rows])) -- the resolved tag rides its own green group, separate from the meta chrome assert.are.same({ "✓ resolved", "differThreadResolvedTag" }, rows[#rows][#rows[#rows]]) end) From 26e7649635414edf0d371fc5a30e536ed1012d0f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:39:14 +0100 Subject: [PATCH 3/8] fix: release a gc thread override once the cursor leaves its anchor an override outranks the cursor, so it has to stop outranking it once the cursor has gone; otherwise dismissing one peek opts that thread out for the session, with no third press to get the default back. --- CHANGELOG.md | 1 + lua/differ/pr/init.lua | 7 ++-- lua/differ/pr/threads.lua | 36 ++++++++++++++-- test/nvim/pr_nav_spec.lua | 79 +++++++++++++++++++++++++++++++++++ test/unit/pr_threads_spec.lua | 13 ++++++ 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5416baf..a335ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The diff drew a cursor line even with `cursorline` off and the PR overview drew none with it on; both now respect the user's config. Opening the PR checks float used to switch `cursorline` on for the rest of the session, it now doesn't - Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box - A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed +- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it; `comments.display = "expanded"` keeps its toggle, having no peek to return to ## [0.1.41] — 2026-09-06 diff --git a/lua/differ/pr/init.lua b/lua/differ/pr/init.lua index c8f0e6f..9e77a32 100644 --- a/lua/differ/pr/init.lua +++ b/lua/differ/pr/init.lua @@ -367,9 +367,10 @@ local function cursor_anchor() return require("differ.pr.threads").anchor_at(session, buf, row) end --- gc: collapse/expand the thread group under the cursor. an explicit toggle --- overrides the cursor-peek default until toggled back; re-apply swaps the boxes in --- place (stacked) or shows/hides the float (split), and a no-op off a thread row +-- gc: collapse/expand the thread group under the cursor. the toggle overrides the +-- default until toggled back, or until the cursor leaves the anchor where the default +-- is the cursor's to make; re-apply swaps the boxes in place (stacked) or shows/hides +-- the float (split), and a no-op off a thread row function M.toggle_thread() local s = session local anchor = cursor_anchor() diff --git a/lua/differ/pr/threads.lua b/lua/differ/pr/threads.lua index bd6ce39..45bc3dd 100644 --- a/lua/differ/pr/threads.lua +++ b/lua/differ/pr/threads.lua @@ -90,7 +90,9 @@ end -- ── state ─────────────────────────────────────────────────────────────────────── -- resolve a thread's collapsed state: an explicit per-thread toggle (gc) wins outright, --- then `expanded` never collapses and the other two open on the cursor's row alone +-- then `expanded` never collapses and the other two open on the cursor's row alone. in +-- the cursor-driven modes the override is released when the cursor leaves the anchor +-- (track_anchor), so gc dismisses a peek for the visit rather than for the session ---@param override boolean|nil -- the gc toggle for this thread ---@param display string -- `comments.display` ---@param group_active boolean -- the cursor is on this thread's anchor row @@ -105,6 +107,34 @@ function M.collapsed_state(override, display, group_active) return not group_active end +-- whether the cursor is what decides a thread's state, which is what makes a gc +-- override transient. derived from collapsed_state rather than naming the display modes, +-- so a new mode can't drift out of step with this +---@param display string -- `comments.display` +---@return boolean +function M.cursor_driven(display) + return M.collapsed_state(nil, display, true) ~= M.collapsed_state(nil, display, false) +end + +-- track the anchor the cursor sits on, releasing any gc override when it moves. an +-- override only outranks the cursor while the cursor is still on that anchor, and at +-- most one group is active, so moving anywhere else drops the lot: clearing the whole +-- table (rather than the group being left) also survives a file switch, where the old +-- group's key is gone and a lookup would strand its override for the session +---@param session table +---@param key string|nil -- the anchor key under the cursor, nil off a thread row +---@return boolean moved +local function track_anchor(session, key) + if key == session.thread_active then + return false + end + session.thread_active = key + if M.cursor_driven(require("differ").get_config().comments.display) then + session.thread_collapsed = {} + end + return true +end + -- `collapsed_state` over the live session and config ---@param session table ---@param t table @@ -692,6 +722,7 @@ function M.on_cursor(session) end local row = vim.api.nvim_win_get_cursor(win)[1] local group = M.anchor_at(session, buf, row) + track_anchor(session, group and group.key) -- before the state is read below if not group or peek_collapsed(session, group) then return M.close_peek() -- off a thread row, or gc hid this one end @@ -712,8 +743,7 @@ function M.on_cursor(session) break end end - if active ~= session.thread_active then - session.thread_active = active + if track_anchor(session, active) then M.apply(session) end end diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 62daef8..36fce46 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -1235,3 +1235,82 @@ describe("pr overview commenting", function() assert.are.equal(2, vim.fn.matchend("- renamed.txt is a list item", pat)) end) end) + +-- gc's override outranks the cursor, so it has to stop outranking it once the cursor +-- has gone: otherwise dismissing one peek opts that thread out of peeking for the +-- session, and there is no third press that gets the cursor-driven default back +describe("pr diff gc override lifetime", function() + local threads = require("differ.pr.threads") + + after_each(function() + if pr.current_session() then + pr.end_session() + end + end) + + -- a live review sitting on the thread anchor, with the peek already open + ---@return table session, integer bufnr, integer win + local function peeking() + local restore = open_overview(default_responses()) + local s = enter_review() + assert.is_true(vim.wait(1000, function() + return (pr.current_session().thread_anchors or {})[1] ~= nil + end)) + local buf = focus_thread_anchor(s) + threads.on_cursor(s) + assert.is_false(threads.collapsed_state(nil, "peek", s.thread_active ~= nil)) + return s, buf, restore + end + + it("gc dismisses the peek for the visit, not for the session", function() + local s, buf, restore = peeking() + local anchor = s.thread_anchors[1] + + assert.is_true(fire(buf, "toggle thread")) + assert.is_true(s.thread_collapsed[threads_result()[1].thread_id]) + + -- step off the anchor: the override has nothing left to outrank + vim.api.nvim_win_set_cursor(0, { anchor.row + 1, 0 }) + threads.on_cursor(s) + assert.are.same({}, s.thread_collapsed) + + -- and back on: it peeks again rather than staying dismissed + vim.api.nvim_win_set_cursor(0, { anchor.row, 0 }) + threads.on_cursor(s) + assert.are.same({}, s.thread_collapsed) + + restore() + end) + + it("the override stands while the cursor stays on the anchor", function() + local s, buf, restore = peeking() + local id = threads_result()[1].thread_id + + assert.is_true(fire(buf, "toggle thread")) + threads.on_cursor(s) -- same row, so nothing is released + assert.is_true(s.thread_collapsed[id]) + + assert.is_true(fire(buf, "toggle thread")) -- toggles back within the visit + assert.is_false(s.thread_collapsed[id]) + + restore() + end) + + it("an expanded-mode override stays put, having no cursor default to return to", function() + local cfg = require("differ").get_config() + local had = cfg.comments.display + cfg.comments.display = "expanded" + local s, buf, restore = peeking() + local anchor = s.thread_anchors[1] + + assert.is_true(fire(buf, "toggle thread")) + assert.is_true(s.thread_collapsed[threads_result()[1].thread_id]) + + vim.api.nvim_win_set_cursor(0, { anchor.row + 1, 0 }) + threads.on_cursor(s) + assert.is_true(s.thread_collapsed[threads_result()[1].thread_id]) + + cfg.comments.display = had + restore() + end) +end) diff --git a/test/unit/pr_threads_spec.lua b/test/unit/pr_threads_spec.lua index 016b6df..19b8c6f 100644 --- a/test/unit/pr_threads_spec.lua +++ b/test/unit/pr_threads_spec.lua @@ -142,3 +142,16 @@ describe("pr.threads.collapsed_state", function() assert.is_false(threads.collapsed_state(false, "peek", false)) end) end) + +-- what makes a gc override transient: an override outranks the cursor only in the modes +-- where the cursor is what decides the state in the first place +describe("pr.threads.cursor_driven", function() + it("is true for the modes whose default moves with the cursor", function() + assert.is_true(threads.cursor_driven("peek")) + assert.is_true(threads.cursor_driven("markers")) + end) + + it("is false for expanded, which reads the same either side of the cursor", function() + assert.is_false(threads.cursor_driven("expanded")) + end) +end) From 0d3856da8990c6e721aa06f3f0deb2cfd0768a67 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:03:56 +0100 Subject: [PATCH 4/8] fix: read the display mode a split layout forces, not the one configured split has no choice but the marker and float surface, so a config of expanded was treated there as having no cursor-driven default, and a gc override on it never lapsed. --- CHANGELOG.md | 2 +- lua/differ/pr/threads.lua | 17 ++++++++++++++--- test/nvim/pr_nav_spec.lua | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a335ffc..b6cba46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The diff drew a cursor line even with `cursorline` off and the PR overview drew none with it on; both now respect the user's config. Opening the PR checks float used to switch `cursorline` on for the rest of the session, it now doesn't - Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box - A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed -- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it; `comments.display = "expanded"` keeps its toggle, having no peek to return to +- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it, in the split layout too; `comments.display = "expanded"` keeps its toggle, having no peek to return to ## [0.1.41] — 2026-09-06 diff --git a/lua/differ/pr/threads.lua b/lua/differ/pr/threads.lua index 45bc3dd..c93f096 100644 --- a/lua/differ/pr/threads.lua +++ b/lua/differ/pr/threads.lua @@ -107,6 +107,18 @@ function M.collapsed_state(override, display, group_active) return not group_active end +-- the display mode actually in effect. split has no choice but the marker + float +-- surface (virt_lines desync the columns), so a config of `expanded` still reads there +-- as a cursor-driven float, and everything keyed off the mode has to agree +---@param session table +---@return string +local function effective_display(session) + if session.view and M.marker_mode(session.view) then + return "markers" + end + return require("differ").get_config().comments.display +end + -- whether the cursor is what decides a thread's state, which is what makes a gc -- override transient. derived from collapsed_state rather than naming the display modes, -- so a new mode can't drift out of step with this @@ -129,7 +141,7 @@ local function track_anchor(session, key) return false end session.thread_active = key - if M.cursor_driven(require("differ").get_config().comments.display) then + if M.cursor_driven(effective_display(session)) then session.thread_collapsed = {} end return true @@ -142,8 +154,7 @@ end ---@return boolean local function thread_collapsed(session, t, group_active) local override = session.thread_collapsed and session.thread_collapsed[t.thread_id] - local display = require("differ").get_config().comments.display - return M.collapsed_state(override, display, group_active) + return M.collapsed_state(override, effective_display(session), group_active) end -- whether threads read as an eol marker plus the peek float rather than inline boxes. diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 36fce46..0f6d4a8 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -1314,3 +1314,42 @@ describe("pr diff gc override lifetime", function() restore() end) end) + +-- split has no choice but the marker + float surface, whatever `comments.display` says, +-- so a config of `expanded` there is still a cursor-driven float and its gc override has +-- to lapse like any other float's +describe("pr diff gc override lifetime under a forced marker layout", function() + local threads = require("differ.pr.threads") + + after_each(function() + if pr.current_session() then + pr.end_session() + end + end) + + it("releases the override in split, even with display = expanded", function() + local cfg = require("differ").get_config() + local had = cfg.comments.display + cfg.comments.display = "expanded" + + local restore = open_overview(default_responses()) + local s = enter_review() + assert.is_true(vim.wait(1000, function() + return (pr.current_session().thread_anchors or {})[1] ~= nil + end)) + s.view.layout = "split" -- what forces marker mode regardless of the config + assert.is_true(threads.marker_mode(s.view)) + + local buf = focus_thread_anchor(s) + threads.on_cursor(s) + assert.is_true(fire(buf, "toggle thread")) + assert.is_true(s.thread_collapsed[threads_result()[1].thread_id]) + + vim.api.nvim_win_set_cursor(0, { s.thread_anchors[1].row + 1, 0 }) + threads.on_cursor(s) + assert.are.same({}, s.thread_collapsed) + + cfg.comments.display = had + restore() + end) +end) From 172dad3d4d41978faed14a967078b43576aa45b2 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:05:39 +0100 Subject: [PATCH 5/8] feat: quote-reply to a conversation comment from the pr overview github doesn't thread these, so gp answers one with a new comment that quotes what it answers; the visual variant quotes the selection alone, which is the part a cursor buys over github's whole-comment quote. --- CHANGELOG.md | 2 +- README.md | 2 +- doc/differ.txt | 9 ++- lua/differ/pr/overview.lua | 147 +++++++++++++++++++++++++++++++----- lua/differ/ui/compose.lua | 5 +- lua/differ/ui/overview.lua | 30 ++++++-- test/nvim/pr_nav_spec.lua | 90 ++++++++++++++++++++-- test/unit/overview_spec.lua | 72 ++++++++++++++++++ 8 files changed, 318 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cba46..004da2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` on a thread box replies to that thread +- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` answers whatever is under the cursor, replying into a code thread or quoting a conversation comment into a new one. In visual mode `gp` quotes just the selection - `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review ### Changed diff --git a/README.md b/README.md index a9452af..d6ef8f4 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command ### PR review -`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` replies to the thread under the cursor and `gc` shows or hides that thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. +`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` answers whatever is under the cursor (replying into a code thread, or quoting a conversation comment into a new one, with the visual-mode `gp` quoting just the selection) and `gc` shows or hides a thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. | Command | Effect | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index 55537a7..a58482e 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -325,10 +325,11 @@ page; no argument lists the repo’s PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment’s file and line. `ga` comments on the PR itself, -`gp` replies to the thread under the cursor and `gc` shows or hides that -thread’s replies, all without leaving the page. The review keymaps (`ga` -comment, `gp` reply, `gr` resolve, `` viewed) are live once you’re in -the diff. +`gp` answers whatever is under the cursor (replying into a code thread, or +quoting a conversation comment into a new one, with the visual-mode `gp` +quoting just the selection) and `gc` shows or hides a thread’s replies, all +without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` +resolve, `` viewed) are live once you’re in the diff. ----------------------------------------------------------------------- Command Effect diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index 811ece2..81005a4 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -27,6 +27,21 @@ local WRAP_INDENT_PAT = "^" .. vim.trim(ui.SPINE) .. "\\s*\\|^\\s*[-*+]\\s\\+\\|^\\s*\\d\\+[\\]:.)}\\t ]\\s*" +-- a body string as its lines, for quoting it back +---@param body string|nil +---@return string[] +local function split_body(body) + return vim.split(body or "", "\n", { plain = true }) +end + +-- drop a thread box's left spine from a picked line, so a selection inside a box quotes +-- as the comment text it is rather than as the drawing around it +---@param line string +---@return string +local function strip_chrome(line) + return (line:gsub("^" .. vim.trim(ui.SPINE) .. "%s*", "")) +end + local GUARD = "differ.pr.overview.guard" -- the page's scratch-buffer name, owned here; the review's on_repurpose asks @@ -38,6 +53,7 @@ local BUFNAME = "differ://overview" -- built row->thread-anchor index ( reads it at press time) local buf = nil local anchors = nil +local quotes = nil -- thread node id -> true for each box showing its replies. lives with the page buffer -- (teardown clears it), since it's a reading state, not something the session owns @@ -112,7 +128,7 @@ function M.teardown() if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end - buf, anchors, expanded, last = nil, nil, {}, nil + buf, anchors, quotes, expanded, last = nil, nil, nil, {}, nil end -- end the session when the page window is closed: pre-review that's the only exit (q @@ -142,7 +158,8 @@ end -- acts on a stale session. e enters the review (panel + diff), r enters and also starts -- a github draft review, q backs into the review when one is open, gx opens the PR url. -- on a thread row enters the review at that thread's file/line; elsewhere it opens --- the url. ga comments on the PR and gp replies into the thread under the cursor, both +-- the url. ga comments on the PR and gp answers whatever is under the cursor, quoting +-- it when there is no thread to reply into (visual gp quotes the selection alone), both -- mirroring the diff's keys. ]t/[t hop between thread boxes; g? floats the cheatsheet ---@param b integer local function set_keymaps(b) @@ -164,15 +181,19 @@ local function set_keymaps(b) require("differ.pr").notify("no PR url", vim.log.levels.WARN) end end - -- the thread anchor whose row span covers the cursor, or nil off a thread section - local function anchor_at_cursor() - local row = vim.api.nvim_win_get_cursor(0)[1] + -- the thread anchor whose row span covers `row`, or nil off a thread section + ---@param row integer + ---@return table|nil + local function anchor_at_row(row) for _, a in ipairs(anchors or {}) do if row >= a.row_start and row <= a.row_end then return a end end end + local function anchor_at_cursor() + return anchor_at_row(vim.api.nvim_win_get_cursor(0)[1]) + end local function select_or_url() local a = anchor_at_cursor() local s = live() @@ -226,7 +247,9 @@ local function set_keymaps(b) require("differ.ui.help").show({ " e / r enter review / enter + start a draft review (thread row: at its file)", " thread row: jump into the review here, else open the PR url", - " ga / gp comment on the PR / reply to the thread under the cursor", + " ga comment on the PR", + " gp reply here: into the thread, or quoting a plain comment", + " {Visual}gp quote just the selection instead of the whole comment", " gc show / hide the replies of the thread under the cursor", " ]t / [t next / previous thread", " gx open the PR in the browser", @@ -238,11 +261,12 @@ local function set_keymaps(b) -- renders from github rather than being patched in locally. the stashed cursor keeps -- the reading position across the rebuild (render consumes it as a one-shot) ---@param s table - ---@param spec { title: string, done: string, send: fun(body: string, cb: fun(err: table|nil)) } + ---@param spec { title: string, done: string, initial?: string, send: fun(body: string, cb: fun(err: table|nil)) } local function compose(s, spec) stash_cursor(s) require("differ.ui.compose").open({ title = spec.title, + initial = spec.initial, anchor_win = s.overview_win, on_submit = function(body) if body == "" then @@ -276,24 +300,46 @@ local function set_keymaps(b) end, }) end - -- gp: reply into the thread under the cursor, joining the draft when a review is in - -- progress (like the diff's gp). github doesn't thread conversation comments, so off - -- a thread row there is nothing to reply to and ga is the way to answer - local function reply() - local s = live() - if not s then - return + -- the flat timeline section whose row span covers `row`, or nil. these are the + -- quote targets: github doesn't thread conversation comments or review verdicts, so + -- an answer is a new comment that says what it is answering + ---@param row integer + ---@return table|nil + local function quote_at(row) + for _, q in ipairs(quotes or {}) do + if row >= q.row_start and row <= q.row_end then + return q + end end - local a = anchor_at_cursor() - if not (a and a.thread_id) then - return require("differ.pr").notify("no thread here to reply to; ga comments on the PR") + end + -- `lines` as a markdown blockquote attributed to @author, ready to type under. the + -- page is chronological, so a reply lands at the bottom and the attribution is the + -- only thing saying what it answers + ---@param author string|nil + ---@param lines string[] + ---@return string + local function quoted(author, lines) + local out = { ("> @%s wrote:"):format(author or "?") } + for _, l in ipairs(lines) do + out[#out + 1] = vim.trim(l) == "" and ">" or ("> " .. l) end + out[#out + 1] = "" + out[#out + 1] = "" + return table.concat(out, "\n") + end + -- reply into `thread_id`, prefilled with `initial` when quoting. joins the draft + -- when a review is in progress, like the diff's gp + ---@param s table + ---@param thread_id string + ---@param initial string|nil + local function reply_to_thread(s, thread_id, initial) local draft = s.review_id and s.review_id ~= "" compose(s, { title = draft and "Reply (draft)" or "Reply (posts immediately)", done = draft and "reply added to your review draft" or "reply posted", + initial = initial, send = function(body, cb) - local args = { in_reply_to = a.thread_id, body = body } + local args = { in_reply_to = thread_id, body = body } if draft then args.review_id = s.review_id end @@ -308,6 +354,66 @@ local function set_keymaps(b) end, }) end + -- answer a flat section: a new conversation comment opening with the quote + ---@param s table + ---@param initial string + local function quote_reply(s, initial) + compose(s, { + title = "Quote reply (posts immediately)", + done = "comment posted", + initial = initial, + send = function(body, cb) + client.post_issue_comment(s.pr, body, cb) + end, + }) + end + -- gp: answer whatever the cursor is on. a thread box replies into the thread; a + -- conversation comment or review verdict has no thread to reply into, so it quotes + -- into a new comment instead + local function reply() + local s = live() + if not s then + return + end + local a = anchor_at_cursor() + if a and a.thread_id then + return reply_to_thread(s, a.thread_id) + end + local q = quote_at(vim.api.nvim_win_get_cursor(0)[1]) + if not q then + return require("differ.pr").notify("nothing here to reply to; ga comments on the PR") + end + quote_reply(s, quoted(q.author, split_body(q.body))) + end + -- gp (visual): quote just the selected lines rather than the whole comment, then + -- route as above. the box chrome is stripped so a selection inside a thread reads as + -- the comment text it is + local function reply_selection() + local s = live() + if not s then + return + end + local r1, r2 = vim.fn.line("v"), vim.fn.line(".") + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), + "n", + false + ) + local lo, hi = math.min(r1, r2), math.max(r1, r2) + local picked = {} + for _, l in ipairs(vim.api.nvim_buf_get_lines(b, lo - 1, hi, false)) do + picked[#picked + 1] = strip_chrome(l) + end + local a = anchor_at_row(lo) + if a and a.thread_id then + return reply_to_thread(s, a.thread_id, quoted(a.author, picked)) + end + local q = quote_at(lo) + if not q then + return require("differ.pr").notify("nothing here to reply to; ga comments on the PR") + end + quote_reply(s, quoted(q.author, picked)) + end -- gc: show or hide the replies of the thread box under the cursor, matching the -- diff's collapse key. the page is rebuilt from what it already holds, so no fetch local function toggle_replies() @@ -325,6 +431,7 @@ local function set_keymaps(b) vim.keymap.set("n", "gx", open_url, opts) vim.keymap.set("n", "ga", comment, opts) vim.keymap.set("n", "gp", reply, opts) + vim.keymap.set("x", "gp", reply_selection, opts) vim.keymap.set("n", "", select_or_url, opts) vim.keymap.set("n", "e", function() enter(false) @@ -376,7 +483,7 @@ end -- (re)build the scratch buffer, paint the built lines + highlight spans + the -- treesitter pass over the hunk snippets, keep the fresh thread-anchor index for ----@param built { lines: string[], highlights: table[], anchors: table[], hunks: table[] } +---@param built { lines: string[], highlights: table[], anchors: table[], quotes: table[], hunks: table[] } local function paint(built) if not (buf and vim.api.nvim_buf_is_valid(buf)) then buf = vim.api.nvim_create_buf(false, true) @@ -387,7 +494,7 @@ local function paint(built) pcall(vim.api.nvim_buf_set_name, buf, BUFNAME) set_keymaps(buf) end - anchors = built.anchors + anchors, quotes = built.anchors, built.quotes vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, built.lines) vim.bo[buf].modifiable = false diff --git a/lua/differ/ui/compose.lua b/lua/differ/ui/compose.lua index 8e7c6a8..40fa702 100644 --- a/lua/differ/ui/compose.lua +++ b/lua/differ/ui/compose.lua @@ -138,7 +138,10 @@ function M.open(opts) end, }) - vim.cmd("startinsert") + -- land past a prefill (a quote, or the conflict re-prompt's old body) rather than + -- above it; startinsert! appends, so an empty buffer still opens at the top + pcall(vim.api.nvim_win_set_cursor, win, { vim.api.nvim_buf_line_count(buf), 0 }) + vim.cmd("startinsert!") return { close = shut } end diff --git a/lua/differ/ui/overview.lua b/lua/differ/ui/overview.lua index c93aed4..998f82d 100644 --- a/lua/differ/ui/overview.lua +++ b/lua/differ/ui/overview.lua @@ -169,12 +169,13 @@ local function verdict_of(item) end -- build the overview buffer content. `anchors` maps each thread item to its 1-based --- row span ({ row_start, row_end, thread_id, path, side, line }), so anywhere in --- the section can jump to the code anchor, and reply into the thread. a highlight is --- { row, col_start, col_end, hl }, 0-based +-- row span ({ row_start, row_end, thread_id, author, path, side, line }), so in +-- the section can jump to the code anchor, and reply into the thread. `quotes` does the +-- same for the flat sections ({ row_start, row_end, author, body }), which have no +-- thread to reply into. a highlight is { row, col_start, col_end, hl }, 0-based ---@param data { meta: table, checks: table|nil, unresolved: integer, total_threads: integer, timeline: table } ---@param opts { expanded?: table, reltime?: fun(ts: string): string }|nil ----@return { lines: string[], highlights: table[], anchors: table[], hunks: table[] } +---@return { lines: string[], highlights: table[], anchors: table[], quotes: table[], hunks: table[] } function M.build(data, opts) opts = opts or {} local reltime = opts.reltime or function(ts) @@ -254,6 +255,7 @@ function M.build(data, opts) -- plain comments and verdicts keep the flat ── header ── style local anchors = {} local hunks = {} + local quotes = {} -- a code thread as a left-spine box: a top-rule header, the root comment's diff hunk -- on inset spine rows, the body, a footer rule with the reply count. records the row @@ -353,15 +355,19 @@ function M.build(data, opts) row_start = row_start, row_end = #lines, thread_id = item.thread_id, + author = item.author, path = item.path, side = item.side, line = item.line, } end - -- a plain comment / review verdict as a flat ── header ── with its body below + -- a plain comment / review verdict as a flat ── header ── with its body below. + -- records its row span in `quotes`: github doesn't thread these, so the only way to + -- answer one is a new comment, and the vim layer quotes it to say which ---@param item table local function render_flat(item) + local row_start = #lines + 1 local v = verdict_of(item) push({ { "── ", "differOverviewMeta" }, @@ -375,6 +381,12 @@ function M.build(data, opts) push({ { line, "differOverviewBody" } }) end end + quotes[#quotes + 1] = { + row_start = row_start, + row_end = #lines, + author = item.author, + body = item.body, + } end for i, item in ipairs(timeline(data.timeline or {})) do @@ -388,7 +400,13 @@ function M.build(data, opts) end end - return { lines = lines, highlights = highlights, anchors = anchors, hunks = hunks } + return { + lines = lines, + highlights = highlights, + anchors = anchors, + quotes = quotes, + hunks = hunks, + } end return M diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 0f6d4a8..37981ed 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -87,7 +87,18 @@ local function default_responses() result = { base = { content = "a\nb\nc\n" }, head = { content = "a\nB\nc\n" } }, }, get_threads = { result = threads_result() }, - get_timeline = { result = { comments = {}, reviews = {} } }, + get_timeline = { + result = { + comments = { + { + author = "kim", + body = "one\ntwo", + created_at = "2026-01-01T00:00:00Z", + }, + }, + reviews = {}, + }, + }, get_checks = { result = { rollup = "SUCCESS", checks = {} } }, } end @@ -103,9 +114,13 @@ local function fire(buf, desc) return false end --- fire a buffer-local keymap by its lhs (the overview's /e/q carry no desc) -local function fire_lhs(buf, lhs) - for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do +-- fire a buffer-local keymap by its lhs, in `mode` (default normal). the overview's +-- /e/q carry no desc, and its gp is bound in visual mode as well +---@param buf integer +---@param lhs string +---@param mode string|nil +local function fire_lhs(buf, lhs, mode) + for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, mode or "n")) do if m.lhs == lhs and m.callback then m.callback() return true @@ -1124,20 +1139,83 @@ describe("pr overview commenting", function() assert.is_nil(params.expected_head) end) - it("gp off a thread row says so and composes nothing", function() + it("gp off any section says so and composes nothing", function() local buf, sent = page() vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { 1, 0 }) assert.is_true(fire_lhs(buf, "gp")) assert.are.equal( - "differ: no thread here to reply to; ga comments on the PR", + "differ: nothing here to reply to; ga comments on the PR", _G.notifs[#_G.notifs].msg ) assert.are.equal(buf, vim.api.nvim_get_current_buf()) -- no compose split opened assert.is_nil(sent_params(sent, "post_comment")) end) + -- github doesn't thread conversation comments, so answering one is a new comment + -- that quotes what it is answering + it("gp on a conversation comment quotes it into a new comment", function() + local buf, sent = page() + local row = row_containing(buf, "@kim commented") + assert.is_truthy(row) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gp")) + local composed = vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + assert.are.same({ "> @kim wrote:", "> one", "> two", "", "" }, composed) + + submit(table.concat(composed, "\n") .. "sounds right") + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_issue_comment") ~= nil + end)) + -- a quote is a plain comment, never a thread reply + assert.is_nil(sent_params(sent, "post_comment")) + end) + + it("visual gp quotes the selection alone, not the whole comment", function() + local buf, sent = page() + local row = row_containing(buf, "two") -- the comment's second body line + assert.is_truthy(row) + vim.api.nvim_set_current_win(pr.current_session().overview_win) + vim.api.nvim_win_set_cursor(0, { row, 0 }) + vim.cmd("normal! V") + assert.are.equal("V", vim.fn.mode()) -- the callback reads the live selection + + assert.is_true(fire_lhs(buf, "gp", "x")) + assert.are.same( + { "> @kim wrote:", "> two", "", "" }, + vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + ) + + submit("> @kim wrote:\n> two\n\nagreed") + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_issue_comment") ~= nil + end)) + end) + + it("visual gp inside a thread box quotes into a reply, spine stripped", function() + local buf, sent = page() + local row = row_containing(buf, "please fix") + assert.is_truthy(row) + vim.api.nvim_set_current_win(pr.current_session().overview_win) + vim.api.nvim_win_set_cursor(0, { row, 0 }) + vim.cmd("normal! V") + assert.are.equal("V", vim.fn.mode()) -- the callback reads the live selection + + assert.is_true(fire_lhs(buf, "gp", "x")) + assert.are.same( + { "> @reviewer wrote:", "> please fix", "", "" }, + vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + ) + + submit("> @reviewer wrote:\n> please fix\n\ndone") + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_comment") ~= nil + end)) + assert.are.equal("th_1", sent_params(sent, "post_comment").in_reply_to) + end) + it("a reply drops the cached thread list so the refreshed page carries it", function() local buf, sent = page() local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) diff --git a/test/unit/overview_spec.lua b/test/unit/overview_spec.lua index dcfbd74..82c4775 100644 --- a/test/unit/overview_spec.lua +++ b/test/unit/overview_spec.lua @@ -315,6 +315,13 @@ describe("ui.overview.build (thread sections + anchors)", function() assert.is_nil(row_of(built, "│ ack")) end) + -- gp quotes a flat section, so the builder has to say where each one sits and what + -- it holds; a thread section is answered by replying, so it is not a quote target + it("records no quote span for a thread section", function() + local built = build(thread_data()) + assert.are.same({}, built.quotes) + end) + it("returns an empty anchor list without threads", function() local built = build({ meta = BASE_META, @@ -326,6 +333,71 @@ describe("ui.overview.build (thread sections + anchors)", function() end) end) +describe("ui.overview.build (quote spans)", function() + local function flat_data() + return { + meta = BASE_META, + unresolved = 0, + total_threads = 0, + timeline = { + comments = { + { author = "kim", body = "one\ntwo", created_at = "2026-01-01T00:00:00Z" }, + }, + reviews = { + { + author = "sam", + state = "APPROVED", + body = "lgtm", + created_at = "2026-01-02T00:00:00Z", + }, + }, + }, + } + end + + it("spans each flat section from its header to its last body row", function() + local built = build(flat_data()) + assert.are.equal(2, #built.quotes) + local q = built.quotes[1] + assert.are.equal("kim", q.author) + assert.are.equal("one\ntwo", q.body) + assert.are.equal( + row_of(built, "── @kim commented · 2026-01-01T00:00:00Z ──"), + q.row_start + ) + assert.are.equal(row_of(built, "two"), q.row_end) + end) + + it("makes a review verdict quotable too, body and all", function() + local built = build(flat_data()) + local q = built.quotes[2] + assert.are.equal("sam", q.author) + assert.are.equal("lgtm", q.body) + assert.are.equal(row_of(built, "lgtm"), q.row_end) + end) + + it("spans a bodyless section over its header alone", function() + local built = build({ + meta = BASE_META, + unresolved = 0, + total_threads = 0, + timeline = { + comments = {}, + reviews = { + { + author = "sam", + state = "APPROVED", + body = "", + created_at = "2026-01-02T00:00:00Z", + }, + }, + }, + }) + assert.are.equal(1, #built.quotes) + assert.are.equal(built.quotes[1].row_start, built.quotes[1].row_end) + end) +end) + describe("ui.overview.build (thread diff hunk)", function() -- a thread carrying `hunk` as its root comment's diff context local function hunk_data(hunk) From e0593d360ba545c506566cd6c89a885da52ef0f8 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:57:07 +0100 Subject: [PATCH 6/8] docs: say what gp quotes and when --- CHANGELOG.md | 2 +- README.md | 2 +- doc/differ.txt | 11 ++++++----- lua/differ/pr/overview.lua | 11 ++++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004da2c..7bc3c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` answers whatever is under the cursor, replying into a code thread or quoting a conversation comment into a new one. In visual mode `gp` quotes just the selection +- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` answers whatever is under the cursor, replying into a code thread or quoting a conversation comment into a new one. In visual mode `gp` quotes just the selection either way - `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review ### Changed diff --git a/README.md b/README.md index d6ef8f4..dbfe3a3 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command ### PR review -`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` answers whatever is under the cursor (replying into a code thread, or quoting a conversation comment into a new one, with the visual-mode `gp` quoting just the selection) and `gc` shows or hides a thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. +`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` answers whatever is under the cursor (a plain reply into a code thread, or a new comment quoting a conversation comment, since GitHub doesn't thread those; the visual-mode `gp` quotes just the selection either way) and `gc` shows or hides a thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. | Command | Effect | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index a58482e..18c79fb 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -325,11 +325,12 @@ page; no argument lists the repo’s PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment’s file and line. `ga` comments on the PR itself, -`gp` answers whatever is under the cursor (replying into a code thread, or -quoting a conversation comment into a new one, with the visual-mode `gp` -quoting just the selection) and `gc` shows or hides a thread’s replies, all -without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` -resolve, `` viewed) are live once you’re in the diff. +`gp` answers whatever is under the cursor (a plain reply into a code thread, or +a new comment quoting a conversation comment, since GitHub doesn’t thread +those; the visual-mode `gp` quotes just the selection either way) and `gc` +shows or hides a thread’s replies, all without leaving the page. The review +keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once +you’re in the diff. ----------------------------------------------------------------------- Command Effect diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index 81005a4..208a0bb 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -158,9 +158,10 @@ end -- acts on a stale session. e enters the review (panel + diff), r enters and also starts -- a github draft review, q backs into the review when one is open, gx opens the PR url. -- on a thread row enters the review at that thread's file/line; elsewhere it opens --- the url. ga comments on the PR and gp answers whatever is under the cursor, quoting --- it when there is no thread to reply into (visual gp quotes the selection alone), both --- mirroring the diff's keys. ]t/[t hop between thread boxes; g? floats the cheatsheet +-- the url. ga comments on the PR and gp answers whatever is under the cursor: a plain +-- reply into a thread, which needs no quote to say what it answers, and a quoting one +-- where there is no thread (visual gp quotes the selection either way). ]t/[t hop +-- between thread boxes; g? floats the cheatsheet ---@param b integer local function set_keymaps(b) local function live() @@ -248,8 +249,8 @@ local function set_keymaps(b) " e / r enter review / enter + start a draft review (thread row: at its file)", " thread row: jump into the review here, else open the PR url", " ga comment on the PR", - " gp reply here: into the thread, or quoting a plain comment", - " {Visual}gp quote just the selection instead of the whole comment", + " gp reply into the thread here, or quote a plain comment into a new one", + " v_gp the same, quoting the selection rather than the whole comment", " gc show / hide the replies of the thread under the cursor", " ]t / [t next / previous thread", " gx open the PR in the browser", From 63607fdf3dce6c44100dfceab287e1bc84a0be32 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:24:16 +0100 Subject: [PATCH 7/8] feat: split the overview's answer keys into gp and gq the cursor decides where an answer goes and the key decides whether it quotes, so neither has to explain the other; gq inside an expanded thread quotes the reply the cursor is on. --- CHANGELOG.md | 2 +- README.md | 2 +- doc/differ.txt | 15 ++--- lua/differ/pr/overview.lua | 126 +++++++++++++++++++------------------ lua/differ/ui/overview.lua | 21 ++++++- test/nvim/pr_nav_spec.lua | 94 +++++++++++++++++++++++---- 6 files changed, 178 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc3c6e..bc4d3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- The PR overview can now be commented on without leaving it: `ga` posts a comment on the pull request, and `gp` answers whatever is under the cursor, replying into a code thread or quoting a conversation comment into a new one. In visual mode `gp` quotes just the selection either way +- The PR overview can now be commented on without leaving it. `ga` posts a comment on the pull request, and `gp`/`gq` answer what's under the cursor: into that code thread, or as a new PR comment where GitHub offers no threading. `gq` opens with the comment quoted, and in visual mode quotes just the selection - `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review ### Changed diff --git a/README.md b/README.md index dbfe3a3..6ab7174 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command ### PR review -`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. `ga` comments on the PR itself, `gp` answers whatever is under the cursor (a plain reply into a code thread, or a new comment quoting a conversation comment, since GitHub doesn't thread those; the visual-mode `gp` quotes just the selection either way) and `gc` shows or hides a thread's replies, all without leaving the page. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. +`:Differ pr []` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps straight to that comment's file and line. The page writes back too, without leaving it: `ga` comments on the PR, `gc` shows or hides a thread's replies, and `gp`/`gq` answer what's under the cursor. The cursor decides where the answer goes — into the code thread it sits in, or a new PR comment anywhere else, which is all GitHub offers off a thread — and the key decides whether it opens with a quote: `gp` doesn't, `gq` quotes the comment, and visual-mode `gq` quotes just the selection. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once you're in the diff. | Command | Effect | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index 18c79fb..384dbd1 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -324,13 +324,14 @@ PR REVIEW *differ-usage-pr-review* page; no argument lists the repo’s PRs and picks from them. `owner/repo#` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `` on a thread jumps -straight to that comment’s file and line. `ga` comments on the PR itself, -`gp` answers whatever is under the cursor (a plain reply into a code thread, or -a new comment quoting a conversation comment, since GitHub doesn’t thread -those; the visual-mode `gp` quotes just the selection either way) and `gc` -shows or hides a thread’s replies, all without leaving the page. The review -keymaps (`ga` comment, `gp` reply, `gr` resolve, `` viewed) are live once -you’re in the diff. +straight to that comment’s file and line. The page writes back too, without +leaving it: `ga` comments on the PR, `gc` shows or hides a thread’s replies, +and `gp`/`gq` answer what’s under the cursor. The cursor decides where the +answer goes — into the code thread it sits in, or a new PR comment anywhere +else, which is all GitHub offers off a thread — and the key decides whether +it opens with a quote: `gp` doesn’t, `gq` quotes the comment, and visual-mode +`gq` quotes just the selection. The review keymaps (`ga` comment, `gp` reply, +`gr` resolve, `` viewed) are live once you’re in the diff. ----------------------------------------------------------------------- Command Effect diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index 208a0bb..14e41b0 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -158,10 +158,11 @@ end -- acts on a stale session. e enters the review (panel + diff), r enters and also starts -- a github draft review, q backs into the review when one is open, gx opens the PR url. -- on a thread row enters the review at that thread's file/line; elsewhere it opens --- the url. ga comments on the PR and gp answers whatever is under the cursor: a plain --- reply into a thread, which needs no quote to say what it answers, and a quoting one --- where there is no thread (visual gp quotes the selection either way). ]t/[t hop --- between thread boxes; g? floats the cheatsheet +-- the url. ga comments on the PR; gp and gq answer what the cursor is on, the cursor +-- deciding where it goes (into the thread box it sits in, else a new PR comment, which +-- is all github offers off a thread) and the key deciding whether it opens with a quote +-- (v_gq quoting the selection alone). ]t/[t hop between thread boxes; g? floats the +-- cheatsheet ---@param b integer local function set_keymaps(b) local function live() @@ -249,8 +250,8 @@ local function set_keymaps(b) " e / r enter review / enter + start a draft review (thread row: at its file)", " thread row: jump into the review here, else open the PR url", " ga comment on the PR", - " gp reply into the thread here, or quote a plain comment into a new one", - " v_gp the same, quoting the selection rather than the whole comment", + " gp / gq answer what's under the cursor, gq opening with a quote of it", + " v_gq the same, quoting the selection rather than the whole comment", " gc show / hide the replies of the thread under the cursor", " ]t / [t next / previous thread", " gx open the PR in the browser", @@ -301,21 +302,32 @@ local function set_keymaps(b) end, }) end - -- the flat timeline section whose row span covers `row`, or nil. these are the - -- quote targets: github doesn't thread conversation comments or review verdicts, so - -- an answer is a new comment that says what it is answering + -- where an answer to `row` goes, and what quoting it would quote. a thread box + -- answers into the thread, and carries the comment the row sits in (the root, or a + -- reply once gc has expanded them); anything else has no thread, so github can only + -- take a new PR comment. nil off every section ---@param row integer - ---@return table|nil - local function quote_at(row) + ---@return { thread_id?: string, author?: string, body?: string }|nil + local function target_at(row) + local a = anchor_at_row(row) + if a and a.thread_id then + local c = nil + for _, span in ipairs(a.comments or {}) do + if row >= span.row_start and row <= span.row_end then + c = span + break + end + end + c = c or (a.comments or {})[1] or {} + return { thread_id = a.thread_id, author = c.author, body = c.body } + end for _, q in ipairs(quotes or {}) do if row >= q.row_start and row <= q.row_end then - return q + return { author = q.author, body = q.body } end end end - -- `lines` as a markdown blockquote attributed to @author, ready to type under. the - -- page is chronological, so a reply lands at the bottom and the attribution is the - -- only thing saying what it answers + -- `lines` as a markdown blockquote attributed to @author, ready to type under ---@param author string|nil ---@param lines string[] ---@return string @@ -328,19 +340,30 @@ local function set_keymaps(b) out[#out + 1] = "" return table.concat(out, "\n") end - -- reply into `thread_id`, prefilled with `initial` when quoting. joins the draft - -- when a review is in progress, like the diff's gp + -- answer `target`, prefilled with `initial`. a thread target replies into the thread, + -- joining the draft when a review is in progress (like the diff's gp); everything + -- else becomes a new PR comment, which is all github offers there ---@param s table - ---@param thread_id string + ---@param target table ---@param initial string|nil - local function reply_to_thread(s, thread_id, initial) + local function answer(s, target, initial) + if not target.thread_id then + return compose(s, { + title = "Comment on the PR (posts immediately)", + done = "comment posted", + initial = initial, + send = function(body, cb) + client.post_issue_comment(s.pr, body, cb) + end, + }) + end local draft = s.review_id and s.review_id ~= "" compose(s, { title = draft and "Reply (draft)" or "Reply (posts immediately)", done = draft and "reply added to your review draft" or "reply posted", initial = initial, send = function(body, cb) - local args = { in_reply_to = thread_id, body = body } + local args = { in_reply_to = target.thread_id, body = body } if draft then args.review_id = s.review_id end @@ -355,41 +378,23 @@ local function set_keymaps(b) end, }) end - -- answer a flat section: a new conversation comment opening with the quote - ---@param s table - ---@param initial string - local function quote_reply(s, initial) - compose(s, { - title = "Quote reply (posts immediately)", - done = "comment posted", - initial = initial, - send = function(body, cb) - client.post_issue_comment(s.pr, body, cb) - end, - }) - end - -- gp: answer whatever the cursor is on. a thread box replies into the thread; a - -- conversation comment or review verdict has no thread to reply into, so it quotes - -- into a new comment instead - local function reply() + -- gp / gq: answer what the cursor is on, gq opening with a quote of it. the + -- destination is the cursor's to decide, the quote the key's + ---@param quote boolean + local function respond(quote) local s = live() if not s then return end - local a = anchor_at_cursor() - if a and a.thread_id then - return reply_to_thread(s, a.thread_id) - end - local q = quote_at(vim.api.nvim_win_get_cursor(0)[1]) - if not q then - return require("differ.pr").notify("nothing here to reply to; ga comments on the PR") + local target = target_at(vim.api.nvim_win_get_cursor(0)[1]) + if not target then + return require("differ.pr").notify("nothing here to answer; ga comments on the PR") end - quote_reply(s, quoted(q.author, split_body(q.body))) + answer(s, target, quote and quoted(target.author, split_body(target.body)) or nil) end - -- gp (visual): quote just the selected lines rather than the whole comment, then - -- route as above. the box chrome is stripped so a selection inside a thread reads as - -- the comment text it is - local function reply_selection() + -- gq (visual): quote the selected lines alone. the box chrome is stripped so a + -- selection inside a thread reads as the comment text it is + local function respond_selection() local s = live() if not s then return @@ -401,19 +406,15 @@ local function set_keymaps(b) false ) local lo, hi = math.min(r1, r2), math.max(r1, r2) + local target = target_at(lo) + if not target then + return require("differ.pr").notify("nothing here to answer; ga comments on the PR") + end local picked = {} for _, l in ipairs(vim.api.nvim_buf_get_lines(b, lo - 1, hi, false)) do picked[#picked + 1] = strip_chrome(l) end - local a = anchor_at_row(lo) - if a and a.thread_id then - return reply_to_thread(s, a.thread_id, quoted(a.author, picked)) - end - local q = quote_at(lo) - if not q then - return require("differ.pr").notify("nothing here to reply to; ga comments on the PR") - end - quote_reply(s, quoted(q.author, picked)) + answer(s, target, quoted(target.author, picked)) end -- gc: show or hide the replies of the thread box under the cursor, matching the -- diff's collapse key. the page is rebuilt from what it already holds, so no fetch @@ -431,8 +432,13 @@ local function set_keymaps(b) vim.keymap.set("n", "gc", toggle_replies, opts) vim.keymap.set("n", "gx", open_url, opts) vim.keymap.set("n", "ga", comment, opts) - vim.keymap.set("n", "gp", reply, opts) - vim.keymap.set("x", "gp", reply_selection, opts) + vim.keymap.set("n", "gp", function() + respond(false) + end, opts) + vim.keymap.set("n", "gq", function() + respond(true) + end, opts) + vim.keymap.set("x", "gq", respond_selection, opts) vim.keymap.set("n", "", select_or_url, opts) vim.keymap.set("n", "e", function() enter(false) diff --git a/lua/differ/ui/overview.lua b/lua/differ/ui/overview.lua index 998f82d..b3cef9a 100644 --- a/lua/differ/ui/overview.lua +++ b/lua/differ/ui/overview.lua @@ -169,7 +169,8 @@ local function verdict_of(item) end -- build the overview buffer content. `anchors` maps each thread item to its 1-based --- row span ({ row_start, row_end, thread_id, author, path, side, line }), so in +-- row span ({ row_start, row_end, thread_id, author, comments, path, side, line }), +-- `comments` being a span per rendered comment for quoting, so in -- the section can jump to the code anchor, and reply into the thread. `quotes` does the -- same for the flat sections ({ row_start, row_end, author, body }), which have no -- thread to reply into. a highlight is { row, col_start, col_end, hl }, 0-based @@ -265,6 +266,9 @@ function M.build(data, opts) local function render_thread(item) local row_start = #lines + 1 local expanded = item.thread_id ~= nil and expanded_ids[item.thread_id] == true + -- one row span per rendered comment, so the vim layer can quote the one under + -- the cursor rather than always the root once gc has expanded the replies + local comments = {} local header = { { TOP, "differOverviewMeta" }, { "@" .. (item.author or "?"), "differOverviewAuthor" }, @@ -322,11 +326,19 @@ function M.build(data, opts) end push_body(item.body) end + -- the root owns everything from the top rule down: its header, hunk and body + comments[1] = { + row_start = row_start, + row_end = #lines, + author = item.author, + body = item.body, + } -- expanded: each reply under its own spine sub-header, mirroring the diff -- overlay's shape, so the same thread reads the same way in both places if expanded then for i = 2, #(item.comments or {}) do local c = item.comments[i] + local reply_start = #lines + 1 push({ { "│", "differOverviewMeta" } }) push({ { SPINE, "differOverviewMeta" }, @@ -334,6 +346,12 @@ function M.build(data, opts) { " · " .. reltime(c.created_at or ""), "differOverviewMeta" }, }) push_body(c.body or "") + comments[#comments + 1] = { + row_start = reply_start, + row_end = #lines, + author = c.author, + body = c.body, + } end end -- the footer's reply count means one thing only: replies you can't see. once @@ -356,6 +374,7 @@ function M.build(data, opts) row_end = #lines, thread_id = item.thread_id, author = item.author, + comments = comments, path = item.path, side = item.side, line = item.line, diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 37981ed..4d2a7db 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -1139,6 +1139,78 @@ describe("pr overview commenting", function() assert.is_nil(params.expected_head) end) + it("gq on a thread quotes its comment into the reply", function() + local buf, sent = page() + local row = row_containing(buf, "commented on a.txt:" .. THREAD_LINE) + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gq")) + assert.are.same( + { "> @reviewer wrote:", "> please fix", "", "" }, + vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + ) + + submit("> @reviewer wrote:\n> please fix\n\ndone") + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_comment") ~= nil + end)) + assert.are.equal("th_1", sent_params(sent, "post_comment").in_reply_to) + end) + + -- the destination is the cursor's to decide and the quote the key's, so gq off a + -- thread lands where gp does: a new comment, since github threads nothing there + it("gq on a conversation comment quotes it into a new comment", function() + local buf, sent = page() + local row = row_containing(buf, "@kim commented") + vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) + + assert.is_true(fire_lhs(buf, "gq")) + assert.are.same( + { "> @kim wrote:", "> one", "> two", "", "" }, + vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + ) + + submit("> @kim wrote:\n> one\n> two\n\nsounds right") + assert.is_true(vim.wait(1000, function() + return sent_params(sent, "post_issue_comment") ~= nil + end)) + assert.is_nil(sent_params(sent, "post_comment")) + end) + + -- once gc has expanded a thread, the quote follows the cursor rather than always + -- taking the root comment + it("gq on an expanded reply quotes that reply, not the thread's root", function() + local responses = default_responses() + local threads = threads_result() + threads[1].comments[#threads[1].comments + 1] = { + id = "c2", + node_id = "gid2", + author = "author", + body = "fixed in the next push", + created_at = "2026-01-02T00:00:00Z", + } + responses.get_threads = { result = threads } + track(open_overview(responses)) + local buf = overview_buf() + local win = pr.current_session().overview_win + + vim.api.nvim_win_set_cursor(win, { + row_containing(buf, "commented on a.txt:" .. THREAD_LINE), + 0, + }) + assert.is_true(fire_lhs(buf, "gc")) + + local reply_row = row_containing(buf, "fixed in the next push") + assert.is_truthy(reply_row) + vim.api.nvim_win_set_cursor(win, { reply_row, 0 }) + assert.is_true(fire_lhs(buf, "gq")) + + assert.are.same( + { "> @author wrote:", "> fixed in the next push", "", "" }, + vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) + ) + end) + it("gp off any section says so and composes nothing", function() local buf, sent = page() vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { 1, 0 }) @@ -1146,34 +1218,32 @@ describe("pr overview commenting", function() assert.is_true(fire_lhs(buf, "gp")) assert.are.equal( - "differ: nothing here to reply to; ga comments on the PR", + "differ: nothing here to answer; ga comments on the PR", _G.notifs[#_G.notifs].msg ) assert.are.equal(buf, vim.api.nvim_get_current_buf()) -- no compose split opened assert.is_nil(sent_params(sent, "post_comment")) end) - -- github doesn't thread conversation comments, so answering one is a new comment - -- that quotes what it is answering - it("gp on a conversation comment quotes it into a new comment", function() + -- github threads nothing off a code thread, so gp there is a new PR comment; the + -- key never quotes, so it opens empty + it("gp on a conversation comment posts an unquoted new comment", function() local buf, sent = page() local row = row_containing(buf, "@kim commented") assert.is_truthy(row) vim.api.nvim_win_set_cursor(pr.current_session().overview_win, { row, 0 }) assert.is_true(fire_lhs(buf, "gp")) - local composed = vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) - assert.are.same({ "> @kim wrote:", "> one", "> two", "", "" }, composed) + assert.are.same({ "" }, vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false)) - submit(table.concat(composed, "\n") .. "sounds right") + submit("agreed") assert.is_true(vim.wait(1000, function() return sent_params(sent, "post_issue_comment") ~= nil end)) - -- a quote is a plain comment, never a thread reply assert.is_nil(sent_params(sent, "post_comment")) end) - it("visual gp quotes the selection alone, not the whole comment", function() + it("visual gq quotes the selection alone, not the whole comment", function() local buf, sent = page() local row = row_containing(buf, "two") -- the comment's second body line assert.is_truthy(row) @@ -1182,7 +1252,7 @@ describe("pr overview commenting", function() vim.cmd("normal! V") assert.are.equal("V", vim.fn.mode()) -- the callback reads the live selection - assert.is_true(fire_lhs(buf, "gp", "x")) + assert.is_true(fire_lhs(buf, "gq", "x")) assert.are.same( { "> @kim wrote:", "> two", "", "" }, vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) @@ -1194,7 +1264,7 @@ describe("pr overview commenting", function() end)) end) - it("visual gp inside a thread box quotes into a reply, spine stripped", function() + it("visual gq inside a thread box quotes into a reply, spine stripped", function() local buf, sent = page() local row = row_containing(buf, "please fix") assert.is_truthy(row) @@ -1203,7 +1273,7 @@ describe("pr overview commenting", function() vim.cmd("normal! V") assert.are.equal("V", vim.fn.mode()) -- the callback reads the live selection - assert.is_true(fire_lhs(buf, "gp", "x")) + assert.is_true(fire_lhs(buf, "gq", "x")) assert.are.same( { "> @reviewer wrote:", "> please fix", "", "" }, vim.api.nvim_buf_get_lines(compose_buf(), 0, -1, false) From bf8add16ee3e5437ba87d2eff6be2d86960f0c55 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:51:01 +0100 Subject: [PATCH 8/8] docs: file the 0.1.42 release section in the changelog --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4d3dd..d23cf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The PR overview can now be commented on without leaving it. `ga` posts a comment on the pull request, and `gp`/`gq` answer what's under the cursor: into that code thread, or as a new PR comment where GitHub offers no threading. `gq` opens with the comment quoted, and in visual mode quotes just the selection - `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review +### Fixed + +- Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box +- A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed +- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it, in the split layout too; `comments.display = "expanded"` keeps its toggle, having no peek to return to + +## [0.1.42] — 2026-09-07 + ### Changed - The PR overview's `g?` cheatsheet now correctly says that `r` starts a draft review, whilst `e` only enters the review @@ -18,9 +26,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - The diff drew a cursor line even with `cursorline` off and the PR overview drew none with it on; both now respect the user's config. Opening the PR checks float used to switch `cursorline` on for the rest of the session, it now doesn't -- Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box -- A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed -- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it, in the split layout too; `comments.display = "expanded"` keeps its toggle, having no peek to return to ## [0.1.41] — 2026-09-06