opencode-nvim-diff-review 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,13 @@ local M = {}
23
23
  --- Used by the cleanup hooks to avoid removing buffers the user had open.
24
24
  local pre_diffview_bufs = {}
25
25
 
26
+ --- Pending cursor position to set after diffview finishes opening a file.
27
+ --- DiffviewGoTo stores the target here, and the autocmd on DiffviewDiffBufWinEnter
28
+ --- applies it. This avoids a race with diffview's async file loading which resets
29
+ --- the cursor to line 1 after opening.
30
+ --- @type { file: string, line: number }?
31
+ local pending_goto = nil
32
+
26
33
  --- Query the current diffview state.
27
34
  --- Returns a JSON string with:
28
35
  --- open: boolean - whether diffview is active
@@ -131,13 +138,34 @@ function DiffviewHunks(ref)
131
138
  end
132
139
  local diff_lines = vim.fn.systemlist(diff_cmd)
133
140
  if vim.v.shell_error ~= 0 then
134
- return vim.json.encode({ error = "git diff failed" })
141
+ local err_msg = table.concat(diff_lines, "\n")
142
+ return vim.json.encode({
143
+ error = "git diff failed: " .. (err_msg ~= "" and err_msg or "unknown error"),
144
+ })
135
145
  end
136
146
 
137
147
  if #diff_lines == 0 then
138
148
  return vim.json.encode({})
139
149
  end
140
150
 
151
+ -- Normalize hunk headers for -U0 output.
152
+ -- With -U0, git omits the count when it's 1 (e.g., "@@ -134 +134,4 @@"
153
+ -- instead of "@@ -134,1 +134,4 @@"). diffview's parser expects the
154
+ -- comma-separated form, so we normalize before parsing.
155
+ for i, line in ipairs(diff_lines) do
156
+ local old_spec, new_spec = line:match("^@@ %-(%S+) %+(%S+) @@")
157
+ if old_spec then
158
+ -- Ensure both sides have the ",count" format
159
+ if not old_spec:match(",") then
160
+ old_spec = old_spec .. ",1"
161
+ end
162
+ if not new_spec:match(",") then
163
+ new_spec = new_spec .. ",1"
164
+ end
165
+ diff_lines[i] = "@@ -" .. old_spec .. " +" .. new_spec .. " @@"
166
+ end
167
+ end
168
+
141
169
  -- Parse the diff using diffview's parser
142
170
  local file_diffs = vcs_utils.parse_diff(diff_lines)
143
171
 
@@ -167,15 +195,17 @@ function DiffviewHunks(ref)
167
195
  return vim.json.encode(hunks)
168
196
  end
169
197
 
170
- --- Navigate diffview to a specific file and line number.
198
+ --- Navigate diffview to a specific file and hunk.
171
199
  ---
172
200
  --- Finds the file in diffview's file list, switches to it if needed,
173
- --- then moves the cursor to the specified line in the right-hand (new) buffer.
201
+ --- positions the cursor at the hunk, and folds all other regions so only
202
+ --- the target hunk (with a few context lines) is visible.
174
203
  ---
175
204
  --- @param file string Repo-relative file path
176
- --- @param line number Line number to jump to (in the new version of the file)
205
+ --- @param line_or_hunk number|table Either a line number or a hunk spec table:
206
+ --- { new_start, new_count, old_start, old_count }
177
207
  --- @return string JSON-encoded result: { ok: true } or { error: string }
178
- function DiffviewGoTo(file, line)
208
+ function DiffviewGoTo(file, line_or_hunk)
179
209
  local ok, lib = pcall(require, "diffview.lib")
180
210
  if not ok then
181
211
  return vim.json.encode({ error = "diffview.nvim not loaded" })
@@ -203,34 +233,28 @@ function DiffviewGoTo(file, line)
203
233
  return vim.json.encode({ error = "File not found in diffview: " .. file })
204
234
  end
205
235
 
236
+ -- Store the target so the autocmd can position the cursor and set up folds
237
+ -- after diffview finishes its async file loading.
238
+ local hunk_spec = type(line_or_hunk) == "table" and line_or_hunk or nil
239
+ local target_line = hunk_spec
240
+ and (hunk_spec.new_start > 0 and hunk_spec.new_start or 1)
241
+ or (type(line_or_hunk) == "number" and line_or_hunk or 1)
242
+
243
+ pending_goto = {
244
+ file = file,
245
+ line = target_line,
246
+ hunk = hunk_spec,
247
+ }
248
+
206
249
  -- Switch to the target file if it's not already the current one
207
250
  local cur_file = panel.cur_file
208
251
  if not cur_file or cur_file.path ~= file then
