diff --git a/DIFF_FEATURE.md b/DIFF_FEATURE.md new file mode 100644 index 00000000..0670ec90 --- /dev/null +++ b/DIFF_FEATURE.md @@ -0,0 +1,245 @@ +# 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. + +## Overview + +OpenCode supports **two different diff viewing modes** to suit your preferences: + +### 1. Enhanced Mode (Default - No Dependencies) + +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! + +**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 = { + diff_mode = "enhanced", -- This is the default + }, + }, +} +``` + +### 2. Unified Mode (Minimal) + +Simple unified diff view in a single buffer for lightweight reviews. + +**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 = { + diff_mode = "unified", + }, + }, +} +``` + +## Keybindings + +### Enhanced Mode + +**Diff View:** +- `gp` - Toggle file panel +- `` - Next file +- `` - Previous file +- `]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 + +**File Panel:** +- `` - Jump to selected file +- `gp` - Close panel +- `q` - Close diff view + +### Unified Mode + +- `n` - Next file +- `p` - Previous file +- `a` - Accept this file (keep changes) +- `r` - Reject this file (revert to original) +- `A` - Accept all files +- `R` - Reject all files +- `q` - Close review + +## Per-Hunk Staging + +**Enhanced mode** supports per-hunk accept/reject operations, allowing you to selectively keep or discard individual changes within a file. + +**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 + +**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 + +**Accept All (`A`):** +- Accept all remaining hunks in the current file +- All changes are kept + +**Implementation:** Uses vim's built-in diff commands (`diffput` to accept, `diffget` to reject). + +## 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) + }, + }, +} +``` + +**Disable diff review:** + +```lua +vim.g.opencode_opts = { + events = { + session_diff = { + enabled = false, + }, + }, +} +``` + +## File Panel + +### Enhanced Mode Panel + +Shows a list of changed files with stats: + +``` +OpenCode Changed Files +──────────────────────────────────────── + +▶ 1. config.lua +12 -5 + 2. diff.lua +87 -34 + 3. health.lua +8 -15 + +──────────────────────────────────────── +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 + +## Health Check + +Run `:checkhealth opencode` to verify your configuration: + +**Enhanced mode:** +``` +opencode.nvim [diff review] + - OK: Session diff review is enabled. + - OK: Diff mode: Enhanced (side-by-side vim diff-mode with file panel) +``` + +**Unified mode:** +``` +opencode.nvim [diff review] + - OK: Session diff review is enabled. + - OK: Diff mode: Unified (simple unified diff view) +``` + +## Mode Comparison + +| 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 | + +**Recommendations:** +- **Best UX**: Use `diff_mode = "enhanced"` (default) - great UX without any plugins +- **Minimal**: Use `diff_mode = "unified"` for simple, lightweight reviews + +## 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 +-- 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") +``` + +## Files + +**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 + +## 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/debug_events.lua b/debug_events.lua new file mode 100644 index 00000000..6a59cb14 --- /dev/null +++ b/debug_events.lua @@ -0,0 +1,14 @@ +-- Debug helper: Add this to your Neovim config temporarily to see ALL opencode events + +vim.api.nvim_create_autocmd("User", { + pattern = "OpencodeEvent:*", + callback = function(args) + local event = args.data.event + vim.notify( + string.format("[EVENT] %s\nProperties: %s", event.type, vim.inspect(event.properties or {})), + vim.log.levels.INFO, + { title = "opencode.debug" } + ) + end, + desc = "Debug all opencode events", +}) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 7a415e7b..10e22a67 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -111,6 +111,14 @@ local defaults = { enabled = true, idle_delay_ms = 1000, }, + session_diff = { + enabled = true, -- Show session review for session.diff events + -- 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) + }, }, provider = { cmd = "opencode", diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua new file mode 100644 index 00000000..9b4c26e6 --- /dev/null +++ b/lua/opencode/diff.lua @@ -0,0 +1,998 @@ +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, " ]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) + 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 } + + -- 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", "dp", function() + M.enhanced_diff_hide_panel() + end, vim.tbl_extend("force", keymap_opts, { desc = "Close file panel" })) + + vim.keymap.set("n", "dq", 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 + -- 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, + 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 ]c and [c (standard vim diff navigation) + vim.keymap.set( + "n", + "]c", + "]c", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Next hunk" }) + ) + vim.keymap.set( + "n", + "[c", + "[c", + vim.tbl_extend("force", { buffer = bufnr, nowait = true, silent = true }, { desc = "Previous hunk" }) + ) + + 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", "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", "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 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 (obtain)" })) + + 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 (put)" })) + + 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 + + -- 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 (]c/[c=hunks, do/dp=accept/reject, dp=panel, }/{ =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 + + +---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 == "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, "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 + 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 } + + -- 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", "{", function() + M.prev_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Previous file" })) + 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", "dr", function() + M.reject_current_file(opts) + end, vim.tbl_extend("force", keymap_opts, { desc = "Reject this file" })) + 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), + vim.log.levels.INFO, + { title = "opencode" } + ) +end + +return M 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/events.lua b/lua/opencode/events.lua index 2e0ec47d..68a73b1c 100644 --- a/lua/opencode/events.lua +++ b/lua/opencode/events.lua @@ -10,6 +10,8 @@ local M = {} ---@field reload? boolean --- ---@field permissions? opencode.events.permissions.Opts +--- +---@field session_diff? opencode.events.session_diff.Opts ---Subscribe to `opencode`'s Server-Sent Events (SSE) and execute `OpencodeEvent:` autocmds. --- diff --git a/lua/opencode/health.lua b/lua/opencode/health.lua index 75250eec..e97399d5 100644 --- a/lua/opencode/health.lua +++ b/lua/opencode/health.lua @@ -142,6 +142,28 @@ 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.") + + 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.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.") + end end return M 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", +})