opencode-nvim-diff-review 0.3.0 → 0.4.0

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,130 @@ 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
+ local HUNK_CONTEXT = 3
274
+
275
+ --- Apply a pending cursor position and hunk focus after diffview has finished
276
+ --- loading a file. Called from the DiffviewDiffBufWinEnter autocmd and from
277
+ --- DiffviewGoTo when the file is already displayed.
278
+ function M._apply_pending_goto()
279
+ if not pending_goto then return end
280
+
281
+ local target_line = pending_goto.line
282
+ local hunk = pending_goto.hunk
283
+ pending_goto = nil
284
+
285
+ -- Small delay to ensure diffview's own cursor positioning (which resets to
286
+ -- line 1 on file_open_new) has completed before we override it.
287
+ vim.defer_fn(function()
288
+ local ok, lib = pcall(require, "diffview.lib")
289
+ if not ok then return end
290
+
291
+ local view = lib.get_current_view()
292
+ if not view then return end
293
+
294
+ local layout = view.cur_layout
295
+ if not layout then return end
296
+
297
+ -- Get the main window (right-hand / "b" side in a 2-way diff)
298
+ local main_win = layout:get_main_win()
299
+ if not main_win or not main_win.id or not vim.api.nvim_win_is_valid(main_win.id) then
300
+ return
301
+ end
302
+
303
+ local main_file = main_win.file
304
+ if not main_file or not main_file.bufnr or not vim.api.nvim_buf_is_loaded(main_file.bufnr) then
305
+ return
306
+ end
307
+
308
+ -- Position cursor at the hunk
309
+ local max_line = vim.api.nvim_buf_line_count(main_file.bufnr)
310
+ local line = math.min(math.max(target_line or 1, 1), max_line)
311
+ vim.api.nvim_win_set_cursor(main_win.id, { line, 0 })
312
+ vim.api.nvim_set_current_win(main_win.id)
313
+
314
+ -- Set up hunk-focus folds if we have hunk boundaries
315
+ if hunk then
316
+ M._apply_hunk_focus(view, hunk)
317
+ end
318
+
319
+ vim.cmd("normal! zz")
320
+ end, 50)
321
+ end
322
+
323
+ --- Create folds that hide everything except the target hunk and a few
324
+ --- context lines around it. Switches both diff windows to foldmethod=manual.
325
+ ---
326
+ --- @param view table The current DiffView
327
+ --- @param hunk table Hunk spec: { new_start, new_count, old_start, old_count }
328
+ function M._apply_hunk_focus(view, hunk)
329
+ local layout = view.cur_layout
330
+ if not layout then return end
331
+
332
+ -- Compute the visible range for each side (old = "a", new = "b").
333
+ -- layout.a and layout.b are Window objects with .id and .file properties.
334
+ -- A hunk with count=0 means pure insertion/deletion — show context around
335
+ -- the start line instead.
336
+ local sides = {}
337
+
338
+ -- "b" side (new/right) — uses new_start/new_count
339
+ if layout.b and layout.b.file and layout.b.file.bufnr
340
+ and vim.api.nvim_buf_is_loaded(layout.b.file.bufnr)
341
+ then
342
+ local lcount = vim.api.nvim_buf_line_count(layout.b.file.bufnr)
343
+ local hunk_first = hunk.new_start > 0 and hunk.new_start or 1
344
+ local hunk_last = hunk.new_count > 0 and (hunk.new_start + hunk.new_count - 1) or hunk_first
345
+ table.insert(sides, {
346
+ win_id = layout.b.id,
347
+ lcount = lcount,
348
+ hunk_first = hunk_first,
349
+ hunk_last = hunk_last,
350
+ })
351
+ end
352
+
353
+ -- "a" side (old/left) — uses old_start/old_count
354
+ if layout.a and layout.a.file and layout.a.file.bufnr
355
+ and vim.api.nvim_buf_is_loaded(layout.a.file.bufnr)
356
+ then
357
+ local lcount = vim.api.nvim_buf_line_count(layout.a.file.bufnr)
358
+ local hunk_first = hunk.old_start > 0 and hunk.old_start or 1
359
+ local hunk_last = hunk.old_count > 0 and (hunk.old_start + hunk.old_count - 1) or hunk_first
360
+ table.insert(sides, {
361
+ win_id = layout.a.id,
362
+ lcount = lcount,
363
+ hunk_first = hunk_first,
364
+ hunk_last = hunk_last,
365
+ })
366
+ end
367
+
368
+ for _, side in ipairs(sides) do
369
+ if vim.api.nvim_win_is_valid(side.win_id) then
370
+ vim.api.nvim_win_call(side.win_id, function()
371
+ -- Switch to manual folds so we have full control
372
+ vim.wo.foldmethod = "manual"
373
+ vim.wo.foldenable = true
374
+
375
+ -- Remove all existing folds
376
+ pcall(vim.cmd, "normal! zE")
377
+
378
+ -- Visible range: hunk lines + context
379
+ local vis_first = math.max(1, side.hunk_first - HUNK_CONTEXT)
380
+ local vis_last = math.min(side.lcount, side.hunk_last + HUNK_CONTEXT)
381
+
382
+ -- Create fold above the visible range
383
+ if vis_first > 1 then
384
+ vim.cmd(string.format("1,%dfold", vis_first - 1))
385
+ end
386
+
387
+ -- Create fold below the visible range
388
+ if vis_last < side.lcount then
389
+ vim.cmd(string.format("%d,%dfold", vis_last + 1, side.lcount))
390
+ end
391
+ end)
392
+ end
393
+ end
394
+ end
395
+
248
396
  --- Diffview hook: called when a diff view is closed.