209
252
  view:set_file(target)
253
+ else
254
+ -- Already on the right file — apply directly
255
+ M._apply_pending_goto()
210
256
  end
211
257
 
212
- -- Move cursor to the target line in the right-hand (new version) buffer.
213
- -- The layout has windows a (left/old) and b (right/new).
214
- -- We need to wait briefly for the file switch to complete, then find
215
- -- the right window and set the cursor.
216
- vim.schedule(function()
217
- -- Find the window showing the "b" (new) side of the diff
218
- local layout = target.layout
219
- if layout and layout.b and layout.b.file then
220
- local b_win = layout.b.win
221
- if b_win and vim.api.nvim_win_is_valid(b_win.id) then
222
- -- Clamp line to buffer length
223
- local bufnr = layout.b.file.bufnr
224
- local max_line = vim.api.nvim_buf_line_count(bufnr)
225
- local target_line = math.min(math.max(line or 1, 1), max_line)
226
- vim.api.nvim_win_set_cursor(b_win.id, { target_line, 0 })
227
- -- Center the view on the target line
228
- vim.api.nvim_set_current_win(b_win.id)
229
- vim.cmd("normal! zz")
230
- end
231
- end
232
- end)
233
-
234
258
  return vim.json.encode({ ok = true })
235
259
  end
236
260
 
@@ -245,6 +269,146 @@ function M.on_view_opened(view)
245
269
  end
246
270
  end
247
271
 
