From a98a0f51c5e41f51a32bda3b85cbe0b57052f9b6 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sat, 29 Nov 2025 21:21:09 -0800 Subject: [PATCH 01/11] 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 7989231c..df4f6dad 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -133,6 +133,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 --port", 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 3caea8f5..9571a5bb 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 local heartbeat_timer = vim.uv.new_timer() local OPENCODE_HEARTBEAT_INTERVAL_MS = 30000 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 f0c9768725c7c66ea6f203b1802097e4945d0f63 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 13:43:13 -0800 Subject: [PATCH 02/11] 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 7513e6710eca7376d6f1a0c08cdd738800299f15 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 13:54:32 -0800 Subject: [PATCH 03/11] 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 cba328430280e8dd66890d28bddf0a31a16e6a6e Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 21:16:59 -0800 Subject: [PATCH 04/11] 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 df4f6dad..5d09943e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -135,6 +135,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 b2a55875..3b83e6aa 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 4aea1954e77ccce5a8367bf15d37beab44d2e634 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 21:25:27 -0800 Subject: [PATCH 05/11] 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 96a05b5f6dc00e40995859398175e94d6a84db13 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Sun, 30 Nov 2025 22:38:37 -0800 Subject: [PATCH 06/11] 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 5d09943e..edb5873d 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -135,7 +135,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 3b83e6aa..5738699a 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 aebbf623f845032f2f8da4f94bb6878f68ed185a Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Tue, 2 Dec 2025 18:42:02 -0800 Subject: [PATCH 07/11] 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 4ede341deaa338eeb2ce2bdb40bef6548d18069f Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Wed, 3 Dec 2025 23:52:29 -0800 Subject: [PATCH 08/11] 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), From 4d201010ee3e166467b9965b1fbb0c9d8923d1f5 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Mon, 29 Dec 2025 15:29:04 -0800 Subject: [PATCH 09/11] fix(diff): resolve symlinks in file paths to prevent buffer duplication When working with symlinked directories (e.g. ~/.config/nvim -> ~/dotfiles/.config/nvim), Neovim can create separate buffers for the same file if accessed via different paths. This causes issues with the diff view where the actual file might be loaded under one path while the diff tries to open it under another. This commit normalizes all file paths by resolving symlinks and converting to absolute paths, ensuring consistent buffer identity. --- lua/opencode/diff.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 9b4c26e6..a1769cad 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -137,7 +137,8 @@ function M.open_enhanced_diff(session_diff) 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 + -- Resolve symlinks and convert to absolute path to prevent buffer duplication + local actual_file = vim.fn.resolve(vim.fn.fnamemodify(file_data.file, ":p")) -- Store mapping for cleanup if not M.state.enhanced_diff_temp_files then From 204e3f5c8d07b731e525fd064fd6748e0bc2b2e1 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Mon, 29 Dec 2025 15:29:27 -0800 Subject: [PATCH 10/11] fix(diff): force buffer reload to ensure Treesitter attachment When the actual file is already loaded in a buffer (from the user's editing session), opening it with :diffsplit reuses the buffer without triggering FileType events. For users who rely on FileType autocmds to attach Treesitter (via vim.treesitter.start), this causes the diff view to show the file without syntax highlighting. This commit detects if the buffer is already loaded and forces a reload with :edit! before opening in diffsplit. This ensures FileType events fire properly and Treesitter attaches to the buffer. Fixes intermittent "no grammar for filetype found" errors in diff view. --- lua/opencode/diff.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index a1769cad..af8367f3 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -408,6 +408,18 @@ function M.enhanced_diff_show_file(index) -- Open the before buffer on the left vim.api.nvim_set_current_buf(before_buf) + -- Force reload if buffer already loaded to ensure FileType event fires + -- This ensures Treesitter highlighting attaches properly + local buf_exists = vim.fn.bufexists(file_entry.actual_file) == 1 + if buf_exists then + local existing_buf = vim.fn.bufnr(file_entry.actual_file) + if vim.api.nvim_buf_is_loaded(existing_buf) then + vim.api.nvim_buf_call(existing_buf, function() + vim.cmd("edit!") + end) + end + end + -- Open the actual file (after) on the right vim.cmd("rightbelow vertical diffsplit " .. vim.fn.fnameescape(file_entry.actual_file)) From 4a6c770c2d9a91a13099a138524da74404f02959 Mon Sep 17 00:00:00 2001 From: chuan2984 Date: Tue, 30 Dec 2025 20:36:32 -0800 Subject: [PATCH 11/11] fix(diff): explicitly trigger filetype detection and Treesitter attachment The previous approach of reloading the buffer before diffsplit was not effective because the reload happened in the wrong context and didn't trigger FileType events when the buffer was subsequently opened in diffsplit. This commit takes a direct approach: 1. Open the file with diffsplit 2. Explicitly run :filetype detect to ensure filetype is set 3. Directly call vim.treesitter.start() to attach Treesitter highlighting This ensures syntax highlighting works regardless of whether the buffer was previously loaded or how FileType autocmds are configured. Fixes the issue where right pane has no filetype and no Treesitter highlighting. --- lua/opencode/diff.lua | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index af8367f3..a304216b 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -408,31 +408,36 @@ function M.enhanced_diff_show_file(index) -- Open the before buffer on the left vim.api.nvim_set_current_buf(before_buf) - -- Force reload if buffer already loaded to ensure FileType event fires - -- This ensures Treesitter highlighting attaches properly - local buf_exists = vim.fn.bufexists(file_entry.actual_file) == 1 - if buf_exists then - local existing_buf = vim.fn.bufnr(file_entry.actual_file) - if vim.api.nvim_buf_is_loaded(existing_buf) then - vim.api.nvim_buf_call(existing_buf, function() - vim.cmd("edit!") - end) - end - end - -- Open the actual file (after) on the right vim.cmd("rightbelow vertical diffsplit " .. vim.fn.fnameescape(file_entry.actual_file)) + -- Store window references (do this before enabling diff mode) + M.state.enhanced_diff_left_win = vim.fn.win_getid(vim.fn.winnr("h")) + M.state.enhanced_diff_right_win = vim.fn.win_getid() + + -- Ensure filetype is set and Treesitter attaches to the right buffer + local after_buf = vim.api.nvim_win_get_buf(M.state.enhanced_diff_right_win) + vim.api.nvim_buf_call(after_buf, function() + -- Force filetype detection + vim.cmd("filetype detect") + + -- Explicitly start Treesitter if available + local ft = vim.bo[after_buf].filetype + if ft and ft ~= "" then + local has_ts, ts_start = pcall(require, "vim.treesitter") + if has_ts then + local lang = vim.treesitter.language.get_lang(ft) or ft + pcall(vim.treesitter.start, after_buf, lang) + end + end + end) + -- 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 }