249
397
  --- Cleans up buffers that diffview created but the user didn't have open before.
250
398
  --- - diffview:// internal buffers are always removed
@@ -289,6 +437,15 @@ function M.setup(opts)
289
437
  _G.DiffviewHunks = DiffviewHunks
290
438
  _G.DiffviewGoTo = DiffviewGoTo
291
439
 
440
+ -- Listen for diffview's DiffviewDiffBufWinEnter autocmd to apply pending
441
+ -- cursor positions after async file loading completes.
442
+ vim.api.nvim_create_autocmd("User", {
443
+ pattern = "DiffviewDiffBufWinEnter",
444
+ callback = function()
445
+ M._apply_pending_goto()
446
+ end,
447
+ })
448
+
292
449
  -- Configure diffview hooks
293
450
  local dv_ok, diffview = pcall(require, "diffview")
294
451
  if not dv_ok then
@@ -105,12 +105,17 @@ 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."
@@ -120,7 +125,7 @@ const formatHunkPosition = (): string => {
120
125
 
121
126
  /**
122
127
  * Match an order item from the agent to a hunk in the available hunks list.
123
- * Identity is {file, old_start, new_start}.
128
+ * A hunk is uniquely identified by {file, old_start, old_count, new_start, new_count}.
124
129
  */
125
130
  const findHunk = (
126
131
  hunks: HunkItem[],
@@ -135,6 +140,26 @@ const findHunk = (
135
140
  h.new_count === orderItem.new_count
136
141
  )
137
142
 
143
+ /**
144
+ * Format a summary of the files covered in the review queue.
145
+ */
146
+ const formatQueueSummary = (queue: HunkItem[]): string => {
147
+ const fileGroups = new Map<string, { status: string; count: number }>()
148
+ for (const item of queue) {
149
+ const existing = fileGroups.get(item.file)
150
+ if (existing) {
151
+ existing.count++
152
+ } else {
153
+ fileGroups.set(item.file, { status: item.status, count: 1 })
154
+ }
155
+ }
156
+ const lines = Array.from(fileGroups.entries()).map(
157
+ ([file, { status, count }]) =>
158
+ ` ${file} (${statusLabel(status)}) — ${count} hunk${count === 1 ? "" : "s"}`
159
+ )
160
+ return `\nFiles in review:\n${lines.join("\n")}`
161
+ }
162
+
138
163
  // --- Plugin ---
139
164
 
140
165
  export const DiffReviewPlugin: Plugin = async (ctx) => {
@@ -258,15 +283,16 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
258
283
 
259
284
  const goToHunk = async (item: HunkItem): Promise<void> => {
260
285
  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
286
+ // Pass hunk boundaries so Lua can set up folds to focus on this hunk
287
+ const hunkSpec = `{new_start=${item.new_start},new_count=${item.new_count},old_start=${item.old_start},old_count=${item.old_count}}`
263
288
  const raw = await nvimExpr(
264
- `luaeval("DiffviewGoTo('${file}', ${line})")`
289
+ `luaeval("DiffviewGoTo('${file}', ${hunkSpec})")`
265
290
  )
266
291
  const result = JSON.parse(raw.trim())
267
292
  if (result.error) throw new Error(result.error)
268
- // Give diffview time to switch files and position the cursor
269
- await Bun.sleep(300)
293
+ // Give diffview time to switch files and the Lua side to
294
+ // position cursor and set up folds
295
+ await Bun.sleep(500)
270
296
  }
271
297
 
272
298
  try {
@@ -353,7 +379,8 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
353
379
 
354
380
  return `Started review with ${reviewQueue.length} item${reviewQueue.length === 1 ? "" : "s"}` +
355
381
  (ref ? ` (comparing against ${ref})` : " (uncommitted changes vs HEAD)") +
356
- `. ${formatHunkPosition()}`
382
+ `. ${formatHunkPosition()}` +
383
+ formatQueueSummary(reviewQueue)
357
384
  }
358
385
 
359
386
  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.0",
4
4
  "description": "Agent-driven guided code review for Neovim + OpenCode",
5
5
  "main": "opencode-plugin/index.ts",
6
6
  "keywords": [