opencode-nvim-diff-review 0.1.0 → 0.3.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/README.md +80 -42
- package/lua/diff-review/init.lua +173 -4
- package/opencode-plugin/index.ts +314 -112
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
# nvim-diff-review-opencode-plugin
|
|
2
2
|
|
|
3
|
-
An agent-driven guided code review tool for Neovim and [OpenCode](https://opencode.ai). An AI agent walks you through code changes
|
|
3
|
+
An agent-driven guided code review tool for Neovim and [OpenCode](https://opencode.ai). An AI agent walks you through code changes hunk by hunk, showing side-by-side diffs in Neovim while discussing the changes in a separate OpenCode chat.
|
|
4
4
|
|
|
5
5
|
## How it works
|
|
6
6
|
|
|
7
|
-
The plugin connects an OpenCode agent to Neovim's [diffview.nvim](https://github.com/sindrets/diffview.nvim) via Neovim's built-in RPC socket. The agent can
|
|
7
|
+
The plugin connects an OpenCode agent to Neovim's [diffview.nvim](https://github.com/sindrets/diffview.nvim) via Neovim's built-in RPC socket. The agent can query all change hunks, reorder them for narrative coherence, open diffs, and navigate between hunks — all while the user interacts via the OpenCode chat.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
┌────────────────────────────┬──────────────────────┐
|
|
11
11
|
│ │ │
|
|
12
12
|
│ Neovim showing diff of │ OpenCode agent: │
|
|
13
13
|
│ src/store/selectors.js │ │
|
|
14
|
-
│
|
|
15
|
-
│
|
|
16
|
-
│
|
|
17
|
-
│
|
|
14
|
+
│ (cursor at hunk 2 of 3) │ "I added validation │
|
|
15
|
+
│ │ for the edge case │
|
|
16
|
+
│ - old code in red │ where the block │
|
|
17
|
+
│ + new code in green │ name is │
|
|
18
|
+
│ │ undefined..." │
|
|
18
19
|
│ │ │
|
|
19
20
|
│ │ Any questions about │
|
|
20
|
-
│ │
|
|
21
|
+
│ │ this change? │
|
|
21
22
|
└────────────────────────────┴──────────────────────┘
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
The review workflow:
|
|
25
26
|
|
|
26
|
-
1. Agent
|
|
27
|
-
2. Agent
|
|
28
|
-
3.
|
|
29
|
-
4.
|
|
30
|
-
5.
|
|
31
|
-
6.
|
|
32
|
-
7.
|
|
33
|
-
8.
|
|
27
|
+
1. Agent queries all change hunks across all files
|
|
28
|
+
2. Agent decides a review order — reordering for narrative coherence (e.g., data model first, then API, then UI) or using natural order for small changes
|
|
29
|
+
3. Agent opens the diff view and begins the review walk-through
|
|
30
|
+
4. For each hunk: agent explains the change, user asks questions or leaves feedback
|
|
31
|
+
5. Agent notes feedback but **does not edit files** during the review
|
|
32
|
+
6. After the last hunk, agent closes the diff view
|
|
33
|
+
7. Agent proposes a commit message and commits the original work
|
|
34
|
+
8. If feedback was left, agent applies it as a separate commit
|
|
35
|
+
9. Optionally, a second review of just the feedback changes
|
|
34
36
|
|
|
35
37
|
## Components
|
|
36
38
|
|
|
@@ -40,7 +42,10 @@ The plugin has two parts that are installed separately:
|
|
|
40
42
|
|
|
41
43
|
A Lua module that:
|
|
42
44
|
|
|
43
|
-
- Registers
|
|
45
|
+
- Registers global functions queryable via Neovim's RPC socket:
|
|
46
|
+
- `DiffviewState()` — current diffview state (file, position, file list)
|
|
47
|
+
- `DiffviewHunks(ref?)` — all diff hunks as a flat array, parsed from `git diff` output using diffview.nvim's diff parser
|
|
48
|
+
- `DiffviewGoTo(file, line)` — navigate diffview to a specific file and line
|
|
44
49
|
- Provides diffview.nvim hooks that clean up buffers when the diff view is closed (so reviewed files don't linger as open tabs)
|
|
45
50
|
|
|
46
51
|
### 2. OpenCode plugin (`opencode-plugin/index.ts`)
|
|
@@ -49,11 +54,12 @@ An OpenCode plugin that registers a `diff_review` tool the AI agent uses to cont
|
|
|
49
54
|
|
|
50
55
|
| Action | Description |
|
|
51
56
|
|--------|-------------|
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
+
| `get_hunks` | Get all diff hunks across all files as a flat array. Each hunk is self-contained with file path, status, and line ranges. Optionally scoped to specific files or a git ref. |
|
|
58
|
+
| `start_review` | Open the diff view and begin the review walk-through. Accepts an optional ordered array of hunks (from `get_hunks`) to control review order. If omitted, uses natural hunk order. Navigates to the first item. |
|
|
59
|
+
| `next` | Navigate to the next item in the review queue. Prevents wrap-around at the last item. |
|
|
60
|
+
| `prev` | Navigate to the previous item in the review queue. Prevents wrap-around at the first item. |
|
|
61
|
+
| `status` | Get current position in the review queue without navigating. |
|
|
62
|
+
| `close` | Close the diff view and clear the review queue. |
|
|
57
63
|
|
|
58
64
|
## Dependencies
|
|
59
65
|
|
|
@@ -85,7 +91,7 @@ Add the plugin to your `opencode.json` configuration:
|
|
|
85
91
|
|
|
86
92
|
```json
|
|
87
93
|
{
|
|
88
|
-
"plugin": ["
|
|
94
|
+
"plugin": ["opencode-nvim-diff-review"]
|
|
89
95
|
}
|
|
90
96
|
```
|
|
91
97
|
|
|
@@ -95,15 +101,29 @@ Restart OpenCode to load the plugin. The `diff_review` tool will be available to
|
|
|
95
101
|
|
|
96
102
|
### 3. Neovim RPC socket
|
|
97
103
|
|
|
98
|
-
The tool communicates with Neovim via its RPC socket.
|
|
104
|
+
The tool communicates with Neovim via its RPC socket. In most cases, **no configuration is needed** — the tool auto-discovers running Neovim instances.
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
export NVIM_SOCKET=/tmp/nvim.sock
|
|
103
|
-
nvim --listen $NVIM_SOCKET
|
|
104
|
-
```
|
|
106
|
+
#### Auto-discovery (default)
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
The tool automatically finds Neovim by:
|
|
109
|
+
|
|
110
|
+
1. Checking the `NVIM_SOCKET` environment variable (if set, always used)
|
|
111
|
+
2. Scanning for Neovim sockets in standard locations (`$TMPDIR` and `/tmp`)
|
|
112
|
+
3. Preferring the Neovim instance whose working directory matches the current project
|
|
113
|
+
4. Falling back to the first live Neovim instance found
|
|
114
|
+
|
|
115
|
+
This means if you just run `nvim` in your project directory, OpenCode will find it automatically.
|
|
116
|
+
|
|
117
|
+
#### Explicit configuration (optional)
|
|
118
|
+
|
|
119
|
+
If auto-discovery doesn't work for your setup (e.g., multiple Neovim instances in the same directory), you can set the socket path explicitly:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export NVIM_SOCKET=/tmp/nvim.sock
|
|
123
|
+
nvim --listen $NVIM_SOCKET
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Make sure `NVIM_SOCKET` is set in the environment where OpenCode runs.
|
|
107
127
|
|
|
108
128
|
If you use [CMUX](https://cmux.com), you can set this in your workspace configuration so both Neovim and OpenCode share the socket path automatically.
|
|
109
129
|
|
|
@@ -126,42 +146,60 @@ OpenCode (agent) Neovim (editor)
|
|
|
126
146
|
|
|
127
147
|
The tool uses Neovim's `--remote-expr` to evaluate Lua expressions on the running Neovim instance. This is a standard Neovim feature that works in any terminal — no CMUX or specific terminal emulator required.
|
|
128
148
|
|
|
129
|
-
|
|
149
|
+
### Hunk-based review
|
|
150
|
+
|
|
151
|
+
The review operates at the **hunk level** rather than the file level. This means:
|
|
152
|
+
|
|
153
|
+
- A file with 3 separate change regions is presented as 3 review items
|
|
154
|
+
- The agent can reorder hunks across files for narrative coherence (e.g., show a data model change in `model.ts` before the API endpoint in `api.ts` that uses it, even if they're in different files)
|
|
155
|
+
- Small files with a single change are naturally one review item
|
|
156
|
+
- The agent can filter out trivial hunks (e.g., import reordering) from the review
|
|
157
|
+
|
|
158
|
+
Hunks are retrieved by running `git diff -U0` and parsing the output with diffview.nvim's built-in unified diff parser (`diffview.vcs.utils.parse_diff`). The `-U0` flag produces zero-context hunks, giving exact change boundaries.
|
|
159
|
+
|
|
160
|
+
### Review queue
|
|
161
|
+
|
|
162
|
+
The review queue is managed on the TypeScript (OpenCode plugin) side. It holds:
|
|
163
|
+
|
|
164
|
+
- An ordered array of hunk items (set by `start_review`)
|
|
165
|
+
- The current position in the queue (advanced by `next`/`prev`)
|
|
166
|
+
|
|
167
|
+
The Lua (Neovim) side is stateless — it provides functions to query hunks and navigate the diff view, but doesn't track review progress. This keeps the Neovim plugin simple and the state management in one place.
|
|
168
|
+
|
|
169
|
+
### Key design decisions
|
|
130
170
|
|
|
131
171
|
- **`--headless` flag on remote calls**: Prevents Neovim from emitting terminal escape sequences when invoked from a subprocess (e.g., Bun's shell). Without this, the JSON response gets polluted with control codes.
|
|
132
|
-
- **State queries via
|
|
133
|
-
- **Wrap-around prevention**:
|
|
172
|
+
- **State queries via global Lua functions**: `DiffviewState()`, `DiffviewHunks()`, and `DiffviewGoTo()` are registered as globals so they can be called via `luaeval()` from Neovim's `--remote-expr` without needing the module require path.
|
|
173
|
+
- **Wrap-around prevention**: The tool checks queue bounds before navigating and refuses to advance past the first/last item.
|
|
134
174
|
- **Buffer cleanup on close**: diffview.nvim intentionally keeps local file buffers open after closing (so you can continue editing). The plugin tracks which buffers existed before the review and removes any new ones on close — unless they have unsaved edits.
|
|
135
|
-
- **Small delays after navigation**:
|
|
175
|
+
- **Small delays after navigation**: 300ms sleeps after diffview commands to let the UI update before querying state. Without this, the state query can return stale data.
|
|
176
|
+
- **Socket auto-discovery**: When `NVIM_SOCKET` is not set, the tool scans `$TMPDIR/nvim.$USER/` and `/tmp` for Neovim socket files, verifies each is live, and uses `lsof` to match the Neovim process's working directory against the current project. This allows zero-configuration usage in ad-hoc terminals — just run `nvim` and OpenCode will find it.
|
|
136
177
|
|
|
137
178
|
### Review workflow instructions
|
|
138
179
|
|
|
139
180
|
The tool description embeds detailed workflow instructions for the AI agent:
|
|
140
181
|
|
|
141
182
|
- **Lint before review**: The agent is told to fix lint/format issues before opening the diff, so the user only sees clean changes.
|
|
183
|
+
- **Narrative ordering**: The agent analyzes hunks and reorders them for coherent presentation — explaining foundational changes before dependent ones.
|
|
142
184
|
- **No edits during review**: The agent is explicitly instructed to never edit files during the review. Feedback is collected and applied afterward.
|
|
143
185
|
- **Two-commit pattern**: Original work is committed first, then feedback changes are committed separately. This gives clean git history and allows the second review to show only the feedback diff.
|
|
144
|
-
- **Interactive pacing**: The agent explains each
|
|
186
|
+
- **Interactive pacing**: The agent explains each hunk, asks for feedback, and waits for the user's response before moving on.
|
|
145
187
|
|
|
146
188
|
## Future improvements
|
|
147
189
|
|
|
148
190
|
These were discussed during development but not yet implemented:
|
|
149
191
|
|
|
150
|
-
###
|
|
192
|
+
### Expand (surrounding context)
|
|
151
193
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
### Logical ordering
|
|
155
|
-
|
|
156
|
-
Instead of reviewing files in filesystem order, the agent would use its understanding of the codebase to present changes in a narrative order — e.g., "first the data model change, then the API that uses it, then the UI that calls the API." This would make reviews of larger changesets more coherent.
|
|
194
|
+
Show surrounding code context around the current hunk. Useful when the agent or user needs to see more of the file to understand a change. Would be implemented as an `expand` action.
|
|
157
195
|
|
|
158
196
|
### Accept/reject per-hunk
|
|
159
197
|
|
|
160
198
|
Allow the user to accept or reject individual hunks from the diff view, similar to `git add -p`. This would integrate with diffview.nvim's staging capabilities.
|
|
161
199
|
|
|
162
|
-
###
|
|
200
|
+
### Logical ordering
|
|
163
201
|
|
|
164
|
-
|
|
202
|
+
Instead of the agent deciding order ad-hoc, build smarter ordering heuristics — e.g., analyzing import graphs to automatically determine dependency order between changed files.
|
|
165
203
|
|
|
166
204
|
### OpenCode session diff integration
|
|
167
205
|
|
package/lua/diff-review/init.lua
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
-- Provides:
|
|
4
4
|
-- 1. A global DiffviewState() function that external tools can query via Neovim's
|
|
5
5
|
-- RPC socket to get the current diffview state (file, position, file list).
|
|
6
|
-
-- 2.
|
|
6
|
+
-- 2. A global DiffviewHunks() function that returns all diff hunks across all files
|
|
7
|
+
-- as a flat array, using diffview.nvim's diff parser on git diff output.
|
|
8
|
+
-- 3. A global DiffviewGoTo(file, line) function that navigates diffview to a
|
|
9
|
+
-- specific file and line number.
|
|
10
|
+
-- 4. Diffview hooks that clean up buffers when the diff view is closed.
|
|
7
11
|
--
|
|
8
12
|
-- Requires: sindrets/diffview.nvim
|
|
9
13
|
--
|
|
10
14
|
-- Usage from outside Neovim:
|
|
11
15
|
-- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewState()')"
|
|
16
|
+
-- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewHunks()')"
|
|
17
|
+
-- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewHunks(\"HEAD~3\")')"
|
|
18
|
+
-- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewGoTo(\"src/foo.ts\", 42)')"
|
|
12
19
|
|
|
13
20
|
local M = {}
|
|
14
21
|
|
|
@@ -68,6 +75,165 @@ function DiffviewState()
|
|
|
68
75
|
})
|
|
69
76
|
end
|
|
70
77
|
|
|
78
|
+
--- Get all diff hunks across all files as a flat array.
|
|
79
|
+
---
|
|
80
|
+
--- Runs `git diff` and parses the output using diffview.nvim's diff parser.
|
|
81
|
+
--- Each hunk is a self-contained object with its file path, git status, and
|
|
82
|
+
--- line range information.
|
|
83
|
+
---
|
|
84
|
+
--- @param ref string? Optional git ref to diff against (e.g. "HEAD~3").
|
|
85
|
+
--- Defaults to diffing uncommitted changes vs HEAD.
|
|
86
|
+
--- If diffview is open and has a rev_arg, that is used as
|
|
87
|
+
--- the default instead.
|
|
88
|
+
--- @return string JSON-encoded flat array of hunk objects:
|
|
89
|
+
--- [{ file, status, old_start, old_count, new_start, new_count, header }]
|
|
90
|
+
function DiffviewHunks(ref)
|
|
91
|
+
local ok, lib = pcall(require, "diffview.lib")
|
|
92
|
+
if not ok then
|
|
93
|
+
return vim.json.encode({ error = "diffview.nvim not loaded" })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local vcs_utils = require("diffview.vcs.utils")
|
|
97
|
+
|
|
98
|
+
-- Determine the git toplevel directory
|
|
99
|
+
local toplevel = vim.fn.systemlist("git rev-parse --show-toplevel")[1]
|
|
100
|
+
if vim.v.shell_error ~= 0 or not toplevel then
|
|
101
|
+
return vim.json.encode({ error = "Not in a git repository" })
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
-- If diffview is open, try to use its rev_arg as the default ref
|
|
105
|
+
if not ref then
|
|
106
|
+
local view = lib.get_current_view()
|
|
107
|
+
if view and view.rev_arg and view.rev_arg ~= "" then
|
|
108
|
+
ref = view.rev_arg
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
-- Get git status for each file (to include status letters in hunk data)
|
|
113
|
+
-- Use --name-status with the diff to get file statuses
|
|
114
|
+
local status_cmd = "git diff --name-status"
|
|
115
|
+
if ref then
|
|
116
|
+
status_cmd = status_cmd .. " " .. ref
|
|
117
|
+
end
|
|
118
|
+
local status_lines = vim.fn.systemlist(status_cmd)
|
|
119
|
+
local file_statuses = {}
|
|
120
|
+
for _, line in ipairs(status_lines) do
|
|
121
|
+
local status, path = line:match("^(%a)%s+(.+)$")
|
|
122
|
+
if status and path then
|
|
123
|
+
file_statuses[path] = status
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
-- Run git diff to get the full patch output
|
|
128
|
+
local diff_cmd = "git diff -U0"
|
|
129
|
+
if ref then
|
|
130
|
+
diff_cmd = diff_cmd .. " " .. ref
|
|
131
|
+
end
|
|
132
|
+
local diff_lines = vim.fn.systemlist(diff_cmd)
|
|
133
|
+
if vim.v.shell_error ~= 0 then
|
|
134
|
+
return vim.json.encode({ error = "git diff failed" })
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if #diff_lines == 0 then
|
|
138
|
+
return vim.json.encode({})
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
-- Parse the diff using diffview's parser
|
|
142
|
+
local file_diffs = vcs_utils.parse_diff(diff_lines)
|
|
143
|
+
|
|
144
|
+
-- Flatten into a single array: one entry per hunk, each with its file info
|
|
145
|
+
local hunks = {}
|
|
146
|
+
for _, file_diff in ipairs(file_diffs) do
|
|
147
|
+
local path = file_diff.path_new or file_diff.path_old or ""
|
|
148
|
+
local status = file_statuses[path] or "M"
|
|
149
|
+
|
|
150
|
+
for _, hunk in ipairs(file_diff.hunks) do
|
|
151
|
+
local header = string.format(
|
|
152
|
+
"@@ -%d,%d +%d,%d @@",
|
|
153
|
+
hunk.old_row, hunk.old_size, hunk.new_row, hunk.new_size
|
|
154
|
+
)
|
|
155
|
+
table.insert(hunks, {
|
|
156
|
+
file = path,
|
|
157
|
+
status = status,
|
|
158
|
+
old_start = hunk.old_row,
|
|
159
|
+
old_count = hunk.old_size,
|
|
160
|
+
new_start = hunk.new_row,
|
|
161
|
+
new_count = hunk.new_size,
|
|
162
|
+
header = header,
|
|
163
|
+
})
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return vim.json.encode(hunks)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
--- Navigate diffview to a specific file and line number.
|
|
171
|
+
---
|
|
172
|
+
--- 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.
|
|
174
|
+
---
|
|
175
|
+
--- @param file string Repo-relative file path
|
|
176
|
+
--- @param line number Line number to jump to (in the new version of the file)
|
|
177
|
+
--- @return string JSON-encoded result: { ok: true } or { error: string }
|
|
178
|
+
function DiffviewGoTo(file, line)
|
|
179
|
+
local ok, lib = pcall(require, "diffview.lib")
|
|
180
|
+
if not ok then
|
|
181
|
+
return vim.json.encode({ error = "diffview.nvim not loaded" })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
local view = lib.get_current_view()
|
|
185
|
+
if not view then
|
|
186
|
+
return vim.json.encode({ error = "diffview is not open" })
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
local utils = require("diffview.utils")
|
|
190
|
+
local panel = view.panel
|
|
191
|
+
local files = panel:ordered_file_list()
|
|
192
|
+
|
|
193
|
+
-- Find the target file in the file list
|
|
194
|
+
local target = nil
|
|
195
|
+
for _, f in ipairs(files) do
|
|
196
|
+
if f.path == file then
|
|
197
|
+
target = f
|
|
198
|
+
break
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if not target then
|
|
203
|
+
return vim.json.encode({ error = "File not found in diffview: " .. file })
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
-- Switch to the target file if it's not already the current one
|
|
207
|
+
local cur_file = panel.cur_file
|
|
208
|
+
if not cur_file or cur_file.path ~= file then
|
|
209
|
+
view:set_file(target)
|
|
210
|
+
end
|
|
211
|
+
|
|
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
|
+
return vim.json.encode({ ok = true })
|
|
235
|
+
end
|
|
236
|
+
|
|
71
237
|
--- Diffview hook: called when a diff view is opened.
|
|
72
238
|
--- Snapshots the current buffer list so we know which buffers to clean up later.
|
|
73
239
|
function M.on_view_opened(view)
|
|
@@ -103,7 +269,8 @@ function M.on_view_closed(view)
|
|
|
103
269
|
end
|
|
104
270
|
|
|
105
271
|
--- Set up the plugin. Call this from your plugin spec or init.lua.
|
|
106
|
-
--- Configures diffview.nvim hooks for buffer cleanup
|
|
272
|
+
--- Configures diffview.nvim hooks for buffer cleanup and registers global
|
|
273
|
+
--- functions for external tool access.
|
|
107
274
|
---
|
|
108
275
|
--- Example with lazy.nvim:
|
|
109
276
|
--- {
|
|
@@ -116,9 +283,11 @@ end
|
|
|
116
283
|
function M.setup(opts)
|
|
117
284
|
opts = opts or {}
|
|
118
285
|
|
|
119
|
-
-- Register
|
|
120
|
-
-- but this ensures
|
|
286
|
+
-- Register global functions (already defined at module load,
|
|
287
|
+
-- but this ensures they're available even if the module is lazy-loaded)
|
|
121
288
|
_G.DiffviewState = DiffviewState
|
|
289
|
+
_G.DiffviewHunks = DiffviewHunks
|
|
290
|
+
_G.DiffviewGoTo = DiffviewGoTo
|
|
122
291
|
|
|
123
292
|
-- Configure diffview hooks
|
|
124
293
|
local dv_ok, diffview = pcall(require, "diffview")
|
package/opencode-plugin/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// --- Types ---
|
|
4
|
+
|
|
5
|
+
interface HunkItem {
|
|
6
|
+
file: string
|
|
5
7
|
status: string
|
|
8
|
+
old_start: number
|
|
9
|
+
old_count: number
|
|
10
|
+
new_start: number
|
|
11
|
+
new_count: number
|
|
12
|
+
header: string
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
interface DiffviewState {
|
|
@@ -12,10 +19,124 @@ interface DiffviewState {
|
|
|
12
19
|
status?: string
|
|
13
20
|
index?: number
|
|
14
21
|
total?: number
|
|
15
|
-
files?:
|
|
22
|
+
files?: { path: string; status: string }[]
|
|
16
23
|
error?: string
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
// --- Review queue state (persists across tool calls within a session) ---
|
|
27
|
+
|
|
28
|
+
let reviewQueue: HunkItem[] = []
|
|
29
|
+
let reviewPosition = -1 // -1 means review not started
|
|
30
|
+
let reviewRef: string | undefined
|
|
31
|
+
let reviewFiles: string[] | undefined
|
|
32
|
+
|
|
33
|
+
// --- Neovim socket discovery ---
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Discover a Neovim RPC socket when NVIM_SOCKET is not explicitly set.
|
|
37
|
+
*
|
|
38
|
+
* Strategy:
|
|
39
|
+
* 1. Check NVIM_SOCKET env var (always wins)
|
|
40
|
+
* 2. Scan for socket files in known locations
|
|
41
|
+
* 3. Verify each is live by attempting a connection
|
|
42
|
+
* 4. Prefer the Neovim instance whose cwd matches ours (same project)
|
|
43
|
+
* 5. Fall back to the first live socket found
|
|
44
|
+
*/
|
|
45
|
+
const discoverNvimSocket = async (): Promise<string | null> => {
|
|
46
|
+
// 1. Explicit env var — skip discovery entirely
|
|
47
|
+
if (process.env.NVIM_SOCKET) return process.env.NVIM_SOCKET
|
|
48
|
+
|
|
49
|
+
// 2. Scan for socket files
|
|
50
|
+
const tmpdir = process.env.TMPDIR || "/tmp"
|
|
51
|
+
const user = process.env.USER || "unknown"
|
|
52
|
+
let socketPaths: string[] = []
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const output =
|
|
56
|
+
await Bun.$`find -L ${tmpdir}/nvim.${user} /tmp -maxdepth 4 -type s -name "nvim*" 2>/dev/null`.text()
|
|
57
|
+
socketPaths = output.trim().split("\n").filter(Boolean)
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
if (socketPaths.length === 0) return null
|
|
61
|
+
|
|
62
|
+
// 3 & 4. Check each socket — prefer cwd match, fall back to first live one
|
|
63
|
+
const ourCwd = process.cwd()
|
|
64
|
+
let fallback: string | null = null
|
|
65
|
+
|
|
66
|
+
for (const socketPath of socketPaths) {
|
|
67
|
+
try {
|
|
68
|
+
// Verify socket is live with a simple expression
|
|
69
|
+
await Bun.$`nvim --headless --server ${socketPath} --remote-expr "1+1"`.text()
|
|
70
|
+
|
|
71
|
+
// Try to get the PID from the socket filename (default sockets: nvim.<pid>.0)
|
|
72
|
+
let pid: string | undefined
|
|
73
|
+
const pidFromName = socketPath.match(/nvim\.(\d+)\.\d+$/)
|
|
74
|
+
if (pidFromName) {
|
|
75
|
+
pid = pidFromName[1]
|
|
76
|
+
} else {
|
|
77
|
+
// For --listen sockets (no PID in filename), find the owning process
|
|
78
|
+
try {
|
|
79
|
+
const lsof = await Bun.$`lsof ${socketPath} 2>/dev/null`.text()
|
|
80
|
+
const pidMatch = lsof.match(/nvim\s+(\d+)/)
|
|
81
|
+
if (pidMatch) pid = pidMatch[1]
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get the cwd of the Neovim process and compare with ours
|
|
86
|
+
if (pid) {
|
|
87
|
+
try {
|
|
88
|
+
const lsof = await Bun.$`lsof -p ${pid} -Fn 2>/dev/null`.text()
|
|
89
|
+
const cwdMatch = lsof.match(/fcwd\nn(.+)/)
|
|
90
|
+
if (cwdMatch && cwdMatch[1] === ourCwd) {
|
|
91
|
+
return socketPath // Exact cwd match — this is our Neovim
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Remember the first live socket as fallback
|
|
97
|
+
if (!fallback) fallback = socketPath
|
|
98
|
+
} catch {
|
|
99
|
+
// Socket not responsive — stale socket from a crashed Neovim, skip it
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return fallback
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Helpers ---
|
|
107
|
+
|
|
108
|
+
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"
|
|
114
|
+
|
|
115
|
+
const formatHunkPosition = (): string => {
|
|
116
|
+
if (reviewQueue.length === 0) return "No review in progress."
|
|
117
|
+
const item = reviewQueue[reviewPosition]
|
|
118
|
+
return `Reviewing: ${item.file} (${statusLabel(item.status)}) ${item.header} — item ${reviewPosition + 1} of ${reviewQueue.length}.`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Match an order item from the agent to a hunk in the available hunks list.
|
|
123
|
+
* Identity is {file, old_start, new_start}.
|
|
124
|
+
*/
|
|
125
|
+
const findHunk = (
|
|
126
|
+
hunks: HunkItem[],
|
|
127
|
+
orderItem: { file: string; old_start: number; old_count: number; new_start: number; new_count: number }
|
|
128
|
+
): HunkItem | undefined =>
|
|
129
|
+
hunks.find(
|
|
130
|
+
h =>
|
|
131
|
+
h.file === orderItem.file &&
|
|
132
|
+
h.old_start === orderItem.old_start &&
|
|
133
|
+
h.old_count === orderItem.old_count &&
|
|
134
|
+
h.new_start === orderItem.new_start &&
|
|
135
|
+
h.new_count === orderItem.new_count
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// --- Plugin ---
|
|
139
|
+
|
|
19
140
|
export const DiffReviewPlugin: Plugin = async (ctx) => {
|
|
20
141
|
return {
|
|
21
142
|
tool: {
|
|
@@ -29,64 +150,90 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
|
|
|
29
150
|
"- If you discover lint errors during review, close the diff, fix them, then restart\n\n" +
|
|
30
151
|
"Workflow:\n" +
|
|
31
152
|
"1. Fix any lint/format issues in your changes first\n" +
|
|
32
|
-
"2. Call with action '
|
|
33
|
-
"
|
|
34
|
-
"3.
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
153
|
+
"2. Call with action 'get_hunks' to retrieve all change hunks across all files.\n" +
|
|
154
|
+
" Each hunk includes file path, status, and line range info.\n" +
|
|
155
|
+
"3. Analyze the hunks and decide a review order. Reorder them for narrative\n" +
|
|
156
|
+
" coherence — e.g. show the data model change before the API that uses it,\n" +
|
|
157
|
+
" then the UI that calls the API. For small changes, the natural order is fine.\n" +
|
|
158
|
+
" You may also filter out hunks that are trivial (e.g. import reordering).\n" +
|
|
159
|
+
"4. Call with action 'start_review' with the ordered hunks array to open the diff\n" +
|
|
160
|
+
" view and begin. If you omit the order, natural hunk order is used.\n" +
|
|
161
|
+
"5. Explain the current hunk shown in the diff view.\n" +
|
|
162
|
+
"6. Ask the user if they have questions or feedback about these changes.\n" +
|
|
163
|
+
"7. If the user requests changes or leaves feedback, acknowledge it and note it\n" +
|
|
164
|
+
" down — but DO NOT make any changes yet. Continue the review.\n" +
|
|
165
|
+
"8. Call with action 'next' to advance to the next item in the review queue.\n" +
|
|
166
|
+
" When you reach the last item, 'next' will tell you there are no more items.\n" +
|
|
167
|
+
"9. Repeat steps 5-8 for each item\n" +
|
|
168
|
+
"10. Call with action 'close' when the review is complete\n" +
|
|
169
|
+
"11. Propose a git commit message for the CURRENT changes and commit if the user approves\n" +
|
|
170
|
+
"12. If the user left feedback or change requests during the review, NOW apply them\n" +
|
|
44
171
|
" — this creates a clean separation: one commit for the original work,\n" +
|
|
45
172
|
" a second commit for review feedback changes\n" +
|
|
46
|
-
"
|
|
173
|
+
"13. If you made feedback changes, offer to walk through them with a second diff_review\n" +
|
|
47
174
|
" — since the original work is already committed, this diff will only show\n" +
|
|
48
175
|
" the feedback changes, making them easy to verify\n\n" +
|
|
49
|
-
"CRITICAL: During the review (steps
|
|
176
|
+
"CRITICAL: During the review (steps 5-9), NEVER make changes to files.\n" +
|
|
50
177
|
"Only collect feedback. Apply changes AFTER the review is closed and the\n" +
|
|
51
178
|
"original work is committed.\n\n" +
|
|
52
|
-
"Every response includes the current
|
|
179
|
+
"Every response includes the current item and position (e.g., 'item 2 of 5') " +
|
|
53
180
|
"so you always know where you are in the review. Use the 'status' action " +
|
|
54
181
|
"to re-orient if you lose track.",
|
|
55
182
|
args: {
|
|
56
183
|
action: tool.schema
|
|
57
|
-
.enum(["
|
|
184
|
+
.enum(["get_hunks", "start_review", "next", "prev", "status", "close"])
|
|
58
185
|
.describe(
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"status: get current
|
|
186
|
+
"get_hunks: retrieve all diff hunks across all files as a flat array. " +
|
|
187
|
+
"start_review: open the diff view and begin reviewing, optionally with a custom order. " +
|
|
188
|
+
"next: navigate to the next item in the review queue. " +
|
|
189
|
+
"prev: navigate to the previous item in the review queue. " +
|
|
190
|
+
"status: get current position in the review queue without navigating. " +
|
|
191
|
+
"close: close the diff view and end the review."
|
|
192
|
+
),
|
|
193
|
+
ref: tool.schema
|
|
194
|
+
.string()
|
|
195
|
+
.optional()
|
|
196
|
+
.describe(
|
|
197
|
+
"Git ref to diff against (get_hunks and start_review only). " +
|
|
198
|
+
"Defaults to showing uncommitted changes vs HEAD. " +
|
|
199
|
+
"Examples: HEAD~3, a commit hash, origin/main"
|
|
64
200
|
),
|
|
65
201
|
files: tool.schema
|
|
66
202
|
.array(tool.schema.string())
|
|
67
203
|
.optional()
|
|
68
204
|
.describe(
|
|
69
|
-
"File paths to include in the diff (
|
|
70
|
-
"Omit to
|
|
205
|
+
"File paths to include in the diff (get_hunks and start_review only). " +
|
|
206
|
+
"Omit to include all uncommitted changes."
|
|
71
207
|
),
|
|
72
|
-
|
|
73
|
-
.
|
|
208
|
+
order: tool.schema
|
|
209
|
+
.array(
|
|
210
|
+
tool.schema.object({
|
|
211
|
+
file: tool.schema.string().describe("Repo-relative file path"),
|
|
212
|
+
old_start: tool.schema.number().describe("Start line in old version"),
|
|
213
|
+
old_count: tool.schema.number().describe("Line count in old version"),
|
|
214
|
+
new_start: tool.schema.number().describe("Start line in new version"),
|
|
215
|
+
new_count: tool.schema.number().describe("Line count in new version"),
|
|
216
|
+
})
|
|
217
|
+
)
|
|
74
218
|
.optional()
|
|
75
219
|
.describe(
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
220
|
+
"Custom review order (start_review only). Array of hunk identifiers " +
|
|
221
|
+
"from the get_hunks response, in the order you want to review them. " +
|
|
222
|
+
"Each item needs: file, old_start, old_count, new_start, new_count. " +
|
|
223
|
+
"Omit to use the natural hunk order."
|
|
79
224
|
),
|
|
80
225
|
},
|
|
81
226
|
async execute(args, context) {
|
|
82
|
-
const socket =
|
|
227
|
+
const socket = await discoverNvimSocket()
|
|
83
228
|
if (!socket) {
|
|
84
|
-
return "
|
|
85
|
-
"
|
|
229
|
+
return "Could not find a running Neovim instance.\n\n" +
|
|
230
|
+
"The tool looks for Neovim in this order:\n" +
|
|
231
|
+
"1. NVIM_SOCKET environment variable (if set)\n" +
|
|
232
|
+
"2. Neovim instances whose working directory matches this project\n" +
|
|
233
|
+
"3. Any running Neovim instance\n\n" +
|
|
86
234
|
"Quick setup:\n" +
|
|
87
235
|
" export NVIM_SOCKET=/tmp/nvim.sock\n" +
|
|
88
|
-
" nvim --listen $NVIM_SOCKET
|
|
89
|
-
"If using CMUX, the workspace command sets this automatically."
|
|
236
|
+
" nvim --listen $NVIM_SOCKET"
|
|
90
237
|
}
|
|
91
238
|
|
|
92
239
|
const nvimExpr = (expr: string) =>
|
|
@@ -101,117 +248,172 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
|
|
|
101
248
|
}
|
|
102
249
|
}
|
|
103
250
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const formatState = (state: DiffviewState): string => {
|
|
112
|
-
if (!state.open) return "Diff view is not open."
|
|
113
|
-
if (!state.current_file)
|
|
114
|
-
return `Diff view is open but no files to show (${state.total ?? 0} files total).`
|
|
115
|
-
return `Currently showing: ${state.current_file} (${statusLabel(state.status)}) — file ${state.index} of ${state.total}.`
|
|
251
|
+
const getHunks = async (ref?: string): Promise<HunkItem[]> => {
|
|
252
|
+
const luaArg = ref ? `"${ref.replace(/"/g, '\\"')}"` : ""
|
|
253
|
+
const raw = await nvimExpr(`luaeval("DiffviewHunks(${luaArg})")`)
|
|
254
|
+
const parsed = JSON.parse(raw.trim())
|
|
255
|
+
if (parsed.error) throw new Error(parsed.error)
|
|
256
|
+
return parsed as HunkItem[]
|
|
116
257
|
}
|
|
117
258
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
259
|
+
const goToHunk = async (item: HunkItem): Promise<void> => {
|
|
260
|
+
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
|
|
263
|
+
const raw = await nvimExpr(
|
|
264
|
+
`luaeval("DiffviewGoTo('${file}', ${line})")`
|
|
265
|
+
)
|
|
266
|
+
const result = JSON.parse(raw.trim())
|
|
267
|
+
if (result.error) throw new Error(result.error)
|
|
268
|
+
// Give diffview time to switch files and position the cursor
|
|
269
|
+
await Bun.sleep(300)
|
|
124
270
|
}
|
|
125
271
|
|
|
126
272
|
try {
|
|
127
273
|
switch (args.action) {
|
|
128
|
-
case "
|
|
274
|
+
case "get_hunks": {
|
|
275
|
+
// Store ref/files for later use by start_review
|
|
276
|
+
reviewRef = args.ref
|
|
277
|
+
reviewFiles = args.files
|
|
278
|
+
|
|
279
|
+
const hunks = await getHunks(args.ref)
|
|
280
|
+
|
|
281
|
+
if (hunks.length === 0) {
|
|
282
|
+
return "No changes found." +
|
|
283
|
+
(args.ref ? ` (compared against ${args.ref})` : "")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Summarize: count files and hunks
|
|
287
|
+
const fileSet = new Set(hunks.map(h => h.file))
|
|
288
|
+
const summary = `Found ${hunks.length} hunk${hunks.length === 1 ? "" : "s"} ` +
|
|
289
|
+
`across ${fileSet.size} file${fileSet.size === 1 ? "" : "s"}` +
|
|
290
|
+
(args.ref ? ` (compared against ${args.ref})` : "") + ".\n\n"
|
|
291
|
+
|
|
292
|
+
return summary + JSON.stringify(hunks, null, 2)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case "start_review": {
|
|
296
|
+
// Use ref/files from get_hunks if not explicitly provided
|
|
297
|
+
const ref = args.ref ?? reviewRef
|
|
298
|
+
const files = args.files ?? reviewFiles
|
|
299
|
+
|
|
300
|
+
// Open diffview
|
|
129
301
|
let cmd = "DiffviewOpen"
|
|
130
|
-
if (
|
|
131
|
-
cmd += ` ${
|
|
302
|
+
if (ref) {
|
|
303
|
+
cmd += ` ${ref}`
|
|
132
304
|
}
|
|
133
|
-
if (
|
|
134
|
-
const escaped =
|
|
305
|
+
if (files && files.length > 0) {
|
|
306
|
+
const escaped = files.map(f => f.replace(/ /g, "\\ ")).join(" ")
|
|
135
307
|
cmd += ` -- ${escaped}`
|
|
136
308
|
}
|
|
137
309
|
await nvimExpr(`luaeval("vim.cmd('${cmd.replace(/'/g, "''")}')")`)
|
|
138
|
-
// Give diffview
|
|
310
|
+
// Give diffview time to populate the file list
|
|
139
311
|
await Bun.sleep(500)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
312
|
+
|
|
313
|
+
// Build the review queue
|
|
314
|
+
if (args.order && args.order.length > 0) {
|
|
315
|
+
// Agent provided a custom order — resolve each item to a full hunk
|
|
316
|
+
const allHunks = await getHunks(ref)
|
|
317
|
+
const queue: HunkItem[] = []
|
|
318
|
+
const unmatched: string[] = []
|
|
319
|
+
|
|
320
|
+
for (const orderItem of args.order) {
|
|
321
|
+
const match = findHunk(allHunks, orderItem)
|
|
322
|
+
if (match) {
|
|
323
|
+
queue.push(match)
|
|
324
|
+
} else {
|
|
325
|
+
unmatched.push(`${orderItem.file} ${orderItem.old_start}→${orderItem.new_start}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (queue.length === 0) {
|
|
330
|
+
return "Could not match any items in the provided order to actual hunks. " +
|
|
331
|
+
`Unmatched: ${unmatched.join(", ")}. ` +
|
|
332
|
+
"Call 'get_hunks' to see available hunks."
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
reviewQueue = queue
|
|
336
|
+
|
|
337
|
+
if (unmatched.length > 0) {
|
|
338
|
+
// Warn but proceed with what we have
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
// No custom order — use natural hunk order
|
|
342
|
+
reviewQueue = await getHunks(ref)
|
|
343
|
+
|
|
344
|
+
if (reviewQueue.length === 0) {
|
|
345
|
+
return "Opened diff view but no hunks found." +
|
|
346
|
+
(ref ? ` (compared against ${ref})` : "")
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Navigate to the first item
|
|
351
|
+
reviewPosition = 0
|
|
352
|
+
await goToHunk(reviewQueue[0])
|
|
353
|
+
|
|
354
|
+
return `Started review with ${reviewQueue.length} item${reviewQueue.length === 1 ? "" : "s"}` +
|
|
355
|
+
(ref ? ` (comparing against ${ref})` : " (uncommitted changes vs HEAD)") +
|
|
356
|
+
`. ${formatHunkPosition()}`
|
|
145
357
|
}
|
|
146
358
|
|
|
147
359
|
case "next": {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// If already on the last file, don't navigate (diffview wraps around)
|
|
152
|
-
if (before.index !== undefined && before.total !== undefined &&
|
|
153
|
-
before.index >= before.total) {
|
|
154
|
-
return `Already at the last file (file ${before.index} of ${before.total}). ` +
|
|
155
|
-
`${formatState(before)} There are no more files to review. ` +
|
|
156
|
-
"Use action 'close' to end the review."
|
|
360
|
+
if (reviewQueue.length === 0) {
|
|
361
|
+
return "No review in progress. Call 'start_review' first."
|
|
157
362
|
}
|
|
158
363
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Detect wrap-around: if index went down, diffview wrapped to the beginning
|
|
164
|
-
if (before.index !== undefined && after.index !== undefined &&
|
|
165
|
-
after.index < before.index) {
|
|
166
|
-
// Undo the wrap by going back
|
|
167
|
-
await nvimExpr(`luaeval("require('diffview').emit('select_prev_entry')")`)
|
|
168
|
-
await Bun.sleep(200)
|
|
169
|
-
const restored = await getState()
|
|
170
|
-
return `Already at the last file (file ${restored.index} of ${restored.total}). ` +
|
|
171
|
-
`${formatState(restored)} There are no more files to review. ` +
|
|
364
|
+
if (reviewPosition >= reviewQueue.length - 1) {
|
|
365
|
+
return `Already at the last item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
|
|
366
|
+
`${formatHunkPosition()} There are no more items to review. ` +
|
|
172
367
|
"Use action 'close' to end the review."
|
|
173
368
|
}
|
|
174
369
|
|
|
175
|
-
|
|
370
|
+
reviewPosition++
|
|
371
|
+
await goToHunk(reviewQueue[reviewPosition])
|
|
372
|
+
|
|
373
|
+
return `Navigated to next item. ${formatHunkPosition()}`
|
|
176
374
|
}
|
|
177
375
|
|
|
178
376
|
case "prev": {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// If already on the first file, don't navigate (diffview wraps around)
|
|
183
|
-
if (before.index !== undefined && before.index <= 1) {
|
|
184
|
-
return `Already at the first file (file ${before.index} of ${before.total}). ` +
|
|
185
|
-
`${formatState(before)} There are no previous files.`
|
|
377
|
+
if (reviewQueue.length === 0) {
|
|
378
|
+
return "No review in progress. Call 'start_review' first."
|
|
186
379
|
}
|
|
187
380
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Detect wrap-around: if index went up, diffview wrapped to the end
|
|
193
|
-
if (before.index !== undefined && after.index !== undefined &&
|
|
194
|
-
after.index > before.index) {
|
|
195
|
-
// Undo the wrap by going forward
|
|
196
|
-
await nvimExpr(`luaeval("require('diffview').emit('select_next_entry')")`)
|
|
197
|
-
await Bun.sleep(200)
|
|
198
|
-
const restored = await getState()
|
|
199
|
-
return `Already at the first file (file ${restored.index} of ${restored.total}). ` +
|
|
200
|
-
`${formatState(restored)} There are no previous files.`
|
|
381
|
+
if (reviewPosition <= 0) {
|
|
382
|
+
return `Already at the first item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
|
|
383
|
+
`${formatHunkPosition()} There are no previous items.`
|
|
201
384
|
}
|
|
202
385
|
|
|
203
|
-
|
|
386
|
+
reviewPosition--
|
|
387
|
+
await goToHunk(reviewQueue[reviewPosition])
|
|
388
|
+
|
|
389
|
+
return `Navigated to previous item. ${formatHunkPosition()}`
|
|
204
390
|
}
|
|
205
391
|
|
|
206
392
|
case "status": {
|
|
207
393
|
const state = await getState()
|
|
208
|
-
if (!state.open
|
|
209
|
-
|
|
394
|
+
if (!state.open && reviewQueue.length === 0) {
|
|
395
|
+
return "No review in progress and diff view is not open."
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (reviewQueue.length === 0) {
|
|
399
|
+
return "Diff view is open but no review queue. Call 'start_review' to begin."
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return formatHunkPosition()
|
|
210
403
|
}
|
|
211
404
|
|
|
212
405
|
case "close": {
|
|
213
406
|
await nvimExpr(`luaeval("require('diffview').close()")`)
|
|
214
|
-
|
|
407
|
+
|
|
408
|
+
// Clear review state
|
|
409
|
+
const itemCount = reviewQueue.length
|
|
410
|
+
reviewQueue = []
|
|
411
|
+
reviewPosition = -1
|
|
412
|
+
reviewRef = undefined
|
|
413
|
+
reviewFiles = undefined
|
|
414
|
+
|
|
415
|
+
return `Closed the diff view and ended the review` +
|
|
416
|
+
(itemCount > 0 ? ` (reviewed ${itemCount} item${itemCount === 1 ? "" : "s"}).` : ".")
|
|
215
417
|
}
|
|
216
418
|
}
|
|
217
419
|
} catch (e: any) {
|