From e5130a5c46dc0aef962e0b3a2f3d4233618aea98 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sat, 29 Nov 2025 21:21:09 -0800 Subject: [PATCH 1/8] feat(diff): a working rough draft --- DIFF_FEATURE.md | 172 ++++++++++++++++ debug_events.lua | 14 ++ lua/opencode/config.lua | 4 + lua/opencode/diff.lua | 352 +++++++++++++++++++++++++++++++++ lua/opencode/events.lua | 2 + plugin/events/session_diff.lua | 20 ++ 6 files changed, 564 insertions(+) create mode 100644 DIFF_FEATURE.md create mode 100644 debug_events.lua create mode 100644 lua/opencode/diff.lua create mode 100644 plugin/events/session_diff.lua diff --git a/DIFF_FEATURE.md b/DIFF_FEATURE.md new file mode 100644 index 00000000..f295a11a --- /dev/null +++ b/DIFF_FEATURE.md @@ -0,0 +1,172 @@ +# OpenCode Diff Review Feature + +This feature provides PR-style review of changes made by OpenCode, allowing you to review, navigate, and accept/reject file edits. + +## Configuration + +**Enabled by default** - no configuration needed! + +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + enabled = true, -- PR-style review (default: true) + }, + }, +} +``` + +**To disable:** +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + enabled = false, -- Disable diff review + }, + }, +} +``` + +## How It Works + +1. **AI makes edits** across multiple files +2. **Files are written** to disk immediately +3. **`session.diff` event fires** with complete change data: + - All modified files in one event + - Each file includes `before` (original) and `after` (new) content +4. **Review UI opens** showing current file's changes +5. **Navigate and decide:** + - `n` - Next file + - `p` - Previous file + - `a` - Accept this file (keep changes) + - `r` - Reject this file (restore original using `before` content) + - `A` - Accept all files + - `R` - Reject all files + - `q` - Close review (keeps current state) + +**Restore Strategy:** +- Uses `before` content from `session.diff` event +- Writes original content back to disk +- Reloads buffer if open in editor +- No Git dependencies required + - `A` - Accept all files + - `R` - Reject all files + - `q` - Close review (keeps current state) + +**Restore Strategy:** +- Uses `before` content from `session.diff` event +- Writes original content back to disk +- Reloads buffer if open in editor +- No Git dependencies required + +### Permission-Based Review + +1. **AI wants to edit file** → Permission request fires +2. **Shows unified diff** in vertical split +3. **User decides:** + - `aa` - Accept edit (file will be written) + - `ar` or `q` - Reject edit (file won't be modified) +4. **Repeat for each file** individually + +## Usage Example + +### Testing Session Diff Review + +1. **Enable the feature** (it's on by default) +2. **Ask OpenCode to make changes:** + ``` + Update file1.txt and file2.txt with programming jokes + ``` +3. **Wait for OpenCode to finish** +4. **Review UI appears** showing all changes +5. **Navigate with `n`/`p`**, accept with `a`, or reject with `r` + +## Files + +**Core Implementation:** +- `plugin/events/session_diff.lua` - Listens for `OpencodeEvent:session.diff` +- `lua/opencode/diff.lua` - Review UI and restore logic +- `lua/opencode/config.lua` - Configuration options +- `lua/opencode/events.lua` - Type definitions + +**Legacy (kept for compatibility):** +- `plugin/events/permissions.lua` - Permission-based review (disabled by default) + +## Current Limitations + +1. **Simple diff display** - Shows before/after content, not unified diff format (yet) +2. **No syntax highlighting** - Displays as plain diff format +3. **No per-hunk review** - Accept/reject entire file only +4. **Buffer management** - Opens in vertical split (not configurable yet) + +## Future Enhancements + +- [ ] Proper unified diff rendering with syntax highlighting +- [ ] Per-hunk accept/reject +- [ ] Floating window option +- [ ] Side-by-side diff view +- [ ] Integration with existing diff tools (vim-fugitive, diffview.nvim) +- [ ] Configurable keybindings +- [ ] Auto-close after accepting all +- [ ] File filtering/searching in multi-file reviews + +## Architecture + +### Event Flow + +``` +AI makes edits + ↓ +Files written to disk + ↓ +OpencodeEvent:session.diff fires + ↓ +plugin/events/session_diff.lua catches it + ↓ +lua/opencode/diff.lua handles review + ↓ +User reviews in split buffer + ↓ +Accept (keep) or Reject (restore from 'before' content) +``` + +### Restore Strategy + +Instead of Git stash/commit, we use the `before` content from the event: + +```lua +-- session.diff event includes: +{ + diff = { + { + file = "path/to/file.lua", + before = "original content...", -- ← We use this! + after = "new content...", + additions = 10, + deletions = 5 + } + } +} + +-- To revert: +vim.fn.writefile(vim.split(file_data.before, "\n"), file_data.file) +``` + +**Benefits:** +- No Git dependency +- No pollution of Git history +- 100% accurate (exact original content) +- Works in any project + +## Comparison: Permission vs Session Diff + +| Aspect | Permission Review | Session Diff Review | +|--------|------------------|---------------------| +| **Timing** | Before file write | After file write | +| **Unified view** | ❌ One file at a time | ✅ All files together | +| **Navigation** | ❌ Sequential only | ✅ Free navigation | +| **Configuration** | Needs OpenCode config | Works out of the box | +| **Undo method** | Don't write file | Restore from `before` | +| **Reliability** | ⚠️ Works sometimes | ✅ Always works | + +**Recommendation:** Use Session Diff Review for better UX. diff --git a/debug_events.lua b/debug_events.lua new file mode 100644 index 00000000..6a59cb14 --- /dev/null +++ b/debug_events.lua @@ -0,0 +1,14 @@ +-- Debug helper: Add this to your Neovim config temporarily to see ALL opencode events + +vim.api.nvim_create_autocmd("User", { + pattern = "OpencodeEvent:*", + callback = function(args) + local event = args.data.event + vim.notify( + string.format("[EVENT] %s\nProperties: %s", event.type, vim.inspect(event.properties or {})), + vim.log.levels.INFO, + { title = "opencode.debug" } + ) + end, + desc = "Debug all opencode events", +}) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 7a415e7b..099aa01b 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -111,6 +111,10 @@ local defaults = { enabled = true, idle_delay_ms = 1000, }, + session_diff = { + enabled = true, -- Show session review for session.diff events + open_in_tab = false, -- Open review in a new tab (and reuse the same tab for navigation) + }, }, provider = { cmd = "opencode", diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua new file mode 100644 index 00000000..ccb6ff7a --- /dev/null +++ b/lua/opencode/diff.lua @@ -0,0 +1,352 @@ +local M = {} + +---@class opencode.events.session_diff.Opts +--- +---Whether to enable the ability to review diff after the agent finishes responding +---@field enabled boolean +--- +---Whether to open the review in a new tab (and reuse the same tab for navigation) +---@field open_in_tab? boolean + +---@class opencode.diff.State +---@field bufnr number? Temporary buffer for diff display +---@field winnr number? Window number for diff display +---@field tabnr number? Tab number for diff display (when using open_in_tab) +---@field session_diff table? Session diff data for session review + +M.state = { + bufnr = nil, + winnr = nil, + tabnr = nil, + session_diff = nil, +} + +---Clean up diff buffer and state +function M.cleanup() + if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then + vim.api.nvim_buf_delete(M.state.bufnr, { force = true }) + end + + M.state.bufnr = nil +end + +---Check if diff content is actually empty (no meaningful changes) +---@param file_data table File diff data +---@return boolean +local function is_diff_empty(file_data) + local before = file_data.before or "" + local after = file_data.after or "" + return before == after or (before == "" and after == "") +end + +---Show diff review for an assistant message +---@param message table Message info from message.updated event +---@param opts opencode.events.session_diff.Opts +function M.show_message_diff(message, opts) + -- Extract diffs from message.summary.diffs + local diffs = message.summary and message.summary.diffs or {} + + if #diffs == 0 then + return -- No diffs to show + end + + -- Filter out empty diffs + local files_with_changes = {} + for _, file_data in ipairs(diffs) do + if not is_diff_empty(file_data) then + table.insert(files_with_changes, { + file = file_data.file, + before = file_data.before, + after = file_data.after, + additions = file_data.additions, + deletions = file_data.deletions, + }) + end + end + + -- Only show review if we have non-empty files + if #files_with_changes == 0 then + return + end + + M.state.session_diff = { + session_id = message.sessionID, + message_id = message.id, + files = files_with_changes, + current_index = 1, + } + + M.show_review(opts) +end + +---Revert a single file to its original state using 'before' content +---@param file_data table File diff data with 'before' content +function M.revert_file(file_data) + if not file_data.before then + vim.notify( + string.format("Cannot revert %s: no 'before' content available", file_data.file), + vim.log.levels.WARN, + { title = "opencode" } + ) + return false + end + + local lines = vim.split(file_data.before, "\n") + local success = pcall(vim.fn.writefile, lines, file_data.file) + + if success then + -- Reload the buffer if it's open + local bufnr = vim.fn.bufnr(file_data.file) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("edit!") + end) + end + return true + else + vim.notify(string.format("Failed to revert %s", file_data.file), vim.log.levels.ERROR, { title = "opencode" }) + return false + end +end + +---Accept all changes (close review UI) +function M.accept_all_changes() + vim.notify("Accepted all changes", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() +end + +---Reject all changes (revert all files) +function M.reject_all_changes() + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local reverted = 0 + for _, file_data in ipairs(diff_state.files) do + if M.revert_file(file_data) then + reverted = reverted + 1 + end + end + + vim.notify( + string.format("Reverted %d/%d files", reverted, #diff_state.files), + vim.log.levels.INFO, + { title = "opencode" } + ) + M.cleanup_session_diff() +end + +---Accept current file (mark as accepted, move to next) +---@param opts opencode.events.session_diff.Opts +function M.accept_current_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local current_file = diff_state.files[diff_state.current_index] + vim.notify(string.format("Accepted: %s", current_file.file), vim.log.levels.INFO, { title = "opencode" }) + + -- Move to next file or close if done + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + else + vim.notify("All files reviewed", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() + end +end + +---Reject current file (revert it, move to next) +---@param opts opencode.events.session_diff.Opts +function M.reject_current_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local current_file = diff_state.files[diff_state.current_index] + M.revert_file(current_file) + + -- Move to next file or close if done + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + else + vim.notify("All files reviewed", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() + end +end + +---Navigate to next file +---@param opts opencode.events.session_diff.Opts +function M.next_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + end +end + +---Navigate to previous file +---@param opts opencode.events.session_diff.Opts +function M.prev_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + if diff_state.current_index > 1 then + diff_state.current_index = diff_state.current_index - 1 + M.show_review(opts) + end +end + +---Clean up session diff state and UI +function M.cleanup_session_diff() + if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then + vim.api.nvim_buf_delete(M.state.bufnr, { force = true }) + end + + M.state.bufnr = nil + M.state.winnr = nil + M.state.tabnr = nil + M.state.session_diff = nil +end + +---Show session changes review UI +---@param opts opencode.events.session_diff.Opts +function M.show_review(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local total_files = #diff_state.files + local current_file = diff_state.files[diff_state.current_index] + + -- Reuse existing buffer if available, otherwise create new one + local bufnr = M.state.bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + bufnr = vim.api.nvim_create_buf(false, true) + M.state.bufnr = bufnr + + -- Set buffer options + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = "diff" + end + + -- Build simple diff content + local lines = {} + table.insert(lines, string.format("=== OpenCode Changes Review [%d/%d] ===", diff_state.current_index, total_files)) + table.insert(lines, "") + table.insert(lines, string.format("File: %s", current_file.file)) + table.insert(lines, string.format("Changes: +%d -%d", current_file.additions or 0, current_file.deletions or 0)) + table.insert(lines, "") + table.insert(lines, "--- Before") + table.insert(lines, "+++ After") + table.insert(lines, "") + + -- Show a simple before/after + if current_file.before then + table.insert(lines, "=== BEFORE ===") + for _, line in ipairs(vim.split(current_file.before, "\n")) do + table.insert(lines, "- " .. line) + end + end + + table.insert(lines, "") + + if current_file.after then + table.insert(lines, "=== AFTER ===") + for _, line in ipairs(vim.split(current_file.after, "\n")) do + table.insert(lines, "+ " .. line) + end + end + + table.insert(lines, "") + table.insert(lines, "=== Keybindings ===") + table.insert(lines, " next file |

