From abc483253da155f3f35d776b471b6612cefb6fe9 Mon Sep 17 00:00:00 2001 From: spoloxs Date: Tue, 7 Jul 2026 03:14:35 +0530 Subject: [PATCH 1/6] feat: add unified inline diff layout (ClaudeCodeBuffer) Introduce a VS Code-style unified inline diff: a single persistent, read-only buffer (ClaudeCodeBuffer) showing deleted lines in red/ strikethrough and added lines in green, interleaved in one pane. - Pure-Lua interleave via vim.text.diff/vim.diff (zero external deps, #169) - Single reused buffer (bufhidden=hide) kept open showing the reviewed file after accept/reject, so it survives window/tab closes - Tears down any prior unified diff in the same buffer before rendering, preventing stale diffs from accumulating (fixes #205) - Wires ClaudeCodeDiffOpened/ClaudeCodeClosed User autocmds for the unified layout (noted in #294) - Tracks originating client_id so close_diffs_for_client can tear the diff down on disconnect (parity with the native path, #261) - Dispatch in diff.lua branches on layout == 'unified' Implements the unified inline diff layout described in #294. --- lua/claudecode/diff.lua | 181 +++++++++++++++----- lua/claudecode/diff_inline.lua | 290 ++++++++++++++++++++++----------- 2 files changed, 340 insertions(+), 131 deletions(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 25b9cdad..94280885 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,28 @@ end local function display_terminal_in_new_tab() local original_tab = vim.api.nvim_get_current_tabpage() + local function get_or_create_diff_tab() + local tabs = vim.api.nvim_list_tabpages() + if #tabs >= 2 then + return tabs[2] + end + 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 +430,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 +447,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) @@ -1308,6 +1389,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 +1420,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 @@ -1515,18 +1603,42 @@ function M._setup_blocking_diff(params, resolution_callback) }) end) -- End of pcall + ---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. +---@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 + -- 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 + 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 +1755,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 +1763,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 +1916,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 +1931,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 +1944,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 +1958,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 +1969,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..9c026d7f 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -7,7 +7,41 @@ 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` +--- Name of the single persistent buffer used for every unified diff. It is reused +--- across diffs (cleared and re-rendered) instead of being wiped on accept/reject, +--- so it always stays open showing the file whose changes were reviewed. +local CLAUDE_BUFFER_NAME = "ClaudeCodeBuffer" + +---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 + -- Reuse: reset state so a fresh diff can be rendered into it. + 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 + 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` --- (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. ---@return function|nil @@ -118,7 +152,30 @@ 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. +--- Shared by the static and streaming renderers so the two stay consistent. +---@param buf number Buffer handle +---@param index number 1-based line index +---@param line_type string "added" | "deleted" | "unchanged" +local function apply_line_mark(buf, index, line_type) + if line_type == "added" then + 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 + 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 @@ -126,19 +183,7 @@ 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 - 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", - }) - end + apply_line_mark(buf, i, lt) end end @@ -229,6 +274,34 @@ end function M.setup_inline_diff(params, resolution_callback, config) local diff = require("claudecode.diff") + -- Hoist handles/resources so the caller can clean up on failure before state registration. + local original_tab_number = nil + local new_tab_handle = nil + local had_terminal_in_original = false + local created_new_tab = false + local terminal_win_in_new_tab = nil + local autocmd_ids = {} + local buf = nil + local partial_setup = { + new_buffer = nil, + original_tab_number = nil, + new_tab_number = nil, + had_terminal_in_original = false, + diff_window = nil, + fallback_window = nil, + autocmd_ids = autocmd_ids, + } + + -- Run a setup step, rolling back any unregistered resources on failure. + 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 + -- Version check: the diff function (vim.text.diff / vim.diff) requires Neovim >= 0.9.0 if not get_diff_fn() then error({ @@ -259,29 +332,48 @@ function M.setup_inline_diff(params, resolution_callback, config) -- Read old file content local old_text = "" if not is_new_file then - local f = io.open(params.old_file_path, "r") + local f, open_err = 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 for diff", + data = tostring(open_err or 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") + -- Use the single persistent ClaudeCodeBuffer (cleared + re-rendered per diff). + buf = guard(get_or_create_claudecode_buffer) - -- Render content + highlights + -- If a previous unified diff is still active in this buffer, tear it down first so + -- only one diff owns the shared buffer at a time. + local stale_names = {} + for name, d in pairs(diff._get_active_diffs()) do + if d.layout == "unified" and d.new_buffer == buf then + stale_names[#stale_names + 1] = name + end + end + for _, name in ipairs(stale_names) do + diff._cleanup_diff_state(name, "replaced by new diff") + end + + -- Render content + highlights (static full render). M.render_diff_buffer(buf, lines, line_types) vim.api.nvim_buf_set_option(buf, "modifiable", false) + partial_setup.new_buffer = buf -- Buffer metadata vim.b[buf].claudecode_diff_tab_name = tab_name @@ -294,34 +386,10 @@ function M.setup_inline_diff(params, resolution_callback, config) end -- 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) - 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 - ) + original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle = guard(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 @@ -335,45 +403,56 @@ function M.setup_inline_diff(params, resolution_callback, config) 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 function reuse_or_open_diff_window() + local diff_win = diff.get_or_create_editor_win_in_diff_tab() + if diff_win then + vim.api.nvim_set_current_win(diff_win) + end + return diff_win + end + 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] - end + local diff_win = reuse_or_open_diff_window() + + if diff_win then + vim.api.nvim_set_current_win(diff_win) + vim.api.nvim_win_set_buf(diff_win, buf) else - editor_win = diff._find_main_editor_window() - if not editor_win then - fallback_window = guard_inline_setup(create_fallback_editor_window) - partial_setup.fallback_window = fallback_window - editor_win = 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 + if not editor_win and #tab_wins > 0 then + editor_win = tab_wins[1] + end + else + editor_win = diff._find_main_editor_window() + if not editor_win then + fallback_window = guard(create_fallback_editor_window) + partial_setup.fallback_window = fallback_window + editor_win = fallback_window + end 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 - 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() + guard(function() vim.cmd("wincmd =") end) @@ -403,7 +482,7 @@ function M.setup_inline_diff(params, resolution_callback, config) end -- Restore terminal width after opening the split - guard_inline_setup(function() + guard(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 @@ -420,7 +499,7 @@ function M.setup_inline_diff(params, resolution_callback, config) end) -- Register autocmds and state with layout = "unified" - guard_inline_setup(function() + guard(function() local aug = diff._get_autocmd_group() autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWriteCmd", { @@ -541,18 +620,12 @@ function M.cleanup_inline_diff(tab_name, diff_data) -- Handle new-tab cleanup if diff_data.created_new_tab then + -- Keep tab 2 open: the persistent ClaudeCodeBuffer now shows the reviewed file + -- there. Just restore focus to the original tab per the user's workflow. 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 - 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) - 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 @@ -586,9 +659,38 @@ function M.cleanup_inline_diff(tab_name, diff_data) end end - -- Buffer might already be wiped by bufhidden=wipe when its window closed + -- Keep the fixed ClaudeCodeBuffer open: instead of wiping it, clear the diff + -- state and load the file whose changes were reviewed so it stays on screen. 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 }) + pcall(function() + local b = diff_data.new_buffer + vim.api.nvim_buf_set_option(b, "modifiable", true) + vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) + + local content_lines = {} + local f = io.open(diff_data.old_file_path, "r") + if f then + local text = f:read("*a") or "" + f:close() + content_lines = vim.split(text, "\n", { plain = true }) + if #content_lines > 0 and content_lines[#content_lines] == "" then + table.remove(content_lines, #content_lines) + end + end + vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines) + + -- Keep it as a persistent review buffer (no auto-wipe) showing the file. + 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 = diff._detect_filetype(diff_data.old_file_path) + if ft and ft ~= "" then + vim.api.nvim_set_option_value("filetype", ft, { buf = b }) + end + end) end logger.debug("diff", "Cleaned up inline diff for", tab_name) From c71ea4d42de62decb48f0671684bce6637c389e7 Mon Sep 17 00:00:00 2001 From: spoloxs Date: Tue, 7 Jul 2026 03:26:45 +0530 Subject: [PATCH 2/6] fix: address review issues in unified inline diff - Hoist format_error to module scope so the open_diff_blocking error path can use it (it was defined inside _setup_blocking_diff and raised a secondary error, obscuring the original failure). - Declare error_msg as local (was implicitly a global). - Stop reusing tabs[2] in get_or_create_diff_tab; always :tabnew so cleanup never closes an unrelated user tab (issue flagged by reviewer). - Rename the internal export get_or_create_editor_win_in_diff_tab -> _get_or_create_editor_win_in_diff_tab for API consistency. - Guard reuse_or_open_diff_window to only reuse a window in the current tabpage, avoiding hijacking/switching to an unrelated tab. --- lua/claudecode/diff.lua | 54 ++++++++++++++++------------------ lua/claudecode/diff_inline.lua | 20 ++++++++++--- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 94280885..81223a6f 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -397,10 +397,6 @@ local function display_terminal_in_new_tab() local original_tab = vim.api.nvim_get_current_tabpage() local function get_or_create_diff_tab() - local tabs = vim.api.nvim_list_tabpages() - if #tabs >= 2 then - return tabs[2] - end vim.cmd("tabnew") mark_tabnew_buffer_ephemeral() return vim.api.nvim_get_current_tabpage() @@ -1141,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 @@ -1603,31 +1623,9 @@ function M._setup_blocking_diff(params, resolution_callback) }) end) -- End of pcall - ---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. ----@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 - -- Handle setup errors if not setup_success then - error_msg = "Failed to setup diff operation: " .. format_error(setup_error) + 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) @@ -1969,7 +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 +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 9c026d7f..a8a16afe 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -404,11 +404,23 @@ function M.setup_inline_diff(params, resolution_callback, config) end local function reuse_or_open_diff_window() - local diff_win = diff.get_or_create_editor_win_in_diff_tab() - if diff_win then - vim.api.nvim_set_current_win(diff_win) + local diff_win = diff._get_or_create_editor_win_in_diff_tab() + if diff_win and vim.api.nvim_win_is_valid(diff_win) then + -- Only reuse a window that lives in the current tabpage, so we never hijack + -- or switch away from an unrelated user tab. + local in_current_tab = false + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if w == diff_win then + in_current_tab = true + break + end + end + if in_current_tab then + vim.api.nvim_set_current_win(diff_win) + return diff_win + end end - return diff_win + return nil end local editor_win From d08e7d0808bdacff1a5c7b77802d7fce3ef5a2dd Mon Sep 17 00:00:00 2001 From: spoloxs Date: Tue, 7 Jul 2026 04:13:18 +0530 Subject: [PATCH 3/6] refactor: simplify unified diff to always use persistent ClaudeCodeBuffer in current tab - The unified diff no longer auto-opens a window or creates tab 2. - prepares silently. - Added and for user-initiated buffer access (works in any tab, creates a vsplit if needed). - Cleanup closes any window showing and keeps the buffer alive showing the reviewed file. - Removed , , tab2 logic from this path; those remain for native diff. This eliminates the tab-3 confusion: the diff always lives in and the user decides where to view it. --- lua/claudecode/diff_inline.lua | 488 +++++++++++---------------------- 1 file changed, 164 insertions(+), 324 deletions(-) diff --git a/lua/claudecode/diff_inline.lua b/lua/claudecode/diff_inline.lua index a8a16afe..871ab78e 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -1,6 +1,6 @@ --- 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") @@ -12,13 +12,16 @@ local ns = vim.api.nvim_create_namespace("claudecode_inline_diff") --- so it always stays open showing the file whose changes were reviewed. local CLAUDE_BUFFER_NAME = "ClaudeCodeBuffer" +--- Bumped whenever a reveal starts or the diff is torn down, so any in-flight +--- streaming animation stops and does not mutate the buffer. +local reveal_gen = 0 + ---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 - -- Reuse: reset state so a fresh diff can be rendered into it. pcall(function() vim.api.nvim_buf_set_option(existing, "modifiable", true) vim.api.nvim_buf_clear_namespace(existing, ns, 0, -1) @@ -41,16 +44,13 @@ local function get_or_create_claudecode_buffer() return b end ----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. +---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 }) @@ -75,7 +75,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 @@ -85,7 +84,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) @@ -97,12 +95,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 @@ -113,14 +109,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" @@ -128,7 +122,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" @@ -155,7 +148,6 @@ end -- ── Rendering ───────────────────────────────────────────────────── --- Apply the add/delete highlight + sign extmark for a single line. ---- Shared by the static and streaming renderers so the two stay consistent. ---@param buf number Buffer handle ---@param index number 1-based line index ---@param line_type string "added" | "deleted" | "unchanged" @@ -181,36 +173,93 @@ end ---@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 ---- 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 -local function create_fallback_editor_window() - vim.cmd("rightbelow vsplit") - local fallback_win = vim.api.nvim_get_current_win() +---@param wpm number Words per minute +local function wpm_to_ms_per_char(wpm) + return (60.0 / wpm / 5.0) * 1000.0 +end - local ok, err = pcall(function() - local scratch_buf = vim.api.nvim_create_buf(false, true) - if scratch_buf == 0 then - error({ code = -32000, message = "Buffer creation failed", data = "Could not create fallback editor buffer" }) +--- 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) + + local full = table.concat(lines, "\n") + local len = #full + if len == 0 then + vim.api.nvim_buf_set_option(buf, "modifiable", false) + return + end + + reveal_gen = reveal_gen + 1 + local my_gen = reveal_gen + local buf_lines = { "" } + vim.api.nvim_buf_set_lines(buf, 0, -1, false, buf_lines) + local committed = 0 + local marked_count = 0 + + local function reveal_up_to(target_pos) + target_pos = math.min(len, target_pos) + if target_pos <= committed then + return end - vim.api.nvim_buf_set_option(scratch_buf, "bufhidden", "wipe") - vim.api.nvim_win_set_buf(fallback_win, scratch_buf) - end) + local delta = full:sub(committed + 1, target_pos) + committed = target_pos + + local parts = vim.split(delta, "\n", { plain = true }) + buf_lines[#buf_lines] = buf_lines[#buf_lines] .. parts[1] + for k = 2, #parts do + buf_lines[#buf_lines + 1] = parts[k] + end + + local tail_from = #buf_lines - #parts + 1 + vim.api.nvim_buf_set_lines(buf, tail_from - 1, -1, false, vim.list_slice(buf_lines, tail_from)) + + local complete_count + if committed >= len then + complete_count = (#buf_lines > 0 and buf_lines[#buf_lines] == "") and (#buf_lines - 1) or #buf_lines + else + complete_count = math.max(0, #buf_lines - 1) + end + complete_count = math.min(complete_count, #line_types) + + for i = marked_count + 1, complete_count do + apply_line_mark(buf, i, line_types[i]) + end + marked_count = complete_count + end - if not ok then - if fallback_win and vim.api.nvim_win_is_valid(fallback_win) then - pcall(vim.api.nvim_win_close, fallback_win, true) + local function tick() + if my_gen ~= reveal_gen then + return end - error(err) + if not vim.api.nvim_buf_is_valid(buf) or committed >= len then + vim.api.nvim_buf_set_option(buf, "modifiable", false) + return + end + + reveal_up_to(committed + chars_per_tick) + if committed >= len or not vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_set_option(buf, "modifiable", false) + return + end + + local wpm = math.random(400, 1000) + vim.defer_fn(tick, math.max(delay_floor_ms, math.floor(wpm_to_ms_per_char(wpm)))) end - return fallback_win + tick() end --- Clean up inline diff resources if setup fails before state registration. @@ -218,81 +267,35 @@ end ---@param partial table Partially created resources local function cleanup_unregistered_inline_setup(tab_name, partial) 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 - 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 - 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 without auto-opening a window. +--- Call `open_inline_diff()` to show the buffer wherever you want. ---@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") - -- Hoist handles/resources so the caller can clean up on failure before state registration. - local original_tab_number = nil - local new_tab_handle = nil - local had_terminal_in_original = false - local created_new_tab = false - local terminal_win_in_new_tab = nil local autocmd_ids = {} local buf = nil local partial_setup = { new_buffer = nil, - original_tab_number = nil, - new_tab_number = nil, - had_terminal_in_original = false, - diff_window = nil, - fallback_window = nil, autocmd_ids = autocmd_ids, } - -- Run a setup step, rolling back any unregistered resources on failure. local function guard(action) local results = { pcall(action) } if not results[1] then @@ -302,12 +305,11 @@ function M.setup_inline_diff(params, resolution_callback, config) return unpack(results, 2) end - -- Version check: the diff function (vim.text.diff / vim.diff) requires Neovim >= 0.9.0 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 @@ -317,212 +319,74 @@ function M.setup_inline_diff(params, resolution_callback, config) local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 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) 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, open_err = io.open(params.old_file_path, "r") + 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 for diff", - data = tostring(open_err or params.old_file_path), - }) + error({ code = -32000, message = "Failed to read original file", data = params.old_file_path }) end end - -- Compute diff 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), - }) + error({ code = -32000, message = "Failed to compute inline diff", data = tostring(lines) }) end - -- Use the single persistent ClaudeCodeBuffer (cleared + re-rendered per diff). buf = guard(get_or_create_claudecode_buffer) - -- If a previous unified diff is still active in this buffer, tear it down first so - -- only one diff owns the shared buffer at a time. - local stale_names = {} + -- Tear down any prior diff using this buffer for name, d in pairs(diff._get_active_diffs()) do if d.layout == "unified" and d.new_buffer == buf then - stale_names[#stale_names + 1] = name + diff._cleanup_diff_state(name, "replaced by new diff") end end - for _, name in ipairs(stale_names) do - diff._cleanup_diff_state(name, "replaced by new diff") - end - -- Render content + highlights (static full render). - M.render_diff_buffer(buf, lines, line_types) - vim.api.nvim_buf_set_option(buf, "modifiable", false) + -- Render (streaming or static) - no window opened + if config and config.diff_opts and config.diff_opts.stream_reveal then + vim.api.nvim_buf_set_option(buf, "modifiable", true) + 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 1, + }) + else + M.render_diff_buffer(buf, lines, line_types) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + end partial_setup.new_buffer = buf - -- 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 }) end - -- Handle new-tab mode - 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(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 - - local function reuse_or_open_diff_window() - local diff_win = diff._get_or_create_editor_win_in_diff_tab() - if diff_win and vim.api.nvim_win_is_valid(diff_win) then - -- Only reuse a window that lives in the current tabpage, so we never hijack - -- or switch away from an unrelated user tab. - local in_current_tab = false - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if w == diff_win then - in_current_tab = true - break - end - end - if in_current_tab then - vim.api.nvim_set_current_win(diff_win) - return diff_win - end - end - return nil - end - - local editor_win - local fallback_window - local diff_win = reuse_or_open_diff_window() - - if diff_win then - vim.api.nvim_set_current_win(diff_win) - vim.api.nvim_win_set_buf(diff_win, buf) - else - 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 - if not editor_win and #tab_wins > 0 then - editor_win = tab_wins[1] - end - else - editor_win = diff._find_main_editor_window() - if not editor_win then - fallback_window = guard(create_fallback_editor_window) - partial_setup.fallback_window = fallback_window - editor_win = fallback_window - end - 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 - - -- Configure window for sign column display - pcall(vim.api.nvim_set_option_value, "signcolumn", "yes", { win = diff_win }) - - -- Equalize window widths - guard(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 - end) - end - - -- Restore terminal width after opening the split - guard(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" + -- Register state (window opened later via open_inline_diff or autocmd) 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 autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd(ev, { group = aug, @@ -532,15 +396,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, new_buffer = buf, - new_window = diff_win, - target_window = editor_win, - fallback_window = fallback_window, lines = lines, line_types = line_types, is_new_file = is_new_file, @@ -550,135 +410,118 @@ 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) end --- ── Resolution functions ────────────────────────────────────────── +-- ── User commands ──────────────────────────────────────────────── + +---Open ClaudeCodeBuffer in the current tab. Creates a vsplit if not already shown. +---@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 + vim.notify("ClaudeCode: no diff buffer ready", vim.log.levels.INFO) + return + end + + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_buf(win) == buf then + vim.api.nvim_set_current_win(win) + return + end + end + + vim.cmd("rightbelow vsplit") + local 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 =") + + 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 +end + +---Close ClaudeCodeBuffer window if visible in current tab. +function M.close_inline_diff() + local buf = vim.fn.bufnr(CLAUDE_BUFFER_NAME) + if buf == -1 then + return + end + 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 + +-- ── 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") 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 }, - }, + content = { { type = "text", text = "DIFF_REJECTED" }, { type = "text", text = tab_name } }, } - diff_data.status = "rejected" diff_data.result_content = result - if diff_data.resolution_callback then diff_data.resolution_callback(result) end end --- ── Cleanup ─────────────────────────────────────────────────────── - --- Clean up an inline diff's state and UI. ---@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 + reveal_gen = reveal_gen + 1 -- Stop streaming 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 - -- Keep tab 2 open: the persistent ClaudeCodeBuffer now shows the reviewed file - -- there. Just restore focus to the original tab per the user's workflow. - 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 - - -- 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) - 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 + -- Close any window(s) showing the buffer + if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then + for _, win in ipairs(vim.fn.win_findbuf(diff_data.new_buffer)) do + pcall(vim.api.nvim_win_close, win, false) end end - -- Keep the fixed ClaudeCodeBuffer open: instead of wiping it, clear the diff - -- state and load the file whose changes were reviewed so it stays on screen. + -- Keep the ClaudeCodeBuffer open: load the reviewed file content after diff if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then pcall(function() local b = diff_data.new_buffer vim.api.nvim_buf_set_option(b, "modifiable", true) vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) - local content_lines = {} local f = io.open(diff_data.old_file_path, "r") if f then @@ -686,18 +529,15 @@ function M.cleanup_inline_diff(tab_name, diff_data) f:close() content_lines = vim.split(text, "\n", { plain = true }) if #content_lines > 0 and content_lines[#content_lines] == "" then - table.remove(content_lines, #content_lines) + table.remove(content_lines) end end vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines) - - -- Keep it as a persistent review buffer (no auto-wipe) showing the file. 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 = diff._detect_filetype(diff_data.old_file_path) if ft and ft ~= "" then vim.api.nvim_set_option_value("filetype", ft, { buf = b }) @@ -708,4 +548,4 @@ function M.cleanup_inline_diff(tab_name, diff_data) logger.debug("diff", "Cleaned up inline diff for", tab_name) end -return M +return M \ No newline at end of file From 955f6317fc8e88b2d998e38ad706e728d2441cb4 Mon Sep 17 00:00:00 2001 From: spoloxs Date: Thu, 9 Jul 2026 00:44:58 +0530 Subject: [PATCH 4/6] fix(diff): keep ClaudeCodeBuffer showing updated file; harden lifecycle Addresses review feedback and several lifecycle bugs in the unified inline diff (ClaudeCodeBuffer): - Never revert the buffer to the old file on resolve. cleanup now loads new_file_contents (the proposed change) so the buffer shows the updated file after both accept and reject, instead of stale old content (on accept Claude may not have written the file yet). Resolves the Copilot comment about reloading old_file_path for new files. - Do not close the buffer's window on resolve; the persistent buffer stays on screen in place (bufhidden="hide"). - Drop the WinClosed reject autocmd: with bufhidden="hide" closing/toggling the window must only hide it, never send DIFF_REJECTED. Rejection is explicit (ClaudeCodeDiffDeny) or via real buffer deletion (:bd/:bw). - On reject, resolve every queued diff as DIFF_REJECTED so their blocking openDiff MCP calls don't hang forever. - Reset current_diff_tab on setup failure so a failed setup can't wedge the queue behind a dead diff; set the pending marker only after a successful render, and guard rendering so failures run cleanup. - Cancel any in-flight streaming animation on resolve/cleanup so it can't clobber the reloaded file view. - Auto-scroll the diff window to the first changed line (centered) whenever the buffer is shown. Co-Authored-By: Claude Opus 4.8 (1M context) --- lua/claudecode/diff_inline.lua | 425 ++++++++++++++++++++++++--------- 1 file changed, 307 insertions(+), 118 deletions(-) diff --git a/lua/claudecode/diff_inline.lua b/lua/claudecode/diff_inline.lua index 871ab78e..23ee3c8e 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -7,15 +7,19 @@ local logger = require("claudecode.logger") local ns = vim.api.nvim_create_namespace("claudecode_inline_diff") ---- Name of the single persistent buffer used for every unified diff. It is reused ---- across diffs (cleared and re-rendered) instead of being wiped on accept/reject, ---- so it always stays open showing the file whose changes were reviewed. +--- Name of the single persistent buffer used for every unified diff. local CLAUDE_BUFFER_NAME = "ClaudeCodeBuffer" ---- Bumped whenever a reveal starts or the diff is torn down, so any in-flight ---- streaming animation stops and does not mutate the buffer. +--- 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 @@ -38,7 +42,7 @@ local function get_or_create_claudecode_buffer() if b == 0 then error({ code = -32000, message = "Buffer creation failed", data = "Could not create ClaudeCodeBuffer" }) end - vim.api.nvim_buf_set_name(b, CLAUDE_BUFFER_NAME) + 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 @@ -151,15 +155,20 @@ end ---@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 - vim.api.nvim_buf_set_extmark(buf, ns, index - 1, 0, { + 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 - vim.api.nvim_buf_set_extmark(buf, ns, index - 1, 0, { + return vim.api.nvim_buf_set_extmark(buf, ns, index - 1, 0, { line_hl_group = "ClaudeCodeInlineDiffDelete", sign_text = "-", sign_hl_group = "ClaudeCodeInlineDiffDeleteSign", @@ -184,7 +193,7 @@ local function wpm_to_ms_per_char(wpm) end --- Progressively reveal `lines` in `buf` with a natural "typing" cadence. ---- O(n) incremental, cancellable via `reveal_gen`. +---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 @@ -194,78 +203,122 @@ local function render_diff_buffer_streamed(buf, lines, line_types, opts) 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) - local full = table.concat(lines, "\n") - local len = #full - if len == 0 then - vim.api.nvim_buf_set_option(buf, "modifiable", false) - return - end + vim.api.nvim_buf_set_option(buf, "modifiable", true) reveal_gen = reveal_gen + 1 local my_gen = reveal_gen - local buf_lines = { "" } - vim.api.nvim_buf_set_lines(buf, 0, -1, false, buf_lines) - local committed = 0 - local marked_count = 0 - - local function reveal_up_to(target_pos) - target_pos = math.min(len, target_pos) - if target_pos <= committed then - return - end - local delta = full:sub(committed + 1, target_pos) - committed = target_pos + -- 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 + initial[i] = (lt == "unchanged") and lines[i] or "" + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial) - local parts = vim.split(delta, "\n", { plain = true }) - buf_lines[#buf_lines] = buf_lines[#buf_lines] .. parts[1] - for k = 2, #parts do - buf_lines[#buf_lines + 1] = parts[k] + -- 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 - local tail_from = #buf_lines - #parts + 1 - vim.api.nvim_buf_set_lines(buf, tail_from - 1, -1, false, vim.list_slice(buf_lines, tail_from)) - - local complete_count - if committed >= len then - complete_count = (#buf_lines > 0 and buf_lines[#buf_lines] == "") and (#buf_lines - 1) or #buf_lines - else - complete_count = math.max(0, #buf_lines - 1) + -- 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 - complete_count = math.min(complete_count, #line_types) + end - for i = marked_count + 1, complete_count do - apply_line_mark(buf, i, line_types[i]) + 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 - marked_count = complete_count + mark_ids[ci] = apply_line_mark(buf, ci, line_types[ci]) end - local function tick() + local function step() if my_gen ~= reveal_gen then return end - if not vim.api.nvim_buf_is_valid(buf) or committed >= len then - vim.api.nvim_buf_set_option(buf, "modifiable", false) + 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 - reveal_up_to(committed + chars_per_tick) - if committed >= len or not vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_set_option(buf, "modifiable", false) + 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(tick, math.max(delay_floor_ms, math.floor(wpm_to_ms_per_char(wpm)))) + vim.defer_fn(step, math.max(delay_floor_ms, math.floor(wpm_to_ms_per_char(wpm)))) end - tick() + 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 --- Clean up inline diff resources if setup fails before state registration. ---@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") @@ -316,11 +369,36 @@ function M.setup_inline_diff(params, resolution_callback, config) 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 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, @@ -347,32 +425,46 @@ function M.setup_inline_diff(params, resolution_callback, config) end buf = guard(get_or_create_claudecode_buffer) + partial_setup.new_buffer = buf + + -- Cancel any in-flight animation from a previous diff before rendering. + cancel_animation() - -- Tear down any prior diff using this buffer - for name, d in pairs(diff._get_active_diffs()) do - if d.layout == "unified" and d.new_buffer == buf then - diff._cleanup_diff_state(name, "replaced by new diff") + -- 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 + end) - -- Render (streaming or static) - no window opened - if config and config.diff_opts and config.diff_opts.stream_reveal then - vim.api.nvim_buf_set_option(buf, "modifiable", true) - 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 1, - }) - else - M.render_diff_buffer(buf, lines, line_types) - vim.api.nvim_buf_set_option(buf, "modifiable", false) - end - partial_setup.new_buffer = buf + -- 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 vim.b[buf].claudecode_diff_tab_name = tab_name vim.b[buf].claudecode_inline_diff = true - local ft = diff._detect_filetype(params.new_file_path) - if ft and ft ~= "" then + -- 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 + + 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 @@ -387,7 +479,12 @@ function M.setup_inline_diff(params, resolution_callback, config) 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, @@ -413,31 +510,49 @@ function M.setup_inline_diff(params, resolution_callback, config) client_id = params.client_id, }) end) + + -- If the buffer is already visible (e.g. a queued diff whose window was opened + -- before this setup ran), jump to the first change now. + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_buf(win) == buf then + scroll_to_first_change(buf, win) + break + end + end 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 - vim.notify("ClaudeCode: no diff buffer ready", vim.log.levels.INFO) - return + 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 - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_buf(win) == buf then - vim.api.nvim_set_current_win(win) - return + 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 - vim.cmd("rightbelow vsplit") - local 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 =") + 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") @@ -446,20 +561,29 @@ function M.open_inline_diff(tab_name) diff_data.new_window = win end end + + scroll_to_first_change(buf, win) end ----Close ClaudeCodeBuffer window if visible in current tab. -function M.close_inline_diff() +---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 then - return - end - 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 + 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 + +---Close the inline diff buffer window (alias for toggle for backwards compatibility). +function M.close_inline_diff() + M.toggle_inline_diff() end -- ── Resolution & Cleanup ───────────────────────────────────────── @@ -481,71 +605,136 @@ function M.resolve_inline_as_saved(tab_name, diff_data) if diff_data.resolution_callback then diff_data.resolution_callback(result) end + + -- 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. ---@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 = { + diff_data.status = "rejected" + diff_data.result_content = { content = { { type = "text", text = "DIFF_REJECTED" }, { type = "text", text = tab_name } }, } - diff_data.status = "rejected" - diff_data.result_content = result 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 + +--- 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") - reveal_gen = reveal_gen + 1 -- Stop streaming animation + -- 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 - -- Close any window(s) showing the buffer - if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then - for _, win in ipairs(vim.fn.win_findbuf(diff_data.new_buffer)) do - pcall(vim.api.nvim_win_close, win, false) - end - end - - -- Keep the ClaudeCodeBuffer open: load the reviewed file content after diff + -- Keep the ClaudeCodeBuffer open showing the UPDATED (proposed) file content — + -- never revert to the old file. `new_file_contents` is the change Claude proposed; + -- reading old_file_path here would show stale content (on accept Claude may not + -- have written the file yet). Applies to both accept and reject so the buffer + -- always displays the new version. (do NOT close the window) if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then pcall(function() local b = diff_data.new_buffer vim.api.nvim_buf_set_option(b, "modifiable", true) vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) - local content_lines = {} - local f = io.open(diff_data.old_file_path, "r") - if f then - local text = f:read("*a") or "" - f:close() - content_lines = vim.split(text, "\n", { plain = true }) - if #content_lines > 0 and content_lines[#content_lines] == "" then - table.remove(content_lines) - end - end + local content_lines = split_lines(diff_data.new_file_contents or "") vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines) 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 = diff._detect_filetype(diff_data.old_file_path) - if ft and ft ~= "" then + 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 + -- Clear current diff marker + current_diff_tab = nil + logger.debug("diff", "Cleaned up inline diff for", tab_name) end +--- 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 + + -- Open the window first (so the buffer appears immediately when we set up the diff) + M.open_inline_diff(next.params.tab_name) + + 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 From c22be987f05a46d337393c36a4b76e5c0d80c454 Mon Sep 17 00:00:00 2001 From: spoloxs Date: Thu, 9 Jul 2026 01:05:14 +0530 Subject: [PATCH 5/6] fix(diff): auto-open unified diff window; reject restores original content - Restore auto-opening the ClaudeCodeBuffer in a split in the current tab at setup (reusing an open window if present), with a scratch fallback window for terminal-only tabs. This re-satisfies the maintainer's unified-layout lifecycle tests (ClaudeCodeDiffOpened carries a valid diff_window/ target_window; terminal-only fallback_window; setup-failure cleanup), which the earlier "simplify" refactor had broken by removing window opening. - On resolve, keep the diff window open (persistent buffer) but close the throwaway scratch fallback window. - cleanup now shows plain content: proposed on accept, ORIGINAL on reject/ cancel (previously it left the proposed change on screen after a reject). Original text is captured at setup as old_file_contents. - Jump to the first changed line in the diff window. Tests: - Update cleanup_inline_diff specs to the persistent-buffer behavior (window stays open; accept shows proposed, reject shows original). - Add nvim_buf_clear_namespace / nvim_buf_del_extmark to the vim mock so the cleanup and streaming reanchor paths are exercised under busted. Co-Authored-By: Claude Opus 4.8 (1M context) --- lua/claudecode/diff_inline.lua | 114 ++++++++++++++++++++++++++------ tests/mocks/vim.lua | 28 ++++++++ tests/unit/diff_inline_spec.lua | 54 ++++++++++++--- 3 files changed, 168 insertions(+), 28 deletions(-) diff --git a/lua/claudecode/diff_inline.lua b/lua/claudecode/diff_inline.lua index 23ee3c8e..989d89ea 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -313,6 +313,32 @@ local function scroll_to_first_change(buf, win) end) end +--- 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() + + local ok, err = pcall(function() + local scratch_buf = vim.api.nvim_create_buf(false, true) + 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) + + if not ok then + if fallback_win and vim.api.nvim_win_is_valid(fallback_win) then + pcall(vim.api.nvim_win_close, fallback_win, true) + end + error(err) + end + + return fallback_win +end + --- Clean up inline diff resources if setup fails before state registration. ---@param tab_name string Diff identifier ---@param partial table Partially created resources @@ -327,6 +353,13 @@ local function cleanup_unregistered_inline_setup(tab_name, partial) for _, autocmd_id in ipairs(partial.autocmd_ids or {}) do pcall(vim.api.nvim_del_autocmd, autocmd_id) 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 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 @@ -334,8 +367,8 @@ end -- ── Setup ───────────────────────────────────────────────────────── ---- Prepare the unified diff in ClaudeCodeBuffer without auto-opening a window. ---- Call `open_inline_diff()` to show the buffer wherever you want. +--- 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 @@ -346,6 +379,8 @@ function M.setup_inline_diff(params, resolution_callback, config) local buf = nil local partial_setup = { new_buffer = nil, + diff_window = nil, + fallback_window = nil, autocmd_ids = autocmd_ids, } @@ -468,7 +503,37 @@ function M.setup_inline_diff(params, resolution_callback, config) vim.api.nvim_set_option_value("filetype", ft, { buf = buf }) end - -- Register state (window opened later via open_inline_diff or autocmd) + -- 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 + end + if not diff_win then + editor_win = diff._find_main_editor_window() + if not editor_win then + fallback_window = guard(create_fallback_editor_window) + partial_setup.fallback_window = fallback_window + editor_win = fallback_window + 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 =") + + -- Register state guard(function() local aug = diff._get_autocmd_group() autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWriteCmd", { @@ -497,7 +562,11 @@ function M.setup_inline_diff(params, resolution_callback, config) 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, + fallback_window = fallback_window, lines = lines, line_types = line_types, is_new_file = is_new_file, @@ -511,14 +580,8 @@ function M.setup_inline_diff(params, resolution_callback, config) }) end) - -- If the buffer is already visible (e.g. a queued diff whose window was opened - -- before this setup ran), jump to the first change now. - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_buf(win) == buf then - scroll_to_first_change(buf, win) - break - end - end + -- Jump to the first change in the diff window. + scroll_to_first_change(buf, diff_win) end -- ── User commands ──────────────────────────────────────────────── @@ -686,17 +749,29 @@ function M.cleanup_inline_diff(tab_name, diff_data) pcall(vim.api.nvim_del_autocmd, autocmd_id) end - -- Keep the ClaudeCodeBuffer open showing the UPDATED (proposed) file content — - -- never revert to the old file. `new_file_contents` is the change Claude proposed; - -- reading old_file_path here would show stale content (on accept Claude may not - -- have written the file yet). Applies to both accept and reject so the buffer - -- always displays the new version. (do NOT close the window) + -- 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 + + -- Keep the ClaudeCodeBuffer open, showing plain file content (no diff markup). + -- On ACCEPT show the proposed content (what gets written — reading old_file_path + -- here would be stale since Claude may not have written yet). On REJECT/cancel + -- show the ORIGINAL content so the discarded change is not left on screen. + -- (do NOT close the window) if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then pcall(function() local b = diff_data.new_buffer vim.api.nvim_buf_set_option(b, "modifiable", true) vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) - local content_lines = split_lines(diff_data.new_file_contents or "") + local content + if diff_data.status == "saved" then + content = diff_data.new_file_contents or "" + else + content = diff_data.old_file_contents or "" + end + local content_lines = split_lines(content) vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines) vim.api.nvim_buf_set_option(b, "buftype", "nofile") vim.api.nvim_buf_set_option(b, "bufhidden", "hide") @@ -724,9 +799,8 @@ function M._process_next_queued_diff() local next = table.remove(diff_queue, 1) if not next then return end - -- Open the window first (so the buffer appears immediately when we set up the diff) - M.open_inline_diff(next.params.tab_name) - + -- 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)) 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) From c80ec64f55f1fe77c49071ba0b170d1a066bca1c Mon Sep 17 00:00:00 2001 From: spoloxs Date: Thu, 9 Jul 2026 01:36:23 +0530 Subject: [PATCH 6/6] fix(diff): show accepted content when accept arrives as a pending close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Claude-side accept writes the file and then closes the still-pending diff; close_diff_by_tab_name treats a pending close as a reject, so status becomes "rejected" and the buffer showed the original — i.e. the accepted change did not appear. cleanup_inline_diff now reconciles from disk for the non-"saved" path: it shows the original immediately, then (after Claude's async write lands) reloads the file from disk. Disk is the source of truth — new content if the change was applied, original if it was genuinely rejected (file untouched). Explicit `:w` accepts (status "saved") still trust new_file_contents with no disk read, so they never show stale content. Co-Authored-By: Claude Opus 4.8 (1M context) --- lua/claudecode/diff_inline.lua | 51 ++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/lua/claudecode/diff_inline.lua b/lua/claudecode/diff_inline.lua index 989d89ea..9d148312 100644 --- a/lua/claudecode/diff_inline.lua +++ b/lua/claudecode/diff_inline.lua @@ -756,23 +756,23 @@ function M.cleanup_inline_diff(tab_name, diff_data) end -- Keep the ClaudeCodeBuffer open, showing plain file content (no diff markup). - -- On ACCEPT show the proposed content (what gets written — reading old_file_path - -- here would be stale since Claude may not have written yet). On REJECT/cancel - -- show the ORIGINAL content so the discarded change is not left on screen. + -- 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() - local b = diff_data.new_buffer vim.api.nvim_buf_set_option(b, "modifiable", true) vim.api.nvim_buf_clear_namespace(b, ns, 0, -1) - local content - if diff_data.status == "saved" then - content = diff_data.new_file_contents or "" - else - content = diff_data.old_file_contents or "" - end - local content_lines = split_lines(content) - vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines) + 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) @@ -783,6 +783,33 @@ function M.cleanup_inline_diff(tab_name, diff_data) vim.api.nvim_set_option_value("filetype", ft, { buf = b }) end end) + + -- 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 + 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 -- Clear current diff marker