272
+ --- Number of context lines to show above and below a focused hunk.
273
+ --- This provides enough surrounding code to understand the change without
274
+ --- overwhelming the view with unrelated code.
275
+ local HUNK_CONTEXT = 5
276
+
277
+ --- Apply a pending cursor position and hunk focus after diffview has finished
278
+ --- loading a file. Called from the DiffviewDiffBufWinEnter autocmd and from
279
+ --- DiffviewGoTo when the file is already displayed.
280
+ function M._apply_pending_goto()
281
+ if not pending_goto then return end
282
+
283
+ local target_line = pending_goto.line
284
+ local hunk = pending_goto.hunk
285
+ pending_goto = nil
286
+
287
+ -- Small delay to ensure diffview's own cursor positioning (which resets to
288
+ -- line 1 on file_open_new) has completed before we override it.
289
+ vim.defer_fn(function()
290
+ local ok, lib = pcall(require, "diffview.lib")
291
+ if not ok then return end
292
+
293
+ local view = lib.get_current_view()
294
+ if not view then return end
295
+
296
+ local layout = view.cur_layout
297
+ if not layout then return end
298
+
299
+ -- Get the main window (right-hand / "b" side in a 2-way diff)
300
+ local main_win = layout:get_main_win()
301
+ if not main_win or not main_win.id or not vim.api.nvim_win_is_valid(main_win.id) then
302
+ return
303
+ end
304
+
305
+ local main_file = main_win.file
306
+ if not main_file or not main_file.bufnr or not vim.api.nvim_buf_is_loaded(main_file.bufnr) then
307
+ return
308
+ end
309
+
310
+ -- Position cursor at the hunk
311
+ local max_line = vim.api.nvim_buf_line_count(main_file.bufnr)
312
+ local line = math.min(math.max(target_line or 1, 1), max_line)
313
+ vim.api.nvim_win_set_cursor(main_win.id, { line, 0 })
314
+ vim.api.nvim_set_current_win(main_win.id)
315
+
316
+ -- Set up hunk-focus folds if we have hunk boundaries
317
+ if hunk then
318
+ M._apply_hunk_focus(view, hunk)
319
+ end
320
+
321
+ vim.cmd("normal! zz")
322
+ end, 50)
323
+ end
324
+
325
+ --- Create folds that hide everything except the target hunk and a few
326
+ --- context lines around it. Switches both diff windows to foldmethod=manual.
327
+ ---
328
+ --- @param view table The current DiffView
329
+ --- @param hunk table Hunk spec: { new_start, new_count, old_start, old_count }
330
+ function M._apply_hunk_focus(view, hunk)
331
+ local layout = view.cur_layout
332
+ if not layout then return end
333
+
334
+ -- Compute the visible range for each side (old = "a", new = "b").
335
+ -- layout.a and layout.b are Window objects with .id and .file properties.
336
+ -- A hunk with count=0 means pure insertion/deletion — show context around
337
+ -- the start line instead.
338
+ local sides = {}
339
+
340
+ -- "b" side (new/right) — uses new_start/new_count
341
+ if layout.b and layout.b.file and layout.b.file.bufnr
342
+ and vim.api.nvim_buf_is_loaded(layout.b.file.bufnr)
343
+ then
344
+ local lcount = vim.api.nvim_buf_line_count(layout.b.file.bufnr)
345
+ local hunk_first = hunk.new_start > 0 and hunk.new_start or 1
346
+ local hunk_last = hunk.new_count > 0 and (hunk.new_start + hunk.new_count - 1) or hunk_first
347
+ table.insert(sides, {
348
+ win_id = layout.b.id,
349
+ lcount = lcount,
350
+ hunk_first = hunk_first,
351
+ hunk_last = hunk_last,
352
+ })
353
+ end
354
+
355
+ -- "a" side (old/left) — uses old_start/old_count
356
+ if layout.a and layout.a.file and layout.a.file.bufnr
357
+ and vim.api.nvim_buf_is_loaded(layout.a.file.bufnr)
358
+ then
359
+ local lcount = vim.api.nvim_buf_line_count(layout.a.file.bufnr)
360
+ local hunk_first = hunk.old_start > 0 and hunk.old_start or 1
361
+ local hunk_last = hunk.old_count > 0 and (hunk.old_start + hunk.old_count - 1) or hunk_first
362
+ table.insert(sides, {
363
+ win_id = layout.a.id,
364
+ lcount = lcount,
365
+ hunk_first = hunk_first,
366
+ hunk_last = hunk_last,
367
+ })
368
+ end
369
+
370
+ for _, side in ipairs(sides) do
371
+ if vim.api.nvim_win_is_valid(side.win_id) then
372
+ vim.api.nvim_win_call(side.win_id, function()
373
+ -- Switch to manual folds so we have full control
374
+ vim.wo.foldmethod = "manual"
375
+ vim.wo.foldenable = true
376
+
377
+ -- Show absolute line numbers so the user can correlate with the
378
+ -- hunk header line ranges (e.g. @@ -273,1 +273,3 @@)
379
+ vim.wo.number = true
380
+ vim.wo.relativenumber = false
381
+
382
+ -- Override the Folded highlight in this window so fold lines don't
383
+ -- look like diff modifications. Uses winhl to scope it to this window.
384
+ local existing_winhl = vim.wo.winhl or ""
385
+ if not existing_winhl:match("Folded:") then
386
+ vim.wo.winhl = existing_winhl
387
+ .. (existing_winhl ~= "" and "," or "")
388
+ .. "Folded:DiffviewDiffFoldedReview"
389
+ end
390
+
391
+ -- Remove all existing folds
392
+ pcall(vim.cmd, "normal! zE")
393
+
394
+ -- Visible range: hunk lines + context
395
+ local vis_first = math.max(1, side.hunk_first - HUNK_CONTEXT)
396
+ local vis_last = math.min(side.lcount, side.hunk_last + HUNK_CONTEXT)
397
+
398
+ -- Create fold above the visible range
399
+ if vis_first > 1 then
400
+ vim.cmd(string.format("1,%dfold", vis_first - 1))
401
+ end
402
+
403
+ -- Create fold below the visible range
404
+ if vis_last < side.lcount then
405
+ vim.cmd(string.format("%d,%dfold", vis_last + 1, side.lcount))
406
+ end
407
+ end)
408
+ end
409
+ end
410
+ end
411
+
248
412
  --- Diffview hook: called when a diff view is closed.
249
413
  --- Cleans up buffers that diffview created but the user didn't have open before.
250
414
  --- - diffview:// internal buffers are always removed
@@ -289,6 +453,20 @@ function M.setup(opts)
289
453
  _G.DiffviewHunks = DiffviewHunks
290
454
  _G.DiffviewGoTo = DiffviewGoTo
291
455
 
456
+ -- Define a neutral highlight for fold lines in hunk-focus mode.
457
+ -- Links to Comment by default so folds look like muted separators
458
+ -- rather than diff modifications. Users can override this.
459
+ vim.api.nvim_set_hl(0, "DiffviewDiffFoldedReview", { link = "Comment", default = true })
460
+
461
+ -- Listen for diffview's DiffviewDiffBufWinEnter autocmd to apply pending
462
+ -- cursor positions after async file loading completes.
463
+ vim.api.nvim_create_autocmd("User", {
464
+ pattern = "DiffviewDiffBufWinEnter",
465
+ callback = function()
466
+ M._apply_pending_goto()
467
+ end,
468
+ })
469
+
292
470
  -- Configure diffview hooks
293
471
  local dv_ok, diffview = pcall(require, "diffview")
294
472
  if not dv_ok then
