diff --git a/CLAUDE.md b/CLAUDE.md index b396b7b..0298fc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,8 @@ require("claudecode").setup({ The `diff_opts` configuration allows you to customize diff behavior: - `keep_terminal_focus` (boolean, default: `false`) - When enabled, keeps focus in the Claude Code terminal when a diff opens instead of moving focus to the diff buffer. This allows you to continue using terminal keybindings like `` for accepting/rejecting diffs without accidentally triggering other mappings. +- `open_in_new_tab` (boolean, default: `false`) - Open diffs in a new tab instead of the current tab. +- `hide_terminal_in_new_tab` (boolean, default: `false`) - When opening diffs in a new tab, do not show the Claude terminal split in that new tab. The terminal remains in the original tab, giving maximum screen estate for reviewing the diff. **Example use case**: If you frequently use `` or arrow keys in the Claude Code terminal to accept/reject diffs, enable this option to prevent focus from moving to the diff buffer where `` might trigger unintended actions. @@ -296,6 +298,8 @@ The `diff_opts` configuration allows you to customize diff behavior: require("claudecode").setup({ diff_opts = { keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens + open_in_new_tab = true, -- Open diff in a separate tab + hide_terminal_in_new_tab = true, -- In the new tab, do not show Claude terminal auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, diff --git a/dev-config.lua b/dev-config.lua index d1a34bc..d4d84dc 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -40,29 +40,29 @@ return { }, -- Development configuration - all options shown with defaults commented out + ---@type ClaudeCodeConfig opts = { -- Server Configuration - -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range - -- auto_start = true, -- Auto-start server on Neovim startup - -- log_level = "info", -- "trace", "debug", "info", "warn", "error" - -- terminal_cmd = nil, -- Custom terminal command (default: "claude") + -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range + -- auto_start = true, -- Auto-start server on Neovim startup + -- log_level = "info", -- "trace", "debug", "info", "warn", "error" + -- terminal_cmd = nil, -- Custom terminal command (default: "claude") -- Selection Tracking - -- track_selection = true, -- Enable real-time selection tracking - -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) + -- track_selection = true, -- Enable real-time selection tracking + -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) -- Connection Management - -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) - -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) - -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) + -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) + -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) + -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) -- Diff Integration -- diff_opts = { - -- auto_close_on_accept = true, -- Close diff view after accepting changes - -- show_diff_stats = true, -- Show diff statistics - -- vertical_split = true, -- Use vertical split for diffs - -- open_in_current_tab = true, -- Open diffs in current tab vs new tab - -- keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens + -- layout = "horizontal", -- "vertical" or "horizontal" diff layout + -- open_in_new_tab = true, -- Open diff in a new tab (false = use current tab) + -- keep_terminal_focus = true, -- Keep focus in terminal after opening diff + -- hide_terminal_in_new_tab = true, -- Hide Claude terminal in the new diff tab for more review space -- }, -- Terminal Configuration diff --git a/fixtures/nvim-tree/lazy-lock.json b/fixtures/nvim-tree/lazy-lock.json index ebf5acd..aa68837 100644 --- a/fixtures/nvim-tree/lazy-lock.json +++ b/fixtures/nvim-tree/lazy-lock.json @@ -1,6 +1,6 @@ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, - "nvim-tree.lua": { "branch": "master", "commit": "0a7fcdf3f8ba208f4260988a198c77ec11748339" }, + "nvim-tree.lua": { "branch": "master", "commit": "0a52012d611f3c1492b8d2aba363fabf734de91d" }, "nvim-web-devicons": { "branch": "master", "commit": "3362099de3368aa620a8105b19ed04c2053e38c0" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 5676781..4826ad8 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -19,11 +19,10 @@ M.defaults = { connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds) queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds) diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = true, -- Use current tab instead of creating new tab + layout = "vertical", + open_in_new_tab = false, -- Open diff in a new tab (false = use current tab) keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens + hide_terminal_in_new_tab = false, -- If true and opening in a new tab, do not show Claude terminal there }, models = { { name = "Claude Opus 4.1 (Latest)", value = "opus" }, @@ -104,11 +103,16 @@ function M.validate(config) assert(type(config.queue_timeout) == "number" and config.queue_timeout > 0, "queue_timeout must be a positive number") assert(type(config.diff_opts) == "table", "diff_opts must be a table") - assert(type(config.diff_opts.auto_close_on_accept) == "boolean", "diff_opts.auto_close_on_accept must be a boolean") - assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean") - assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean") - assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean") + assert( + config.diff_opts.layout == "vertical" or config.diff_opts.layout == "horizontal", + "diff_opts.layout must be 'vertical' or 'horizontal'" + ) + assert(type(config.diff_opts.open_in_new_tab) == "boolean", "diff_opts.open_in_new_tab must be a boolean") assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean") + assert( + type(config.diff_opts.hide_terminal_in_new_tab) == "boolean", + "diff_opts.hide_terminal_in_new_tab must be a boolean" + ) -- Validate env assert(type(config.env) == "table", "env must be a table") diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 934ff89..58dd045 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -4,14 +4,34 @@ local M = {} local logger = require("claudecode.logger") --- Global state management for active diffs +-- Window options for terminal display (internal type, not exposed in public API) +---@class WindowOptions +---@field number boolean Show line numbers +---@field relativenumber boolean Show relative line numbers +---@field signcolumn string Sign column display mode +---@field statuscolumn string Status column format +---@field foldcolumn string Fold column width +---@field cursorline boolean Highlight cursor line +---@field cursorcolumn boolean Highlight cursor column +---@field colorcolumn string Columns to highlight +---@field cursorlineopt string Cursor line options +---@field spell boolean Enable spell checking +---@field list boolean Show invisible characters +---@field wrap boolean Wrap long lines +---@field linebreak boolean Break lines at word boundaries +---@field breakindent boolean Indent wrapped lines +---@field showbreak string String to show at line breaks +---@field scrolloff number Lines to keep above/below cursor +---@field sidescrolloff number Columns to keep left/right of cursor ---@type ClaudeCodeConfig local config ---@type number local autocmd_group ----Get or create the autocmd group + +---Get or create the autocmd group for diff operations +---@return number autocmd_group The autocmd group ID local function get_autocmd_group() if not autocmd_group then autocmd_group = vim.api.nvim_create_augroup("ClaudeCodeMCPDiff", { clear = true }) @@ -31,7 +51,6 @@ local function find_main_editor_window() local filetype = vim.api.nvim_buf_get_option(buf, "filetype") local win_config = vim.api.nvim_win_get_config(win) - -- Check if this is a suitable window local is_suitable = true -- Skip floating windows @@ -39,12 +58,10 @@ local function find_main_editor_window() is_suitable = false end - -- Skip special buffer types if is_suitable and (buftype == "terminal" or buftype == "prompt") then is_suitable = false end - -- Skip known sidebar filetypes if is_suitable and ( @@ -60,7 +77,6 @@ local function find_main_editor_window() is_suitable = false end - -- This looks like a main editor window if is_suitable then return win end @@ -87,7 +103,6 @@ local function find_claudecode_terminal_window() for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == terminal_bufnr then local win_config = vim.api.nvim_win_get_config(win) - -- Skip floating windows if not (win_config.relative and win_config.relative ~= "") then return win end @@ -97,6 +112,184 @@ local function find_claudecode_terminal_window() return nil 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 + -- Ensure the new window is created below the current one regardless of user 'splitbelow' setting + vim.cmd("belowright split") + else + -- Ensure the new window is created to the right of the current one regardless of user 'splitright' setting + vim.cmd("rightbelow vsplit") + end +end + +---Capture window-local options from a window +---@param win_id number Window ID to capture options from +---@return WindowOptions options Window options +local function capture_window_options(win_id) + local options = {} + + -- Display options + options.number = vim.api.nvim_get_option_value("number", { win = win_id }) + options.relativenumber = vim.api.nvim_get_option_value("relativenumber", { win = win_id }) + options.signcolumn = vim.api.nvim_get_option_value("signcolumn", { win = win_id }) + options.statuscolumn = vim.api.nvim_get_option_value("statuscolumn", { win = win_id }) + options.foldcolumn = vim.api.nvim_get_option_value("foldcolumn", { win = win_id }) + + -- Visual options + options.cursorline = vim.api.nvim_get_option_value("cursorline", { win = win_id }) + options.cursorcolumn = vim.api.nvim_get_option_value("cursorcolumn", { win = win_id }) + options.colorcolumn = vim.api.nvim_get_option_value("colorcolumn", { win = win_id }) + options.cursorlineopt = vim.api.nvim_get_option_value("cursorlineopt", { win = win_id }) + + -- Text options + options.spell = vim.api.nvim_get_option_value("spell", { win = win_id }) + options.list = vim.api.nvim_get_option_value("list", { win = win_id }) + options.wrap = vim.api.nvim_get_option_value("wrap", { win = win_id }) + options.linebreak = vim.api.nvim_get_option_value("linebreak", { win = win_id }) + options.breakindent = vim.api.nvim_get_option_value("breakindent", { win = win_id }) + options.showbreak = vim.api.nvim_get_option_value("showbreak", { win = win_id }) + + -- Scroll options + options.scrolloff = vim.api.nvim_get_option_value("scrolloff", { win = win_id }) + options.sidescrolloff = vim.api.nvim_get_option_value("sidescrolloff", { win = win_id }) + + return options +end + +---Apply window-local options to a window +---@param win_id number Window ID to apply options to +---@param options WindowOptions Window options to apply +local function apply_window_options(win_id, options) + for opt_name, opt_value in pairs(options) do + pcall(vim.api.nvim_set_option_value, opt_name, opt_value, { win = win_id }) + end +end + +---Get default terminal window options +---@return WindowOptions Default options for terminal windows +local function get_default_terminal_options() + return { + number = false, + relativenumber = false, + signcolumn = "no", + statuscolumn = "", + foldcolumn = "0", + cursorline = false, + cursorcolumn = false, + colorcolumn = "", + cursorlineopt = "both", + spell = false, + list = false, + wrap = true, + linebreak = false, + breakindent = false, + showbreak = "", + scrolloff = 0, + sidescrolloff = 0, + } +end + +---Display existing Claude Code terminal in new tab +---@return number original_tab The original tab number +---@return number? terminal_win Terminal window in new tab +---@return boolean had_terminal_in_original True if terminal was visible in original tab +---@return number? new_tab The handle of the newly created tab +local function display_terminal_in_new_tab() + local original_tab = vim.api.nvim_get_current_tabpage() + + -- Get existing terminal buffer + local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") + if not terminal_ok then + vim.cmd("tabnew") + local new_tab = vim.api.nvim_get_current_tabpage() + 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") + local new_tab = vim.api.nvim_get_current_tabpage() + return original_tab, nil, false, new_tab + end + + local existing_terminal_win = find_claudecode_terminal_window() + local had_terminal_in_original = existing_terminal_win ~= nil + local terminal_options + if existing_terminal_win then + terminal_options = capture_window_options(existing_terminal_win) + else + terminal_options = get_default_terminal_options() + end + + vim.cmd("tabnew") + local new_tab = vim.api.nvim_get_current_tabpage() + + -- Mark the initial, unnamed buffer in the new tab as ephemeral to avoid leaks + -- When this buffer gets hidden (replaced or tab closed), wipe it automatically. + local initial_buf = vim.api.nvim_get_current_buf() + local name_ok, initial_name = pcall(vim.api.nvim_buf_get_name, initial_buf) + local mod_ok, initial_modified = pcall(vim.api.nvim_buf_get_option, initial_buf, "modified") + local linecount_ok, initial_linecount = pcall(function() + return vim.api.nvim_buf_line_count(initial_buf) + end) + if name_ok and mod_ok and linecount_ok then + if (initial_name == nil or initial_name == "") and initial_modified == false and initial_linecount <= 1 then + pcall(vim.api.nvim_buf_set_option, initial_buf, "bufhidden", "wipe") + end + end + + local terminal_config = config.terminal or {} + local split_side = terminal_config.split_side or "right" + local split_width = terminal_config.split_width_percentage or 0.30 + + -- Optionally hide the Claude terminal in the new tab for more review space + local hide_in_new_tab = false + if config and config.diff_opts and type(config.diff_opts.hide_terminal_in_new_tab) == "boolean" then + hide_in_new_tab = config.diff_opts.hide_terminal_in_new_tab + end + + if hide_in_new_tab or not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then + -- Do not create a terminal split in the 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() + + if split_side == "left" then + vim.cmd("wincmd H") + else + vim.cmd("wincmd L") + end + + vim.api.nvim_win_set_buf(terminal_win, terminal_bufnr) + + apply_window_options(terminal_win, terminal_options) + + -- Set up autocmd to enter terminal mode when focusing this terminal window + vim.api.nvim_create_autocmd("BufEnter", { + buffer = terminal_bufnr, + group = get_autocmd_group(), + callback = function() + -- Only enter insert mode if we're in a terminal buffer and in normal mode + if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then + vim.cmd("startinsert") + end + end, + desc = "Auto-enter terminal mode when focusing Claude Code terminal", + }) + + local total_width = vim.o.columns + local terminal_width = math.floor(total_width * split_width) + vim.api.nvim_win_set_width(terminal_win, terminal_width) + + vim.cmd("wincmd " .. (split_side == "right" and "h" or "l")) + + return original_tab, terminal_win, had_terminal_in_original, new_tab +end + ---Check if a buffer has unsaved changes (is dirty). ---@param file_path string The file path to check ---@return boolean true if the buffer is dirty, false otherwise @@ -113,10 +306,10 @@ local function is_buffer_dirty(file_path) end ---Setup the diff module ----@param user_config ClaudeCodeConfig? The configuration passed from init.lua +---@param user_config ClaudeCodeConfig The configuration passed from init.lua function M.setup(user_config) -- Store the configuration for later use - config = user_config or {} + config = user_config end ---Open a diff view between two files @@ -225,6 +418,9 @@ local function cleanup_temp_file(tmp_file) end ---Detect filetype from a path or existing buffer (best-effort) +---@param path string The file path to detect filetype from +---@param buf number? Optional buffer number to check for filetype +---@return string? filetype The detected filetype or nil local function detect_filetype(path, buf) -- 1) Try Neovim's builtin matcher if available (>=0.10) if vim.filetype and type(vim.filetype.match) == "function" then @@ -269,6 +465,170 @@ local function detect_filetype(path, buf) return simple_map[ext] end +---Decide whether to reuse the target window or split for the original file +---@param target_win NvimWin +---@param old_file_path string +---@param is_new_file boolean +---@param terminal_win_in_new_tab NvimWin|nil +---@return DiffWindowChoice +local function choose_original_window(target_win, old_file_path, is_new_file, terminal_win_in_new_tab) + local in_new_tab = terminal_win_in_new_tab ~= nil + local current_buf = vim.api.nvim_win_get_buf(target_win) + local current_buf_path = vim.api.nvim_buf_get_name(current_buf) + local is_empty_buffer = current_buf_path == "" and vim.api.nvim_buf_get_option(current_buf, "modified") == false + + if in_new_tab then + return { + decision = "reuse", + original_win = target_win, + reused_buf = current_buf, + in_new_tab = true, + } + end + + if is_new_file then + if is_empty_buffer then + return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false } + else + return { decision = "split", original_win = target_win, reused_buf = nil, in_new_tab = false } + end + end + + if current_buf_path == old_file_path then + return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false } + elseif is_empty_buffer then + return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false } + else + return { decision = "split", original_win = target_win, reused_buf = nil, in_new_tab = false } + end +end + +---Ensure the original window displays the proper buffer for the diff +---@param original_win NvimWin +---@param old_file_path string +---@param is_new_file boolean +---@param existing_buffer NvimBuf|nil +---@return NvimBuf original_buf +local function load_original_buffer(original_win, old_file_path, is_new_file, existing_buffer) + if is_new_file then + local empty_buffer = vim.api.nvim_create_buf(false, true) + if not empty_buffer or empty_buffer == 0 then + local error_msg = "Failed to create empty buffer for new file diff" + logger.error("diff", error_msg) + error({ code = -32000, message = "Buffer creation failed", data = error_msg }) + end + + local ok, err = pcall(function() + vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)") + vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {}) + vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile") + vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false) + vim.api.nvim_buf_set_option(empty_buffer, "readonly", true) + end) + + if not ok then + pcall(vim.api.nvim_buf_delete, empty_buffer, { force = true }) + local error_msg = "Failed to configure empty buffer: " .. tostring(err) + logger.error("diff", error_msg) + error({ code = -32000, message = "Buffer configuration failed", data = error_msg }) + end + + vim.api.nvim_win_set_buf(original_win, empty_buffer) + return empty_buffer + end + + if existing_buffer and vim.api.nvim_buf_is_valid(existing_buffer) then + vim.api.nvim_win_set_buf(original_win, existing_buffer) + return existing_buffer + end + + vim.api.nvim_set_current_win(original_win) + vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) + return vim.api.nvim_win_get_buf(original_win) +end + +---Create the proposed side split, set diff, filetype, context, and terminal focus/width +---@param original_win NvimWin +---@param original_buf NvimBuf +---@param new_buf NvimBuf +---@param old_file_path string +---@param tab_name string +---@param terminal_win_in_new_tab NvimWin|nil +---@param target_win_for_meta NvimWin +---@return NvimWin new_win +local function setup_new_buffer( + original_win, + original_buf, + new_buf, + old_file_path, + tab_name, + terminal_win_in_new_tab, + target_win_for_meta +) + vim.api.nvim_set_current_win(original_win) + vim.cmd("diffthis") + + create_split() + local new_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(new_win, new_buf) + + local original_ft = detect_filetype(old_file_path, original_buf) + if original_ft and original_ft ~= "" then + vim.api.nvim_set_option_value("filetype", original_ft, { buf = new_buf }) + end + vim.cmd("diffthis") + + vim.cmd("wincmd =") + + vim.api.nvim_set_current_win(new_win) + + vim.b[new_buf].claudecode_diff_tab_name = tab_name + vim.b[new_buf].claudecode_diff_new_win = new_win + vim.b[new_buf].claudecode_diff_target_win = target_win_for_meta + + 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 = find_claudecode_terminal_window() + if terminal_win then + vim.api.nvim_set_current_win(terminal_win) + vim.cmd("startinsert") + end + end) + end + + 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) + else + local terminal_win = find_claudecode_terminal_window() + if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then + local current_tab = vim.api.nvim_get_current_tabpage() + local term_tab = nil + pcall(function() + term_tab = vim.api.nvim_win_get_tabpage(terminal_win) + end) + if term_tab == current_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) + pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width) + end + end + end + + return new_win +end + ---Open diff using native Neovim functionality ---@param old_file_path string Path to the original file ---@param new_file_path string Path to the new file (used for naming) @@ -293,13 +653,13 @@ function M._open_native_diff(old_file_path, new_file_path, new_file_contents, ta local buftype = vim.api.nvim_buf_get_option(buf, "buftype") if buftype == "terminal" or buftype == "nofile" then - vim.cmd("vsplit") + create_split() end end vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) vim.cmd("diffthis") - vim.cmd("vsplit") + create_split() vim.cmd("edit " .. vim.fn.fnameescape(tmp_file)) vim.api.nvim_buf_set_name(0, new_file_path .. " (New)") @@ -356,7 +716,7 @@ function M._resolve_diff_as_saved(tab_name, buffer_id) return end - logger.debug("diff", "Resolving diff as saved for", tab_name, "from buffer", buffer_id) + logger.debug("diff", "Accepting diff for", tab_name) -- Get content from buffer local content_lines = vim.api.nvim_buf_get_lines(buffer_id, 0, -1, false) @@ -366,14 +726,7 @@ function M._resolve_diff_as_saved(tab_name, buffer_id) final_content = final_content .. "\n" end - -- Close diff windows (unified behavior) - if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then - vim.api.nvim_win_close(diff_data.new_window, true) - end - if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then - vim.api.nvim_set_current_win(diff_data.target_window) - vim.cmd("diffoff") - end + -- Do not modify windows/tabs here; wait for explicit close_tab tool call to clean up UI -- Create MCP-compliant response local result = { @@ -388,29 +741,19 @@ function M._resolve_diff_as_saved(tab_name, buffer_id) -- Resume the coroutine with the result (for deferred response system) if diff_data.resolution_callback then - logger.debug("diff", "Resuming coroutine for saved diff", tab_name) diff_data.resolution_callback(result) else logger.debug("diff", "No resolution callback found for saved diff", tab_name) end - -- Reload the original file buffer after a delay to ensure Claude CLI has written the file - vim.defer_fn(function() - local current_diff_data = active_diffs[tab_name] - local original_cursor_pos = current_diff_data and current_diff_data.original_cursor_pos - M.reload_file_buffers_manual(diff_data.old_file_path, original_cursor_pos) - end, 200) - - -- NOTE: Diff state cleanup is handled by close_tab tool or explicit cleanup calls - logger.debug("diff", "Diff saved, awaiting close_tab command for cleanup") + -- NOTE: Diff state cleanup is handled exclusively by the close_tab tool call + logger.debug("diff", "Diff saved; awaiting close_tab for cleanup") end ----Reload file buffers after external changes (called when diff is closed) +---Reload file buffers after external changes ---@param file_path string Path to the file that was externally modified ---@param original_cursor_pos table? Original cursor position to restore {row, col} local function reload_file_buffers(file_path, original_cursor_pos) - logger.debug("diff", "Reloading buffers for file:", file_path, original_cursor_pos and "(restoring cursor)" or "") - local reloaded_count = 0 -- Find and reload any open buffers for this file for _, buf in ipairs(vim.api.nvim_list_bufs()) do @@ -421,8 +764,6 @@ local function reload_file_buffers(file_path, original_cursor_pos) if buf_name == file_path then -- Check if buffer is modified - only reload unmodified buffers for safety local modified = vim.api.nvim_buf_get_option(buf, "modified") - logger.debug("diff", "Found matching buffer", buf, "modified:", modified) - if not modified then -- Try to find a window displaying this buffer for proper context local win_id = nil @@ -452,8 +793,6 @@ local function reload_file_buffers(file_path, original_cursor_pos) end end end - - logger.debug("diff", "Completed buffer reload - reloaded", reloaded_count, "buffers for file:", file_path) end ---Resolve diff as rejected (user closed/rejected) @@ -475,23 +814,17 @@ function M._resolve_diff_as_rejected(tab_name) diff_data.status = "rejected" diff_data.result_content = result - -- Clean up diff state and resources BEFORE resolving to prevent any interference - M._cleanup_diff_state(tab_name, "diff rejected") - - -- Use vim.schedule to ensure the resolution callback happens after all cleanup - vim.schedule(function() - -- Resume the coroutine with the result (for deferred response system) - if diff_data.resolution_callback then - logger.debug("diff", "Resuming coroutine for rejected diff", tab_name) - diff_data.resolution_callback(result) - end - end) + -- Do not perform UI cleanup here; wait for explicit close_tab tool call. + -- Resume the coroutine with the result (for deferred response system) + if diff_data.resolution_callback then + diff_data.resolution_callback(result) + end end ---Register autocmds for a specific diff ---@param tab_name string The diff identifier ---@param new_buffer number New file buffer ID ----@return table autocmd_ids List of autocmd IDs +---@return table List of autocmd IDs local function register_diff_autocmds(tab_name, new_buffer) local autocmd_ids = {} @@ -500,7 +833,6 @@ local function register_diff_autocmds(tab_name, new_buffer) group = get_autocmd_group(), buffer = new_buffer, callback = function() - logger.debug("diff", "BufWriteCmd (:w) triggered - accepting diff changes for", tab_name) M._resolve_diff_as_saved(tab_name, new_buffer) -- Prevent actual file write since we're handling it through MCP return true @@ -514,7 +846,6 @@ local function register_diff_autocmds(tab_name, new_buffer) group = get_autocmd_group(), buffer = new_buffer, callback = function() - logger.debug("diff", "BufDelete triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) @@ -524,7 +855,6 @@ local function register_diff_autocmds(tab_name, new_buffer) group = get_autocmd_group(), buffer = new_buffer, callback = function() - logger.debug("diff", "BufUnload triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) @@ -534,7 +864,6 @@ local function register_diff_autocmds(tab_name, new_buffer) group = get_autocmd_group(), buffer = new_buffer, callback = function() - logger.debug("diff", "BufWipeout triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) @@ -546,109 +875,75 @@ local function register_diff_autocmds(tab_name, new_buffer) end ---Create diff view from a specific window ----@param target_window number The window to use as base for the diff +---@param target_window NvimWin|nil The window to use as base for the diff ---@param old_file_path string Path to the original file ----@param new_buffer number New file buffer ID +---@param new_buffer NvimBuf New file buffer ID ---@param tab_name string The diff identifier ---@param is_new_file boolean Whether this is a new file (doesn't exist yet) ----@return table layout Info about the created diff layout -function M._create_diff_view_from_window(target_window, old_file_path, new_buffer, tab_name, is_new_file) +---@param terminal_win_in_new_tab NvimWin|nil Terminal window in new tab if created +---@param existing_buffer NvimBuf|nil Existing buffer for the file if already loaded +---@return DiffLayoutInfo layout Info about the created diff layout +function M._create_diff_view_from_window( + target_window, + old_file_path, + new_buffer, + tab_name, + is_new_file, + terminal_win_in_new_tab, + existing_buffer +) -- If no target window provided, create a new window in suitable location if not target_window then - -- Try to create a new window in the main area - vim.cmd("wincmd t") -- Go to top-left - vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right) + -- If we have a terminal window in the new tab, we're already positioned correctly + if terminal_win_in_new_tab then + -- We're already in the main area after display_terminal_in_new_tab + target_window = vim.api.nvim_get_current_win() + else + -- Try to create a new window in the main area + vim.cmd("wincmd t") -- Go to top-left + vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right) - local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) - local buftype = vim.api.nvim_buf_get_option(buf, "buftype") - local filetype = vim.api.nvim_buf_get_option(buf, "filetype") + local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) + local buftype = vim.api.nvim_buf_get_option(buf, "buftype") + local filetype = vim.api.nvim_buf_get_option(buf, "filetype") - if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then - vim.cmd("vsplit") - end + if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then + create_split() + end - target_window = vim.api.nvim_get_current_win() + target_window = vim.api.nvim_get_current_win() + end else vim.api.nvim_set_current_win(target_window) end - local original_buffer - if is_new_file then - local empty_buffer = vim.api.nvim_create_buf(false, true) - if not empty_buffer or empty_buffer == 0 then - local error_msg = "Failed to create empty buffer for new file diff" - logger.error("diff", error_msg) - error({ - code = -32000, - message = "Buffer creation failed", - data = error_msg, - }) - end - - -- Set buffer properties with error handling - local success, err = pcall(function() - vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)") - vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {}) - vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile") - vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false) - vim.api.nvim_buf_set_option(empty_buffer, "readonly", true) - end) + -- Decide window placement for the original file + local choice = choose_original_window(target_window, old_file_path, is_new_file, terminal_win_in_new_tab) - if not success then - pcall(vim.api.nvim_buf_delete, empty_buffer, { force = true }) - local error_msg = "Failed to configure empty buffer: " .. tostring(err) - logger.error("diff", error_msg) - error({ - code = -32000, - message = "Buffer configuration failed", - data = error_msg, - }) - end - - vim.api.nvim_win_set_buf(target_window, empty_buffer) - original_buffer = empty_buffer + local original_window + if choice.decision == "split" then + vim.api.nvim_set_current_win(target_window) + create_split() + original_window = vim.api.nvim_get_current_win() else - vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) - original_buffer = vim.api.nvim_win_get_buf(target_window) - end - - vim.cmd("diffthis") - - vim.cmd("vsplit") - local new_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(new_win, new_buffer) - - -- Ensure new buffer inherits filetype from original for syntax highlighting (#20) - local original_ft = detect_filetype(old_file_path, original_buffer) - if original_ft and original_ft ~= "" then - vim.api.nvim_set_option_value("filetype", original_ft, { buf = new_buffer }) + original_window = choice.original_win end - vim.cmd("diffthis") - - vim.cmd("wincmd =") - - -- Always focus the diff window first for proper visual flow and window arrangement - vim.api.nvim_set_current_win(new_win) - -- Store diff context in buffer variables for user commands - vim.b[new_buffer].claudecode_diff_tab_name = tab_name - vim.b[new_buffer].claudecode_diff_new_win = new_win - vim.b[new_buffer].claudecode_diff_target_win = target_window + local original_buffer = load_original_buffer(original_window, old_file_path, is_new_file, existing_buffer) - -- After all diff setup is complete, optionally return focus to terminal - if config and config.diff_opts and config.diff_opts.keep_terminal_focus then - vim.schedule(function() - local terminal_win = find_claudecode_terminal_window() - if terminal_win then - vim.api.nvim_set_current_win(terminal_win) - end - end) - end + local new_win = setup_new_buffer( + original_window, + original_buffer, + new_buffer, + old_file_path, + tab_name, + terminal_win_in_new_tab, + target_window + ) - -- Return window information for later storage return { new_window = new_win, - target_window = target_window, + target_window = original_window, original_buffer = original_buffer, } end @@ -667,32 +962,87 @@ function M._cleanup_diff_state(tab_name, reason) pcall(vim.api.nvim_del_autocmd, autocmd_id) end - -- Clean up the new buffer only (not the old buffer which is the user's file) - 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 }) + -- Clean up new tab if we created one (do this first to avoid double cleanup) + if diff_data.created_new_tab then + -- Always switch to the original tab first (if valid) + 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 + + -- Prefer closing the specific new tab we created, if we tracked its handle/number + if diff_data.new_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.new_tab_number) then + -- Prefer closing by switching to the new tab then executing :tabclose + pcall(vim.api.nvim_set_current_tabpage, diff_data.new_tab_number) + pcall(vim.cmd, "tabclose") + -- Restore original tab focus if still valid + 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 + else + -- Fallback: close the previously current tab if it's still around and not the original + local current_tab = vim.api.nvim_get_current_tabpage() + if vim.api.nvim_tabpage_is_valid(current_tab) and current_tab ~= diff_data.original_tab_number then + pcall(vim.cmd, "tabclose " .. vim.api.nvim_tabpage_get_number(current_tab)) + end + end + + -- Optionally ensure the Claude 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) + -- And restore its configured width if it is visible + local terminal_win = find_claudecode_terminal_window() + if terminal_win and vim.api.nvim_win_is_valid(terminal_win) 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) + pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width) + end + end + else + -- Close new diff window if still open (only if not in a new tab) + 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 + + -- Turn off diff mode in target window if it still exists + if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then + vim.api.nvim_win_call(diff_data.target_window, function() + vim.cmd("diffoff") + end) + end + + -- After closing the diff in the same tab, restore terminal width if visible + local terminal_win = find_claudecode_terminal_window() + if terminal_win and vim.api.nvim_win_is_valid(terminal_win) 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) + pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width) + end end - -- Close new diff window if still open - 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) + -- ALWAYS clean up buffers regardless of tab mode (fixes buffer leak) + -- Clean up the new buffer (proposed changes) + if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then + pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true }) end - -- Turn off diff mode in target window if it still exists - if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then - vim.api.nvim_win_call(diff_data.target_window, function() - vim.cmd("diffoff") - end) + -- Clean up the original buffer if it was created for a new file + if diff_data.is_new_file and diff_data.original_buffer and vim.api.nvim_buf_is_valid(diff_data.original_buffer) then + pcall(vim.api.nvim_buf_delete, diff_data.original_buffer, { force = true }) end -- Remove from active diffs active_diffs[tab_name] = nil - logger.debug("diff", "Cleaned up diff state for", tab_name, "due to:", reason) + logger.debug("diff", "Cleaned up diff for", tab_name) end ---Clean up all active diffs ---@param reason string Reason for cleanup --- NOTE: This will become a public closeAllDiffTabs tool in the future function M._cleanup_all_active_diffs(reason) for tab_name, _ in pairs(active_diffs) do M._cleanup_diff_state(tab_name, reason) @@ -708,11 +1058,9 @@ function M._setup_blocking_diff(params, resolution_callback) -- Wrap the setup in error handling to ensure cleanup on failure local setup_success, setup_error = pcall(function() - -- Step 1: Check if the file exists (allow new files) local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 local is_new_file = not old_file_exists - -- Step 1.5: Check if the file buffer has unsaved changes if old_file_exists then local is_dirty = is_buffer_dirty(params.old_file_path) if is_dirty then @@ -724,39 +1072,58 @@ function M._setup_blocking_diff(params, resolution_callback) end end - -- Step 2: Find if the file is already open in a buffer (only for existing files) + local original_tab_number = vim.api.nvim_get_current_tabpage() + local created_new_tab = false + local terminal_win_in_new_tab = nil local existing_buffer = nil local target_window = nil + -- Track new tab handle and original terminal visibility for robust cleanup + local new_tab_handle = nil + local had_terminal_in_original = false + + 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 = + display_terminal_in_new_tab() + created_new_tab = true + + -- In new tab, no existing windows to use, so target_window will be created + target_window = nil + existing_buffer = nil + -- Track extra metadata about terminal/tab for robust cleanup + M._last_had_terminal_in_original = had_terminal_in_original -- for debugging + M._last_new_tab_number = new_tab_handle -- for debugging + end - if old_file_exists then - -- Look for existing buffer with this file - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then - local buf_name = vim.api.nvim_buf_get_name(buf) - if buf_name == params.old_file_path then - existing_buffer = buf - break + -- Only look for existing windows if we're NOT in a new tab + if not created_new_tab then + if old_file_exists then + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then + local buf_name = vim.api.nvim_buf_get_name(buf) + if buf_name == params.old_file_path then + existing_buffer = buf + break + end end end - end - -- Find window containing this buffer (if any) - if existing_buffer then - for _, win in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_buf(win) == existing_buffer then - target_window = win - break + if existing_buffer then + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == existing_buffer then + target_window = win + break + end end end end - end - -- If no existing buffer/window, find a suitable main editor window - if not target_window then - target_window = find_main_editor_window() + if not target_window then + target_window = find_main_editor_window() + end end - -- If we still can't find a suitable window, error out - if not target_window then + -- If created_new_tab is true, target_window stays nil and will be created in the new tab + -- If we still can't find a suitable window AND we're not in a new tab, error out + if not target_window and not created_new_tab then error({ code = -32000, message = "No suitable editor window found", @@ -764,7 +1131,6 @@ function M._setup_blocking_diff(params, resolution_callback) }) end - -- Step 3: Create scratch buffer for new content local new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch if new_buffer == 0 then error({ @@ -786,16 +1152,18 @@ function M._setup_blocking_diff(params, resolution_callback) vim.api.nvim_buf_set_option(new_buffer, "buftype", "acwrite") -- Allows saving but stays as scratch-like vim.api.nvim_buf_set_option(new_buffer, "modifiable", true) - -- Step 4: Set up diff view using the target window - local diff_info = - M._create_diff_view_from_window(target_window, params.old_file_path, new_buffer, tab_name, is_new_file) + local diff_info = M._create_diff_view_from_window( + target_window, + params.old_file_path, + new_buffer, + tab_name, + is_new_file, + terminal_win_in_new_tab, + existing_buffer + ) - -- Step 5: Register autocmds for user interaction monitoring local autocmd_ids = register_diff_autocmds(tab_name, new_buffer) - -- Step 6: Store diff state - - -- Save the original cursor position before storing diff state local original_cursor_pos = nil if diff_info.target_window and vim.api.nvim_win_is_valid(diff_info.target_window) then original_cursor_pos = vim.api.nvim_win_get_cursor(diff_info.target_window) @@ -810,6 +1178,11 @@ function M._setup_blocking_diff(params, resolution_callback) target_window = diff_info.target_window, original_buffer = diff_info.original_buffer, original_cursor_pos = original_cursor_pos, + 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, autocmd_ids = autocmd_ids, created_at = vim.fn.localtime(), status = "pending", @@ -931,8 +1304,6 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t -- Yield and wait indefinitely for user interaction - the resolve functions will resume us local user_action_result = coroutine.yield() - logger.debug("diff", "User action completed for", tab_name) - -- Return the result directly - this will be sent by the deferred response system return user_action_result end @@ -967,21 +1338,33 @@ function M.close_diff_by_tab_name(tab_name) return true end - -- If still pending, treat as rejection + -- If the diff was already rejected, just clean up now + if diff_data.status == "rejected" then + M._cleanup_diff_state(tab_name, "diff tab closed after reject") + return true + end + + -- If still pending, treat as rejection and clean up if diff_data.status == "pending" then + -- Mark as rejected and then clean up UI state now that we received explicit close request M._resolve_diff_as_rejected(tab_name) + M._cleanup_diff_state(tab_name, "diff tab closed after reject") return true end return false end ---Test helper function (only for testing) +---Test helper function (only for testing) +---@return table active_diffs The active diffs table function M._get_active_diffs() return active_diffs end ---Manual buffer reload function for testing/debugging +---Manual buffer reload function for testing/debugging +---@param file_path string Path to the file to reload +---@param original_cursor_pos table? Original cursor position {row, col} +---@return nil function M.reload_file_buffers_manual(file_path, original_cursor_pos) return reload_file_buffers(file_path, original_cursor_pos) end @@ -1005,24 +1388,29 @@ end function M.deny_current_diff() local current_buffer = vim.api.nvim_get_current_buf() local tab_name = vim.b[current_buffer].claudecode_diff_tab_name - local new_win = vim.b[current_buffer].claudecode_diff_new_win - local target_window = vim.b[current_buffer].claudecode_diff_target_win if not tab_name then vim.notify("No active diff found in current buffer", vim.log.levels.WARN) return end - -- Close windows and clean up (same logic as the original keymap) - if new_win and vim.api.nvim_win_is_valid(new_win) then - vim.api.nvim_win_close(new_win, true) - end - if target_window and vim.api.nvim_win_is_valid(target_window) then - vim.api.nvim_set_current_win(target_window) - vim.cmd("diffoff") - end - + -- Do not close windows/tabs here; just mark as rejected. M._resolve_diff_as_rejected(tab_name) end return M +---@alias NvimWin integer +---@alias NvimBuf integer + +---@alias DiffWindowDecision "reuse"|"split" + +---@class DiffLayoutInfo +---@field new_window NvimWin +---@field target_window NvimWin +---@field original_buffer NvimBuf + +---@class DiffWindowChoice +---@field decision DiffWindowDecision +---@field original_win NvimWin +---@field reused_buf NvimBuf|nil +---@field in_new_tab boolean diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 2dad779..1224cb2 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -1,3 +1,4 @@ +---@meta ---@brief [[ --- Centralized type definitions for ClaudeCode.nvim public API. --- This module contains all user-facing types and configuration structures. @@ -14,11 +15,10 @@ -- Diff behavior configuration ---@class ClaudeCodeDiffOptions ----@field auto_close_on_accept boolean ----@field show_diff_stats boolean ----@field vertical_split boolean ----@field open_in_current_tab boolean ----@field keep_terminal_focus boolean +---@field layout ClaudeCodeDiffLayout +---@field open_in_new_tab boolean Open diff in a new tab (false = use current tab) +---@field keep_terminal_focus boolean Keep focus in terminal after opening diff +---@field hide_terminal_in_new_tab boolean Hide Claude terminal in newly created diff tab -- Model selection option ---@class ClaudeCodeModelOption @@ -28,6 +28,9 @@ -- Log level type alias ---@alias ClaudeCodeLogLevel "trace"|"debug"|"info"|"warn"|"error" +-- Diff layout type alias +---@alias ClaudeCodeDiffLayout "vertical"|"horizontal" + -- Terminal split side positioning ---@alias ClaudeCodeSplitSide "left"|"right" diff --git a/tests/integration/command_args_spec.lua b/tests/integration/command_args_spec.lua index 05787c0..7e6539d 100644 --- a/tests/integration/command_args_spec.lua +++ b/tests/integration/command_args_spec.lua @@ -153,10 +153,9 @@ describe("ClaudeCode command arguments integration", function() track_selection = true, visual_demotion_delay_ms = 50, diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = false, + layout = "vertical", + open_in_new_tab = true, -- Note: inverted from open_in_current_tab = false + keep_terminal_focus = false, }, }, opts or {}) end, diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index d5eca8e..77a3302 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -61,12 +61,17 @@ end local vim = { _buffers = {}, - _windows = { [1000] = { buf = 1 } }, -- Initialize with a default window + _windows = { [1000] = { buf = 1, width = 80 } }, -- winid -> { buf, width, cursor, config } + _win_tab = { [1000] = 1 }, -- winid -> tabpage + _tab_windows = { [1] = { 1000 } }, -- tabpage -> { winids } + _next_winid = 1001, _commands = {}, _autocmds = {}, _vars = {}, _options = {}, _current_window = 1000, + _tabs = { [1] = true }, + _current_tabpage = 1, api = { nvim_create_user_command = function(name, callback, opts) @@ -172,7 +177,7 @@ local vim = { -- buffer or window. Here, it's stored in a general options table if not -- a buffer-local option, or in the buffer's options table if `opts.buf` is provided. -- A more complex mock might be needed for intricate scope-related tests. - if opts and opts.scope == "local" and opts.buf then + if opts and opts.buf then if vim._buffers[opts.buf] then if not vim._buffers[opts.buf].options then vim._buffers[opts.buf].options = {} @@ -273,7 +278,7 @@ local vim = { end, nvim_get_current_win = function() - return 1000 -- Mock window ID + return vim._current_window end, nvim_set_current_win = function(winid) @@ -283,14 +288,17 @@ local vim = { end, nvim_list_wins = function() - -- Return a list of window IDs + -- Return a list of window IDs for the current tab local wins = {} - for winid, _ in pairs(vim._windows) do - table.insert(wins, winid) + local list = vim._tab_windows[vim._current_tabpage] or {} + for _, winid in ipairs(list) do + if vim._windows[winid] then + table.insert(wins, winid) + end end if #wins == 0 then -- Always have at least one window - table.insert(wins, 1000) + table.insert(wins, vim._current_window) end return wins end, @@ -299,7 +307,24 @@ local vim = { if not vim._windows[winid] then vim._windows[winid] = {} end + local old_buf = vim._windows[winid].buf vim._windows[winid].buf = bufnr + -- If old buffer is no longer displayed in any window, and has bufhidden=wipe, delete it + if old_buf and vim._buffers[old_buf] then + local still_visible = false + for _, w in pairs(vim._windows) do + if w.buf == old_buf then + still_visible = true + break + end + end + if not still_visible then + local opts = vim._buffers[old_buf].options or {} + if opts.bufhidden == "wipe" then + vim._buffers[old_buf] = nil + end + end + end end, nvim_win_get_buf = function(winid) @@ -314,7 +339,36 @@ local vim = { end, nvim_win_close = function(winid, force) + local old_buf = vim._windows[winid] and vim._windows[winid].buf vim._windows[winid] = nil + -- remove from tab mapping + local tab = vim._win_tab[winid] + if tab and vim._tab_windows[tab] then + local new_list = {} + for _, w in ipairs(vim._tab_windows[tab]) do + if w ~= winid then + table.insert(new_list, w) + end + end + vim._tab_windows[tab] = new_list + end + vim._win_tab[winid] = nil + -- Apply bufhidden=wipe if now hidden + if old_buf and vim._buffers[old_buf] then + local still_visible = false + for _, w in pairs(vim._windows) do + if w.buf == old_buf then + still_visible = true + break + end + end + if not still_visible then + local opts = vim._buffers[old_buf].options or {} + if opts.bufhidden == "wipe" then + vim._buffers[old_buf] = nil + end + end + end end, nvim_win_call = function(winid, callback) @@ -333,13 +387,49 @@ local vim = { return {} end, + nvim_win_set_width = function(winid, width) + if vim._windows[winid] then + vim._windows[winid].width = width + end + end, + + nvim_win_get_width = function(winid) + return (vim._windows[winid] and vim._windows[winid].width) or 80 + end, + nvim_get_current_tabpage = function() - return 1 + return vim._current_tabpage + end, + + nvim_set_current_tabpage = function(tab) + if vim._tabs[tab] then + vim._current_tabpage = tab + end + end, + + nvim_tabpage_is_valid = function(tab) + return vim._tabs[tab] == true + end, + + nvim_tabpage_get_number = function(tab) + return tab end, nvim_tabpage_set_var = function(tabpage, name, value) -- Mock tabpage variable setting end, + + nvim_win_get_tabpage = function(winid) + return vim._win_tab[winid] or vim._current_tabpage + end, + + nvim_buf_line_count = function(bufnr) + local b = vim._buffers[bufnr] + if not b or not b.lines then + return 0 + end + return #b.lines + end, }, fn = { @@ -446,6 +536,133 @@ local vim = { cmd = function(command) -- Store the last command for test assertions. vim._last_command = command + -- Implement minimal behavior for essential commands + if command == "tabnew" then + -- Create new tab with a new window and an unnamed buffer + local new_tab = 1 + for k, _ in pairs(vim._tabs) do + if k >= new_tab then + new_tab = k + 1 + end + end + vim._tabs[new_tab] = true + vim._current_tabpage = new_tab + + -- Create a new unnamed buffer + local bufnr = vim.api.nvim_create_buf(false, true) + vim._buffers[bufnr].name = "" + vim._buffers[bufnr].options = vim._buffers[bufnr].options or {} + vim._buffers[bufnr].options.modified = false + vim._buffers[bufnr].lines = { "" } + + -- Create a new window for this tab + local winid = vim._next_winid + vim._next_winid = vim._next_winid + 1 + vim._windows[winid] = { buf = bufnr, width = 80 } + vim._win_tab[winid] = new_tab + vim._tab_windows[new_tab] = { winid } + vim._current_window = winid + elseif command:match("vsplit") then + -- Split current window vertically; new window shows same buffer + local cur = vim._current_window + local curtab = vim._current_tabpage + local bufnr = vim._windows[cur] and vim._windows[cur].buf or 1 + local winid = vim._next_winid + vim._next_winid = vim._next_winid + 1 + vim._windows[winid] = { buf = bufnr, width = 80 } + vim._win_tab[winid] = curtab + local list = vim._tab_windows[curtab] or {} + table.insert(list, winid) + vim._tab_windows[curtab] = list + vim._current_window = winid + elseif command:match("[^%w]split$") or command == "split" then + -- Horizontal split: model similarly by creating a new window entry + local cur = vim._current_window + local curtab = vim._current_tabpage + local bufnr = vim._windows[cur] and vim._windows[cur].buf or 1 + local winid = vim._next_winid + vim._next_winid = vim._next_winid + 1 + vim._windows[winid] = { buf = bufnr, width = 80 } + vim._win_tab[winid] = curtab + local list = vim._tab_windows[curtab] or {} + table.insert(list, winid) + vim._tab_windows[curtab] = list + vim._current_window = winid + elseif command:match("^edit ") then + local path = command:sub(6) + -- Remove surrounding quotes if any + path = path:gsub("^'", ""):gsub("'$", "") + -- Find or create buffer for this path + local bufnr = -1 + for id, b in pairs(vim._buffers) do + if b.name == path then + bufnr = id + break + end + end + if bufnr == -1 then + bufnr = vim.api.nvim_create_buf(true, false) + vim._buffers[bufnr].name = path + -- Try to read file content if exists + local f = io.open(path, "r") + if f then + -- Only read if the handle supports :read (avoid tests that stub io.open for writing only) + local ok_read = (type(f) == "userdata") or (type(f) == "table" and type(f.read) == "function") + if ok_read then + local content = f:read("*a") or "" + if type(f.close) == "function" then + pcall(f.close, f) + end + vim._buffers[bufnr].lines = {} + for line in (content .. "\n"):gmatch("(.-)\n") do + table.insert(vim._buffers[bufnr].lines, line) + end + else + -- Gracefully ignore non-readable stubs + end + end + end + vim.api.nvim_win_set_buf(vim._current_window, bufnr) + elseif command:match("^tabclose") then + -- Close current tab: remove all its windows and switch to the lowest-numbered remaining tab + local curtab = vim._current_tabpage + local wins = vim._tab_windows[curtab] or {} + for _, w in ipairs(wins) do + if vim._windows[w] then + vim.api.nvim_win_close(w, true) + end + end + vim._tab_windows[curtab] = nil + vim._tabs[curtab] = nil + -- switch to lowest-numbered existing tab + local new_cur = nil + for t, _ in pairs(vim._tabs) do + if not new_cur or t < new_cur then + new_cur = t + end + end + if not new_cur then + -- recreate a default tab and window + vim._tabs[1] = true + local bufnr = vim.api.nvim_create_buf(true, false) + vim._buffers[bufnr].name = "/home/user/project/test.lua" + local winid = vim._next_winid + vim._next_winid = vim._next_winid + 1 + vim._windows[winid] = { buf = bufnr, width = 80 } + vim._win_tab[winid] = 1 + vim._tab_windows[1] = { winid } + vim._current_window = winid + vim._current_tabpage = 1 + else + vim._current_tabpage = new_cur + local list = vim._tab_windows[new_cur] + if list and #list > 0 then + vim._current_window = list[1] + end + end + else + -- other commands (wincmd etc.) are recorded but not simulated + end end, json = { @@ -767,14 +984,18 @@ vim._mock = { add_window = function(winid, bufnr, cursor) vim._windows[winid] = { - buffer = bufnr, + buf = bufnr, cursor = cursor or { 1, 0 }, + width = 80, } end, reset = function() vim._buffers = {} vim._windows = {} + vim._win_tab = {} + vim._tab_windows = {} + vim._next_winid = 1000 vim._commands = {} vim._autocmds = {} vim._vars = {} @@ -789,6 +1010,19 @@ if _G.vim == nil then _G.vim = vim end vim._mock.add_buffer(1, "/home/user/project/test.lua", "local test = {}\nreturn test") -vim._mock.add_window(0, 1, { 1, 0 }) +vim._mock.add_window(1000, 1, { 1, 0 }) +vim._win_tab[1000] = 1 +vim._tab_windows[1] = { 1000 } +vim._current_window = 1000 + +-- Global options table (minimal) +vim.o = setmetatable({ columns = 120, lines = 40 }, { + __index = function(_, k) + return vim._options[k] + end, + __newindex = function(_, k, v) + vim._options[k] = v + end, +}) return vim diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 3e0d63c..0fb32e5 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -84,10 +84,9 @@ describe("Configuration", function() track_selection = false, visual_demotion_delay_ms = 50, diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = true, + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, }, models = {}, -- Empty models array should be rejected } @@ -107,10 +106,9 @@ describe("Configuration", function() track_selection = false, visual_demotion_delay_ms = 50, diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = true, + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, }, models = { { name = "Test Model" }, -- Missing value field @@ -150,10 +148,8 @@ describe("Configuration", function() connection_timeout = 10000, queue_timeout = 5000, diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = true, + layout = "vertical", + open_in_new_tab = false, keep_terminal_focus = true, }, env = {}, @@ -177,10 +173,8 @@ describe("Configuration", function() connection_timeout = 10000, queue_timeout = 5000, diff_opts = { - auto_close_on_accept = true, - show_diff_stats = true, - vertical_split = true, - open_in_current_tab = true, + layout = "vertical", + open_in_new_tab = false, keep_terminal_focus = "invalid", -- Should be boolean }, env = {}, diff --git a/tests/unit/diff_hide_terminal_new_tab_spec.lua b/tests/unit/diff_hide_terminal_new_tab_spec.lua new file mode 100644 index 0000000..e3b624b --- /dev/null +++ b/tests/unit/diff_hide_terminal_new_tab_spec.lua @@ -0,0 +1,122 @@ +require("tests.busted_setup") + +describe("Diff new-tab with hidden terminal", function() + local open_diff_tool = require("claudecode.tools.open_diff") + local diff = require("claudecode.diff") + + local test_old_file = "/tmp/claudecode_diff_hide_old.txt" + local test_new_file = "/tmp/claudecode_diff_hide_new.txt" + local test_tab_name = "hide-term-in-new-tab" + + before_each(function() + -- Create a real file so filereadable() returns 1 in mocks + local f = io.open(test_old_file, "w") + f:write("line1\nline2\n") + f:close() + + -- Ensure a clean diff state + diff._cleanup_all_active_diffs("test_setup") + + -- Provide minimal config directly to diff module + diff.setup({ + terminal = { split_side = "right", split_width_percentage = 0.30 }, + diff_opts = { + layout = "vertical", + open_in_new_tab = true, + keep_terminal_focus = false, + hide_terminal_in_new_tab = true, + }, + }) + + -- Stub terminal provider with a valid terminal buffer (should be ignored due to hide flag) + local term_buf = vim.api.nvim_create_buf(false, true) + package.loaded["claudecode.terminal"] = { + get_active_terminal_bufnr = function() + return term_buf + end, + ensure_visible = function() end, + } + end) + + after_each(function() + os.remove(test_old_file) + os.remove(test_new_file) + -- Clear stub to avoid side effects + package.loaded["claudecode.terminal"] = nil + diff._cleanup_all_active_diffs("test_teardown") + end) + + it("does not place a terminal split in the new tab when hidden", function() + local params = { + old_file_path = test_old_file, + new_file_path = test_new_file, + new_file_contents = "updated content\n", + tab_name = test_tab_name, + } + + local co = coroutine.create(function() + open_diff_tool.handler(params) + end) + + -- Start the tool (it will yield waiting for user action) + local ok, err = coroutine.resume(co) + assert.is_true(ok, tostring(err)) + assert.equal("suspended", coroutine.status(co)) + + -- Inspect active diff metadata + local active = diff._get_active_diffs() + assert.is_table(active[test_tab_name]) + assert.is_true(active[test_tab_name].created_new_tab) + -- Key assertion: no terminal window was created in the new tab + assert.is_nil(active[test_tab_name].terminal_win_in_new_tab) + + -- Resolve to finish the coroutine + vim.schedule(function() + diff._resolve_diff_as_rejected(test_tab_name) + end) + vim.wait(100, function() + return coroutine.status(co) == "dead" + end) + end) + + it("wipes the initial unnamed buffer created by tabnew", function() + local params = { + old_file_path = test_old_file, + new_file_path = test_new_file, + new_file_contents = "updated content\n", + tab_name = test_tab_name, + } + + -- Start handler + local co = coroutine.create(function() + open_diff_tool.handler(params) + end) + + local ok, err = coroutine.resume(co) + assert.is_true(ok, tostring(err)) + assert.equal("suspended", coroutine.status(co)) + + -- After diff opens, the initial unnamed buffer in the new tab should be gone + -- because plugin marks it bufhidden=wipe and then replaces it + local unnamed_count = 0 + for _, buf in pairs(vim._buffers) do + if buf.name == nil or buf.name == "" then + unnamed_count = unnamed_count + 1 + end + end + -- There may be zero unnamed buffers, or other tests may create scratch buffers with names + -- The important assertion is that there is no unnamed buffer with bufhidden=wipe lingering + for id, buf in pairs(vim._buffers) do + local bh = buf.options and buf.options.bufhidden or nil + assert.not_equal("wipe", bh, "Found lingering unnamed buffer with bufhidden=wipe (buf " .. tostring(id) .. ")") + end + + -- Cleanup by rejecting + vim.schedule(function() + diff._resolve_diff_as_rejected(test_tab_name) + end) + vim.wait(100, function() + return coroutine.status(co) == "dead" + end) + end) +end) diff --git a/tests/unit/diff_mcp_spec.lua b/tests/unit/diff_mcp_spec.lua index 3ba3d20..daf3212 100644 --- a/tests/unit/diff_mcp_spec.lua +++ b/tests/unit/diff_mcp_spec.lua @@ -153,9 +153,10 @@ describe("MCP-compliant diff operations", function() end) coroutine.resume(co) - -- Simulate completion + -- Simulate completion and explicit close_tab vim.schedule(function() diff._resolve_diff_as_saved(test_tab_name, 1) + diff.close_diff_by_tab_name(test_tab_name) end) vim.wait(1000, function() @@ -180,9 +181,10 @@ describe("MCP-compliant diff operations", function() local mid_autocmd_count = #vim.api.nvim_get_autocmds({ group = "ClaudeCodeMCPDiff" }) assert.is_true(mid_autocmd_count > initial_autocmd_count, "Autocmds should be created") - -- Simulate completion + -- Simulate completion and explicit close_tab vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) + diff.close_diff_by_tab_name(test_tab_name) end) vim.wait(1000, function() @@ -205,9 +207,10 @@ describe("MCP-compliant diff operations", function() -- Verify diff is tracked -- Note: This test may need adjustment based on actual buffer creation - -- Clean up + -- Clean up via reject + close_tab vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) + diff.close_diff_by_tab_name(test_tab_name) end) vim.wait(1000, function() diff --git a/tests/unit/diff_spec.lua b/tests/unit/diff_spec.lua index 2c58e9e..6b6f98d 100644 --- a/tests/unit/diff_spec.lua +++ b/tests/unit/diff_spec.lua @@ -127,9 +127,9 @@ describe("Diff Module", function() it("should create diff with correct parameters", function() diff.setup({ diff_opts = { - vertical_split = true, - show_diff_stats = false, - auto_close_on_accept = true, + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, }, }) @@ -182,9 +182,9 @@ describe("Diff Module", function() it("should use horizontal split when configured", function() diff.setup({ diff_opts = { - vertical_split = false, - show_diff_stats = false, - auto_close_on_accept = true, + layout = "horizontal", + open_in_new_tab = false, + keep_terminal_focus = false, }, }) @@ -209,19 +209,19 @@ describe("Diff Module", function() expect(result.success).to_be_true() local found_split = false - local found_vertical_split = false + local found_vsplit = false for _, cmd in ipairs(commands) do - if cmd:find("split", 1, true) and not cmd:find("vertical split", 1, true) then - found_split = true + if cmd:find("vsplit", 1, true) then + found_vsplit = true end - if cmd:find("vertical split", 1, true) then - found_vertical_split = true + if cmd:find("split", 1, true) and not cmd:find("vsplit", 1, true) then + found_split = true end end - expect(found_split).to_be_true() - expect(found_vertical_split).to_be_false() + expect(found_split).to_be_true() -- Should use horizontal split (accepts modifiers like belowright) + expect(found_vsplit).to_be_false() -- Should not use vertical split rawset(io, "open", old_io_open) end) diff --git a/tests/unit/diff_ui_cleanup_spec.lua b/tests/unit/diff_ui_cleanup_spec.lua new file mode 100644 index 0000000..454049b --- /dev/null +++ b/tests/unit/diff_ui_cleanup_spec.lua @@ -0,0 +1,119 @@ +require("tests.busted_setup") + +local diff = require("claudecode.diff") + +describe("Diff UI cleanup behavior", function() + local test_old_file = "/tmp/test_ui_cleanup_old.txt" + local tab_name = "test_ui_cleanup_tab" + + before_each(function() + -- Prepare a dummy file + local f = io.open(test_old_file, "w") + f:write("line1\nline2\n") + f:close() + + -- Reset tabs mock + vim._tabs = { [1] = true, [2] = true } + vim._current_tabpage = 2 -- Simulate we're on the newly created tab during cleanup + end) + + after_each(function() + os.remove(test_old_file) + -- Ensure cleanup doesn't leave state behind + diff._cleanup_all_active_diffs("test_teardown") + end) + + it("closes the created new tab on accept only after close_tab is invoked", function() + -- Minimal windows/buffers for cleanup paths + local new_win = 2001 + local target_win = 2002 + vim._windows[new_win] = { buf = 2 } + vim._windows[target_win] = { buf = 3 } + + -- Register a pending diff that was opened in a new tab + diff._register_diff_state(tab_name, { + old_file_path = test_old_file, + new_window = new_win, + target_window = target_win, + new_buffer = 2, + original_buffer = 3, + original_cursor_pos = { 1, 0 }, + original_tab_number = 1, + created_new_tab = true, + new_tab_number = 2, + had_terminal_in_original = false, + autocmd_ids = {}, + status = "pending", + resolution_callback = function(_) end, + is_new_file = false, + }) + + -- Resolve as saved: should NOT close the tab yet + diff._resolve_diff_as_saved(tab_name, 2) + assert.is_true( + vim._last_command == nil or vim._last_command:match("^tabclose") == nil, + "Did not expect ':tabclose' before close_tab tool call" + ) + + -- Simulate close_tab tool invocation + local closed = diff.close_diff_by_tab_name(tab_name) + assert.is_true(closed) + -- Verify a tabclose command was issued now + assert.is_true( + type(vim._last_command) == "string" and vim._last_command:match("^tabclose") ~= nil, + "Expected a ':tabclose' command to be executed on close_tab" + ) + end) + + it("keeps Claude terminal visible in original tab on reject when previously visible", function() + -- Spy on terminal.ensure_visible by preloading a stub module + local ensure_calls = 0 + package.loaded["claudecode.terminal"] = { + ensure_visible = function() + ensure_calls = ensure_calls + 1 + return true + end, + get_active_terminal_bufnr = function() + return nil + end, + } + + -- Minimal windows/buffers for cleanup paths + local new_win = 2101 + local target_win = 2102 + vim._windows[new_win] = { buf = 4 } + vim._windows[target_win] = { buf = 5 } + + -- Register a pending diff that was opened in a new tab, and track that + -- the terminal was visible in the original tab when the diff was created + diff._register_diff_state(tab_name, { + old_file_path = test_old_file, + new_window = new_win, + target_window = target_win, + new_buffer = 4, + original_buffer = 5, + original_cursor_pos = { 1, 0 }, + original_tab_number = 1, + created_new_tab = true, + new_tab_number = 2, + had_terminal_in_original = true, + autocmd_ids = {}, + status = "pending", + resolution_callback = function(_) end, + is_new_file = false, + }) + + -- Mark as rejected and verify no cleanup yet + diff._resolve_diff_as_rejected(tab_name) + assert.equals(0, ensure_calls) + + -- Simulate close_tab tool invocation for a pending diff (treated as reject) + local closed = diff.close_diff_by_tab_name(tab_name) + assert.is_true(closed) + -- ensure_visible should have been called exactly once during cleanup + assert.equals(1, ensure_calls) + + -- Clear the stub to avoid side effects for other tests + package.loaded["claudecode.terminal"] = nil + end) +end)