diff --git a/CLAUDE.md b/CLAUDE.md index 0298fc12..7a900690 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,22 +288,27 @@ require("claudecode").setup({ The `diff_opts` configuration allows you to customize diff behavior: +- `layout` ("vertical"|"horizontal", default: `"vertical"`) - Whether the diff panes open in a vertical or horizontal split. - `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. +- `on_new_file_reject` ("keep_empty"|"close_window", default: `"keep_empty"`) - Behavior when rejecting a diff for a new file (where the old file did not exist). +- Legacy aliases (still supported): `vertical_split` (maps to `layout`) and `open_in_current_tab` (inverse of `open_in_new_tab`). **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. ```lua 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 + layout = "vertical", -- "vertical" or "horizontal" + 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, - open_in_current_tab = true, + on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window" + + -- Legacy aliases (still supported): + -- vertical_split = true, + -- open_in_current_tab = true, }, }) ``` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7c0311ad..ca704373 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -153,6 +153,9 @@ vv netrw # Start Neovim with built-in netrw configuration # List available configurations list-configs + +# Minimal repro environment (copies fixtures/repro/example into /tmp) +repro ``` **Example fixture structure** (`fixtures/my-integration/`): diff --git a/README.md b/README.md index 117cc51d..6e85012e 100644 --- a/README.md +++ b/README.md @@ -277,10 +277,15 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). -- Diff Integration diff_opts = { - auto_close_on_accept = true, - vertical_split = true, - open_in_current_tab = true, - keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens (including floating terminals) + layout = "vertical", -- "vertical" or "horizontal" + open_in_new_tab = false, + keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens + hide_terminal_in_new_tab = false, + -- on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window" + + -- Legacy aliases (still supported): + -- vertical_split = true, + -- open_in_current_tab = true, }, }, keys = { diff --git a/fixtures/bin/repro b/fixtures/bin/repro new file mode 100755 index 00000000..f9686d2a --- /dev/null +++ b/fixtures/bin/repro @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail + +# repro - Launch Neovim with the minimal "repro" fixture + a temp workspace. +# +# This is intended to provide a low-noise environment for reproducing issues in claudecode.nvim. + +FIXTURES_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" + +# Source common functions +source "$FIXTURES_DIR/bin/common.sh" + +config="repro" +if ! validate_config "$FIXTURES_DIR" "$config"; then + exit 1 +fi + +keep_workspace=false + +# Parse repro-specific flags. +# Everything else is passed to nvim. +while [[ $# -gt 0 ]]; do + case "$1" in + --keep|--no-reset) + keep_workspace=true + shift + ;; + --help|-h) + cat <<'EOF' +repro - Launch Neovim with the minimal "repro" fixture + a temp workspace. + +Usage: + repro [--keep] [nvim args...] + +Options: + --keep, --no-reset Reuse the existing repro workspace instead of resetting it + +Examples: + repro + repro --keep + repro --headless "+qall!" +EOF + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +nvim_args=("$@") + +template_dir="$FIXTURES_DIR/$config/example" +if [[ ! -d "$template_dir" ]]; then + echo "Error: repro template directory not found: $template_dir" >&2 + exit 1 +fi + +workspace_dir="${TMPDIR:-/tmp}/claudecode.nvim-repro" + +# Safety check before deleting +if [[ -z "$workspace_dir" || "$workspace_dir" == "/" ]]; then + echo "Error: unsafe workspace_dir: '$workspace_dir'" >&2 + exit 1 +fi + +if [[ "$keep_workspace" == "true" && -d "$workspace_dir" ]]; then + echo "Repro workspace (kept): $workspace_dir" +else + rm -rf "$workspace_dir" + mkdir -p "$workspace_dir" + cp -R "$template_dir/." "$workspace_dir/" + echo "Repro workspace (reset): $workspace_dir" +fi + +echo "Repro config: $FIXTURES_DIR/$config" +echo "Tip: :e README.md in the repro workspace for steps." + +# If no args are provided, open the default file to keep the window non-empty. +if [[ ${#nvim_args[@]} -eq 0 ]]; then + nvim_args=("a.txt") +fi + +(cd "$workspace_dir" && NVIM_APPNAME="$config" XDG_CONFIG_HOME="$FIXTURES_DIR" nvim "${nvim_args[@]}") diff --git a/fixtures/nvim-aliases.sh b/fixtures/nvim-aliases.sh index d2748938..5cf0d8d7 100755 --- a/fixtures/nvim-aliases.sh +++ b/fixtures/nvim-aliases.sh @@ -14,7 +14,10 @@ alias vv="$BIN_DIR/vv" alias vve="$BIN_DIR/vve" # shellcheck disable=SC2139 alias list-configs="$BIN_DIR/list-configs" +# shellcheck disable=SC2139 +alias repro="$BIN_DIR/repro" echo "Neovim configuration aliases loaded!" echo "Use 'vv ' or 'vve ' to test configurations" +echo "Use 'repro' for a minimal claudecode.nvim repro environment" echo "Use 'list-configs' to see available options" diff --git a/fixtures/repro/example/README.md b/fixtures/repro/example/README.md new file mode 100644 index 00000000..59980099 --- /dev/null +++ b/fixtures/repro/example/README.md @@ -0,0 +1,56 @@ +# claudecode.nvim repro workspace + +This directory is copied into a temp workspace when you run `repro`. + +## Quick start + +From the repo root: + +```sh +source fixtures/nvim-aliases.sh +repro +``` + +That will: + +- create `/tmp/claudecode.nvim-repro` (reset on every run; use `repro --keep` to reuse) +- open Neovim with the **minimal** `fixtures/repro` config +- open `a.txt` so your current window is non-empty + +## Iterating on the config + +The Neovim config lives at `fixtures/repro/init.lua`. + +- Edit it from another terminal: + + ```sh + vve repro + ``` + + Then restart the running `repro` Neovim instance to pick up changes. + +- Or edit it from inside the running `repro` session: + + ```vim + :ReproEditConfig + ``` + +> Note: config changes generally require restarting Neovim (this fixture avoids a plugin manager / hot-reload). + +## Example flow (sanity check) + +A basic end-to-end diff flow you can use to sanity-check the environment: + +1. Start Claude: + + - press `ac` (starts the server if needed, then opens the terminal), **or** + - run `:ClaudeCodeStart` then `:ClaudeCode` + +2. Ask Claude to edit `b.txt` (do not open it in a window first) +3. Accept the diff with `:w` (or `aa`) +4. Confirm you didn’t get any extra leftover windows: `:echo winnr('$')` + +## Notes + +- This fixture uses the **native** terminal provider to avoid depending on external plugins. +- To tweak the config, edit `fixtures/repro/init.lua` (or run `vve repro`). diff --git a/fixtures/repro/example/a.txt b/fixtures/repro/example/a.txt new file mode 100644 index 00000000..1128bd19 --- /dev/null +++ b/fixtures/repro/example/a.txt @@ -0,0 +1,4 @@ +This is file A. + +Keep this file open. +Ask Claude to edit b.txt (without opening b.txt in a window first). diff --git a/fixtures/repro/example/b.txt b/fixtures/repro/example/b.txt new file mode 100644 index 00000000..c0d0fb45 --- /dev/null +++ b/fixtures/repro/example/b.txt @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/fixtures/repro/init.lua b/fixtures/repro/init.lua new file mode 100644 index 00000000..58a9b668 --- /dev/null +++ b/fixtures/repro/init.lua @@ -0,0 +1,95 @@ +-- Minimal repro config for claudecode.nvim issues. +-- +-- This fixture intentionally avoids a plugin manager so it's easy to run and reason about. +-- +-- Usage (from repo root): +-- source fixtures/nvim-aliases.sh +-- repro +-- +-- To edit this config: +-- vve repro + +-- Ensure this repo is on the runtimepath so `plugin/claudecode.lua` is loaded. +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") + +vim.opt.rtp:prepend(repo_root) + +-- Basic editor settings +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +claudecode.setup({ + auto_start = false, -- avoid noisy startup + make restarts deterministic + log_level = "debug", + terminal = { + provider = "native", + auto_close = false, + }, + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + }, +}) + +local function ensure_claudecode_started() + local ok_start, started_or_err, port_or_err = pcall(function() + return claudecode.start(false) + end) + + if not ok_start then + vim.notify("ClaudeCode start crashed: " .. tostring(started_or_err), vim.log.levels.ERROR) + return false + end + + local started = started_or_err + if started then + return true + end + + -- start() returns false + "Already running" when running. + if port_or_err == "Already running" then + return true + end + + vim.notify("ClaudeCode failed to start: " .. tostring(port_or_err), vim.log.levels.ERROR) + return false +end + +-- Keymaps (kept small on purpose) +vim.keymap.set("n", "ac", function() + if ensure_claudecode_started() then + local terminal = require("claudecode.terminal") + terminal.simple_toggle({}, nil) + end +end, { desc = "Toggle Claude" }) + +vim.keymap.set("n", "af", function() + if ensure_claudecode_started() then + local terminal = require("claudecode.terminal") + terminal.focus_toggle({}, nil) + end +end, { desc = "Focus Claude" }) + +vim.keymap.set("n", "aa", "ClaudeCodeDiffAccept", { desc = "Accept diff" }) +vim.keymap.set("n", "ad", "ClaudeCodeDiffDeny", { desc = "Deny diff" }) + +-- Convenience helpers for iterating on this fixture. +vim.api.nvim_create_user_command("ReproEditConfig", function() + local config_path = vim.fn.stdpath("config") .. "/init.lua" + + -- Open the config file without `:edit` / `vim.cmd(...)` so we don't trigger + -- Treesitter "vim" language injections (which can be noisy if parsers/queries mismatch). + local bufnr = vim.fn.bufadd(config_path) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) +end, { desc = "Edit the repro Neovim config" }) + +vim.keymap.set("n", "ae", "ReproEditConfig", { desc = "Edit repro config" }) +vim.keymap.set("n", "aw", function() + vim.notify(("windows in tab: %d"):format(vim.fn.winnr("$"))) +end, { desc = "Claude: show window count" }) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index e301f8ad..79f8bb9d 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -917,6 +917,7 @@ function M._create_diff_view_from_window( existing_buffer ) local original_buffer_created_by_plugin = false + local target_window_created_by_plugin = false -- If no target window provided, create a new window in suitable location if not target_window then @@ -950,6 +951,7 @@ function M._create_diff_view_from_window( vim.api.nvim_set_current_win(target_window) create_split() original_window = vim.api.nvim_get_current_win() + target_window_created_by_plugin = true else original_window = choice.original_win end @@ -981,6 +983,7 @@ function M._create_diff_view_from_window( return { new_window = new_win, target_window = original_window, + target_window_created_by_plugin = target_window_created_by_plugin, original_buffer = original_buffer, original_buffer_created_by_plugin = original_buffer_created_by_plugin, } @@ -1050,11 +1053,19 @@ function M._cleanup_diff_state(tab_name, reason) pcall(vim.api.nvim_win_close, diff_data.new_window, true) end - -- Turn off diff mode in target window if it still exists + -- If we created an extra window/split for the diff, close it. Otherwise just disable diff mode. 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) + if diff_data.target_window_created_by_plugin then + -- Try a non-forced close first to avoid dropping any user edits in that window. + pcall(vim.api.nvim_win_close, diff_data.target_window, false) + end + + -- If the target window is still around, ensure diff mode is off. + 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 end -- After closing the diff in the same tab, restore terminal width if visible. @@ -1231,6 +1242,7 @@ function M._setup_blocking_diff(params, resolution_callback) new_buffer = new_buffer, new_window = diff_info.new_window, target_window = diff_info.target_window, + target_window_created_by_plugin = diff_info.target_window_created_by_plugin, original_buffer = diff_info.original_buffer, original_buffer_created_by_plugin = diff_info.original_buffer_created_by_plugin, original_cursor_pos = original_cursor_pos, @@ -1285,8 +1297,13 @@ end function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name) -- Check for existing diff with same tab_name if active_diffs[tab_name] then - -- Resolve the existing diff as rejected before replacing - M._resolve_diff_as_rejected(tab_name) + local existing_diff = active_diffs[tab_name] + -- Resolve the existing diff as rejected before replacing, but only if it was still pending. + if existing_diff.status == "pending" then + M._resolve_diff_as_rejected(tab_name) + end + -- Always clean up any leftover UI/state so we don't leak windows when reusing tab_names. + M._cleanup_diff_state(tab_name, "replaced by new diff") end -- Set up blocking diff operation @@ -1463,7 +1480,9 @@ return M ---@class DiffLayoutInfo ---@field new_window NvimWin ---@field target_window NvimWin +---@field target_window_created_by_plugin boolean ---@field original_buffer NvimBuf +---@field original_buffer_created_by_plugin boolean ---@class DiffWindowChoice ---@field decision DiffWindowDecision diff --git a/tests/unit/diff_split_window_cleanup_spec.lua b/tests/unit/diff_split_window_cleanup_spec.lua new file mode 100644 index 00000000..a534dfd5 --- /dev/null +++ b/tests/unit/diff_split_window_cleanup_spec.lua @@ -0,0 +1,130 @@ +require("tests.busted_setup") + +local function reset_vim_state_for_splits() + assert(vim and vim._mock and vim._mock.reset, "Expected vim mock with _mock.reset()") + + vim._mock.reset() + + -- Recreate a minimal tab/window state suitable for split operations. + vim._tabs = { [1] = true } + vim._current_tabpage = 1 + vim._current_window = 1000 + vim._next_winid = 1001 + + vim._mock.add_buffer(1, "/home/user/project/test.lua", "local test = {}\nreturn test", { modified = false }) + vim._mock.add_window(1000, 1, { 1, 0 }) + vim._win_tab[1000] = 1 + vim._tab_windows[1] = { 1000 } +end + +describe("Diff split window cleanup", function() + local diff + local test_old_file = "/tmp/test_split_window_cleanup_old.txt" + local tab_name = "test_split_window_cleanup_tab" + + before_each(function() + reset_vim_state_for_splits() + + -- Prepare a dummy file + local f = assert(io.open(test_old_file, "w")) + f:write("line1\nline2\n") + f:close() + + -- 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") + + diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + }, + terminal = {}, + }) + end) + + after_each(function() + os.remove(test_old_file) + if diff and diff._cleanup_all_active_diffs then + diff._cleanup_all_active_diffs("test teardown") + end + package.loaded["claudecode.diff"] = nil + end) + + it("closes the plugin-created original split after accept when close_tab is invoked", function() + local params = { + old_file_path = test_old_file, + new_file_path = test_old_file, + new_file_contents = "new1\nnew2\n", + tab_name = tab_name, + } + + diff._setup_blocking_diff(params, function() end) + + local state = diff._get_active_diffs()[tab_name] + assert.is_table(state) + + local new_win = state.new_window + local target_win = state.target_window + + -- Should have created an extra split for the original side (target_win != 1000) + assert.are_not.equal(1000, target_win) + assert.is_true(vim.api.nvim_win_is_valid(target_win)) + assert.is_true(vim.api.nvim_win_is_valid(new_win)) + + diff._resolve_diff_as_saved(tab_name, state.new_buffer) + + -- Accept should not close windows yet + assert.is_true(vim.api.nvim_win_is_valid(target_win)) + + local closed = diff.close_diff_by_tab_name(tab_name) + assert.is_true(closed) + + assert.is_false(vim.api.nvim_win_is_valid(new_win)) + assert.is_false(vim.api.nvim_win_is_valid(target_win)) + assert.is_true(vim.api.nvim_win_is_valid(1000)) + end) + + it("does not close the reused target window when the old file is already open", function() + -- Open the old file in the main window so choose_original_window reuses it + vim.cmd("edit " .. vim.fn.fnameescape(test_old_file)) + + local params = { + old_file_path = test_old_file, + new_file_path = test_old_file, + new_file_contents = "new content\n", + tab_name = tab_name, + } + + diff._setup_blocking_diff(params, function() end) + + local state = diff._get_active_diffs()[tab_name] + assert.is_table(state) + + local new_win = state.new_window + local target_win = state.target_window + + assert.are.equal(1000, target_win) + assert.is_true(vim.api.nvim_win_is_valid(new_win)) + + diff._resolve_diff_as_saved(tab_name, state.new_buffer) + + local closed = diff.close_diff_by_tab_name(tab_name) + assert.is_true(closed) + + assert.is_false(vim.api.nvim_win_is_valid(new_win)) + assert.is_true(vim.api.nvim_win_is_valid(1000)) + + -- In reuse scenario, diff mode should have been disabled. + assert.are.equal("diffoff", vim._last_command) + end) +end)