prev file") + table.insert(lines, " accept this file | reject this file") + table.insert(lines, " accept all | reject all") + table.insert(lines, " close review") + + -- Set buffer content + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false + + -- Handle window/tab display + if opts.open_in_tab then + -- Check if we have a tab already + if M.state.tabnr and vim.api.nvim_tabpage_is_valid(M.state.tabnr) then + -- Switch to the existing tab + vim.api.nvim_set_current_tabpage(M.state.tabnr) + -- Find the window in this tab showing our buffer + local found_win = false + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(M.state.tabnr)) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then + vim.api.nvim_set_current_win(win) + found_win = true + break + end + end + if not found_win then + -- Create a new window in this tab + vim.cmd("only") + vim.api.nvim_win_set_buf(0, bufnr) + end + else + -- Create a new tab + vim.cmd("tabnew") + M.state.tabnr = vim.api.nvim_get_current_tabpage() + vim.api.nvim_win_set_buf(0, bufnr) + end + else + -- Check if we have an existing window + if M.state.winnr and vim.api.nvim_win_is_valid(M.state.winnr) then + -- Reuse the existing window + vim.api.nvim_set_current_win(M.state.winnr) + vim.api.nvim_win_set_buf(M.state.winnr, bufnr) + else + -- Create a new split + vim.cmd("vsplit") + M.state.winnr = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(M.state.winnr, bufnr) + end + end + + -- Set up keybindings (need to wrap opts in closures) + local keymap_opts = { buffer = bufnr, nowait = true, silent = true } + + vim.keymap.set("n", "n", function() + M.next_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Next file" })) + vim.keymap.set("n", "p", function() + M.prev_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Previous file" })) + vim.keymap.set("n", "a", function() + M.accept_current_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Accept this file" })) + vim.keymap.set("n", "r", function() + M.reject_current_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Reject this file" })) + vim.keymap.set("n", "A", M.accept_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Accept all" })) + vim.keymap.set("n", "R", M.reject_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Reject all" })) + vim.keymap.set("n", "q", M.cleanup_session_diff, vim.tbl_extend("force", keymap_opts, { desc = "Close review" })) + + vim.notify( + string.format("Review [%d/%d]: %s", diff_state.current_index, total_files, current_file.file), + vim.log.levels.INFO, + { title = "opencode" } + ) +end + +return M diff --git a/lua/opencode/events.lua b/lua/opencode/events.lua index 2e0ec47d..68a73b1c 100644 --- a/lua/opencode/events.lua +++ b/lua/opencode/events.lua @@ -10,6 +10,8 @@ local M = {} ---@field reload? boolean --- ---@field permissions? opencode.events.permissions.Opts +--- +---@field session_diff? opencode.events.session_diff.Opts ---Subscribe to `opencode`'s Server-Sent Events (SSE) and execute `OpencodeEvent:` autocmds. --- diff --git a/plugin/events/session_diff.lua b/plugin/events/session_diff.lua new file mode 100644 index 00000000..d8a33779 --- /dev/null +++ b/plugin/events/session_diff.lua @@ -0,0 +1,20 @@ +vim.api.nvim_create_autocmd("User", { + group = vim.api.nvim_create_augroup("OpencodeSessionDiff", { clear = true }), + pattern = "OpencodeEvent:message.updated", + callback = function(args) + ---@type opencode.cli.client.Event + local event = args.data.event + + local opts = require("opencode.config").opts.events.session_diff or {} + if not opts.enabled then + return + end + + -- Only show review for assistant messages that have diffs + local message = event.properties.info + if message and message.role == "user" and message.summary and message.summary.diffs then + require("opencode.diff").show_message_diff(message, opts) + end + end, + desc = "Display session diff review from opencode", +}) From c7028d3c8c676f44db6c9743352dff0c54da5478 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 13:43:13 -0800 Subject: [PATCH 2/8] feat(diff): add better diff display --- lua/opencode/diff.lua | 104 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index ccb6ff7a..1395bc0c 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -39,6 +39,81 @@ local function is_diff_empty(file_data) return before == after or (before == "" and after == "") end +---Generate unified diff using vim.diff() +---@param file_path string Path to the file +---@param before string Original content +---@param after string New content +---@param additions number Number of additions +---@param deletions number Number of deletions +---@return string[] lines Lines of unified diff output +local function generate_unified_diff(file_path, before, after, additions, deletions) + local lines = {} + + -- Add diff header + table.insert(lines, string.format("diff --git a/%s b/%s", file_path, file_path)) + + -- Handle edge cases + local is_new_file = before == "" or before == nil + local is_deleted_file = after == "" or after == nil + + if is_new_file then + table.insert(lines, "new file") + table.insert(lines, "--- /dev/null") + table.insert(lines, string.format("+++ b/%s", file_path)) + elseif is_deleted_file then + table.insert(lines, "deleted file") + table.insert(lines, string.format("--- a/%s", file_path)) + table.insert(lines, "+++ /dev/null") + else + table.insert(lines, string.format("--- a/%s", file_path)) + table.insert(lines, string.format("+++ b/%s", file_path)) + end + + -- Add change stats + table.insert(lines, string.format("@@ +%d,-%d @@", additions or 0, deletions or 0)) + table.insert(lines, "") + + -- Generate unified diff using vim.diff() + if not is_new_file and not is_deleted_file then + local ok, diff_result = pcall(vim.diff, before, after, { + result_type = "unified", + algorithm = "histogram", + ctxlen = 3, + indent_heuristic = true, + }) + + if ok and diff_result and diff_result ~= "" then + -- vim.diff returns a string, split it into lines + for _, line in ipairs(vim.split(diff_result, "\n")) do + table.insert(lines, line) + end + else + -- Fallback: show simple line-by-line diff + table.insert(lines, "--- Original") + for _, line in ipairs(vim.split(before, "\n")) do + table.insert(lines, "- " .. line) + end + table.insert(lines, "") + table.insert(lines, "+++ Modified") + for _, line in ipairs(vim.split(after, "\n")) do + table.insert(lines, "+ " .. line) + end + end + elseif is_new_file and after then + -- New file: show all lines as additions + for _, line in ipairs(vim.split(after, "\n")) do + table.insert(lines, "+ " .. line) + end + elseif is_deleted_file and before then + -- Deleted file: show all lines as deletions + for _, line in ipairs(vim.split(before, "\n")) do + table.insert(lines, "- " .. line) + end + end + + return lines +end + ---Show diff review for an assistant message ---@param message table Message info from message.updated event ---@param opts opencode.events.session_diff.Opts @@ -243,32 +318,25 @@ function M.show_review(opts) vim.bo[bufnr].filetype = "diff" end - -- Build simple diff content + -- Build unified diff content local lines = {} table.insert(lines, string.format("=== OpenCode Changes Review [%d/%d] ===", diff_state.current_index, total_files)) table.insert(lines, "") table.insert(lines, string.format("File: %s", current_file.file)) table.insert(lines, string.format("Changes: +%d -%d", current_file.additions or 0, current_file.deletions or 0)) table.insert(lines, "") - table.insert(lines, "--- Before") - table.insert(lines, "+++ After") - table.insert(lines, "") - - -- Show a simple before/after - if current_file.before then - table.insert(lines, "=== BEFORE ===") - for _, line in ipairs(vim.split(current_file.before, "\n")) do - table.insert(lines, "- " .. line) - end - end - table.insert(lines, "") + -- Generate and insert unified diff + local diff_lines = generate_unified_diff( + current_file.file, + current_file.before or "", + current_file.after or "", + current_file.additions, + current_file.deletions + ) - if current_file.after then - table.insert(lines, "=== AFTER ===") - for _, line in ipairs(vim.split(current_file.after, "\n")) do - table.insert(lines, "+ " .. line) - end + for _, line in ipairs(diff_lines) do + table.insert(lines, line) end table.insert(lines, "") From bce2f257aa501ac84cecb6f27f815dc2fcd1d1a9 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 13:54:32 -0800 Subject: [PATCH 3/8] chore(diff): update doc --- DIFF_FEATURE.md | 87 ++++++++++++++----------------------------------- 1 file changed, 25 insertions(+), 62 deletions(-) diff --git a/DIFF_FEATURE.md b/DIFF_FEATURE.md index f295a11a..c6a38626 100644 --- a/DIFF_FEATURE.md +++ b/DIFF_FEATURE.md @@ -11,17 +11,19 @@ vim.g.opencode_opts = { events = { session_diff = { enabled = true, -- PR-style review (default: true) + open_in_tab = false, -- Open review in tab instead of vsplit(default: false) }, }, } ``` **To disable:** + ```lua vim.g.opencode_opts = { events = { session_diff = { - enabled = false, -- Disable diff review + enabled = false, }, }, } @@ -31,7 +33,7 @@ vim.g.opencode_opts = { 1. **AI makes edits** across multiple files 2. **Files are written** to disk immediately -3. **`session.diff` event fires** with complete change data: +3. **`message.updated` event fires** with complete change data: - All modified files in one event - Each file includes `before` (original) and `after` (new) content 4. **Review UI opens** showing current file's changes @@ -45,28 +47,14 @@ vim.g.opencode_opts = { - `q` - Close review (keeps current state) **Restore Strategy:** -- Uses `before` content from `session.diff` event -- Writes original content back to disk -- Reloads buffer if open in editor -- No Git dependencies required - - `A` - Accept all files - - `R` - Reject all files - - `q` - Close review (keeps current state) -**Restore Strategy:** -- Uses `before` content from `session.diff` event +- Uses `before` content from `messaged.updated` event - Writes original content back to disk - Reloads buffer if open in editor - No Git dependencies required - -### Permission-Based Review - -1. **AI wants to edit file** → Permission request fires -2. **Shows unified diff** in vertical split -3. **User decides:** - - `aa` - Accept edit (file will be written) - - `ar` or `q` - Reject edit (file won't be modified) -4. **Repeat for each file** individually + - `A` - Accept all files + - `R` - Reject all files + - `q` - Close review (keeps current state) ## Usage Example @@ -74,9 +62,11 @@ vim.g.opencode_opts = { 1. **Enable the feature** (it's on by default) 2. **Ask OpenCode to make changes:** + ``` Update file1.txt and file2.txt with programming jokes ``` + 3. **Wait for OpenCode to finish** 4. **Review UI appears** showing all changes 5. **Navigate with `n`/`p`**, accept with `a`, or reject with `r` @@ -84,24 +74,18 @@ vim.g.opencode_opts = { ## Files **Core Implementation:** -- `plugin/events/session_diff.lua` - Listens for `OpencodeEvent:session.diff` -- `lua/opencode/diff.lua` - Review UI and restore logic -- `lua/opencode/config.lua` - Configuration options -- `lua/opencode/events.lua` - Type definitions -**Legacy (kept for compatibility):** -- `plugin/events/permissions.lua` - Permission-based review (disabled by default) +- `plugin/events/session_diff.lua` - Listens for `OpencodeEvent:message.updated` +- `lua/opencode/diff.lua` - Review UI and restore logic ## Current Limitations -1. **Simple diff display** - Shows before/after content, not unified diff format (yet) -2. **No syntax highlighting** - Displays as plain diff format +1. **Simple diff display** - Shows before/after content using vim.diff(unified) 3. **No per-hunk review** - Accept/reject entire file only -4. **Buffer management** - Opens in vertical split (not configurable yet) ## Future Enhancements -- [ ] Proper unified diff rendering with syntax highlighting +- [x] Proper unified diff rendering with syntax highlighting - [ ] Per-hunk accept/reject - [ ] Floating window option - [ ] Side-by-side diff view @@ -115,19 +99,17 @@ vim.g.opencode_opts = { ### Event Flow ``` -AI makes edits - ↓ -Files written to disk - ↓ -OpencodeEvent:session.diff fires - ↓ -plugin/events/session_diff.lua catches it - ↓ -lua/opencode/diff.lua handles review - ↓ -User reviews in split buffer - ↓ -Accept (keep) or Reject (restore from 'before' content) + → session.created + → message.updated (user) + → session.status (busy) + → message.updated (assistant starts) + → message.part.updated (streaming response) + → [4x tool calls executed, files edited] + → message.updated (finish: "tool-calls") + → session.diff (ONE event with all cumulative changes in the session) + → message.updated (Using this as the indicator for a Q&A cycle, only contains diff for files + changed, not like session.diff that contains everything) + → session.status (idle) ``` ### Restore Strategy @@ -151,22 +133,3 @@ Instead of Git stash/commit, we use the `before` content from the event: -- To revert: vim.fn.writefile(vim.split(file_data.before, "\n"), file_data.file) ``` - -**Benefits:** -- No Git dependency -- No pollution of Git history -- 100% accurate (exact original content) -- Works in any project - -## Comparison: Permission vs Session Diff - -| Aspect | Permission Review | Session Diff Review | -|--------|------------------|---------------------| -| **Timing** | Before file write | After file write | -| **Unified view** | ❌ One file at a time | ✅ All files together | -| **Navigation** | ❌ Sequential only | ✅ Free navigation | -| **Configuration** | Needs OpenCode config | Works out of the box | -| **Undo method** | Don't write file | Restore from `before` | -| **Reliability** | ⚠️ Works sometimes | ✅ Always works | - -**Recommendation:** Use Session Diff Review for better UX. From 4f026553179768912cdcafd397c5486d3a68f81f Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 21:16:59 -0800 Subject: [PATCH 4/8] feat(diff): add basic diff view using neovim native diff --- DIFF_FEATURE.md | 180 ++++++++++++++--- lua/opencode/config.lua | 1 + lua/opencode/diff.lua | 429 +++++++++++++++++++++++++++++++++++++++- lua/opencode/health.lua | 15 ++ 4 files changed, 594 insertions(+), 31 deletions(-) diff --git a/DIFF_FEATURE.md b/DIFF_FEATURE.md index c6a38626..e898ff98 100644 --- a/DIFF_FEATURE.md +++ b/DIFF_FEATURE.md @@ -2,6 +2,61 @@ This feature provides PR-style review of changes made by OpenCode, allowing you to review, navigate, and accept/reject file edits. +## Enhanced Diff View (Default) + +OpenCode provides an enhanced diff viewing experience using vim's built-in diff-mode with side-by-side comparison! + +### Features + +- **Side-by-side diff**: Split view with before/after comparison +- **Syntax highlighting**: Vim's native diff highlighting +- **Hunk navigation**: Jump between changes with `]c` / `[c` +- **File panel**: Toggleable list of all changed files (`gp`) +- **File navigation**: `` / `` to cycle through files +- **Revert support**: Press `R` to revert the current file +- **Single tab**: All files use the same tab for better workspace management +- **Standard vim diff**: All standard diff-mode commands work (`:h diff-mode`) + +### How It Works + +1. **Temp files created**: OpenCode creates temp files with `before` content +2. **Actual files contain**: The `after` content (already written) +3. **Side-by-side diff**: Temp file (left) vs actual file (right) +4. **Navigate seamlessly**: Switch between files in the same tab +5. **File panel**: See all changed files at a glance + +### Keybindings (Enhanced Diff Mode) + +- `gp` - Toggle file panel (shows all changed files) +- `` - Next file +- `` - Previous file +- `]c` - Next hunk (change) +- `[c` - Previous hunk (change) +- `R` - Revert current file to original +- `q` - Close diff view +- See `:h diff-mode` for more diff commands + +### File Panel + +Press `gp` to toggle a sidebar showing all changed files: + +``` +OpenCode Changed Files +──────────────────────────────────────── + +▶ 1. config.lua +12 -5 + 2. diff.lua +87 -34 + 3. health.lua +0 -15 + +──────────────────────────────────────── +Press to jump, gp to close panel +``` + +- `▶` indicates the current file +- `` - Jump to selected file +- `gp` - Close panel +- `q` - Close entire diff view + ## Configuration **Enabled by default** - no configuration needed! @@ -11,13 +66,26 @@ vim.g.opencode_opts = { events = { session_diff = { enabled = true, -- PR-style review (default: true) - open_in_tab = false, -- Open review in tab instead of vsplit(default: false) + use_enhanced_diff = true, -- Use enhanced vim diff-mode (default: true) + open_in_tab = false, -- For basic mode: open in tab (default: false) + }, + }, +} +``` + +**To use basic unified diff view** (single buffer with diff output): + +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + use_enhanced_diff = false, -- Use basic unified diff view }, }, } ``` -**To disable:** +**To disable diff review entirely:** ```lua vim.g.opencode_opts = { @@ -36,25 +104,43 @@ vim.g.opencode_opts = { 3. **`message.updated` event fires** with complete change data: - All modified files in one event - Each file includes `before` (original) and `after` (new) content -4. **Review UI opens** showing current file's changes +4. **Review UI opens** automatically: + - **Enhanced mode** (default): Side-by-side diff in new tab with file panel + - **Basic mode**: Unified diff view in split/tab 5. **Navigate and decide:** - - `n` - Next file - - `p` - Previous file - - `a` - Accept this file (keep changes) - - `r` - Reject this file (restore original using `before` content) - - `A` - Accept all files - - `R` - Reject all files - - `q` - Close review (keeps current state) + - **Enhanced mode**: `gp` for file panel, `` / `` for files, `]c` / `[c` for hunks, `R` to revert + - **Basic mode**: `n` / `p` for files, `a` / `r` to accept/reject, `A` / `R` for all **Restore Strategy:** -- Uses `before` content from `messaged.updated` event +- Uses `before` content from `message.updated` event - Writes original content back to disk - Reloads buffer if open in editor - No Git dependencies required - - `A` - Accept all files - - `R` - Reject all files - - `q` - Close review (keeps current state) + +## Keybindings + +### Enhanced Mode (Default) + +When using enhanced diff view (side-by-side with vim diff-mode): +- `gp` - Toggle file panel +- `` - Next file +- `` - Previous file +- `]c` - Next hunk (change) +- `[c` - Previous hunk (change) +- `R` - Revert current file to original +- `q` - Close diff view + +### Basic Mode (Unified Diff) + +When enhanced mode is disabled: +- `n` - Next file +- `p` - Previous file +- `a` - Accept this file (keep changes) +- `r` - Reject this file (restore original using `before` content) +- `A` - Accept all files +- `R` - Reject all files +- `q` - Close review (keeps current state) ## Usage Example @@ -68,8 +154,10 @@ vim.g.opencode_opts = { ``` 3. **Wait for OpenCode to finish** -4. **Review UI appears** showing all changes -5. **Navigate with `n`/`p`**, accept with `a`, or reject with `r` +4. **Review UI appears** showing all changes in side-by-side diff +5. **Navigate with ``/``** or press `gp` for file panel +6. **Review hunks** with `]c`/`[c` +7. **Revert if needed** with `R`, or close with `q` ## Files @@ -80,16 +168,26 @@ vim.g.opencode_opts = { ## Current Limitations +### Enhanced Mode +1. **Manual acceptance** - Files stay changed until you revert them +2. **No per-hunk revert** - Must revert entire file (could be added with staging logic) +3. **Temp files** - Creates temp directory for before content (auto-cleaned on close) + +### Basic Mode 1. **Simple diff display** - Shows before/after content using vim.diff(unified) -3. **No per-hunk review** - Accept/reject entire file only +2. **No per-hunk review** - Accept/reject entire file only +3. **Limited navigation** - File-level only, no hunk jumping + +**Recommendation**: Use enhanced mode (default) for the best experience! ## Future Enhancements -- [x] Proper unified diff rendering with syntax highlighting -- [ ] Per-hunk accept/reject -- [ ] Floating window option -- [ ] Side-by-side diff view -- [ ] Integration with existing diff tools (vim-fugitive, diffview.nvim) +- [x] Side-by-side vim diff-mode view +- [x] File panel for navigation +- [x] Single tab with buffer switching +- [ ] Per-hunk accept/reject (staging) +- [ ] Floating window option for file panel +- [ ] Integration with other diff tools (vim-fugitive, mini.diff) - [ ] Configurable keybindings - [ ] Auto-close after accepting all - [ ] File filtering/searching in multi-file reviews @@ -117,15 +215,17 @@ vim.g.opencode_opts = { Instead of Git stash/commit, we use the `before` content from the event: ```lua --- session.diff event includes: +-- message.updated event includes: { - diff = { - { - file = "path/to/file.lua", - before = "original content...", -- ← We use this! - after = "new content...", - additions = 10, - deletions = 5 + summary = { + diffs = { + { + file = "path/to/file.lua", + before = "original content...", -- ← We use this! + after = "new content...", + additions = 10, + deletions = 5 + } } } } @@ -133,3 +233,23 @@ Instead of Git stash/commit, we use the `before` content from the event: -- To revert: vim.fn.writefile(vim.split(file_data.before, "\n"), file_data.file) ``` + +### Enhanced Diff Implementation + +```lua +-- 1. Create temp directory +local temp_dir = vim.fn.tempname() .. "_opencode_diff" + +-- 2. Write before content to temp files +local temp_before = temp_dir .. "/" .. filename .. ".before" +vim.fn.writefile(vim.split(before_content, "\n"), temp_before) + +-- 3. Open side-by-side diff in single tab +vim.cmd("tabnew") +vim.cmd("edit " .. temp_before) -- Left: before +vim.cmd("rightbelow vertical diffsplit " .. actual_file) -- Right: after +vim.cmd("diffthis") -- Enable diff mode + +-- 4. Navigate between files in same tab +-- Just switch buffers in the same windows! +``` diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 099aa01b..820c6f60 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -113,6 +113,7 @@ local defaults = { }, session_diff = { enabled = true, -- Show session review for session.diff events + use_enhanced_diff = true, -- Use enhanced diff view with vim diff-mode (side-by-side) open_in_tab = false, -- Open review in a new tab (and reuse the same tab for navigation) }, }, diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 1395bc0c..3edd3f7a 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -5,6 +5,9 @@ local M = {} ---Whether to enable the ability to review diff after the agent finishes responding ---@field enabled boolean --- +---Whether to use enhanced diff view with vim diff-mode (side-by-side) +---@field use_enhanced_diff? boolean +--- ---Whether to open the review in a new tab (and reuse the same tab for navigation) ---@field open_in_tab? boolean @@ -114,6 +117,422 @@ local function generate_unified_diff(file_path, before, after, additions, deleti return lines end +---Open changes in enhanced diff view using vim's diff-mode +---@param session_diff table Session diff data with files +function M.open_enhanced_diff(session_diff) + -- If we already have an active diff view, close it first + if M.state.enhanced_diff_tab and vim.api.nvim_tabpage_is_valid(M.state.enhanced_diff_tab) then + M.cleanup_enhanced_diff() + end + + -- Write before content to temp files for each changed file + local temp_dir = vim.fn.tempname() .. "_opencode_diff" + vim.fn.mkdir(temp_dir, "p") + + local file_entries = {} + + for _, file_data in ipairs(session_diff.files) do + -- Write before content to temp file + local temp_before = temp_dir .. "/" .. vim.fn.fnamemodify(file_data.file, ":t") .. ".before" + vim.fn.writefile(vim.split(file_data.before or "", "\n"), temp_before) + + -- Use actual file for after (it already has new content from OpenCode) + local actual_file = file_data.file + + -- Store mapping for cleanup + if not M.state.enhanced_diff_temp_files then + M.state.enhanced_diff_temp_files = {} + end + table.insert(M.state.enhanced_diff_temp_files, temp_before) + + table.insert(file_entries, { + path = file_data.file, + oldpath = nil, + status = "M", + stats = { + additions = file_data.additions or 0, + deletions = file_data.deletions or 0, + }, + temp_before = temp_before, + actual_file = actual_file, + }) + end + + -- Store session data for later use + M.state.enhanced_diff_session = session_diff + M.state.enhanced_diff_temp_dir = temp_dir + M.state.enhanced_diff_files = file_entries + M.state.enhanced_diff_current_index = 1 + M.state.enhanced_diff_panel_visible = false + + -- Open first file in diff mode + if #file_entries > 0 then + -- Create a new tab for the diff view + vim.cmd("tabnew") + M.state.enhanced_diff_tab = vim.api.nvim_get_current_tabpage() + + -- Show the first file + M.enhanced_diff_show_file(1) + + -- Show file panel by default if multiple files + if #file_entries > 1 then + vim.defer_fn(function() + M.enhanced_diff_show_panel() + end, 100) -- Small delay to let diff view settle + end + + -- Set up autocommand to cleanup on tab close + vim.api.nvim_create_autocmd("TabClosed", { + pattern = tostring(M.state.enhanced_diff_tab), + callback = function() + M.cleanup_enhanced_diff_silent() + end, + once = true, + desc = "Cleanup OpenCode diff temp files on tab close", + }) + end +end + +---Navigate to next file in enhanced diff view +function M.enhanced_diff_next_file() + if not M.state.enhanced_diff_files or not M.state.enhanced_diff_current_index then + return + end + + local current = M.state.enhanced_diff_current_index + local total = #M.state.enhanced_diff_files + + if current < total then + M.state.enhanced_diff_current_index = current + 1 + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Navigate to previous file in enhanced diff view +function M.enhanced_diff_prev_file() + if not M.state.enhanced_diff_files or not M.state.enhanced_diff_current_index then + return + end + + local current = M.state.enhanced_diff_current_index + + if current > 1 then + M.state.enhanced_diff_current_index = current - 1 + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Toggle the file panel visibility +function M.enhanced_diff_toggle_panel() + if not M.state.enhanced_diff_files then + return + end + + if M.state.enhanced_diff_panel_visible then + M.enhanced_diff_hide_panel() + else + M.enhanced_diff_show_panel() + end +end + +---Show the file panel with all changed files +function M.enhanced_diff_show_panel() + if not M.state.enhanced_diff_files or M.state.enhanced_diff_panel_visible then + return + end + + -- Create panel buffer + local panel_buf = vim.api.nvim_create_buf(false, true) + vim.bo[panel_buf].buftype = "nofile" + vim.bo[panel_buf].bufhidden = "wipe" + vim.bo[panel_buf].swapfile = false + vim.bo[panel_buf].filetype = "opencode-diff-panel" + vim.api.nvim_buf_set_name(panel_buf, "OpenCode Files") + + -- Build panel content + local lines = {} + table.insert(lines, "OpenCode Changed Files") + table.insert(lines, string.rep("─", 40)) + table.insert(lines, "") + + for i, entry in ipairs(M.state.enhanced_diff_files) do + local marker = (i == M.state.enhanced_diff_current_index) and "▶ " or " " + local stats = string.format("+%d -%d", entry.stats.additions, entry.stats.deletions) + table.insert(lines, string.format("%s%d. %s %s", marker, i, vim.fn.fnamemodify(entry.path, ":t"), stats)) + end + + table.insert(lines, "") + table.insert(lines, string.rep("─", 40)) + table.insert(lines, "Keymaps:") + table.insert(lines, " Jump to file") + table.insert(lines, " Next file") + table.insert(lines, " Previous file") + table.insert(lines, " ]x Next hunk") + table.insert(lines, " [x Previous hunk") + table.insert(lines, " gp Toggle panel") + table.insert(lines, " R Revert file") + table.insert(lines, " q Close diff") + + vim.bo[panel_buf].modifiable = true + vim.api.nvim_buf_set_lines(panel_buf, 0, -1, false, lines) + vim.bo[panel_buf].modifiable = false + + -- Calculate panel width as 20% of screen width (minimum 15 columns) + local total_width = vim.o.columns + local panel_width = math.max(15, math.floor(total_width * 0.2)) + + -- Open panel in a left vertical split + vim.cmd("topleft " .. panel_width .. "vsplit") + local panel_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(panel_win, panel_buf) + + -- Panel window options + vim.wo[panel_win].number = false + vim.wo[panel_win].relativenumber = false + vim.wo[panel_win].signcolumn = "no" + vim.wo[panel_win].foldcolumn = "0" + vim.wo[panel_win].cursorline = true + + -- Store panel state + M.state.enhanced_diff_panel_buf = panel_buf + M.state.enhanced_diff_panel_win = panel_win + M.state.enhanced_diff_panel_visible = true + + -- Set up panel keybindings + local keymap_opts = { buffer = panel_buf, nowait = true, silent = true } + + vim.keymap.set("n", "", function() + M.enhanced_diff_panel_select() + end, vim.tbl_extend("force", keymap_opts, { desc = "Jump to selected file" })) + + vim.keymap.set("n", "gp", function() + M.enhanced_diff_hide_panel() + end, vim.tbl_extend("force", keymap_opts, { desc = "Close file panel" })) + + vim.keymap.set("n", "q", function() + M.cleanup_enhanced_diff() + end, vim.tbl_extend("force", keymap_opts, { desc = "Close OpenCode diff" })) + + -- Move cursor back to diff windows + vim.cmd("wincmd l") +end + +---Hide the file panel +function M.enhanced_diff_hide_panel() + if not M.state.enhanced_diff_panel_visible then + return + end + + if M.state.enhanced_diff_panel_win and vim.api.nvim_win_is_valid(M.state.enhanced_diff_panel_win) then + vim.api.nvim_win_close(M.state.enhanced_diff_panel_win, true) + end + + M.state.enhanced_diff_panel_buf = nil + M.state.enhanced_diff_panel_win = nil + M.state.enhanced_diff_panel_visible = false +end + +---Jump to the file selected in the panel +function M.enhanced_diff_panel_select() + if not M.state.enhanced_diff_panel_buf or not M.state.enhanced_diff_files then + return + end + + -- Get current line in panel + local line = vim.api.nvim_win_get_cursor(0)[1] + + -- Lines 1-3 are header, files start at line 4 + local file_index = line - 3 + + if file_index >= 1 and file_index <= #M.state.enhanced_diff_files then + -- Hide panel before showing file + M.enhanced_diff_hide_panel() + M.state.enhanced_diff_current_index = file_index + M.enhanced_diff_show_file(file_index) + end +end + +---Show a specific file in the diff view +---@param index number File index to show +function M.enhanced_diff_show_file(index) + local file_entry = M.state.enhanced_diff_files[index] + if not file_entry then + return + end + + -- Save panel state + local panel_was_visible = M.state.enhanced_diff_panel_visible + + -- Hide panel temporarily + if panel_was_visible then + M.enhanced_diff_hide_panel() + end + + -- Close all windows except panel in current tab + vim.cmd("only") + + -- Create a scratch buffer for the "before" content + local before_buf = vim.api.nvim_create_buf(false, true) + local before_lines = vim.fn.readfile(file_entry.temp_before) + vim.api.nvim_buf_set_lines(before_buf, 0, -1, false, before_lines) + vim.bo[before_buf].buftype = "nofile" + vim.bo[before_buf].bufhidden = "wipe" + vim.bo[before_buf].swapfile = false + + -- Set a unique buffer name + local buf_name = string.format("opencode://before/%d/%s", index, vim.fn.fnamemodify(file_entry.path, ":t")) + pcall(vim.api.nvim_buf_set_name, before_buf, buf_name) + + -- Detect filetype from the actual file + local ft = vim.filetype.match({ filename = file_entry.actual_file }) or "" + vim.bo[before_buf].filetype = ft + + -- Open the before buffer on the left + vim.api.nvim_set_current_buf(before_buf) + + -- Open the actual file (after) on the right + vim.cmd("rightbelow vertical diffsplit " .. vim.fn.fnameescape(file_entry.actual_file)) + + -- Enable diff mode + vim.cmd("wincmd p") + vim.cmd("diffthis") + vim.cmd("wincmd p") + vim.cmd("diffthis") + + -- Store window references + M.state.enhanced_diff_left_win = vim.fn.win_getid(vim.fn.winnr("h")) + M.state.enhanced_diff_right_win = vim.fn.win_getid() + + -- Set up keybindings for both diff windows + local keymap_opts = { buffer = true, nowait = true, silent = true } + + for _, bufnr in ipairs({ + vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win), + vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win), + }) do + vim.keymap.set( + "n", + "", + function() + M.enhanced_diff_next_file() + end, + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Next file in OpenCode diff" }) + ) + + vim.keymap.set( + "n", + "", + function() + M.enhanced_diff_prev_file() + end, + vim.tbl_extend( + "force", + { buffer = bufnr, nowait = true, silent = true }, + { desc = "Previous file in OpenCode diff" } + ) + ) + + -- Hunk navigation with ]x and [x + vim.keymap.set( + "n", + "]x", + "]c", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true, remap = true }, { desc = "Next hunk" }) + ) + vim.keymap.set( + "n", + "[x", + "[c", + vim.tbl_extend( + "force", + { buffer = bufnr, nowait = true, silent = true, remap = true }, + { desc = "Previous hunk" } + ) + ) + + vim.keymap.set("n", "gp", function() + M.enhanced_diff_toggle_panel() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Toggle file panel" })) + + vim.keymap.set("n", "q", function() + M.cleanup_enhanced_diff() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Close OpenCode diff" })) + + vim.keymap.set("n", "R", function() + M.enhanced_diff_revert_current() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Revert current file" })) + end + + -- Restore panel if it was visible + if panel_was_visible then + M.enhanced_diff_show_panel() + end + + vim.notify( + string.format( + "OpenCode Diff [%d/%d]: %s (]x/[x=hunks, gp=panel, Tab/S-Tab=files)", + index, + #M.state.enhanced_diff_files, + vim.fn.fnamemodify(file_entry.path, ":t") + ), + vim.log.levels.INFO, + { title = "opencode" } + ) +end + +---Revert the current file being viewed +function M.enhanced_diff_revert_current() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_session then + return + end + + local file_data = M.state.enhanced_diff_session.files[M.state.enhanced_diff_current_index] + if file_data then + M.revert_file(file_data) + -- Refresh the diff view + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Clean up enhanced diff temp files and state (silent version for autocmd) +function M.cleanup_enhanced_diff_silent() + -- Hide panel if visible + if M.state.enhanced_diff_panel_visible then + M.enhanced_diff_hide_panel() + end + + -- Clean up temp files + if M.state.enhanced_diff_temp_dir and vim.fn.isdirectory(M.state.enhanced_diff_temp_dir) == 1 then + vim.fn.delete(M.state.enhanced_diff_temp_dir, "rf") + end + + -- Clear state + M.state.enhanced_diff_files = nil + M.state.enhanced_diff_current_index = nil + M.state.enhanced_diff_session = nil + M.state.enhanced_diff_temp_files = nil + M.state.enhanced_diff_temp_dir = nil + M.state.enhanced_diff_tab = nil + M.state.enhanced_diff_panel_buf = nil + M.state.enhanced_diff_panel_win = nil + M.state.enhanced_diff_panel_visible = nil + M.state.enhanced_diff_left_win = nil + M.state.enhanced_diff_right_win = nil +end + +---Clean up enhanced diff temp files and state +function M.cleanup_enhanced_diff() + -- Close the diff tab + if M.state.enhanced_diff_tab and vim.api.nvim_tabpage_is_valid(M.state.enhanced_diff_tab) then + vim.api.nvim_set_current_tabpage(M.state.enhanced_diff_tab) + vim.cmd("tabclose") + end + + M.cleanup_enhanced_diff_silent() + + vim.notify("Closed OpenCode diff", vim.log.levels.INFO, { title = "opencode" }) +end + ---Show diff review for an assistant message ---@param message table Message info from message.updated event ---@param opts opencode.events.session_diff.Opts @@ -144,13 +563,21 @@ function M.show_message_diff(message, opts) return end - M.state.session_diff = { + local session_diff = { session_id = message.sessionID, message_id = message.id, files = files_with_changes, current_index = 1, } + -- Use enhanced diff view (side-by-side with vim diff-mode) if enabled + if opts.use_enhanced_diff ~= false then + M.open_enhanced_diff(session_diff) + return + end + + -- Fallback to basic unified diff view + M.state.session_diff = session_diff M.show_review(opts) end diff --git a/lua/opencode/health.lua b/lua/opencode/health.lua index 75250eec..a714f554 100644 --- a/lua/opencode/health.lua +++ b/lua/opencode/health.lua @@ -142,6 +142,21 @@ function M.check() vim.health.warn("The `" .. provider.name .. "` provider is not available — " .. ok, advice) end end + + vim.health.start("opencode.nvim [diff review]") + + local session_diff_opts = require("opencode.config").opts.events.session_diff + if session_diff_opts.enabled then + vim.health.ok("Session diff review is enabled.") + + if session_diff_opts.use_enhanced_diff ~= false then + vim.health.ok("Enhanced diff mode is enabled: side-by-side diff using vim diff-mode.") + else + vim.health.info("Enhanced diff mode is disabled: using basic unified diff view.") + end + else + vim.health.info("Session diff review is disabled.") + end end return M From 9944fc30c635a7766890d04e9e27a9a8eff29770 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 21:25:27 -0800 Subject: [PATCH 5/8] feat(diff): add hunk staging --- lua/opencode/diff.lua | 111 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 3edd3f7a..4d3a0d13 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -269,6 +269,9 @@ function M.enhanced_diff_show_panel() table.insert(lines, " Previous file") table.insert(lines, " ]x Next hunk") table.insert(lines, " [x Previous hunk") + table.insert(lines, " a Accept hunk") + table.insert(lines, " r Reject hunk") + table.insert(lines, " A Accept all hunks") table.insert(lines, " gp Toggle panel") table.insert(lines, " R Revert file") table.insert(lines, " q Close diff") @@ -461,6 +464,19 @@ function M.enhanced_diff_show_file(index) vim.keymap.set("n", "R", function() M.enhanced_diff_revert_current() end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Revert current file" })) + + -- Per-hunk staging keymaps + vim.keymap.set("n", "a", function() + M.enhanced_diff_accept_hunk() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept current hunk" })) + + vim.keymap.set("n", "r", function() + M.enhanced_diff_reject_hunk() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Reject current hunk" })) + + vim.keymap.set("n", "A", function() + M.enhanced_diff_accept_all_hunks() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept all hunks" })) end -- Restore panel if it was visible @@ -470,7 +486,7 @@ function M.enhanced_diff_show_file(index) vim.notify( string.format( - "OpenCode Diff [%d/%d]: %s (]x/[x=hunks, gp=panel, Tab/S-Tab=files)", + "OpenCode Diff [%d/%d]: %s (]x/[x=hunks, a/r=accept/reject, gp=panel, Tab/S-Tab=files)", index, #M.state.enhanced_diff_files, vim.fn.fnamemodify(file_entry.path, ":t") @@ -480,6 +496,99 @@ function M.enhanced_diff_show_file(index) ) end +---Accept current hunk under cursor (keep the change) +---Uses diffput to push changes from "after" (right) to "before" (left) buffer +function M.enhanced_diff_accept_hunk() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Get current window to determine which buffer we're in + local current_win = vim.api.nvim_get_current_win() + local is_in_right = (current_win == M.state.enhanced_diff_right_win) + + -- We need to be in the "after" (right) window to push changes + if not is_in_right then + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + end + + -- Use diffput to push current hunk to the "before" (left) buffer + vim.cmd("diffput") + + -- Write the "before" buffer back to temp file to persist the change + local before_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win) + local before_lines = vim.api.nvim_buf_get_lines(before_buf, 0, -1, false) + vim.fn.writefile(before_lines, file_entry.temp_before) + + vim.notify("Accepted hunk", vim.log.levels.INFO, { title = "opencode" }) +end + +---Reject current hunk under cursor (revert the change) +---Uses diffget to pull original content from "before" (left) to "after" (right) buffer +function M.enhanced_diff_reject_hunk() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Get current window to determine which buffer we're in + local current_win = vim.api.nvim_get_current_win() + local is_in_right = (current_win == M.state.enhanced_diff_right_win) + + -- We need to be in the "after" (right) window to pull changes + if not is_in_right then + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + end + + -- Use diffget to pull original content from "before" (left) buffer + vim.cmd("diffget") + + -- Save the "after" buffer (actual file) since it's been modified + local after_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win) + vim.api.nvim_buf_call(after_buf, function() + vim.cmd("write") + end) + + vim.notify("Rejected hunk", vim.log.levels.INFO, { title = "opencode" }) +end + +---Accept all remaining hunks in the current file +function M.enhanced_diff_accept_all_hunks() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Switch to "after" (right) window + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + + -- Get the "after" buffer content (this has all the changes we want to keep) + local after_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win) + local after_lines = vim.api.nvim_buf_get_lines(after_buf, 0, -1, false) + + -- Write it to the "before" buffer + local before_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win) + vim.api.nvim_buf_set_lines(before_buf, 0, -1, false, after_lines) + + -- Write the "before" buffer to temp file to persist + vim.fn.writefile(after_lines, file_entry.temp_before) + + vim.notify("Accepted all hunks in current file", vim.log.levels.INFO, { title = "opencode" }) +end + ---Revert the current file being viewed function M.enhanced_diff_revert_current() if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_session then From a36adb69736a4c2adfd69f27ccb64cf9a99e0175 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 22:38:37 -0800 Subject: [PATCH 6/8] chore(diff): update comments --- DIFF_FEATURE.md | 354 +++++----- lua/opencode/config.lua | 5 +- lua/opencode/diff.lua | 35 +- lua/opencode/diff.lua.backup | 1231 ++++++++++++++++++++++++++++++++++ lua/opencode/health.lua | 13 +- 5 files changed, 1442 insertions(+), 196 deletions(-) create mode 100644 lua/opencode/diff.lua.backup diff --git a/DIFF_FEATURE.md b/DIFF_FEATURE.md index e898ff98..0670ec90 100644 --- a/DIFF_FEATURE.md +++ b/DIFF_FEATURE.md @@ -2,254 +2,244 @@ This feature provides PR-style review of changes made by OpenCode, allowing you to review, navigate, and accept/reject file edits. -## Enhanced Diff View (Default) +## Overview -OpenCode provides an enhanced diff viewing experience using vim's built-in diff-mode with side-by-side comparison! +OpenCode supports **two different diff viewing modes** to suit your preferences: -### Features +### 1. Enhanced Mode (Default - No Dependencies) -- **Side-by-side diff**: Split view with before/after comparison -- **Syntax highlighting**: Vim's native diff highlighting -- **Hunk navigation**: Jump between changes with `]c` / `[c` -- **File panel**: Toggleable list of all changed files (`gp`) -- **File navigation**: `` / `` to cycle through files -- **Revert support**: Press `R` to revert the current file -- **Single tab**: All files use the same tab for better workspace management -- **Standard vim diff**: All standard diff-mode commands work (`:h diff-mode`) +Uses vim's built-in diff-mode with side-by-side comparison and a custom file panel. **This is the default mode** - works out of the box! -### How It Works - -1. **Temp files created**: OpenCode creates temp files with `before` content -2. **Actual files contain**: The `after` content (already written) -3. **Side-by-side diff**: Temp file (left) vs actual file (right) -4. **Navigate seamlessly**: Switch between files in the same tab -5. **File panel**: See all changed files at a glance - -### Keybindings (Enhanced Diff Mode) - -- `gp` - Toggle file panel (shows all changed files) -- `` - Next file -- `` - Previous file -- `]c` - Next hunk (change) -- `[c` - Previous hunk (change) -- `R` - Revert current file to original -- `q` - Close diff view -- See `:h diff-mode` for more diff commands - -### File Panel - -Press `gp` to toggle a sidebar showing all changed files: - -``` -OpenCode Changed Files -──────────────────────────────────────── - -▶ 1. config.lua +12 -5 - 2. diff.lua +87 -34 - 3. health.lua +0 -15 - -──────────────────────────────────────── -Press to jump, gp to close panel -``` - -- `▶` indicates the current file -- `` - Jump to selected file -- `gp` - Close panel -- `q` - Close entire diff view - -## Configuration - -**Enabled by default** - no configuration needed! +**Features:** +- Side-by-side diff with syntax highlighting +- Custom file panel showing all changed files +- Per-hunk staging with `a`/`r` keymaps +- File navigation with ``/`` +- Hunk navigation with `]x`/`[x` +- Single tab for all files +- No external dependencies required +**Configuration:** ```lua vim.g.opencode_opts = { events = { session_diff = { - enabled = true, -- PR-style review (default: true) - use_enhanced_diff = true, -- Use enhanced vim diff-mode (default: true) - open_in_tab = false, -- For basic mode: open in tab (default: false) + diff_mode = "enhanced", -- This is the default }, }, } ``` -**To use basic unified diff view** (single buffer with diff output): +### 2. Unified Mode (Minimal) -```lua -vim.g.opencode_opts = { - events = { - session_diff = { - use_enhanced_diff = false, -- Use basic unified diff view - }, - }, -} -``` +Simple unified diff view in a single buffer for lightweight reviews. -**To disable diff review entirely:** +**Features:** +- Minimal UI +- Unified diff format (like `git diff`) +- File-level accept/reject +- Lightweight and fast +**Configuration:** ```lua vim.g.opencode_opts = { events = { session_diff = { - enabled = false, + diff_mode = "unified", }, }, } ``` -## How It Works - -1. **AI makes edits** across multiple files -2. **Files are written** to disk immediately -3. **`message.updated` event fires** with complete change data: - - All modified files in one event - - Each file includes `before` (original) and `after` (new) content -4. **Review UI opens** automatically: - - **Enhanced mode** (default): Side-by-side diff in new tab with file panel - - **Basic mode**: Unified diff view in split/tab -5. **Navigate and decide:** - - **Enhanced mode**: `gp` for file panel, `` / `` for files, `]c` / `[c` for hunks, `R` to revert - - **Basic mode**: `n` / `p` for files, `a` / `r` to accept/reject, `A` / `R` for all - -**Restore Strategy:** - -- Uses `before` content from `message.updated` event -- Writes original content back to disk -- Reloads buffer if open in editor -- No Git dependencies required - ## Keybindings -### Enhanced Mode (Default) +### Enhanced Mode -When using enhanced diff view (side-by-side with vim diff-mode): +**Diff View:** - `gp` - Toggle file panel - `` - Next file - `` - Previous file -- `]c` - Next hunk (change) -- `[c` - Previous hunk (change) -- `R` - Revert current file to original +- `]x` - Next hunk +- `[x` - Previous hunk +- `a` - Accept current hunk (keep change) +- `r` - Reject current hunk (revert change) +- `A` - Accept all hunks in current file +- `R` - Revert entire current file - `q` - Close diff view -### Basic Mode (Unified Diff) +**File Panel:** +- `` - Jump to selected file +- `gp` - Close panel +- `q` - Close diff view + +### Unified Mode -When enhanced mode is disabled: - `n` - Next file - `p` - Previous file - `a` - Accept this file (keep changes) -- `r` - Reject this file (restore original using `before` content) +- `r` - Reject this file (revert to original) - `A` - Accept all files - `R` - Reject all files -- `q` - Close review (keeps current state) +- `q` - Close review -## Usage Example +## Per-Hunk Staging -### Testing Session Diff Review +**Enhanced mode** supports per-hunk accept/reject operations, allowing you to selectively keep or discard individual changes within a file. -1. **Enable the feature** (it's on by default) -2. **Ask OpenCode to make changes:** +**Accept Hunk (`a`):** +1. Position cursor on a hunk you want to keep +2. Press `a` to accept +3. Hunk disappears from diff (both sides now match) +4. Change is kept in the actual file - ``` - Update file1.txt and file2.txt with programming jokes - ``` +**Reject Hunk (`r`):** +1. Position cursor on a hunk you want to revert +2. Press `r` to reject +3. Hunk disappears from diff (both sides now match) +4. Change is reverted in the actual file -3. **Wait for OpenCode to finish** -4. **Review UI appears** showing all changes in side-by-side diff -5. **Navigate with ``/``** or press `gp` for file panel -6. **Review hunks** with `]c`/`[c` -7. **Revert if needed** with `R`, or close with `q` +**Accept All (`A`):** +- Accept all remaining hunks in the current file +- All changes are kept -## Files +**Implementation:** Uses vim's built-in diff commands (`diffput` to accept, `diffget` to reject). -**Core Implementation:** +## Configuration + +**Full configuration options:** + +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + enabled = true, -- Enable diff review (default: true) + diff_mode = "enhanced", -- "enhanced" | "unified" (default: "enhanced") + open_in_tab = false, -- For unified mode (default: false) + }, + }, +} +``` -- `plugin/events/session_diff.lua` - Listens for `OpencodeEvent:message.updated` -- `lua/opencode/diff.lua` - Review UI and restore logic +**Disable diff review:** -## Current Limitations +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + enabled = false, + }, + }, +} +``` -### Enhanced Mode -1. **Manual acceptance** - Files stay changed until you revert them -2. **No per-hunk revert** - Must revert entire file (could be added with staging logic) -3. **Temp files** - Creates temp directory for before content (auto-cleaned on close) +## File Panel -### Basic Mode -1. **Simple diff display** - Shows before/after content using vim.diff(unified) -2. **No per-hunk review** - Accept/reject entire file only -3. **Limited navigation** - File-level only, no hunk jumping +### Enhanced Mode Panel -**Recommendation**: Use enhanced mode (default) for the best experience! +Shows a list of changed files with stats: -## Future Enhancements +``` +OpenCode Changed Files +──────────────────────────────────────── + +▶ 1. config.lua +12 -5 + 2. diff.lua +87 -34 + 3. health.lua +8 -15 -- [x] Side-by-side vim diff-mode view -- [x] File panel for navigation -- [x] Single tab with buffer switching -- [ ] Per-hunk accept/reject (staging) -- [ ] Floating window option for file panel -- [ ] Integration with other diff tools (vim-fugitive, mini.diff) -- [ ] Configurable keybindings -- [ ] Auto-close after accepting all -- [ ] File filtering/searching in multi-file reviews +──────────────────────────────────────── +Keymaps: + Jump to file + Next file + Previous file + ]x Next hunk + [x Previous hunk + a Accept hunk + r Reject hunk + A Accept all hunks + gp Toggle panel + R Revert file + q Close diff +``` + +- **Dynamic width**: 20% of screen (minimum 25 columns) +- **▶ marker**: Shows current file +- **Stats**: `+additions -deletions` for each file +- **Full keymap reference**: Built into panel footer -## Architecture +## Health Check -### Event Flow +Run `:checkhealth opencode` to verify your configuration: +**Enhanced mode:** ``` - → session.created - → message.updated (user) - → session.status (busy) - → message.updated (assistant starts) - → message.part.updated (streaming response) - → [4x tool calls executed, files edited] - → message.updated (finish: "tool-calls") - → session.diff (ONE event with all cumulative changes in the session) - → message.updated (Using this as the indicator for a Q&A cycle, only contains diff for files - changed, not like session.diff that contains everything) - → session.status (idle) +opencode.nvim [diff review] + - OK: Session diff review is enabled. + - OK: Diff mode: Enhanced (side-by-side vim diff-mode with file panel) ``` -### Restore Strategy +**Unified mode:** +``` +opencode.nvim [diff review] + - OK: Session diff review is enabled. + - OK: Diff mode: Unified (simple unified diff view) +``` -Instead of Git stash/commit, we use the `before` content from the event: +## Mode Comparison -```lua --- message.updated event includes: -{ - summary = { - diffs = { - { - file = "path/to/file.lua", - before = "original content...", -- ← We use this! - after = "new content...", - additions = 10, - deletions = 5 - } - } - } -} +| Feature | Enhanced | Unified | +|---------|----------|---------| +| **Dependencies** | None | None | +| **UI Quality** | ⭐⭐⭐⭐ | ⭐⭐ | +| **File Panel** | Custom | None | +| **Side-by-side** | ✅ | ❌ | +| **Per-hunk staging** | ✅ | ❌ | +| **File navigation** | ✅ | ✅ | +| **Hunk navigation** | ✅ | ❌ | +| **Syntax highlighting** | ✅ | Limited | --- To revert: -vim.fn.writefile(vim.split(file_data.before, "\n"), file_data.file) -``` +**Recommendations:** +- **Best UX**: Use `diff_mode = "enhanced"` (default) - great UX without any plugins +- **Minimal**: Use `diff_mode = "unified"` for simple, lightweight reviews -### Enhanced Diff Implementation +## How It Works + +1. **AI makes edits** across multiple files +2. **Files are written** to disk immediately +3. **`message.updated` event fires** with change data +4. **Diff mode determined** from config +5. **Review UI opens** automatically based on mode: + - **Enhanced**: Custom vim diff-mode implementation + - **Unified**: Simple unified diff buffer +6. **Navigate and stage:** + - Use keymaps to navigate files/hunks + - Accept or reject individual hunks (enhanced mode) + - Changes persist immediately to disk + +**Restore Strategy:** All modes use the `before` content from the event (no Git required): ```lua --- 1. Create temp directory -local temp_dir = vim.fn.tempname() .. "_opencode_diff" +-- To revert a file: +vim.fn.writefile(vim.split(file_data.before, "\n"), file_data.file) + +-- To revert a hunk: +vim.cmd("diffget") -- Pull original from "before" buffer +vim.cmd("write") +``` --- 2. Write before content to temp files -local temp_before = temp_dir .. "/" .. filename .. ".before" -vim.fn.writefile(vim.split(before_content, "\n"), temp_before) +## Files --- 3. Open side-by-side diff in single tab -vim.cmd("tabnew") -vim.cmd("edit " .. temp_before) -- Left: before -vim.cmd("rightbelow vertical diffsplit " .. actual_file) -- Right: after -vim.cmd("diffthis") -- Enable diff mode +**Core Implementation:** +- `plugin/events/session_diff.lua` - Event listener +- `lua/opencode/diff.lua` - Both diff modes +- `lua/opencode/config.lua` - Configuration +- `lua/opencode/health.lua` - Health check --- 4. Navigate between files in same tab --- Just switch buffers in the same windows! -``` +## Future Enhancements + +- [x] Side-by-side vim diff-mode view +- [x] File panel for navigation +- [x] Per-hunk accept/reject (staging) +- [ ] Configurable keybindings +- [ ] Auto-close after accepting all +- [ ] File filtering/search in panel +- [ ] Custom diff algorithms diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 820c6f60..10e22a67 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -113,7 +113,10 @@ local defaults = { }, session_diff = { enabled = true, -- Show session review for session.diff events - use_enhanced_diff = true, -- Use enhanced diff view with vim diff-mode (side-by-side) + -- Diff mode: "enhanced" | "unified" + -- "enhanced": Use vim diff-mode side-by-side with file panel (default) + -- "unified": Simple unified diff view (minimal, fallback option) + diff_mode = "enhanced", open_in_tab = false, -- Open review in a new tab (and reuse the same tab for navigation) }, }, diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 4d3a0d13..9f444762 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -5,8 +5,8 @@ local M = {} ---Whether to enable the ability to review diff after the agent finishes responding ---@field enabled boolean --- ----Whether to use enhanced diff view with vim diff-mode (side-by-side) ----@field use_enhanced_diff? boolean +---Diff mode to use: "enhanced" | "unified" +---@field diff_mode? "enhanced"|"unified" --- ---Whether to open the review in a new tab (and reuse the same tab for navigation) ---@field open_in_tab? boolean @@ -285,16 +285,20 @@ function M.enhanced_diff_show_panel() local panel_width = math.max(15, math.floor(total_width * 0.2)) -- Open panel in a left vertical split - vim.cmd("topleft " .. panel_width .. "vsplit") + vim.cmd("topleft vsplit") local panel_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(panel_win, panel_buf) + -- Set the window width explicitly + vim.api.nvim_win_set_width(panel_win, panel_width) + -- Panel window options vim.wo[panel_win].number = false vim.wo[panel_win].relativenumber = false vim.wo[panel_win].signcolumn = "no" vim.wo[panel_win].foldcolumn = "0" vim.wo[panel_win].cursorline = true + vim.wo[panel_win].winfixwidth = true -- Prevent width changes -- Store panel state M.state.enhanced_diff_panel_buf = panel_buf @@ -642,6 +646,7 @@ function M.cleanup_enhanced_diff() vim.notify("Closed OpenCode diff", vim.log.levels.INFO, { title = "opencode" }) end + ---Show diff review for an assistant message ---@param message table Message info from message.updated event ---@param opts opencode.events.session_diff.Opts @@ -679,15 +684,25 @@ function M.show_message_diff(message, opts) current_index = 1, } - -- Use enhanced diff view (side-by-side with vim diff-mode) if enabled - if opts.use_enhanced_diff ~= false then + -- Determine which diff mode to use + local diff_mode = opts.diff_mode or "enhanced" + + -- Route to appropriate diff viewer + if diff_mode == "enhanced" then + M.open_enhanced_diff(session_diff) + elseif diff_mode == "unified" then + -- Use the simple unified diff view + M.state.session_diff = session_diff + M.show_review(opts) + else + -- Default to enhanced if unknown mode + vim.notify( + string.format("Unknown diff_mode '%s'. Using enhanced mode.", diff_mode), + vim.log.levels.WARN, + { title = "opencode" } + ) M.open_enhanced_diff(session_diff) - return end - - -- Fallback to basic unified diff view - M.state.session_diff = session_diff - M.show_review(opts) end ---Revert a single file to its original state using 'before' content diff --git a/lua/opencode/diff.lua.backup b/lua/opencode/diff.lua.backup new file mode 100644 index 00000000..b2517e5e --- /dev/null +++ b/lua/opencode/diff.lua.backup @@ -0,0 +1,1231 @@ +local M = {} + +---@class opencode.events.session_diff.Opts +--- +---Whether to enable the ability to review diff after the agent finishes responding +---@field enabled boolean +--- +---Diff mode to use: "enhanced" | "unified" +---@field diff_mode? "enhanced"|"unified" +--- +---Whether to open the review in a new tab (and reuse the same tab for navigation) +---@field open_in_tab? boolean + +---@class opencode.diff.State +---@field bufnr number? Temporary buffer for diff display +---@field winnr number? Window number for diff display +---@field tabnr number? Tab number for diff display (when using open_in_tab) +---@field session_diff table? Session diff data for session review + +M.state = { + bufnr = nil, + winnr = nil, + tabnr = nil, + session_diff = nil, +} + +---Clean up diff buffer and state +function M.cleanup() + if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then + vim.api.nvim_buf_delete(M.state.bufnr, { force = true }) + end + + M.state.bufnr = nil +end + +---Check if diff content is actually empty (no meaningful changes) +---@param file_data table File diff data +---@return boolean +local function is_diff_empty(file_data) + local before = file_data.before or "" + local after = file_data.after or "" + return before == after or (before == "" and after == "") +end + +---Generate unified diff using vim.diff() +---@param file_path string Path to the file +---@param before string Original content +---@param after string New content +---@param additions number Number of additions +---@param deletions number Number of deletions +---@return string[] lines Lines of unified diff output +local function generate_unified_diff(file_path, before, after, additions, deletions) + local lines = {} + + -- Add diff header + table.insert(lines, string.format("diff --git a/%s b/%s", file_path, file_path)) + + -- Handle edge cases + local is_new_file = before == "" or before == nil + local is_deleted_file = after == "" or after == nil + + if is_new_file then + table.insert(lines, "new file") + table.insert(lines, "--- /dev/null") + table.insert(lines, string.format("+++ b/%s", file_path)) + elseif is_deleted_file then + table.insert(lines, "deleted file") + table.insert(lines, string.format("--- a/%s", file_path)) + table.insert(lines, "+++ /dev/null") + else + table.insert(lines, string.format("--- a/%s", file_path)) + table.insert(lines, string.format("+++ b/%s", file_path)) + end + + -- Add change stats + table.insert(lines, string.format("@@ +%d,-%d @@", additions or 0, deletions or 0)) + table.insert(lines, "") + + -- Generate unified diff using vim.diff() + if not is_new_file and not is_deleted_file then + local ok, diff_result = pcall(vim.diff, before, after, { + result_type = "unified", + algorithm = "histogram", + ctxlen = 3, + indent_heuristic = true, + }) + + if ok and diff_result and diff_result ~= "" then + -- vim.diff returns a string, split it into lines + for _, line in ipairs(vim.split(diff_result, "\n")) do + table.insert(lines, line) + end + else + -- Fallback: show simple line-by-line diff + table.insert(lines, "--- Original") + for _, line in ipairs(vim.split(before, "\n")) do + table.insert(lines, "- " .. line) + end + table.insert(lines, "") + table.insert(lines, "+++ Modified") + for _, line in ipairs(vim.split(after, "\n")) do + table.insert(lines, "+ " .. line) + end + end + elseif is_new_file and after then + -- New file: show all lines as additions + for _, line in ipairs(vim.split(after, "\n")) do + table.insert(lines, "+ " .. line) + end + elseif is_deleted_file and before then + -- Deleted file: show all lines as deletions + for _, line in ipairs(vim.split(before, "\n")) do + table.insert(lines, "- " .. line) + end + end + + return lines +end + +---Open changes in enhanced diff view using vim's diff-mode +---@param session_diff table Session diff data with files +function M.open_enhanced_diff(session_diff) + -- If we already have an active diff view, close it first + if M.state.enhanced_diff_tab and vim.api.nvim_tabpage_is_valid(M.state.enhanced_diff_tab) then + M.cleanup_enhanced_diff() + end + + -- Write before content to temp files for each changed file + local temp_dir = vim.fn.tempname() .. "_opencode_diff" + vim.fn.mkdir(temp_dir, "p") + + local file_entries = {} + + for _, file_data in ipairs(session_diff.files) do + -- Write before content to temp file + local temp_before = temp_dir .. "/" .. vim.fn.fnamemodify(file_data.file, ":t") .. ".before" + vim.fn.writefile(vim.split(file_data.before or "", "\n"), temp_before) + + -- Use actual file for after (it already has new content from OpenCode) + local actual_file = file_data.file + + -- Store mapping for cleanup + if not M.state.enhanced_diff_temp_files then + M.state.enhanced_diff_temp_files = {} + end + table.insert(M.state.enhanced_diff_temp_files, temp_before) + + table.insert(file_entries, { + path = file_data.file, + oldpath = nil, + status = "M", + stats = { + additions = file_data.additions or 0, + deletions = file_data.deletions or 0, + }, + temp_before = temp_before, + actual_file = actual_file, + }) + end + + -- Store session data for later use + M.state.enhanced_diff_session = session_diff + M.state.enhanced_diff_temp_dir = temp_dir + M.state.enhanced_diff_files = file_entries + M.state.enhanced_diff_current_index = 1 + M.state.enhanced_diff_panel_visible = false + + -- Open first file in diff mode + if #file_entries > 0 then + -- Create a new tab for the diff view + vim.cmd("tabnew") + M.state.enhanced_diff_tab = vim.api.nvim_get_current_tabpage() + + -- Show the first file + M.enhanced_diff_show_file(1) + + -- Show file panel by default if multiple files + if #file_entries > 1 then + vim.defer_fn(function() + M.enhanced_diff_show_panel() + end, 100) -- Small delay to let diff view settle + end + + -- Set up autocommand to cleanup on tab close + vim.api.nvim_create_autocmd("TabClosed", { + pattern = tostring(M.state.enhanced_diff_tab), + callback = function() + M.cleanup_enhanced_diff_silent() + end, + once = true, + desc = "Cleanup OpenCode diff temp files on tab close", + }) + end +end + +---Navigate to next file in enhanced diff view +function M.enhanced_diff_next_file() + if not M.state.enhanced_diff_files or not M.state.enhanced_diff_current_index then + return + end + + local current = M.state.enhanced_diff_current_index + local total = #M.state.enhanced_diff_files + + if current < total then + M.state.enhanced_diff_current_index = current + 1 + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Navigate to previous file in enhanced diff view +function M.enhanced_diff_prev_file() + if not M.state.enhanced_diff_files or not M.state.enhanced_diff_current_index then + return + end + + local current = M.state.enhanced_diff_current_index + + if current > 1 then + M.state.enhanced_diff_current_index = current - 1 + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Toggle the file panel visibility +function M.enhanced_diff_toggle_panel() + if not M.state.enhanced_diff_files then + return + end + + if M.state.enhanced_diff_panel_visible then + M.enhanced_diff_hide_panel() + else + M.enhanced_diff_show_panel() + end +end + +---Show the file panel with all changed files +function M.enhanced_diff_show_panel() + if not M.state.enhanced_diff_files or M.state.enhanced_diff_panel_visible then + return + end + + -- Create panel buffer + local panel_buf = vim.api.nvim_create_buf(false, true) + vim.bo[panel_buf].buftype = "nofile" + vim.bo[panel_buf].bufhidden = "wipe" + vim.bo[panel_buf].swapfile = false + vim.bo[panel_buf].filetype = "opencode-diff-panel" + vim.api.nvim_buf_set_name(panel_buf, "OpenCode Files") + + -- Build panel content + local lines = {} + table.insert(lines, "OpenCode Changed Files") + table.insert(lines, string.rep("─", 40)) + table.insert(lines, "") + + for i, entry in ipairs(M.state.enhanced_diff_files) do + local marker = (i == M.state.enhanced_diff_current_index) and "▶ " or " " + local stats = string.format("+%d -%d", entry.stats.additions, entry.stats.deletions) + table.insert(lines, string.format("%s%d. %s %s", marker, i, vim.fn.fnamemodify(entry.path, ":t"), stats)) + end + + table.insert(lines, "") + table.insert(lines, string.rep("─", 40)) + table.insert(lines, "Keymaps:") + table.insert(lines, " Jump to file") + table.insert(lines, " Next file") + table.insert(lines, " Previous file") + table.insert(lines, " ]x Next hunk") + table.insert(lines, " [x Previous hunk") + table.insert(lines, " a Accept hunk") + table.insert(lines, " r Reject hunk") + table.insert(lines, " A Accept all hunks") + table.insert(lines, " gp Toggle panel") + table.insert(lines, " R Revert file") + table.insert(lines, " q Close diff") + + vim.bo[panel_buf].modifiable = true + vim.api.nvim_buf_set_lines(panel_buf, 0, -1, false, lines) + vim.bo[panel_buf].modifiable = false + + -- Calculate panel width as 20% of screen width (minimum 15 columns) + local total_width = vim.o.columns + local panel_width = math.max(15, math.floor(total_width * 0.2)) + + -- Open panel in a left vertical split + vim.cmd("topleft vsplit") + local panel_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(panel_win, panel_buf) + + -- Set the window width explicitly + vim.api.nvim_win_set_width(panel_win, panel_width) + + -- Panel window options + vim.wo[panel_win].number = false + vim.wo[panel_win].relativenumber = false + vim.wo[panel_win].signcolumn = "no" + vim.wo[panel_win].foldcolumn = "0" + vim.wo[panel_win].cursorline = true + vim.wo[panel_win].winfixwidth = true -- Prevent width changes + + -- Store panel state + M.state.enhanced_diff_panel_buf = panel_buf + M.state.enhanced_diff_panel_win = panel_win + M.state.enhanced_diff_panel_visible = true + + -- Set up panel keybindings + local keymap_opts = { buffer = panel_buf, nowait = true, silent = true } + + vim.keymap.set("n", "", function() + M.enhanced_diff_panel_select() + end, vim.tbl_extend("force", keymap_opts, { desc = "Jump to selected file" })) + + vim.keymap.set("n", "gp", function() + M.enhanced_diff_hide_panel() + end, vim.tbl_extend("force", keymap_opts, { desc = "Close file panel" })) + + vim.keymap.set("n", "q", function() + M.cleanup_enhanced_diff() + end, vim.tbl_extend("force", keymap_opts, { desc = "Close OpenCode diff" })) + + -- Move cursor back to diff windows + vim.cmd("wincmd l") +end + +---Hide the file panel +function M.enhanced_diff_hide_panel() + if not M.state.enhanced_diff_panel_visible then + return + end + + if M.state.enhanced_diff_panel_win and vim.api.nvim_win_is_valid(M.state.enhanced_diff_panel_win) then + vim.api.nvim_win_close(M.state.enhanced_diff_panel_win, true) + end + + M.state.enhanced_diff_panel_buf = nil + M.state.enhanced_diff_panel_win = nil + M.state.enhanced_diff_panel_visible = false +end + +---Jump to the file selected in the panel +function M.enhanced_diff_panel_select() + if not M.state.enhanced_diff_panel_buf or not M.state.enhanced_diff_files then + return + end + + -- Get current line in panel + local line = vim.api.nvim_win_get_cursor(0)[1] + + -- Lines 1-3 are header, files start at line 4 + local file_index = line - 3 + + if file_index >= 1 and file_index <= #M.state.enhanced_diff_files then + -- Hide panel before showing file + M.enhanced_diff_hide_panel() + M.state.enhanced_diff_current_index = file_index + M.enhanced_diff_show_file(file_index) + end +end + +---Show a specific file in the diff view +---@param index number File index to show +function M.enhanced_diff_show_file(index) + local file_entry = M.state.enhanced_diff_files[index] + if not file_entry then + return + end + + -- Save panel state + local panel_was_visible = M.state.enhanced_diff_panel_visible + + -- Hide panel temporarily + if panel_was_visible then + M.enhanced_diff_hide_panel() + end + + -- Close all windows except panel in current tab + vim.cmd("only") + + -- Create a scratch buffer for the "before" content + local before_buf = vim.api.nvim_create_buf(false, true) + local before_lines = vim.fn.readfile(file_entry.temp_before) + vim.api.nvim_buf_set_lines(before_buf, 0, -1, false, before_lines) + vim.bo[before_buf].buftype = "nofile" + vim.bo[before_buf].bufhidden = "wipe" + vim.bo[before_buf].swapfile = false + + -- Set a unique buffer name + local buf_name = string.format("opencode://before/%d/%s", index, vim.fn.fnamemodify(file_entry.path, ":t")) + pcall(vim.api.nvim_buf_set_name, before_buf, buf_name) + + -- Detect filetype from the actual file + local ft = vim.filetype.match({ filename = file_entry.actual_file }) or "" + vim.bo[before_buf].filetype = ft + + -- Open the before buffer on the left + vim.api.nvim_set_current_buf(before_buf) + + -- Open the actual file (after) on the right + vim.cmd("rightbelow vertical diffsplit " .. vim.fn.fnameescape(file_entry.actual_file)) + + -- Enable diff mode + vim.cmd("wincmd p") + vim.cmd("diffthis") + vim.cmd("wincmd p") + vim.cmd("diffthis") + + -- Store window references + M.state.enhanced_diff_left_win = vim.fn.win_getid(vim.fn.winnr("h")) + M.state.enhanced_diff_right_win = vim.fn.win_getid() + + -- Set up keybindings for both diff windows + local keymap_opts = { buffer = true, nowait = true, silent = true } + + for _, bufnr in ipairs({ + vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win), + vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win), + }) do + vim.keymap.set( + "n", + "", + function() + M.enhanced_diff_next_file() + end, + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Next file in OpenCode diff" }) + ) + + vim.keymap.set( + "n", + "", + function() + M.enhanced_diff_prev_file() + end, + vim.tbl_extend( + "force", + { buffer = bufnr, nowait = true, silent = true }, + { desc = "Previous file in OpenCode diff" } + ) + ) + + -- Hunk navigation with ]x and [x + vim.keymap.set( + "n", + "]x", + "]c", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true, remap = true }, { desc = "Next hunk" }) + ) + vim.keymap.set( + "n", + "[x", + "[c", + vim.tbl_extend( + "force", + { buffer = bufnr, nowait = true, silent = true, remap = true }, + { desc = "Previous hunk" } + ) + ) + + vim.keymap.set("n", "gp", function() + M.enhanced_diff_toggle_panel() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Toggle file panel" })) + + vim.keymap.set("n", "q", function() + M.cleanup_enhanced_diff() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Close OpenCode diff" })) + + vim.keymap.set("n", "R", function() + M.enhanced_diff_revert_current() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Revert current file" })) + + -- Per-hunk staging keymaps + vim.keymap.set("n", "a", function() + M.enhanced_diff_accept_hunk() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept current hunk" })) + + vim.keymap.set("n", "r", function() + M.enhanced_diff_reject_hunk() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Reject current hunk" })) + + vim.keymap.set("n", "A", function() + M.enhanced_diff_accept_all_hunks() + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept all hunks" })) + end + + -- Restore panel if it was visible + if panel_was_visible then + M.enhanced_diff_show_panel() + end + + vim.notify( + string.format( + "OpenCode Diff [%d/%d]: %s (]x/[x=hunks, a/r=accept/reject, gp=panel, Tab/S-Tab=files)", + index, + #M.state.enhanced_diff_files, + vim.fn.fnamemodify(file_entry.path, ":t") + ), + vim.log.levels.INFO, + { title = "opencode" } + ) +end + +---Accept current hunk under cursor (keep the change) +---Uses diffput to push changes from "after" (right) to "before" (left) buffer +function M.enhanced_diff_accept_hunk() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Get current window to determine which buffer we're in + local current_win = vim.api.nvim_get_current_win() + local is_in_right = (current_win == M.state.enhanced_diff_right_win) + + -- We need to be in the "after" (right) window to push changes + if not is_in_right then + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + end + + -- Use diffput to push current hunk to the "before" (left) buffer + vim.cmd("diffput") + + -- Write the "before" buffer back to temp file to persist the change + local before_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win) + local before_lines = vim.api.nvim_buf_get_lines(before_buf, 0, -1, false) + vim.fn.writefile(before_lines, file_entry.temp_before) + + vim.notify("Accepted hunk", vim.log.levels.INFO, { title = "opencode" }) +end + +---Reject current hunk under cursor (revert the change) +---Uses diffget to pull original content from "before" (left) to "after" (right) buffer +function M.enhanced_diff_reject_hunk() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Get current window to determine which buffer we're in + local current_win = vim.api.nvim_get_current_win() + local is_in_right = (current_win == M.state.enhanced_diff_right_win) + + -- We need to be in the "after" (right) window to pull changes + if not is_in_right then + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + end + + -- Use diffget to pull original content from "before" (left) buffer + vim.cmd("diffget") + + -- Save the "after" buffer (actual file) since it's been modified + local after_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win) + vim.api.nvim_buf_call(after_buf, function() + vim.cmd("write") + end) + + vim.notify("Rejected hunk", vim.log.levels.INFO, { title = "opencode" }) +end + +---Accept all remaining hunks in the current file +function M.enhanced_diff_accept_all_hunks() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_files then + return + end + + local file_entry = M.state.enhanced_diff_files[M.state.enhanced_diff_current_index] + if not file_entry then + return + end + + -- Switch to "after" (right) window + vim.api.nvim_set_current_win(M.state.enhanced_diff_right_win) + + -- Get the "after" buffer content (this has all the changes we want to keep) + local after_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win) + local after_lines = vim.api.nvim_buf_get_lines(after_buf, 0, -1, false) + + -- Write it to the "before" buffer + local before_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win) + vim.api.nvim_buf_set_lines(before_buf, 0, -1, false, after_lines) + + -- Write the "before" buffer to temp file to persist + vim.fn.writefile(after_lines, file_entry.temp_before) + + vim.notify("Accepted all hunks in current file", vim.log.levels.INFO, { title = "opencode" }) +end + +---Revert the current file being viewed +function M.enhanced_diff_revert_current() + if not M.state.enhanced_diff_current_index or not M.state.enhanced_diff_session then + return + end + + local file_data = M.state.enhanced_diff_session.files[M.state.enhanced_diff_current_index] + if file_data then + M.revert_file(file_data) + -- Refresh the diff view + M.enhanced_diff_show_file(M.state.enhanced_diff_current_index) + end +end + +---Clean up enhanced diff temp files and state (silent version for autocmd) +function M.cleanup_enhanced_diff_silent() + -- Hide panel if visible + if M.state.enhanced_diff_panel_visible then + M.enhanced_diff_hide_panel() + end + + -- Clean up temp files + if M.state.enhanced_diff_temp_dir and vim.fn.isdirectory(M.state.enhanced_diff_temp_dir) == 1 then + vim.fn.delete(M.state.enhanced_diff_temp_dir, "rf") + end + + -- Clear state + M.state.enhanced_diff_files = nil + M.state.enhanced_diff_current_index = nil + M.state.enhanced_diff_session = nil + M.state.enhanced_diff_temp_files = nil + M.state.enhanced_diff_temp_dir = nil + M.state.enhanced_diff_tab = nil + M.state.enhanced_diff_panel_buf = nil + M.state.enhanced_diff_panel_win = nil + M.state.enhanced_diff_panel_visible = nil + M.state.enhanced_diff_left_win = nil + M.state.enhanced_diff_right_win = nil +end + +---Clean up enhanced diff temp files and state +function M.cleanup_enhanced_diff() + -- Close the diff tab + if M.state.enhanced_diff_tab and vim.api.nvim_tabpage_is_valid(M.state.enhanced_diff_tab) then + vim.api.nvim_set_current_tabpage(M.state.enhanced_diff_tab) + vim.cmd("tabclose") + end + + M.cleanup_enhanced_diff_silent() + + vim.notify("Closed OpenCode diff", vim.log.levels.INFO, { title = "opencode" }) +end + +---Open changes in Diffview.nvim +---@param session_diff table Session diff data with files +function M.open_diffview(session_diff) + -- Check if Diffview is available + if not has_diffview() then + vim.notify( + "Diffview.nvim not found. Falling back to enhanced diff mode.", + vim.log.levels.WARN, + { title = "opencode" } + ) + M.open_enhanced_diff(session_diff) + return + end + + -- Load Diffview modules + local ok, diff_view_module = pcall(require, "diffview.api.views.diff.diff_view") + if not ok then + vim.notify( + "Failed to load Diffview API. Falling back to enhanced diff.", + vim.log.levels.WARN, + { title = "opencode" } + ) + M.open_enhanced_diff(session_diff) + return + end + + local CDiffView = diff_view_module.CDiffView + local Rev = require("diffview.vcs.adapters.git.rev").GitRev + local RevType = require("diffview.vcs.rev").RevType + local lib = require("diffview.lib") + + -- If we already have a Diffview instance, close it first + if M.state.diffview_instance then + vim.notify("Closing existing Diffview to show new changes...", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_diffview() + -- Give Diffview time to clean up + vim.defer_fn(function() + M.open_diffview(session_diff) + end, 100) + return + end + + -- Create temp directory for "before" content + local temp_dir = vim.fn.tempname() .. "_opencode_diff" + vim.fn.mkdir(temp_dir, "p") + + -- Store temp data in memory for the callback + local temp_data = {} -- { [filepath] = lines_array } + + -- Build file list for Diffview + local files = { + working = {}, + } + + for _, file_data in ipairs(session_diff.files) do + -- Write BOTH before and after to temp files + local filename = vim.fn.fnamemodify(file_data.file, ":t") + local temp_before = temp_dir .. "/" .. filename .. ".before" + local temp_after = temp_dir .. "/" .. filename .. ".after" + + local before_lines = vim.split(file_data.before or "", "\n") + local after_lines = vim.split(file_data.after or "", "\n") + + vim.fn.writefile(before_lines, temp_before) + vim.fn.writefile(after_lines, temp_after) + + -- Store paths for reference + temp_data[file_data.file] = { + before_file = temp_before, + after_file = temp_after, + } + + -- Debug: log what we created + vim.notify( + string.format("Created temp files:\n before: %s (%d lines)\n after: %s (%d lines)", + temp_before, #before_lines, temp_after, #after_lines), + vim.log.levels.INFO, + { title = "opencode" } + ) + + -- Add to file list - use actual file paths, not temp paths! + table.insert(files.working, { + path = file_data.file, -- Use actual file path + oldpath = nil, + status = "M", + stats = { + additions = file_data.additions or 0, + deletions = file_data.deletions or 0, + }, + selected = (#files.working == 0), -- First file selected + }) + end + + -- Callback to provide file data + local get_file_data = function(kind, path, split) + -- Force print to see if this is even called + print(string.format(">>> get_file_data called: kind=%s, path=%s, split=%s", kind, path, split)) + + -- Find the temp files for this path + if temp_data[path] then + local file_to_read = nil + if split == "left" then + file_to_read = temp_data[path].before_file + elseif split == "right" then + file_to_read = temp_data[path].after_file + end + + if file_to_read and vim.fn.filereadable(file_to_read) == 1 then + local lines = vim.fn.readfile(file_to_read) + print(string.format(">>> Returning %d lines from %s", #lines, file_to_read)) + return lines + end + end + + print(string.format(">>> NO DATA for path=%s, split=%s", path, split)) + return nil + end + + -- Callback to update files (required by CDiffView) + local update_files = function(view) + return files + end + + -- Create the custom diff view + -- Use CUSTOM for both sides - we provide temp files via callback + local view = CDiffView({ + git_root = vim.fn.getcwd(), + left = Rev(RevType.CUSTOM, "before"), + right = Rev(RevType.CUSTOM, "after"), + files = files, + update_files = update_files, + get_file_data = get_file_data, + }) + + -- Store state for cleanup + M.state.diffview_instance = view + M.state.diffview_temp_dir = temp_dir + M.state.diffview_temp_data = temp_data + M.state.diffview_session = session_diff + + -- Add view to Diffview lib and open it + lib.add_view(view) + view:open() + + -- Setup custom keymaps via autocmd on diff buffers + vim.api.nvim_create_autocmd("FileType", { + pattern = "diff", + callback = function(args) + -- Only apply to Diffview buffers + local bufnr = args.buf + local bufname = vim.api.nvim_buf_get_name(bufnr) + if not bufname:match("^diffview://") then + return + end + + -- Add per-hunk staging keymaps + vim.keymap.set("n", "a", function() + M.diffview_accept_hunk() + end, { buffer = bufnr, nowait = true, silent = true, desc = "Accept current hunk" }) + + vim.keymap.set("n", "r", function() + M.diffview_reject_hunk() + end, { buffer = bufnr, nowait = true, silent = true, desc = "Reject current hunk" }) + + vim.keymap.set("n", "A", function() + M.diffview_accept_all_hunks() + end, { buffer = bufnr, nowait = true, silent = true, desc = "Accept all hunks in file" }) + end, + once = false, + desc = "OpenCode Diffview custom keymaps", + }) + + vim.notify("Opened diff with Diffview.nvim (a/r=accept/reject hunk)", vim.log.levels.INFO, { title = "opencode" }) +end + +---Accept current hunk in Diffview (using diffput) +function M.diffview_accept_hunk() + -- Determine which window we're in + local winnr = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + + -- In Diffview, "b" is typically the right side (working tree) + -- We want to push changes from right to left + if bufname:match("diffview://.*//b/") then + -- We're in the right window, push to left + vim.cmd("diffput") + vim.notify("Accepted hunk", vim.log.levels.INFO, { title = "opencode" }) + else + vim.notify("Navigate to the right-side diff buffer to accept hunks", vim.log.levels.WARN, { title = "opencode" }) + end +end + +---Reject current hunk in Diffview (using diffget) +function M.diffview_reject_hunk() + -- Determine which window we're in + local winnr = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + + -- In Diffview, "b" is typically the right side (working tree) + -- We want to pull changes from left to right + if bufname:match("diffview://.*//b/") then + -- We're in the right window, pull from left + vim.cmd("diffget") + vim.cmd("write") -- Save the actual file + vim.notify("Rejected hunk", vim.log.levels.INFO, { title = "opencode" }) + else + vim.notify("Navigate to the right-side diff buffer to reject hunks", vim.log.levels.WARN, { title = "opencode" }) + end +end + +---Accept all hunks in current file (Diffview) +function M.diffview_accept_all_hunks() + local winnr = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + + if bufname:match("diffview://.*//b/") then + -- Get all content from right buffer and put to left + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + -- Find the corresponding left buffer and update it + -- This is a simplified approach - in practice we'd need to find the paired buffer + vim.notify( + "Accept all: Please use 'Stage Entry' from file panel or manually accept each hunk", + vim.log.levels.INFO, + { title = "opencode" } + ) + else + vim.notify("Navigate to the right-side diff buffer", vim.log.levels.WARN, { title = "opencode" }) + end +end + +---Clean up Diffview temp files and state +function M.cleanup_diffview() + if M.state.diffview_instance then + -- Try to close the view properly + local ok, lib = pcall(require, "diffview.lib") + if ok then + -- Get the current view and close it + local view = M.state.diffview_instance + if view and view.close then + pcall(view.close, view) + end + end + end + + -- Clean up temp files + if M.state.diffview_temp_dir and vim.fn.isdirectory(M.state.diffview_temp_dir) == 1 then + vim.fn.delete(M.state.diffview_temp_dir, "rf") + end + + -- Clear state + M.state.diffview_instance = nil + M.state.diffview_temp_dir = nil + M.state.diffview_temp_data = nil + M.state.diffview_session = nil +end + +---Show diff review for an assistant message +---@param message table Message info from message.updated event +---@param opts opencode.events.session_diff.Opts +function M.show_message_diff(message, opts) + -- Extract diffs from message.summary.diffs + local diffs = message.summary and message.summary.diffs or {} + + if #diffs == 0 then + return -- No diffs to show + end + + -- Filter out empty diffs + local files_with_changes = {} + for _, file_data in ipairs(diffs) do + if not is_diff_empty(file_data) then + table.insert(files_with_changes, { + file = file_data.file, + before = file_data.before, + after = file_data.after, + additions = file_data.additions, + deletions = file_data.deletions, + }) + end + end + + -- Only show review if we have non-empty files + if #files_with_changes == 0 then + return + end + + local session_diff = { + session_id = message.sessionID, + message_id = message.id, + files = files_with_changes, + current_index = 1, + } + + -- Determine which diff mode to use + local diff_mode = opts.diff_mode or "enhanced" + + -- Route to appropriate diff viewer + if diff_mode == "diffview" then + M.open_diffview(session_diff) + elseif diff_mode == "enhanced" then + M.open_enhanced_diff(session_diff) + elseif diff_mode == "unified" then + -- Use the simple unified diff view + M.state.session_diff = session_diff + M.show_review(opts) + else + -- Default to enhanced if unknown mode + vim.notify( + string.format("Unknown diff_mode '%s'. Using enhanced mode.", diff_mode), + vim.log.levels.WARN, + { title = "opencode" } + ) + M.open_enhanced_diff(session_diff) + end +end + +---Revert a single file to its original state using 'before' content +---@param file_data table File diff data with 'before' content +function M.revert_file(file_data) + if not file_data.before then + vim.notify( + string.format("Cannot revert %s: no 'before' content available", file_data.file), + vim.log.levels.WARN, + { title = "opencode" } + ) + return false + end + + local lines = vim.split(file_data.before, "\n") + local success = pcall(vim.fn.writefile, lines, file_data.file) + + if success then + -- Reload the buffer if it's open + local bufnr = vim.fn.bufnr(file_data.file) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("edit!") + end) + end + return true + else + vim.notify(string.format("Failed to revert %s", file_data.file), vim.log.levels.ERROR, { title = "opencode" }) + return false + end +end + +---Accept all changes (close review UI) +function M.accept_all_changes() + vim.notify("Accepted all changes", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() +end + +---Reject all changes (revert all files) +function M.reject_all_changes() + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local reverted = 0 + for _, file_data in ipairs(diff_state.files) do + if M.revert_file(file_data) then + reverted = reverted + 1 + end + end + + vim.notify( + string.format("Reverted %d/%d files", reverted, #diff_state.files), + vim.log.levels.INFO, + { title = "opencode" } + ) + M.cleanup_session_diff() +end + +---Accept current file (mark as accepted, move to next) +---@param opts opencode.events.session_diff.Opts +function M.accept_current_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local current_file = diff_state.files[diff_state.current_index] + vim.notify(string.format("Accepted: %s", current_file.file), vim.log.levels.INFO, { title = "opencode" }) + + -- Move to next file or close if done + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + else + vim.notify("All files reviewed", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() + end +end + +---Reject current file (revert it, move to next) +---@param opts opencode.events.session_diff.Opts +function M.reject_current_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local current_file = diff_state.files[diff_state.current_index] + M.revert_file(current_file) + + -- Move to next file or close if done + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + else + vim.notify("All files reviewed", vim.log.levels.INFO, { title = "opencode" }) + M.cleanup_session_diff() + end +end + +---Navigate to next file +---@param opts opencode.events.session_diff.Opts +function M.next_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + if diff_state.current_index < #diff_state.files then + diff_state.current_index = diff_state.current_index + 1 + M.show_review(opts) + end +end + +---Navigate to previous file +---@param opts opencode.events.session_diff.Opts +function M.prev_file(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + if diff_state.current_index > 1 then + diff_state.current_index = diff_state.current_index - 1 + M.show_review(opts) + end +end + +---Clean up session diff state and UI +function M.cleanup_session_diff() + if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then + vim.api.nvim_buf_delete(M.state.bufnr, { force = true }) + end + + M.state.bufnr = nil + M.state.winnr = nil + M.state.tabnr = nil + M.state.session_diff = nil +end + +---Show session changes review UI +---@param opts opencode.events.session_diff.Opts +function M.show_review(opts) + local diff_state = M.state.session_diff + if not diff_state then + return + end + + local total_files = #diff_state.files + local current_file = diff_state.files[diff_state.current_index] + + -- Reuse existing buffer if available, otherwise create new one + local bufnr = M.state.bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + bufnr = vim.api.nvim_create_buf(false, true) + M.state.bufnr = bufnr + + -- Set buffer options + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = "diff" + end + + -- Build unified diff content + local lines = {} + table.insert(lines, string.format("=== OpenCode Changes Review [%d/%d] ===", diff_state.current_index, total_files)) + table.insert(lines, "") + table.insert(lines, string.format("File: %s", current_file.file)) + table.insert(lines, string.format("Changes: +%d -%d", current_file.additions or 0, current_file.deletions or 0)) + table.insert(lines, "") + + -- Generate and insert unified diff + local diff_lines = generate_unified_diff( + current_file.file, + current_file.before or "", + current_file.after or "", + current_file.additions, + current_file.deletions + ) + + for _, line in ipairs(diff_lines) do + table.insert(lines, line) + end + + table.insert(lines, "") + table.insert(lines, "=== Keybindings ===") + table.insert(lines, " next file |

prev file") + table.insert(lines, " accept this file | reject this file") + table.insert(lines, " accept all | reject all") + table.insert(lines, " close review") + + -- Set buffer content + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false + + -- Handle window/tab display + if opts.open_in_tab then + -- Check if we have a tab already + if M.state.tabnr and vim.api.nvim_tabpage_is_valid(M.state.tabnr) then + -- Switch to the existing tab + vim.api.nvim_set_current_tabpage(M.state.tabnr) + -- Find the window in this tab showing our buffer + local found_win = false + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(M.state.tabnr)) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then + vim.api.nvim_set_current_win(win) + found_win = true + break + end + end + if not found_win then + -- Create a new window in this tab + vim.cmd("only") + vim.api.nvim_win_set_buf(0, bufnr) + end + else + -- Create a new tab + vim.cmd("tabnew") + M.state.tabnr = vim.api.nvim_get_current_tabpage() + vim.api.nvim_win_set_buf(0, bufnr) + end + else + -- Check if we have an existing window + if M.state.winnr and vim.api.nvim_win_is_valid(M.state.winnr) then + -- Reuse the existing window + vim.api.nvim_set_current_win(M.state.winnr) + vim.api.nvim_win_set_buf(M.state.winnr, bufnr) + else + -- Create a new split + vim.cmd("vsplit") + M.state.winnr = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(M.state.winnr, bufnr) + end + end + + -- Set up keybindings (need to wrap opts in closures) + local keymap_opts = { buffer = bufnr, nowait = true, silent = true } + + vim.keymap.set("n", "n", function() + M.next_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Next file" })) + vim.keymap.set("n", "p", function() + M.prev_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Previous file" })) + vim.keymap.set("n", "a", function() + M.accept_current_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Accept this file" })) + vim.keymap.set("n", "r", function() + M.reject_current_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Reject this file" })) + vim.keymap.set("n", "A", M.accept_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Accept all" })) + vim.keymap.set("n", "R", M.reject_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Reject all" })) + vim.keymap.set("n", "q", M.cleanup_session_diff, vim.tbl_extend("force", keymap_opts, { desc = "Close review" })) + + vim.notify( + string.format("Review [%d/%d]: %s", diff_state.current_index, total_files, current_file.file), + vim.log.levels.INFO, + { title = "opencode" } + ) +end + +return M diff --git a/lua/opencode/health.lua b/lua/opencode/health.lua index a714f554..e97399d5 100644 --- a/lua/opencode/health.lua +++ b/lua/opencode/health.lua @@ -149,10 +149,17 @@ function M.check() if session_diff_opts.enabled then vim.health.ok("Session diff review is enabled.") - if session_diff_opts.use_enhanced_diff ~= false then - vim.health.ok("Enhanced diff mode is enabled: side-by-side diff using vim diff-mode.") + local diff_mode = session_diff_opts.diff_mode or "enhanced" + + if diff_mode == "enhanced" then + vim.health.ok("Diff mode: Enhanced (side-by-side vim diff-mode with file panel)") + elseif diff_mode == "unified" then + vim.health.ok("Diff mode: Unified (simple unified diff view)") else - vim.health.info("Enhanced diff mode is disabled: using basic unified diff view.") + vim.health.warn( + "Unknown diff_mode: '" .. diff_mode .. "'. Valid options: 'enhanced', 'unified'", + { "Set opts.events.session_diff.diff_mode to a valid option" } + ) end else vim.health.info("Session diff review is disabled.") From d557922c439946b824f35e8ad631762e4bb86185 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Tue, 2 Dec 2025 18:42:02 -0800 Subject: [PATCH 7/8] fix(diff): use defult [c for next hunk --- lua/opencode/diff.lua | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 9f444762..bdb04c4e 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -443,18 +443,14 @@ function M.enhanced_diff_show_file(index) vim.keymap.set( "n", "]x", - "]c", - vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true, remap = true }, { desc = "Next hunk" }) + "execute 'normal! ]c'", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Next hunk" }) ) vim.keymap.set( "n", "[x", - "[c", - vim.tbl_extend( - "force", - { buffer = bufnr, nowait = true, silent = true, remap = true }, - { desc = "Previous hunk" } - ) + "execute 'normal! [c'", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Previous hunk" }) ) vim.keymap.set("n", "gp", function() From cafdc6f6a4b68363a172378130daf86f0b18bb1c Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Wed, 3 Dec 2025 23:52:29 -0800 Subject: [PATCH 8/8] fix(diff): better nonconflicting keybinds and remove keybinds after buffer deletion --- lua/opencode/diff.lua | 111 +++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 40 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index bdb04c4e..9b4c26e6 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -265,16 +265,16 @@ function M.enhanced_diff_show_panel() table.insert(lines, string.rep("─", 40)) table.insert(lines, "Keymaps:") table.insert(lines, " Jump to file") - table.insert(lines, " Next file") - table.insert(lines, " Previous file") - table.insert(lines, " ]x Next hunk") - table.insert(lines, " [x Previous hunk") - table.insert(lines, " a Accept hunk") - table.insert(lines, " r Reject hunk") - table.insert(lines, " A Accept all hunks") - table.insert(lines, " gp Toggle panel") - table.insert(lines, " R Revert file") - table.insert(lines, " q Close diff") + table.insert(lines, " } Next file") + table.insert(lines, " { Previous file") + table.insert(lines, " ]c Next hunk") + table.insert(lines, " [c Previous hunk") + table.insert(lines, " do Accept hunk (obtain)") + table.insert(lines, " dp Reject hunk (put)") + table.insert(lines, " da Accept all hunks") + table.insert(lines, " dp Toggle panel") + table.insert(lines, " dr Revert file") + table.insert(lines, " dq Close diff") vim.bo[panel_buf].modifiable = true vim.api.nvim_buf_set_lines(panel_buf, 0, -1, false, lines) @@ -308,15 +308,25 @@ function M.enhanced_diff_show_panel() -- Set up panel keybindings local keymap_opts = { buffer = panel_buf, nowait = true, silent = true } + -- Add autocmd to clear keymaps when buffer is deleted + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = panel_buf, + callback = function() + -- Keymaps are automatically cleared when buffer is wiped + end, + once = true, + desc = "Cleanup OpenCode diff panel keymaps", + }) + vim.keymap.set("n", "", function() M.enhanced_diff_panel_select() end, vim.tbl_extend("force", keymap_opts, { desc = "Jump to selected file" })) - vim.keymap.set("n", "gp", function() + vim.keymap.set("n", "dp", function() M.enhanced_diff_hide_panel() end, vim.tbl_extend("force", keymap_opts, { desc = "Close file panel" })) - vim.keymap.set("n", "q", function() + vim.keymap.set("n", "dq", function() M.cleanup_enhanced_diff() end, vim.tbl_extend("force", keymap_opts, { desc = "Close OpenCode diff" })) @@ -417,9 +427,20 @@ function M.enhanced_diff_show_file(index) vim.api.nvim_win_get_buf(M.state.enhanced_diff_left_win), vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win), }) do + -- Add autocmd to clear keymaps when buffer is deleted + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = bufnr, + callback = function() + -- Keymaps are automatically cleared when buffer is wiped + -- This callback is just for logging/debugging if needed + end, + once = true, + desc = "Cleanup OpenCode diff keymaps", + }) + vim.keymap.set( "n", - "", + "}", function() M.enhanced_diff_next_file() end, @@ -428,7 +449,7 @@ function M.enhanced_diff_show_file(index) vim.keymap.set( "n", - "", + "{", function() M.enhanced_diff_prev_file() end, @@ -439,42 +460,42 @@ function M.enhanced_diff_show_file(index) ) ) - -- Hunk navigation with ]x and [x + -- Hunk navigation with ]c and [c (standard vim diff navigation) vim.keymap.set( "n", - "]x", - "execute 'normal! ]c'", + "]c", + "]c", vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Next hunk" }) ) vim.keymap.set( "n", - "[x", - "execute 'normal! [c'", + "[c", + "[c", vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Previous hunk" }) ) - vim.keymap.set("n", "gp", function() + vim.keymap.set("n", "dp", function() M.enhanced_diff_toggle_panel() end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Toggle file panel" })) - vim.keymap.set("n", "q", function() + vim.keymap.set("n", "dq", function() M.cleanup_enhanced_diff() end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Close OpenCode diff" })) - vim.keymap.set("n", "R", function() + vim.keymap.set("n", "dr", function() M.enhanced_diff_revert_current() end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Revert current file" })) - -- Per-hunk staging keymaps - vim.keymap.set("n", "a", function() + -- Per-hunk staging keymaps using standard vim diff commands + vim.keymap.set("n", "do", function() M.enhanced_diff_accept_hunk() - end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept current hunk" })) + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept current hunk (obtain)" })) - vim.keymap.set("n", "r", function() + vim.keymap.set("n", "dp", function() M.enhanced_diff_reject_hunk() - end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Reject current hunk" })) + end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Reject current hunk (put)" })) - vim.keymap.set("n", "A", function() + vim.keymap.set("n", "da", function() M.enhanced_diff_accept_all_hunks() end, vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Accept all hunks" })) end @@ -486,7 +507,7 @@ function M.enhanced_diff_show_file(index) vim.notify( string.format( - "OpenCode Diff [%d/%d]: %s (]x/[x=hunks, a/r=accept/reject, gp=panel, Tab/S-Tab=files)", + "OpenCode Diff [%d/%d]: %s (]c/[c=hunks, do/dp=accept/reject, dp=panel, }/{ =files)", index, #M.state.enhanced_diff_files, vim.fn.fnamemodify(file_entry.path, ":t") @@ -888,10 +909,10 @@ function M.show_review(opts) table.insert(lines, "") table.insert(lines, "=== Keybindings ===") - table.insert(lines, " next file |

prev file") - table.insert(lines, " accept this file | reject this file") - table.insert(lines, " accept all | reject all") - table.insert(lines, " close review") + table.insert(lines, "} next file | { prev file") + table.insert(lines, "da accept this file | dr reject this file") + table.insert(lines, "dA accept all | dR reject all") + table.insert(lines, "dq close review") -- Set buffer content vim.bo[bufnr].modifiable = true @@ -941,21 +962,31 @@ function M.show_review(opts) -- Set up keybindings (need to wrap opts in closures) local keymap_opts = { buffer = bufnr, nowait = true, silent = true } - vim.keymap.set("n", "n", function() + -- Add autocmd to clear keymaps when buffer is deleted + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = bufnr, + callback = function() + -- Keymaps are automatically cleared when buffer is wiped + end, + once = true, + desc = "Cleanup OpenCode unified diff keymaps", + }) + + vim.keymap.set("n", "}", function() M.next_file(opts) end, vim.tbl_extend("force", keymap_opts, { desc = "Next file" })) - vim.keymap.set("n", "p", function() + vim.keymap.set("n", "{", function() M.prev_file(opts) end, vim.tbl_extend("force", keymap_opts, { desc = "Previous file" })) - vim.keymap.set("n", "a", function() + vim.keymap.set("n", "da", function() M.accept_current_file(opts) end, vim.tbl_extend("force", keymap_opts, { desc = "Accept this file" })) - vim.keymap.set("n", "r", function() + vim.keymap.set("n", "dr", function() M.reject_current_file(opts) end, vim.tbl_extend("force", keymap_opts, { desc = "Reject this file" })) - vim.keymap.set("n", "A", M.accept_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Accept all" })) - vim.keymap.set("n", "R", M.reject_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Reject all" })) - vim.keymap.set("n", "q", M.cleanup_session_diff, vim.tbl_extend("force", keymap_opts, { desc = "Close review" })) + vim.keymap.set("n", "dA", M.accept_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Accept all" })) + vim.keymap.set("n", "dR", M.reject_all_changes, vim.tbl_extend("force", keymap_opts, { desc = "Reject all" })) + vim.keymap.set("n", "dq", M.cleanup_session_diff, vim.tbl_extend("force", keymap_opts, { desc = "Close review" })) vim.notify( string.format("Review [%d/%d]: %s", diff_state.current_index, total_files, current_file.file),