@@ -105,22 +105,28 @@ const discoverNvimSocket = async (): Promise<string | null> => {
105
105
 
106
106
  // --- Helpers ---
107
107
 
108
+ const STATUS_LABELS: Record<string, string> = {
109
+ M: "modified",
110
+ A: "added",
111
+ D: "deleted",
112
+ R: "renamed",
113
+ C: "copied",
114
+ T: "type-changed",
115
+ }
116
+
108
117
  const statusLabel = (status: string | undefined): string =>
109
- status === "M" ? "modified" :
110
- status === "A" ? "added" :
111
- status === "D" ? "deleted" :
112
- status === "R" ? "renamed" :
113
- status ?? "changed"
118
+ (status && STATUS_LABELS[status]) ?? "changed"
114
119
 
115
120
  const formatHunkPosition = (): string => {
116
121
  if (reviewQueue.length === 0) return "No review in progress."
117
122
  const item = reviewQueue[reviewPosition]
118
- return `Reviewing: ${item.file} (${statusLabel(item.status)}) ${item.header} — item ${reviewPosition + 1} of ${reviewQueue.length}.`
123
+ const fileCount = new Set(reviewQueue.map(h => h.file)).size
124
+ return `Reviewing: ${item.file} (${statusLabel(item.status)}) ${item.header} — item ${reviewPosition + 1} of ${reviewQueue.length} across ${fileCount} file${fileCount === 1 ? "" : "s"}.`
119
125
  }
120
126
 
121
127
  /**
122
128
  * Match an order item from the agent to a hunk in the available hunks list.
123
- * Identity is {file, old_start, new_start}.
129
+ * A hunk is uniquely identified by {file, old_start, old_count, new_start, new_count}.
124
130
  */
125
131
  const findHunk = (
126
132
  hunks: HunkItem[],
@@ -135,6 +141,26 @@ const findHunk = (
135
141
  h.new_count === orderItem.new_count
136
142
  )
137
143
 
144
+ /**
145
+ * Format a summary of the files covered in the review queue.
146
+ */
147
+ const formatQueueSummary = (queue: HunkItem[]): string => {
148
+ const fileGroups = new Map<string, { status: string; count: number }>()
149
+ for (const item of queue) {
150
+ const existing = fileGroups.get(item.file)
151
+ if (existing) {
152
+ existing.count++
153
+ } else {
154
+ fileGroups.set(item.file, { status: item.status, count: 1 })
155
+ }
156
+ }
157
+ const lines = Array.from(fileGroups.entries()).map(
158
+ ([file, { status, count }]) =>
159
+ ` ${file} (${statusLabel(status)}) — ${count} hunk${count === 1 ? "" : "s"}`
160
+ )
161
+ return `\nFiles in review:\n${lines.join("\n")}`
162
+ }
163
+
138
164
  // --- Plugin ---
139
165
 
140
166
  export const DiffReviewPlugin: Plugin = async (ctx) => {
@@ -258,15 +284,16 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
258
284
 
259
285
  const goToHunk = async (item: HunkItem): Promise<void> => {
260
286
  const file = item.file.replace(/"/g, '\\"')
261
- // Navigate to the new_start line (the line in the new version)
262
- const line = item.new_start > 0 ? item.new_start : 1
287
+ // Pass hunk boundaries so Lua can set up folds to focus on this hunk
288
+ const hunkSpec = `{new_start=${item.new_start},new_count=${item.new_count},old_start=${item.old_start},old_count=${item.old_count}}`
263
289
  const raw = await nvimExpr(
264
- `luaeval("DiffviewGoTo('${file}', ${line})")`
290
+ `luaeval("DiffviewGoTo('${file}', ${hunkSpec})")`
265
291
  )
266
292
  const result = JSON.parse(raw.trim())
267
293
  if (result.error) throw new Error(result.error)
268
- // Give diffview time to switch files and position the cursor
269
- await Bun.sleep(300)
294
+ // Give diffview time to switch files and the Lua side to
295
+ // position cursor and set up folds
296
+ await Bun.sleep(500)
270
297
  }
271
298
 
272
299
  try {
@@ -353,7 +380,8 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
353
380
 
354
381
  return `Started review with ${reviewQueue.length} item${reviewQueue.length === 1 ? "" : "s"}` +
355
382
  (ref ? ` (comparing against ${ref})` : " (uncommitted changes vs HEAD)") +
356
- `. ${formatHunkPosition()}`
383
+ `. ${formatHunkPosition()}` +
384
+ formatQueueSummary(reviewQueue)
357
385
  }
358
386
 
359
387
  case "next": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-nvim-diff-review",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Agent-driven guided code review for Neovim + OpenCode",
5
5
  "main": "opencode-plugin/index.ts",
6
6
  "keywords": [