diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 25b9cdad..81223a6f 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -232,6 +232,69 @@ local function find_claudecode_terminal_window() return floating_fallback end +---Get or create a persistent editor window in tab 2 for Claude Code diffs. +---Finds an existing non-terminal, non-diff window in tab 2 (the Claude editor). +---If none exists, creates a new window to the left of any terminal in tab 2. +local function get_or_create_editor_win_in_diff_tab() + local tabs = vim.api.nvim_list_tabpages() + if #tabs < 2 then + return nil + end + local tab2 = tabs[2] + + -- First, look for an existing Claude Code editor window in tab 2: + -- a non-terminal, non-prompt, non-diff window showing a real file. + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do + local b = vim.api.nvim_win_get_buf(win) + local bt = vim.api.nvim_buf_get_option(b, "buftype") + local ft = vim.api.nvim_buf_get_option(b, "filetype") + if bt ~= "terminal" and bt ~= "prompt" + and not vim.api.nvim_win_get_option(win, "diff") + and ft ~= "neo-tree" and ft ~= "NvimTree" + and ft ~= "oil" and ft ~= "aerial" + then + return win + end + end + + -- No editor window found; try to find any terminal in tab 2 to anchor a new split. + local terminal_win = nil + pcall(function() + local terminal_module = require("claudecode.terminal") + local terminal_bufnr = terminal_module.get_active_terminal_bufnr() + if terminal_bufnr then + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do + if vim.api.nvim_win_get_buf(win) == terminal_bufnr then + terminal_win = win + break + end + end + end + end) + + -- If no terminal in tab 2, fall back to any non-floating, non-special window in tab 2. + if not terminal_win then + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do + local cfg = vim.api.nvim_win_get_config(win) + if not cfg.relative or cfg.relative == "" then + local b = vim.api.nvim_win_get_buf(win) + local bt = vim.api.nvim_buf_get_option(b, "buftype") + if bt ~= "terminal" and bt ~= "prompt" and not vim.api.nvim_win_get_option(win, "diff") then + return win + end + end + end + return nil + end + + vim.api.nvim_set_current_tabpage(tab2) + vim.api.nvim_set_current_win(terminal_win) + vim.cmd("leftabove vsplit") + local new_editor = vim.api.nvim_get_current_win() + vim.cmd("wincmd =") + return new_editor +end + ---Create a split based on configured layout local function create_split() if config and config.diff_opts and config.diff_opts.layout == "horizontal" then @@ -333,20 +396,24 @@ end local function display_terminal_in_new_tab() local original_tab = vim.api.nvim_get_current_tabpage() + local function get_or_create_diff_tab() + vim.cmd("tabnew") + mark_tabnew_buffer_ephemeral() + return vim.api.nvim_get_current_tabpage() + end + -- Get existing terminal buffer local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") if not terminal_ok then - vim.cmd("tabnew") - mark_tabnew_buffer_ephemeral() - local new_tab = vim.api.nvim_get_current_tabpage() + local new_tab = get_or_create_diff_tab() + vim.api.nvim_set_current_tabpage(new_tab) return original_tab, nil, false, new_tab end local terminal_bufnr = terminal_module.get_active_terminal_bufnr() if not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then - vim.cmd("tabnew") - mark_tabnew_buffer_ephemeral() - local new_tab = vim.api.nvim_get_current_tabpage() + local new_tab = get_or_create_diff_tab() + vim.api.nvim_set_current_tabpage(new_tab) return original_tab, nil, false, new_tab end @@ -359,9 +426,8 @@ local function display_terminal_in_new_tab() terminal_options = get_default_terminal_options() end - vim.cmd("tabnew") - mark_tabnew_buffer_ephemeral() - local new_tab = vim.api.nvim_get_current_tabpage() + local new_tab = get_or_create_diff_tab() + vim.api.nvim_set_current_tabpage(new_tab) local terminal_config = config.terminal or {} local split_side = terminal_config.split_side or "right" @@ -377,16 +443,27 @@ local function display_terminal_in_new_tab() return original_tab, nil, had_terminal_in_original, new_tab end - vim.cmd("vsplit") - - local terminal_win = vim.api.nvim_get_current_win() + -- Reuse an existing terminal window in the new tab if already present, instead of splitting again. + local existing_terminal_in_new_tab + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(new_tab)) do + if vim.api.nvim_win_get_buf(win) == terminal_bufnr then + existing_terminal_in_new_tab = win + break + end + end - if split_side == "left" then - vim.cmd("wincmd H") - else - vim.cmd("wincmd L") + if not existing_terminal_in_new_tab then + vim.cmd("vsplit") + existing_terminal_in_new_tab = vim.api.nvim_get_current_win() + if split_side == "left" then + vim.cmd("wincmd H") + else + vim.cmd("wincmd L") + end end + local terminal_win = existing_terminal_in_new_tab + vim.api.nvim_win_set_buf(terminal_win, terminal_bufnr) apply_window_options(terminal_win, terminal_options) @@ -1060,6 +1137,30 @@ end -- Exposed for testing the reject-on-window-close (WinClosed) behavior. M._register_diff_autocmds = register_diff_autocmds +---Format an error value (string or structured {message,data} table) into a flat, +---bounded string. Avoids `tostring(table)` blowups that spam :messages and the UI. +---Defined at module scope so both `_setup_blocking_diff` and `open_diff_blocking` +---can use it (the error path must never itself raise a secondary error). +---@param err any Error value (string or table with optional .message/.data) +---@return string +local function format_error(err) + if type(err) == "table" then + local msg = err.message or "unknown error" + local data = err.data + if data ~= nil and data ~= "" then + if type(data) == "table" then + data = "table" + end + return msg .. " (" .. tostring(data) .. ")" + end + return msg + end + return tostring(err) +end + +-- Exposed for testing/debugging the error formatter. +M._format_error = format_error + ---Create diff view from a specific window ---@param target_window NvimWin|nil The window to use as base for the diff ---@param old_file_path string Path to the original file @@ -1308,6 +1409,7 @@ function M._setup_blocking_diff(params, resolution_callback) local setup_success, setup_error = pcall(function() local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 local is_new_file = not old_file_exists + local original_tab_number = vim.api.nvim_get_current_tabpage() if old_file_exists then local is_dirty = is_buffer_dirty(params.old_file_path) @@ -1338,6 +1440,12 @@ function M._setup_blocking_diff(params, resolution_callback) tab_number = state.created_new_tab and state.new_tab_number or nil, }) end + + if original_tab_number and vim.api.nvim_tabpage_is_valid(original_tab_number) then + vim.schedule(function() + vim.api.nvim_set_current_tabpage(original_tab_number) + end) + end return end @@ -1517,16 +1625,18 @@ function M._setup_blocking_diff(params, resolution_callback) -- Handle setup errors if not setup_success then - local error_msg - if type(setup_error) == "table" and setup_error.message then - -- Handle structured error objects - error_msg = "Failed to setup diff operation: " .. setup_error.message - if setup_error.data then - error_msg = error_msg .. " (" .. setup_error.data .. ")" - end + local error_msg = "Failed to setup diff operation: " .. format_error(setup_error) + + vim.schedule(function() + vim.notify("ClaudeCode diff setup failed: " .. format_error(setup_error), vim.log.levels.ERROR) + end) + + -- Log the structured error before we wrap it, so the original cause is visible in :messages. + local structured = setup_error + if type(structured) == "table" and structured.message then + logger.error("diff", "Diff setup failed for", tab_name, "-", structured.message, structured.data or "") else - -- Handle string errors or other types - error_msg = "Failed to setup diff operation: " .. tostring(setup_error) + logger.error("diff", "Diff setup failed for", tab_name, "-", tostring(structured)) end -- Clean up any partial state that might have been created @@ -1643,16 +1753,7 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t end) if not success then - local error_msg - if type(err) == "table" and err.message then - error_msg = err.message - if err.data then - error_msg = error_msg .. " - " .. err.data - end - else - error_msg = tostring(err) - end - logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", error_msg) + logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", format_error(err)) -- If the error is already structured, propagate it directly if type(err) == "table" and err.code then error(err) @@ -1660,7 +1761,7 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t error({ code = -32000, message = "Error setting up diff", - data = tostring(err), + data = format_error(err), }) end end @@ -1813,6 +1914,7 @@ function M.accept_current_diff() local tab_name = vim.b[current_buffer].claudecode_diff_tab_name if tab_name then M._resolve_diff_as_saved(tab_name, current_buffer) + M._cleanup_diff_state(tab_name, "user accepted") else vim.notify("No active diff found in current buffer", vim.log.levels.WARN) end @@ -1827,6 +1929,7 @@ function M.accept_current_diff() end M._resolve_diff_as_saved(tab_name, current_buffer) + M._cleanup_diff_state(tab_name, "user accepted") end ---Deny/reject the current diff (user command version) @@ -1839,6 +1942,7 @@ function M.deny_current_diff() local tab_name = vim.b[current_buffer].claudecode_diff_tab_name if tab_name then M._resolve_diff_as_rejected(tab_name) + M._cleanup_diff_state(tab_name, "user denied") else vim.notify("No active diff found in current buffer", vim.log.levels.WARN) end @@ -1852,8 +1956,8 @@ function M.deny_current_diff() return end - -- Do not close windows/tabs here; just mark as rejected. M._resolve_diff_as_rejected(tab_name) + M._cleanup_diff_state(tab_name, "user denied") end -- Expose internal utilities for use by diff_inline.lua @@ -1863,6 +1967,7 @@ M._is_buffer_dirty = is_buffer_dirty M._detect_filetype = detect_filetype M._get_autocmd_group = get_autocmd_group M._display_terminal_in_new_tab = display_terminal_in_new_tab +M._get_or_create_editor_win_in_diff_tab = get_or_create_editor_win_in_diff_tab return M ---@alias NvimWin integer diff --git a/lua/claudecode/diff_inline.lua b/lua/claudecode/diff_inline.lua index cd327791..9d148312 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -1,22 +1,60 @@ --- Inline diff module for Claude Code Neovim integration. -- Provides a VS Code-style unified inline diff view with deleted (red/strikethrough) --- and added (green) lines interleaved in a single read-only buffer. +-- and added (green) lines interleaved in a single persistent buffer. local M = {} local logger = require("claudecode.logger") local ns = vim.api.nvim_create_namespace("claudecode_inline_diff") ---- Resolve the diff function. Neovim 0.12 renamed `vim.diff` to `vim.text.diff` ---- (keeping `vim.diff` as a deprecated alias); prefer the new name and fall back to ---- the old one so inline diff works across the supported range and stays forward-compatible. +--- Name of the single persistent buffer used for every unified diff. +local CLAUDE_BUFFER_NAME = "ClaudeCodeBuffer" + +--- Bumped to cancel in-flight animations +local reveal_gen = 0 + +--- Queue of pending diffs waiting for acceptance +--- Each entry: { params, callback, config } +local diff_queue = {} + +--- Currently active (pending) diff tab_name, or nil if none +local current_diff_tab = nil + +---Get the persistent ClaudeCodeBuffer, creating it on first use or resetting it for reuse. +---The buffer is kept alive (`bufhidden = "hide"`) so it survives window/tab closes. +---@return number buf The buffer handle +local function get_or_create_claudecode_buffer() + local existing = vim.fn.bufnr(CLAUDE_BUFFER_NAME) + if existing ~= -1 and vim.api.nvim_buf_is_valid(existing) then + pcall(function() + vim.api.nvim_buf_set_option(existing, "modifiable", true) + vim.api.nvim_buf_clear_namespace(existing, ns, 0, -1) + vim.api.nvim_buf_set_lines(existing, 0, -1, false, {}) + vim.api.nvim_buf_set_option(existing, "buftype", "acwrite") + vim.api.nvim_buf_set_option(existing, "bufhidden", "hide") + vim.b[existing].claudecode_inline_diff = nil + vim.b[existing].claudecode_diff_tab_name = nil + end) + return existing + end + + local b = vim.api.nvim_create_buf(false, true) + if b == 0 then + error({ code = -32000, message = "Buffer creation failed", data = "Could not create ClaudeCodeBuffer" }) + end + pcall(vim.api.nvim_buf_set_name, b, CLAUDE_BUFFER_NAME) + vim.api.nvim_buf_set_option(b, "buftype", "acwrite") + vim.api.nvim_buf_set_option(b, "bufhidden", "hide") + return b +end + +---Resolve the diff function. Neovim 0.12 renamed `vim.diff` to `vim.text.diff`. ---@return function|nil local function get_diff_fn() return (vim.text and vim.text.diff) or vim.diff end --- ── Highlight groups ────────────────────────────────────────────── - +---Setup highlight groups for added/deleted lines. local function setup_highlights() vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffAdd", { bg = "#2a4a2a", default = true }) vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffDelete", { bg = "#4a2a2a", strikethrough = true, default = true }) @@ -41,7 +79,6 @@ local function split_lines(text) end --- Compute an interleaved inline diff from two strings. ---- Returns parallel arrays: lines[] (buffer content) and line_types[] ("unchanged"|"added"|"deleted"). ---@param old_text string Original file content ---@param new_text string Proposed file content ---@return string[] lines Buffer lines for display @@ -51,7 +88,6 @@ function M.compute_inline_diff(old_text, new_text) new_text = new_text or "" local hunks = get_diff_fn()(old_text, new_text, { result_type = "indices" }) or {} - local old_lines = split_lines(old_text) local new_lines = split_lines(new_text) @@ -63,12 +99,10 @@ function M.compute_inline_diff(old_text, new_text) for _, hunk in ipairs(hunks) do local start_a, count_a, _, count_b = hunk[1], hunk[2], hunk[3], hunk[4] - -- Unchanged lines before this hunk local unchanged_count if count_a > 0 then unchanged_count = start_a - old_pos else - -- Pure insertion: start_a is the last unchanged line before the insertion unchanged_count = start_a - old_pos + 1 end @@ -79,14 +113,12 @@ function M.compute_inline_diff(old_text, new_text) new_pos = new_pos + 1 end - -- Deleted lines from old for _ = 1, count_a do result_lines[#result_lines + 1] = old_lines[old_pos] result_types[#result_types + 1] = "deleted" old_pos = old_pos + 1 end - -- Added lines from new for _ = 1, count_b do result_lines[#result_lines + 1] = new_lines[new_pos] result_types[#result_types + 1] = "added" @@ -94,7 +126,6 @@ function M.compute_inline_diff(old_text, new_text) end end - -- Remaining unchanged lines after the last hunk while new_pos <= #new_lines do result_lines[#result_lines + 1] = new_lines[new_pos] result_types[#result_types + 1] = "unchanged" @@ -118,32 +149,173 @@ function M.extract_new_content(lines, line_types) return table.concat(out, "\n") end ---- Apply line highlights and sign-column markers via extmarks. +-- ── Rendering ───────────────────────────────────────────────────── + +--- Apply the add/delete highlight + sign extmark for a single line. +---@param buf number Buffer handle +---@param index number 1-based line index +---@param line_type string "added" | "deleted" | "unchanged" +--- Apply the add/delete highlight + sign extmark for a single line. +---@param buf number Buffer handle +---@param index number 1-based line index +---@param line_type string "added" | "deleted" | "unchanged" +---@return number|nil extmark id (nil for unchanged) +local function apply_line_mark(buf, index, line_type) + if line_type == "added" then + return vim.api.nvim_buf_set_extmark(buf, ns, index - 1, 0, { + line_hl_group = "ClaudeCodeInlineDiffAdd", + sign_text = "+", + sign_hl_group = "ClaudeCodeInlineDiffAddSign", + }) + elseif line_type == "deleted" then + return vim.api.nvim_buf_set_extmark(buf, ns, index - 1, 0, { + line_hl_group = "ClaudeCodeInlineDiffDelete", + sign_text = "-", + sign_hl_group = "ClaudeCodeInlineDiffDeleteSign", + }) + end +end + +--- Render the full diff into `buf` and apply add/delete highlights + signs. ---@param buf number Buffer handle ---@param lines string[] Lines to set ---@param line_types string[] Parallel type array function M.render_diff_buffer(buf, lines, line_types) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + for i, lt in ipairs(line_types) do + apply_line_mark(buf, i, lt) + end +end + +---@param wpm number Words per minute +local function wpm_to_ms_per_char(wpm) + return (60.0 / wpm / 5.0) * 1000.0 +end + +--- Progressively reveal `lines` in `buf` with a natural "typing" cadence. +---O(n) incremental, cancellable via `reveal_gen`. +---@param buf number Buffer handle +---@param lines string[] Full diff lines +---@param line_types string[] Parallel type array +---@param opts table|nil { delay_ms:number, chars_per_tick:number } +local function render_diff_buffer_streamed(buf, lines, line_types, opts) + opts = opts or {} + local chars_per_tick = math.max(1, opts.chars_per_tick or 1) + local delay_floor_ms = math.max(0, opts.delay_ms or 0) + + vim.api.nvim_buf_set_option(buf, "modifiable", true) + + reveal_gen = reveal_gen + 1 + local my_gen = reveal_gen + -- Unchanged lines are shown instantly; added/deleted lines start empty and are + -- typed out one character at a time so only the *changed* lines animate. + local initial = {} for i, lt in ipairs(line_types) do - if lt == "added" then - vim.api.nvim_buf_set_extmark(buf, ns, i - 1, 0, { - line_hl_group = "ClaudeCodeInlineDiffAdd", - sign_text = "+", - sign_hl_group = "ClaudeCodeInlineDiffAddSign", - }) - elseif lt == "deleted" then - vim.api.nvim_buf_set_extmark(buf, ns, i - 1, 0, { - line_hl_group = "ClaudeCodeInlineDiffDelete", - sign_text = "-", - sign_hl_group = "ClaudeCodeInlineDiffDeleteSign", - }) + initial[i] = (lt == "unchanged") and lines[i] or "" + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial) + + -- Apply the add/delete background + sign marks immediately (track ids so we can + -- re-anchor them after each in-place line update — replacing a marked line with + -- nvim_buf_set_lines shifts the extmark down by one and corrupts positions). + local mark_ids = {} + for i, lt in ipairs(line_types) do + if lt ~= "unchanged" then + mark_ids[i] = apply_line_mark(buf, i, lt) + end + end + + -- Collect changed line indices in document order. + local changed = {} + for i, lt in ipairs(line_types) do + if lt ~= "unchanged" then + changed[#changed + 1] = i end end + + local idx = 1 + local filled = {} + for _, ci in ipairs(changed) do + filled[ci] = "" + end + + -- Re-anchor the extmark for line `ci` at its correct row. nvim_buf_set_lines + -- moves the mark down by one, so we delete by id and recreate it. + local function reanchor(ci) + local old_id = mark_ids[ci] + if old_id then + pcall(vim.api.nvim_buf_del_extmark, buf, ns, old_id) + end + mark_ids[ci] = apply_line_mark(buf, ci, line_types[ci]) + end + + local function step() + if my_gen ~= reveal_gen then + return + end + if not vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_set_option, buf, "modifiable", false) + return + end + if idx > #changed then + pcall(vim.api.nvim_buf_set_option, buf, "modifiable", false) + return + end + + local ci = changed[idx] + local target = lines[ci] or "" + local cur = filled[ci] or "" + + if #cur >= #target then + -- This changed line is complete; move on to the next one. + idx = idx + 1 + -- Tail-call via defer so we don't blow the stack on many changed lines. + vim.defer_fn(step, 0) + return + end + + local adv = math.min(chars_per_tick, #target - #cur) + local next_text = target:sub(1, #cur + adv) + filled[ci] = next_text + pcall(vim.api.nvim_buf_set_lines, buf, ci - 1, ci, false, { next_text }) + reanchor(ci) + + local wpm = math.random(400, 1000) + vim.defer_fn(step, math.max(delay_floor_ms, math.floor(wpm_to_ms_per_char(wpm)))) + end + + step() +end + +--- Cancel any in-flight streaming animation. +local function cancel_animation() + reveal_gen = reveal_gen + 1 +end + +--- Move `win`'s cursor to the buffer's first changed line and center it. +--- No-op if the window is invalid or no change line was recorded. +---@param buf number Buffer handle +---@param win number|nil Window handle showing `buf` +local function scroll_to_first_change(buf, win) + if not (win and vim.api.nvim_win_is_valid(win)) then + return + end + local line = vim.b[buf].claudecode_first_change_line + if not line then + return + end + local count = vim.api.nvim_buf_line_count(buf) + line = math.max(1, math.min(line, count)) + pcall(vim.api.nvim_win_set_cursor, win, { line, 0 }) + pcall(vim.api.nvim_win_call, win, function() + vim.cmd("normal! zz") + end) end ---- Create a scratch editor window when the current tab only has terminal/sidebar windows. ----@return number win_id Window ID of the created scratch editor window +--- Create a scratch editor window when the current tab only has terminal/sidebar +--- windows, giving the diff split something to anchor to. Returns the new window. +---@return number win_id local function create_fallback_editor_window() vim.cmd("rightbelow vsplit") local fallback_win = vim.api.nvim_get_current_win() @@ -153,7 +325,6 @@ local function create_fallback_editor_window() if scratch_buf == 0 then error({ code = -32000, message = "Buffer creation failed", data = "Could not create fallback editor buffer" }) end - vim.api.nvim_buf_set_option(scratch_buf, "bufhidden", "wipe") vim.api.nvim_win_set_buf(fallback_win, scratch_buf) end) @@ -172,267 +343,213 @@ end ---@param tab_name string Diff identifier ---@param partial table Partially created resources local function cleanup_unregistered_inline_setup(tab_name, partial) + -- Always clear the pending marker so a failed setup can't wedge the queue. + current_diff_tab = nil local diff = require("claudecode.diff") - if diff._get_active_diffs()[tab_name] then diff._cleanup_diff_state(tab_name, "setup failed") return end - for _, autocmd_id in ipairs(partial.autocmd_ids or {}) do pcall(vim.api.nvim_del_autocmd, autocmd_id) end - - local original_tab = partial.original_tab_number - local stranded_tab = partial.new_tab_number - if not (stranded_tab and vim.api.nvim_tabpage_is_valid(stranded_tab)) then - local current_tab = vim.api.nvim_get_current_tabpage() - if original_tab and vim.api.nvim_tabpage_is_valid(original_tab) and current_tab ~= original_tab then - stranded_tab = current_tab - end + -- Close any windows opened before the failure (diff split + terminal-only fallback). + if partial.diff_window and vim.api.nvim_win_is_valid(partial.diff_window) then + pcall(vim.api.nvim_win_close, partial.diff_window, true) end - - if stranded_tab and vim.api.nvim_tabpage_is_valid(stranded_tab) and stranded_tab ~= original_tab then - pcall(vim.api.nvim_set_current_tabpage, stranded_tab) - pcall(vim.cmd, "tabclose") - if original_tab and vim.api.nvim_tabpage_is_valid(original_tab) then - pcall(vim.api.nvim_set_current_tabpage, original_tab) - end - else - if partial.diff_window and vim.api.nvim_win_is_valid(partial.diff_window) then - pcall(vim.api.nvim_win_close, partial.diff_window, true) - end - - if partial.fallback_window and vim.api.nvim_win_is_valid(partial.fallback_window) then - pcall(vim.api.nvim_win_close, partial.fallback_window, true) - end + if partial.fallback_window and vim.api.nvim_win_is_valid(partial.fallback_window) then + pcall(vim.api.nvim_win_close, partial.fallback_window, true) end - if partial.new_buffer and vim.api.nvim_buf_is_valid(partial.new_buffer) then pcall(vim.api.nvim_buf_delete, partial.new_buffer, { force = true }) end - - if partial.had_terminal_in_original then - local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") - if terminal_ok then - pcall(terminal_module.ensure_visible) - end - end end -- ── Setup ───────────────────────────────────────────────────────── ---- Set up an inline diff view for the given parameters. +--- Prepare the unified diff in ClaudeCodeBuffer and show it in a split in the +--- current tab (reusing an existing ClaudeCodeBuffer window if one is open). ---@param params table Diff parameters (old_file_path, new_file_path, new_file_contents, tab_name) ---@param resolution_callback function Callback to call when diff resolves ---@param config table Plugin configuration function M.setup_inline_diff(params, resolution_callback, config) local diff = require("claudecode.diff") - -- Version check: the diff function (vim.text.diff / vim.diff) requires Neovim >= 0.9.0 + local autocmd_ids = {} + local buf = nil + local partial_setup = { + new_buffer = nil, + diff_window = nil, + fallback_window = nil, + autocmd_ids = autocmd_ids, + } + + local function guard(action) + local results = { pcall(action) } + if not results[1] then + cleanup_unregistered_inline_setup(params.tab_name, partial_setup) + error(results[2]) + end + return unpack(results, 2) + end + if not get_diff_fn() then error({ code = -32000, message = "Inline diff requires Neovim >= 0.9.0", - data = "vim.text.diff()/vim.diff() is not available. Please use layout = 'vertical' or 'horizontal', or upgrade Neovim.", + data = "vim.text.diff()/vim.diff() is not available.", }) end setup_highlights() local tab_name = params.tab_name - local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 + + -- Serial queue: if a diff is already pending/active, queue this one WITHOUT + -- touching the active buffer or its state. This prevents tearing down an + -- in-progress diff when multiple openDiff calls arrive (e.g. Claude opening + -- several files at once), which previously corrupted state and surfaced as a + -- persistent "Diff setup failed" error. + if current_diff_tab ~= nil then + table.insert(diff_queue, { + params = params, + callback = resolution_callback, + config = config, + }) + logger.debug("diff", "Queued inline diff (already pending:", current_diff_tab .. ")") + return + end + + -- Treat unreadable/missing original paths as new files (never throw on the + -- existence probe, which is what triggered setup failures for new files). + local readable_ok, exists = pcall(function() + return vim.fn.filereadable(params.old_file_path) == 1 + end) + local old_file_exists = readable_ok and exists or false local is_new_file = not old_file_exists - -- Dirty buffer check if old_file_exists then - local is_dirty = diff._is_buffer_dirty(params.old_file_path) + local is_dirty = false + local dirty_ok, dirty = pcall(diff._is_buffer_dirty, params.old_file_path) + if dirty_ok then + is_dirty = dirty + end if is_dirty then error({ code = -32000, message = "Cannot create diff: file has unsaved changes", - data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path .. " before creating diff", + data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path, }) end end - -- Read old file content local old_text = "" if not is_new_file then local f = io.open(params.old_file_path, "r") if f then old_text = f:read("*a") or "" f:close() + else + error({ code = -32000, message = "Failed to read original file", data = params.old_file_path }) end end - -- Compute diff - local lines, line_types = M.compute_inline_diff(old_text, params.new_file_contents) - - -- Create scratch buffer - local buf = vim.api.nvim_create_buf(false, true) - if buf == 0 then - error({ code = -32000, message = "Buffer creation failed", data = "Could not create inline diff buffer" }) + local ok_diff, lines, line_types = pcall(M.compute_inline_diff, old_text, params.new_file_contents) + if not ok_diff then + error({ code = -32000, message = "Failed to compute inline diff", data = tostring(lines) }) end - pcall(vim.api.nvim_buf_set_name, buf, tab_name .. " (unified diff)") - vim.api.nvim_buf_set_option(buf, "buftype", "acwrite") - vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") + buf = guard(get_or_create_claudecode_buffer) + partial_setup.new_buffer = buf - -- Render content + highlights - M.render_diff_buffer(buf, lines, line_types) - vim.api.nvim_buf_set_option(buf, "modifiable", false) + -- Cancel any in-flight animation from a previous diff before rendering. + cancel_animation() + + -- Render (streaming or static) - no window opened. Guarded so a render failure + -- runs cleanup instead of escaping with a half-built diff. + guard(function() + if config and config.diff_opts and config.diff_opts.stream_reveal then + render_diff_buffer_streamed(buf, lines, line_types, { + delay_ms = config.diff_opts.stream_reveal_delay_ms or 0, + chars_per_tick = config.diff_opts.stream_reveal_chars_per_tick or 10, + }) + else + M.render_diff_buffer(buf, lines, line_types) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + end + end) + + -- Mark this diff as current only after a successful render, so a setup that + -- throws above never leaves a stale pending marker that queues every future + -- diff behind a dead one. (Safe: setup is synchronous, so no other openDiff + -- can arrive before this returns.) + current_diff_tab = tab_name - -- Buffer metadata vim.b[buf].claudecode_diff_tab_name = tab_name vim.b[buf].claudecode_inline_diff = true - -- Syntax highlighting via filetype - local ft = diff._detect_filetype(params.new_file_path) - if ft and ft ~= "" then - vim.api.nvim_set_option_value("filetype", ft, { buf = buf }) + -- Record the first changed line so any window showing the buffer can jump to it. + local first_change = nil + for i, lt in ipairs(line_types) do + if lt ~= "unchanged" then + first_change = i + break + end end + vim.b[buf].claudecode_first_change_line = first_change - -- Handle new-tab mode - local original_tab_number = vim.api.nvim_get_current_tabpage() - local created_new_tab = false - local terminal_win_in_new_tab = nil - local new_tab_handle = nil - local had_terminal_in_original = false - - local diff_win - local autocmd_ids = {} - local partial_setup = { - new_buffer = buf, - original_tab_number = original_tab_number, - autocmd_ids = autocmd_ids, - } - local function guard_inline_setup(action) - local results = { pcall(action) } - if not results[1] then - cleanup_unregistered_inline_setup(tab_name, partial_setup) - error(results[2]) - end - return unpack(results, 2) + local ft_ok, ft = pcall(diff._detect_filetype, params.new_file_path) + if ft_ok and ft and ft ~= "" then + vim.api.nvim_set_option_value("filetype", ft, { buf = buf }) end - if config and config.diff_opts and config.diff_opts.open_in_new_tab then - original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle = guard_inline_setup( - function() - return diff._display_terminal_in_new_tab() - end - ) - partial_setup.original_tab_number = original_tab_number - partial_setup.new_tab_number = new_tab_handle - partial_setup.had_terminal_in_original = had_terminal_in_original - created_new_tab = true - end - - -- Save terminal window width so we can restore it after the diff closes - local term_win = diff._find_claudecode_terminal_window() - local term_width = nil - if term_win and vim.api.nvim_win_is_valid(term_win) then - term_width = vim.api.nvim_win_get_width(term_win) - end - - -- Open a vsplit for the inline diff buffer - -- When in a new tab, use a window from the current tab rather than the global - -- search which could return a window from the original tab - local editor_win - local fallback_window - if created_new_tab then - local tab_wins = vim.api.nvim_tabpage_list_wins(0) - for _, w in ipairs(tab_wins) do - if w ~= terminal_win_in_new_tab then - editor_win = w - break - end - end - -- Fallback to first window in the new tab - if not editor_win and #tab_wins > 0 then - editor_win = tab_wins[1] + -- Show the buffer in a split in the CURRENT tab. Reuse an existing + -- ClaudeCodeBuffer window if one is already open (e.g. from a prior diff or a + -- queued one); otherwise split off the main editor window. In a terminal-only + -- tab (no editor window) create a throwaway scratch window to anchor the split. + local editor_win, fallback_window, diff_win + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_buf(w) == buf then + diff_win = w + break end - else + end + if not diff_win then editor_win = diff._find_main_editor_window() if not editor_win then - fallback_window = guard_inline_setup(create_fallback_editor_window) + fallback_window = guard(create_fallback_editor_window) partial_setup.fallback_window = fallback_window editor_win = fallback_window end - end - guard_inline_setup(function() - assert(editor_win and vim.api.nvim_win_is_valid(editor_win), "ClaudeCode unified diff target window must be valid") - vim.api.nvim_set_current_win(editor_win) - vim.cmd("rightbelow vsplit") - diff_win = vim.api.nvim_get_current_win() - partial_setup.diff_window = diff_win - vim.api.nvim_win_set_buf(diff_win, buf) - end) - - -- Configure window for sign column display - pcall(vim.api.nvim_set_option_value, "signcolumn", "yes", { win = diff_win }) - - -- Equalize window widths - guard_inline_setup(function() - vim.cmd("wincmd =") - end) - - -- Scroll to first change - for i, lt in ipairs(line_types) do - if lt ~= "unchanged" then - pcall(vim.api.nvim_win_set_cursor, diff_win, { i, 0 }) - break - end - end - - -- Handle terminal focus - if config and config.diff_opts and config.diff_opts.keep_terminal_focus then - vim.schedule(function() - if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then - vim.api.nvim_set_current_win(terminal_win_in_new_tab) - vim.cmd("startinsert") - return - end - - local terminal_win = diff._find_claudecode_terminal_window() - if terminal_win then - vim.api.nvim_set_current_win(terminal_win) - vim.cmd("startinsert") - end + guard(function() + assert(editor_win and vim.api.nvim_win_is_valid(editor_win), "ClaudeCode unified diff target window must be valid") + vim.api.nvim_set_current_win(editor_win) + vim.cmd("rightbelow vsplit") + diff_win = vim.api.nvim_get_current_win() + partial_setup.diff_window = diff_win + vim.api.nvim_win_set_buf(diff_win, buf) end) end + pcall(vim.api.nvim_set_option_value, "signcolumn", "yes", { win = diff_win }) + pcall(vim.cmd, "wincmd =") - -- Restore terminal width after opening the split - guard_inline_setup(function() - if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then - local terminal_config = config.terminal or {} - local split_width = terminal_config.split_width_percentage or 0.30 - local total_width = vim.o.columns - local terminal_width = math.floor(total_width * split_width) - vim.api.nvim_win_set_width(terminal_win_in_new_tab, terminal_width) - elseif term_win and vim.api.nvim_win_is_valid(term_win) then - local win_config = vim.api.nvim_win_get_config(term_win) - local is_floating = win_config.relative and win_config.relative ~= "" - if not is_floating and term_width then - pcall(vim.api.nvim_win_set_width, term_win, term_width) - end - end - end) - - -- Register autocmds and state with layout = "unified" - guard_inline_setup(function() + -- Register state + guard(function() local aug = diff._get_autocmd_group() - autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWriteCmd", { group = aug, buffer = buf, callback = function() diff._resolve_diff_as_saved(tab_name, buf) - return true -- prevent actual write + return true end, }) - - for _, ev in ipairs({ "BufDelete", "BufUnload", "BufWipeout" }) do + -- Reject only on real buffer destruction (:bd/:bw). We deliberately do NOT + -- reject on WinClosed: with bufhidden="hide" the persistent ClaudeCodeBuffer + -- is meant to be freely opened/hidden (e.g. via toggle_inline_diff), so + -- closing its window must only hide it — never send DIFF_REJECTED. Rejection + -- is explicit (ClaudeCodeDiffDeny) or via actual buffer deletion. + for _, ev in ipairs({ "BufDelete", "BufWipeout" }) do autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd(ev, { group = aug, buffer = buf, @@ -441,11 +558,11 @@ function M.setup_inline_diff(params, resolution_callback, config) end, }) end - diff._register_diff_state(tab_name, { old_file_path = params.old_file_path, new_file_path = params.new_file_path, new_file_contents = params.new_file_contents, + old_file_contents = old_text, new_buffer = buf, new_window = diff_win, target_window = editor_win, @@ -459,139 +576,266 @@ function M.setup_inline_diff(params, resolution_callback, config) resolution_callback = resolution_callback, result_content = nil, layout = "unified", - -- Track the originating MCP client so close_diffs_for_client can tear this - -- diff down if that client disconnects (parity with the native path, #261). client_id = params.client_id, - -- Tab/window tracking - original_tab_number = original_tab_number, - created_new_tab = created_new_tab, - new_tab_number = new_tab_handle, - had_terminal_in_original = had_terminal_in_original, - terminal_win_in_new_tab = terminal_win_in_new_tab, - term_win = term_win, - term_width = term_width, }) end) + + -- Jump to the first change in the diff window. + scroll_to_first_change(buf, diff_win) +end + +-- ── User commands ──────────────────────────────────────────────── + +---Open ClaudeCodeBuffer in the current tab. Creates a vsplit if not already shown. +---If buffer doesn't exist yet, creates an empty one (for toggle-anytime use). +---@param tab_name string|nil Optional tab_name to link the window to +function M.open_inline_diff(tab_name) + local buf = vim.fn.bufnr(CLAUDE_BUFFER_NAME) + if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, CLAUDE_BUFFER_NAME) + vim.api.nvim_buf_set_option(buf, "buftype", "nofile") + vim.api.nvim_buf_set_option(buf, "bufhidden", "hide") + vim.api.nvim_buf_set_option(buf, "modifiable", false) + end + + local win + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_buf(w) == buf then + win = w + break + end + end + + if win then + vim.api.nvim_set_current_win(win) + else + vim.cmd("rightbelow vsplit") + win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, buf) + pcall(vim.api.nvim_set_option_value, "signcolumn", "yes", { win = win }) + vim.cmd("wincmd =") + end + + if tab_name then + local diff = require("claudecode.diff") + local diff_data = diff._get_active_diffs()[tab_name] + if diff_data and vim.api.nvim_win_is_valid(win) then + diff_data.new_window = win + end + end + + scroll_to_first_change(buf, win) +end + +---Toggle ClaudeCodeBuffer: close if visible in current tab, open otherwise. +---Creates an empty buffer if none exists (for anytime toggle use). +function M.toggle_inline_diff() + local buf = vim.fn.bufnr(CLAUDE_BUFFER_NAME) + if buf ~= -1 and vim.api.nvim_buf_is_valid(buf) then + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_buf(win) == buf then + pcall(vim.api.nvim_win_close, win, false) + return + end + end + end + -- Buffer doesn't exist or not visible in this tab - open it + M.open_inline_diff() end --- ── Resolution functions ────────────────────────────────────────── +---Close the inline diff buffer window (alias for toggle for backwards compatibility). +function M.close_inline_diff() + M.toggle_inline_diff() +end + +-- ── Resolution & Cleanup ───────────────────────────────────────── --- Resolve an inline diff as saved (user accepted changes). ---@param tab_name string The diff identifier ---@param diff_data table The diff state data function M.resolve_inline_as_saved(tab_name, diff_data) logger.debug("diff", "Accepting inline diff for", tab_name) - local content = M.extract_new_content(diff_data.lines, diff_data.line_types) - -- Preserve trailing newline when original new_file_contents had one if diff_data.new_file_contents:sub(-1) == "\n" and content:sub(-1) ~= "\n" then content = content .. "\n" end - local result = { - content = { - { type = "text", text = "FILE_SAVED" }, - { type = "text", text = content }, - }, + content = { { type = "text", text = "FILE_SAVED" }, { type = "text", text = content } }, } - diff_data.status = "saved" diff_data.result_content = result - if diff_data.resolution_callback then diff_data.resolution_callback(result) - else - logger.debug("diff", "No resolution callback found for saved inline diff", tab_name) end - logger.debug("diff", "Inline diff saved; awaiting close_tab for cleanup") + -- Release this diff's autocmds + state (keep the reused buffer + window) so the + -- next queued diff can take over the same buffer cleanly without duplicate autocmds. + M._release_inline_diff_state(tab_name) + + if #diff_queue > 0 then + -- Advance to the next queued diff after acceptance. + vim.schedule(function() + M._process_next_queued_diff() + end) + else + -- Last diff in the queue: perform final buffer cleanup (reload the accepted + -- file into the persistent buffer; the window stays open via bufhidden="hide"). + M.cleanup_inline_diff(tab_name, diff_data) + end end ---- Resolve an inline diff as rejected (user closed/rejected). +--- Resolve an inline diff as rejected. ---@param tab_name string The diff identifier ---@param diff_data table The diff state data function M.resolve_inline_as_rejected(tab_name, diff_data) - local result = { - content = { - { type = "text", text = "DIFF_REJECTED" }, - { type = "text", text = tab_name }, - }, - } - diff_data.status = "rejected" - diff_data.result_content = result - + diff_data.result_content = { + content = { { type = "text", text = "DIFF_REJECTED" }, { type = "text", text = tab_name } }, + } if diff_data.resolution_callback then - diff_data.resolution_callback(result) + diff_data.resolution_callback(diff_data.result_content) + end + -- Reject: stop the queue entirely (do NOT advance to the next diff), per design. + -- Resolve every queued diff as rejected too, otherwise their blocking openDiff + -- MCP calls never get a response and Claude hangs indefinitely. + for _, queued in ipairs(diff_queue) do + if queued.callback then + pcall(queued.callback, { + content = { + { type = "text", text = "DIFF_REJECTED" }, + { type = "text", text = queued.params.tab_name }, + }, + }) + end end + diff_queue = {} + current_diff_tab = nil + cancel_animation() + -- Clean up via the diff module so active state is fully released while the + -- persistent buffer is reloaded; the window stays open (bufhidden="hide"). + local diff = require("claudecode.diff") + diff._cleanup_diff_state(tab_name, "diff rejected") end --- ── Cleanup ─────────────────────────────────────────────────────── +--- Release a diff's autocmds and active state WITHOUT touching the reused buffer +--- or its window. Used between queued diffs so the next setup starts from a clean +--- state on the same persistent buffer. +---@param tab_name string The diff identifier +function M._release_inline_diff_state(tab_name) + local diff = require("claudecode.diff") + local active = diff._get_active_diffs() + local data = active[tab_name] + if data then + for _, autocmd_id in ipairs(data.autocmd_ids or {}) do + pcall(vim.api.nvim_del_autocmd, autocmd_id) + end + active[tab_name] = nil + end + current_diff_tab = nil +end --- Clean up an inline diff's state and UI. +---Keeps the window open and loads the reviewed file content (persistent buffer behavior). ---@param tab_name string The diff identifier ---@param diff_data table The diff state data function M.cleanup_inline_diff(tab_name, diff_data) local diff = require("claudecode.diff") - -- Clean up autocmds + -- Stop any in-flight typing animation so it can't clobber the reloaded file view. + cancel_animation() + for _, autocmd_id in ipairs(diff_data.autocmd_ids or {}) do pcall(vim.api.nvim_del_autocmd, autocmd_id) end - -- Handle new-tab cleanup - if diff_data.created_new_tab then - if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then - pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number) - end + -- Close the throwaway scratch window created for a terminal-only tab; the diff + -- window itself stays open (persistent buffer). No-op when no fallback was made. + if diff_data.fallback_window and vim.api.nvim_win_is_valid(diff_data.fallback_window) then + pcall(vim.api.nvim_win_close, diff_data.fallback_window, false) + end - if diff_data.new_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.new_tab_number) then - pcall(vim.api.nvim_set_current_tabpage, diff_data.new_tab_number) - pcall(vim.cmd, "tabclose") - if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then - pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number) + -- Keep the ClaudeCodeBuffer open, showing plain file content (no diff markup). + -- The buffer must reflect what actually happened to the file: + -- * Explicit accept (`:w`, status "saved"): show the proposed content. We + -- trust new_file_contents and do NOT read the file — Claude may not have + -- written it yet, so a disk read here would be stale. + -- * Otherwise (explicit reject, or a Claude-side accept that arrives as a + -- pending close and is marked "rejected"): show the original immediately, + -- then reconcile from disk once Claude has finished writing. Disk is the + -- source of truth — new content if the change was applied, original if not. + -- (do NOT close the window) + local accepted = diff_data.status == "saved" + if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then + local b = diff_data.new_buffer + pcall(function() + vim.api.nvim_buf_set_option(b, "modifiable", true) + vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) + local content = accepted and (diff_data.new_file_contents or "") or (diff_data.old_file_contents or "") + vim.api.nvim_buf_set_lines(b, 0, -1, false, split_lines(content)) + vim.api.nvim_buf_set_option(b, "buftype", "nofile") + vim.api.nvim_buf_set_option(b, "bufhidden", "hide") + vim.api.nvim_buf_set_option(b, "modifiable", false) + vim.b[b].claudecode_inline_diff = nil + vim.b[b].claudecode_diff_tab_name = nil + local ft_ok, ft = pcall(diff._detect_filetype, diff_data.new_file_path or diff_data.old_file_path) + if ft_ok and ft and ft ~= "" then + vim.api.nvim_set_option_value("filetype", ft, { buf = b }) end - end + end) - -- Ensure terminal remains visible in the original tab - local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") - if terminal_ok and diff_data.had_terminal_in_original then - pcall(terminal_module.ensure_visible) - local terminal_win = diff._find_claudecode_terminal_window() - if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then - local win_config = vim.api.nvim_win_get_config(terminal_win) - local is_floating = win_config.relative and win_config.relative ~= "" - if not is_floating and diff_data.term_width then - pcall(vim.api.nvim_win_set_width, terminal_win, diff_data.term_width) + -- Non-accept path: reconcile from disk after Claude's async write lands, so a + -- Claude-side accept (written file, pending close) ends up showing the new + -- content rather than the stale original. A genuine reject leaves the file + -- unchanged, so this is a no-op there. + if not accepted then + local path = diff_data.old_file_path + vim.defer_fn(function() + if not (path and vim.api.nvim_buf_is_valid(b)) then + return end - end - end - else - -- Close the diff split window - if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then - pcall(vim.api.nvim_win_close, diff_data.new_window, true) - end - - if diff_data.fallback_window and vim.api.nvim_win_is_valid(diff_data.fallback_window) then - pcall(vim.api.nvim_win_close, diff_data.fallback_window, false) - end - - -- Restore terminal width - if diff_data.term_win and vim.api.nvim_win_is_valid(diff_data.term_win) then - local win_config = vim.api.nvim_win_get_config(diff_data.term_win) - local is_floating = win_config.relative and win_config.relative ~= "" - if not is_floating and diff_data.term_width then - pcall(vim.api.nvim_win_set_width, diff_data.term_win, diff_data.term_width) - end + if vim.fn.filereadable(path) ~= 1 then + return + end + local fh = io.open(path, "r") + if not fh then + return + end + local disk = fh:read("*a") or "" + fh:close() + pcall(function() + vim.api.nvim_buf_set_option(b, "modifiable", true) + vim.api.nvim_buf_set_lines(b, 0, -1, false, split_lines(disk)) + vim.api.nvim_buf_set_option(b, "modifiable", false) + end) + end, 150) end end - -- Buffer might already be wiped by bufhidden=wipe when its window closed - if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then - pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true }) - end + -- Clear current diff marker + current_diff_tab = nil logger.debug("diff", "Cleaned up inline diff for", tab_name) end -return M +--- Process the next queued diff after the current one is resolved. +--- Called after acceptance to show the next file's changes. +function M._process_next_queued_diff() + if #diff_queue == 0 then return end + + local next = table.remove(diff_queue, 1) + if not next then return end + + -- setup_inline_diff shows the buffer itself (reusing an open ClaudeCodeBuffer + -- window in the current tab, or splitting a new one), so no separate open here. + local ok, err = pcall(M.setup_inline_diff, next.params, next.callback, next.config) + if not ok then + logger.error("diff", "Failed to set up queued diff:", tostring(err)) + -- Don't stall the queue on a single bad diff - keep draining it. + vim.schedule(function() + M._process_next_queued_diff() + end) + end +end + +return M \ No newline at end of file diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index a9f2d7b6..12c85df0 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -491,6 +491,34 @@ local vim = { return #vim._buffers[buf].extmarks end, + nvim_buf_del_extmark = function(buf, ns_id, id) + local b = vim._buffers[buf] + if not b or not b.extmarks then + return false + end + local mark = b.extmarks[id] + if mark and mark.ns_id == ns_id then + b.extmarks[id] = nil + return true + end + return false + end, + + nvim_buf_clear_namespace = function(buf, ns_id, _start, _end) + local b = vim._buffers[buf] + if not b or not b.extmarks then + return + end + local kept = {} + for _, m in pairs(b.extmarks) do + -- ns_id == -1 clears all namespaces + if not (ns_id == -1 or m.ns_id == ns_id) then + kept[#kept + 1] = m + end + end + b.extmarks = kept + end, + nvim_set_hl = function(ns_id, name, opts) vim._highlights = vim._highlights or {} vim._highlights[name] = { ns_id = ns_id, opts = opts } diff --git a/tests/unit/diff_inline_spec.lua b/tests/unit/diff_inline_spec.lua index 957f5e98..3e7c3c47 100644 --- a/tests/unit/diff_inline_spec.lua +++ b/tests/unit/diff_inline_spec.lua @@ -425,7 +425,7 @@ describe("Inline diff module", function() vim.api.nvim_del_autocmd = original_del end) - it("should close diff window in new-tab mode", function() + it("should keep the diff window open on cleanup (persistent buffer)", function() -- Simulate a new tab (tab 2) with a terminal window and an editor window local term_buf = vim.api.nvim_create_buf(false, true) local editor_buf = vim.api.nvim_create_buf(false, true) @@ -453,7 +453,7 @@ describe("Inline diff module", function() vim._tab_windows[new_tab] = { term_win, editor_win, diff_win } - -- Also have a window in tab 1 (original tab) to catch wrong-tab bugs + -- Also have a window in tab 1 (original tab) local old_tab = 1 local old_win = vim._next_winid vim._next_winid = vim._next_winid + 1 @@ -468,17 +468,19 @@ describe("Inline diff module", function() original_tab_number = old_tab, new_window = diff_win, new_buffer = diff_buf, + new_file_path = "test.lua", + new_file_contents = "a\nb\n", } diff_inline.cleanup_inline_diff("test_tab", diff_data) - -- Diff window should be closed, original tab window should remain - assert.is_nil(vim._windows[diff_win]) + -- Persistent buffer: the diff window stays open (never auto-closed), and + -- the original tab's window is untouched. + assert.is_not_nil(vim._windows[diff_win]) assert.is_not_nil(vim._windows[old_win]) end) - it("should close diff window when not in new tab", function() - -- Create a window for the diff + local function make_diff_window() local buf = vim.api.nvim_create_buf(false, true) local winid = vim._next_winid vim._next_winid = vim._next_winid + 1 @@ -487,18 +489,54 @@ describe("Inline diff module", function() local tab_wins = vim._tab_windows[vim._current_tabpage] or {} table.insert(tab_wins, winid) vim._tab_windows[vim._current_tabpage] = tab_wins + return buf, winid + end + + it("on accept keeps the window open and shows the proposed content", function() + local buf, winid = make_diff_window() local diff_data = { autocmd_ids = {}, created_new_tab = false, new_window = winid, new_buffer = buf, + new_file_path = "test.lua", + status = "saved", + new_file_contents = "hello\nworld\n", + old_file_contents = "hello\n", } diff_inline.cleanup_inline_diff("test_tab", diff_data) - -- Window should be closed - assert.is_nil(vim._windows[winid]) + -- Window stays open and shows the accepted (proposed) content. + assert.is_not_nil(vim._windows[winid]) + local lines = vim._buffers[buf].lines + assert.are.equal(2, #lines) + assert.are.equal("hello", lines[1]) + assert.are.equal("world", lines[2]) + end) + + it("on reject keeps the window open and shows the ORIGINAL content", function() + local buf, winid = make_diff_window() + + local diff_data = { + autocmd_ids = {}, + created_new_tab = false, + new_window = winid, + new_buffer = buf, + new_file_path = "test.lua", + status = "rejected", + new_file_contents = "hello\nworld\n", + old_file_contents = "hello\n", + } + + diff_inline.cleanup_inline_diff("test_tab", diff_data) + + -- Window stays open; the discarded change is gone — original content only. + assert.is_not_nil(vim._windows[winid]) + local lines = vim._buffers[buf].lines + assert.are.equal(1, #lines) + assert.are.equal("hello", lines[1]) end) end) end)