diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 4826ad8..a3cd70c 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -23,6 +23,7 @@ M.defaults = { 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 + on_new_file_reject = "keep_empty", -- "keep_empty" leaves an empty buffer; "close_window" closes the placeholder split }, models = { { name = "Claude Opus 4.1 (Latest)", value = "opus" }, @@ -113,6 +114,11 @@ function M.validate(config) type(config.diff_opts.hide_terminal_in_new_tab) == "boolean", "diff_opts.hide_terminal_in_new_tab must be a boolean" ) + assert( + type(config.diff_opts.on_new_file_reject) == "string" + and (config.diff_opts.on_new_file_reject == "keep_empty" or config.diff_opts.on_new_file_reject == "close_window"), + "diff_opts.on_new_file_reject must be 'keep_empty' or 'close_window'" + ) -- 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 58dd045..68ddf9d 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -892,9 +892,10 @@ function M._create_diff_view_from_window( terminal_win_in_new_tab, existing_buffer ) + local original_buffer_created_by_plugin = false + -- If no target window provided, create a new window in suitable location if not target_window then - -- 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() @@ -929,8 +930,15 @@ function M._create_diff_view_from_window( original_window = choice.original_win end + -- For new files, we create an empty buffer for the original side + if is_new_file then + original_buffer_created_by_plugin = true + end + + -- Load the original-side buffer into the chosen window local original_buffer = load_original_buffer(original_window, old_file_path, is_new_file, existing_buffer) + -- Set up the proposed buffer and finalize the diff layout local new_win = setup_new_buffer( original_window, original_buffer, @@ -945,6 +953,7 @@ function M._create_diff_view_from_window( new_window = new_win, target_window = original_window, original_buffer = original_buffer, + original_buffer_created_by_plugin = original_buffer_created_by_plugin, } end @@ -1030,8 +1039,13 @@ function M._cleanup_diff_state(tab_name, reason) pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true }) 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 + -- Clean up the original buffer only if it was created by the plugin 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) + and diff_data.original_buffer_created_by_plugin + then pcall(vim.api.nvim_buf_delete, diff_data.original_buffer, { force = true }) end @@ -1177,6 +1191,7 @@ function M._setup_blocking_diff(params, resolution_callback) new_window = diff_info.new_window, target_window = diff_info.target_window, original_buffer = diff_info.original_buffer, + original_buffer_created_by_plugin = diff_info.original_buffer_created_by_plugin, original_cursor_pos = original_cursor_pos, original_tab_number = original_tab_number, created_new_tab = created_new_tab, diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 1224cb2..61a517c 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -19,6 +19,7 @@ ---@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 +---@field on_new_file_reject ClaudeCodeNewFileRejectBehavior Behavior when rejecting a new-file diff -- Model selection option ---@class ClaudeCodeModelOption @@ -31,6 +32,9 @@ -- Diff layout type alias ---@alias ClaudeCodeDiffLayout "vertical"|"horizontal" +-- Behavior when rejecting new-file diffs +---@alias ClaudeCodeNewFileRejectBehavior "keep_empty"|"close_window" + -- Terminal split side positioning ---@alias ClaudeCodeSplitSide "left"|"right" diff --git a/tests/unit/new_file_reject_then_reopen_spec.lua b/tests/unit/new_file_reject_then_reopen_spec.lua new file mode 100644 index 0000000..1bf7a5c --- /dev/null +++ b/tests/unit/new_file_reject_then_reopen_spec.lua @@ -0,0 +1,97 @@ +-- Verifies that rejecting a new-file diff with an empty buffer left open does not crash, +-- and a subsequent write (diff setup) works again. +require("tests.busted_setup") + +describe("New file diff: reject then reopen", function() + local diff + + before_each(function() + -- Fresh vim mock state + if vim and vim._mock and vim._mock.reset then + vim._mock.reset() + end + + -- Minimal logger stub + package.loaded["claudecode.logger"] = { + debug = function() end, + error = function() end, + info = function() end, + warn = function() end, + } + + -- Reload diff module cleanly + package.loaded["claudecode.diff"] = nil + diff = require("claudecode.diff") + + -- Setup config on diff + diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", -- default behavior + }, + terminal = {}, + }) + + -- Create an empty unnamed buffer and set it in current window so _create_diff_view_from_window reuses it + local empty_buf = vim.api.nvim_create_buf(false, true) + -- Ensure name is empty and 'modified' is false + vim.api.nvim_buf_set_name(empty_buf, "") + vim.api.nvim_buf_set_option(empty_buf, "modified", false) + + -- Make current window use this empty buffer + local current_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(current_win, empty_buf) + end) + + it("should reuse empty buffer for new-file diff, not delete it on reject, and allow reopening", function() + local tab_name = "✻ [TestNewFile] new.lua ⧉" + local params = { + old_file_path = "/nonexistent/path/to/new.lua", -- ensure new-file scenario + new_file_path = "/tmp/new.lua", + new_file_contents = "print('hello')\n", + tab_name = tab_name, + } + + -- Track current window buffer (the reused empty buffer) + local target_win = vim.api.nvim_get_current_win() + local reused_buf = vim.api.nvim_win_get_buf(target_win) + assert.is_true(vim.api.nvim_buf_is_valid(reused_buf)) + + -- 1) Setup the diff (should reuse the empty buffer) + local setup_ok, setup_err = pcall(function() + diff._setup_blocking_diff(params, function() end) + end) + assert.is_true(setup_ok, "Diff setup failed unexpectedly: " .. tostring(setup_err)) + + -- Verify state registered (ownership may vary based on window conditions) + local active = diff._get_active_diffs() + assert.is_table(active[tab_name]) + -- Ensure the original buffer reference exists and is valid + assert.is_true(vim.api.nvim_buf_is_valid(active[tab_name].original_buffer)) + + -- 2) Reject the diff; cleanup should NOT delete the reused empty buffer + diff._resolve_diff_as_rejected(tab_name) + + -- After reject, the diff state should be removed + local active_after_reject = diff._get_active_diffs() + assert.is_nil(active_after_reject[tab_name]) + + -- The reused buffer should still be valid (not deleted) + assert.is_true(vim.api.nvim_buf_is_valid(reused_buf)) + + -- 3) Setup the diff again with the same conditions; should succeed + local setup_ok2, setup_err2 = pcall(function() + diff._setup_blocking_diff(params, function() end) + end) + assert.is_true(setup_ok2, "Second diff setup failed unexpectedly: " .. tostring(setup_err2)) + + -- Verify new state exists again + local active_again = diff._get_active_diffs() + assert.is_table(active_again[tab_name]) + + -- Clean up to avoid affecting other tests + diff._cleanup_diff_state(tab_name, "test cleanup") + end) +end)