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.
- package/lua/diff-review/init.lua +184 -27
- package/opencode-plugin/index.ts +39 -12
- package/package.json +1 -1
package/lua/diff-review/init.lua
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
---
|
|
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
|
|
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,
|
|
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
|
package/opencode-plugin/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
262
|
-
const
|
|
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}', ${
|
|
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
|
|
269
|
-
|
|
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": {
|