Skip to content

Commit 70a1438

Browse files
ThomasK33claude
andauthored
fix(diff): scope closeAllDiffTabs to own diffs; never reuse &diff windows (#277) (#290)
## Summary Fixes #277. `claudecode.nvim` was closing and overwriting diff windows it does not own (diffview.nvim, fugitive, native `:diffsplit`/vimdiff). Two independent defects: 1. **`closeAllDiffTabs` was unscoped.** After draining the plugin's own tracked diffs, it additionally closed *every* window with `&diff` set and force-deleted any buffer named like `*.diff` / `diff://` / `fugitive://`, with no check that claudecode created them. The Claude CLI fires this tool at the **start of every user turn** while an IDE is connected, so any open diff review was destroyed on essentially every prompt — only the diffview file panel (not a `&diff` window) survived, matching the reported symptom. 2. **`find_main_editor_window` did not exclude `&diff` windows.** Duplicated in `open_file.lua` and `diff.lua`, it skipped floats/terminals/sidebars but not diff windows, so `openFile`/`openDiff` could `:edit` into one half of a foreign diff — which clears that window's `diff` option and drops its partner out of the diff group, corrupting the layout. ## Changes - `tools/close_all_diff_tabs.lua`: scope to claudecode's tracked-diff registry only (`diff.close_all_diffs`); remove the `&diff` window sweep and the buffer force-delete. This mirrors the official VS Code extension, which closes only the diff tabs it labelled `[Claude Code] …`. - `tools/open_file.lua` & `diff.lua`: skip windows in diff mode in both copies of `find_main_editor_window`, in the `open_file` fallback, and in the buffer-reuse branch of `_setup_blocking_diff`. When no non-diff window exists, the existing graceful fallbacks (split + scratch) apply — no error. - Docs: `CHANGELOG.md` (Unreleased → Bug Fixes) and the `closeAllDiffTabs` description in `CLAUDE.md`. ## Testing - **New reproduction harness** — `fixtures/issue-277/` (with diffview.nvim) + `scripts/repro_issue_277.sh`, which drives a real Neovim TUI and sends the same MCP calls the CLI sends. Each phase is written so `PASS = bug present`. With this change, all three phases flip **3/3 reproduced → 0/3**: `closeAllDiffTabs` returns `CLOSED_0_DIFF_TABS`, diffview/vimdiff windows survive, and `openFile` opens in a new window beside the diff. - **Unit tests**: rewrote the two specs that pinned the old destructive sweep (now assert foreign diffs and `*.diff`/`fugitive://` buffers are spared) and added `&diff` coverage to the window-finder specs. Added `nvim_win_get_option`/`nvim_win_set_option` to the shared vim mock. - `mise run all` is green: **655/655** tests, `luacheck` 0 warnings, treefmt clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b4cabc commit 70a1438

13 files changed

Lines changed: 833 additions & 90 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Bug Fixes
1212

13+
- `closeAllDiffTabs` no longer destroys diffs it does not own. It previously closed every window with `&diff` set and force-deleted any buffer named like `*.diff`/`diff://`/`fugitive://`, so an open diffview.nvim, fugitive, or native `:diffsplit` review was wiped out — and because the Claude CLI calls this tool at the start of every turn, it happened on essentially every prompt. The tool is now scoped to claudecode's own tracked diffs (matching the official VS Code extension, which only closes the tabs it labelled). Relatedly, `openFile`/`openDiff` no longer reuse a window that is in diff mode, which previously `:edit`-ed over one side of an unrelated diff and broke its layout. ([#277](https://github.com/coder/claudecode.nvim/issues/277))
1314
- The Claude terminal now adds the loopback hosts (`localhost`, `127.0.0.1`, `::1`) to `no_proxy`/`NO_PROXY`, so a configured `http_proxy`/`all_proxy` no longer tunnels Claude's `ws://127.0.0.1` IDE connection and causes queued @ mentions to time out. Existing `no_proxy` exclusions are preserved. ([#70](https://github.com/coder/claudecode.nvim/issues/70))
1415
- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228))
1516
- Rejecting a Claude diff with `:q` (or `:close` / `<C-w>c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238))

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ The WebSocket server implements secure authentication using:
101101
- `checkDocumentDirty` - Checks if document has unsaved changes
102102
- `saveDocument` - Saves document with detailed success/failure reporting
103103
- `getWorkspaceFolders` - Gets workspace folder information
104-
- `closeAllDiffTabs` - Closes all diff-related tabs and windows
104+
- `closeAllDiffTabs` - Closes the diffs claudecode itself opened (its tracked diff registry only). It never touches diffs created by other tools such as diffview.nvim, fugitive, or native `:diffsplit`, and `openFile`/`openDiff` never reuse a window that is currently in diff mode (issue #277).
105105
- `getDiagnostics` - Gets language diagnostics (errors, warnings) from the editor
106106

107107
**Internal Tools** (not exposed via MCP):

fixtures/issue-277/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Fixture: issue #277 — closeAllDiffTabs destroys foreign diffs
2+
3+
Reproduction environment for
4+
[#277 "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated diffs (diffview.nvim)"](https://github.com/coder/claudecode.nvim/issues/277).
5+
6+
Two defects:
7+
8+
1. `tools/close_all_diff_tabs.lua` closes **every** window with `&diff` set (and
9+
force-deletes `%.diff$` / `diff://` / `fugitive://` buffers) with no check
10+
that claudecode created them. The Claude CLI invokes `closeAllDiffTabs` at
11+
the **start of each user turn** when an IDE is connected (verified against
12+
CLI 2.1.175), so any diffview.nvim / fugitive / native vimdiff layout that is
13+
open when you submit a prompt gets destroyed.
14+
2. `find_main_editor_window()` (in `tools/open_file.lua` and `diff.lua`) does
15+
not exclude `&diff` windows, so `openFile`/`openDiff` can `:edit` into one
16+
half of a foreign diff, corrupting it (the new buffer joins the diff).
17+
18+
## Scripted reproduction
19+
20+
```bash
21+
scripts/repro_issue_277.sh
22+
```
23+
24+
Drives a real Neovim TUI under agent-tty, opens diffview/native diffs, then
25+
sends the same MCP `tools/call` requests the Claude CLI sends. Prints
26+
`REPRODUCED:`/`NOT REPRODUCED:` per phase; exits 0 when all three defects
27+
reproduce.
28+
29+
## Manual reproduction
30+
31+
```bash
32+
source fixtures/nvim-aliases.sh && vv issue-277 # cwd must be a git repo with changes
33+
```
34+
35+
1. `:DiffviewOpen` — side-by-side diff appears (2 windows with `&diff` + file panel).
36+
2. Connect Claude (`:ClaudeCode`, or any client with the lock-file token).
37+
3. Submit any prompt (or send `closeAllDiffTabs` by hand).
38+
4. Both diff windows close; only the Diffview panel survives. `<leader>aw`
39+
shows the diff-window count; `:ReproState` dumps per-window state.
40+
41+
`:ReproNativeDiff <a> <b>` opens a plugin-free native vimdiff for the same
42+
experiment (no diffview involved).
43+
44+
## Notes
45+
46+
- diffview.nvim is cloned into `stdpath("data")/diffview.nvim`
47+
(`~/.local/share/issue-277/`) on first start.
48+
- The fixture exposes `v:lua.Repro277State()`, `v:lua.Repro277DiffWinCount()`
49+
and `v:lua.Repro277Server()` for `--remote-expr` scripting.

fixtures/issue-277/init.lua

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
-- Fixture for issue #277:
2+
-- "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated
3+
-- diffs (diffview.nvim)"
4+
-- https://github.com/coder/claudecode.nvim/issues/277
5+
--
6+
-- Two defects under test:
7+
-- 1. tools/close_all_diff_tabs.lua closes EVERY `&diff` window (no ownership
8+
-- check), so diffview.nvim / fugitive / native `:diffthis` layouts are
9+
-- destroyed when the Claude CLI fires closeAllDiffTabs (it does so at the
10+
-- start of a user turn whenever an IDE is connected).
11+
-- 2. find_main_editor_window (tools/open_file.lua and diff.lua) does not
12+
-- exclude `&diff` windows, so openFile/openDiff target a diffview window
13+
-- and :edit into it, corrupting the diff layout.
14+
--
15+
-- The fixture pulls in diffview.nvim (cloned on first run) and exposes a
16+
-- window-state probe for scripted verification:
17+
-- nvim --server <sock> --remote-expr 'v:lua.Repro277State()'
18+
--
19+
-- Usage (from repo root):
20+
-- source fixtures/nvim-aliases.sh && vv issue-277
21+
-- or scripted:
22+
-- scripts/repro_issue_277.sh
23+
--
24+
-- Manual repro: open a file in a git repo with uncommitted changes,
25+
-- :DiffviewOpen, connect claude (--ide), submit any prompt -> the side-by-side
26+
-- diff windows close, only the Diffview file panel survives.
27+
28+
local config_dir = vim.fn.stdpath("config")
29+
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
30+
vim.opt.rtp:prepend(repo_root)
31+
32+
vim.g.mapleader = " "
33+
vim.g.maplocalleader = "\\"
34+
35+
-- ---------------------------------------------------------------------------
36+
-- diffview.nvim (cloned into stdpath("data") on first run; no plugin manager)
37+
-- ---------------------------------------------------------------------------
38+
local diffview_dir = vim.fn.stdpath("data") .. "/diffview.nvim"
39+
if vim.fn.isdirectory(diffview_dir) == 0 then
40+
vim.notify("issue-277 fixture: cloning diffview.nvim ...")
41+
local out = vim.fn.system({
42+
"git",
43+
"clone",
44+
"--depth=1",
45+
"https://github.com/sindrets/diffview.nvim",
46+
diffview_dir,
47+
})
48+
assert(vim.v.shell_error == 0, "failed to clone diffview.nvim: " .. out)
49+
end
50+
vim.opt.rtp:prepend(diffview_dir)
51+
52+
local ok_dv, diffview = pcall(require, "diffview")
53+
assert(ok_dv, "Failed to load diffview.nvim: " .. tostring(diffview))
54+
diffview.setup({})
55+
56+
-- ---------------------------------------------------------------------------
57+
-- claudecode.nvim (dev version from this repo)
58+
-- ---------------------------------------------------------------------------
59+
local ok, claudecode = pcall(require, "claudecode")
60+
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
61+
62+
claudecode.setup({
63+
auto_start = true, -- server + lock file immediately, so scripts can connect
64+
-- "warn", not "debug": multi-line debug echoes trip nvim's hit-enter prompt,
65+
-- which blocks --remote-expr probes in the scripted repro.
66+
log_level = "warn",
67+
terminal = {
68+
provider = "native",
69+
auto_close = false,
70+
},
71+
})
72+
73+
vim.o.showtabline = 2
74+
vim.o.laststatus = 2
75+
76+
-- ---------------------------------------------------------------------------
77+
-- Window-state probe (for --remote-expr / on-screen verification)
78+
-- ---------------------------------------------------------------------------
79+
80+
---Compact state of every window across all tabpages.
81+
---@return string JSON: [{win,tab,name,buftype,filetype,diff}...]
82+
function _G.Repro277State()
83+
local out = {}
84+
for _, win in ipairs(vim.api.nvim_list_wins()) do
85+
local buf = vim.api.nvim_win_get_buf(win)
86+
local name = vim.api.nvim_buf_get_name(buf)
87+
out[#out + 1] = {
88+
win = win,
89+
tab = vim.api.nvim_tabpage_get_number(vim.api.nvim_win_get_tabpage(win)),
90+
name = vim.fn.fnamemodify(name, ":t") ~= "" and vim.fn.fnamemodify(name, ":~:.") or "[No Name]",
91+
buftype = vim.bo[buf].buftype,
92+
filetype = vim.bo[buf].filetype,
93+
diff = vim.wo[win].diff,
94+
}
95+
end
96+
return vim.json.encode(out)
97+
end
98+
99+
---WebSocket endpoint of the running claudecode server ("port token", or "" if
100+
---not started yet). Lets scripts connect without scanning ~/.claude/ide.
101+
---@return string
102+
function _G.Repro277Server()
103+
local cc = require("claudecode")
104+
if cc.state.port and cc.state.auth_token then
105+
return cc.state.port .. " " .. cc.state.auth_token
106+
end
107+
return ""
108+
end
109+
110+
---Count of windows currently in diff mode (quick assertion helper).
111+
---@return integer
112+
function _G.Repro277DiffWinCount()
113+
local n = 0
114+
for _, win in ipairs(vim.api.nvim_list_wins()) do
115+
if vim.wo[win].diff then
116+
n = n + 1
117+
end
118+
end
119+
return n
120+
end
121+
122+
vim.api.nvim_create_user_command("ReproState", function()
123+
vim.notify(_G.Repro277State())
124+
end, { desc = "Show issue-277 window state" })
125+
126+
-- Native (plugin-free) diff variant of the same bug: two `:diffsplit` windows.
127+
vim.api.nvim_create_user_command("ReproNativeDiff", function(cmd_opts)
128+
local args = vim.split(cmd_opts.args, "%s+")
129+
assert(#args == 2, "usage: :ReproNativeDiff <file_a> <file_b>")
130+
vim.cmd("edit " .. vim.fn.fnameescape(args[1]))
131+
vim.cmd("vertical diffsplit " .. vim.fn.fnameescape(args[2]))
132+
end, { nargs = "+", complete = "file", desc = "Open a native vimdiff of two files" })
133+
134+
vim.keymap.set("n", "<leader>aw", function()
135+
vim.notify(("diff windows: %d"):format(_G.Repro277DiffWinCount()))
136+
end, { desc = "Show diff window count" })

lua/claudecode/diff.lua

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ local function find_main_editor_window()
141141
is_suitable = false
142142
end
143143

144+
-- Skip windows already in diff mode -- a user vimdiff/diffview.nvim/fugitive
145+
-- pane, or one of claudecode's own diff panes. Opening a file into one
146+
-- clears its window-local 'diff' and destroys that diff layout (issue #277).
147+
if is_suitable and vim.api.nvim_win_get_option(win, "diff") then
148+
is_suitable = false
149+
end
150+
144151
if
145152
is_suitable
146153
and (
@@ -170,6 +177,27 @@ end
170177
-- Exposed for testing the sidebar/explorer exclusion logic.
171178
M._find_main_editor_window = find_main_editor_window
172179

180+
---Whether the given tabpage contains any window in diff mode. A Neovim diff is
181+
---scoped to a tabpage -- every &diff window in a tab participates in one shared
182+
---diff set -- so if a tab already hosts a foreign diff (vimdiff/diffview.nvim/
183+
---fugitive), creating Claude's diff in that tab would make its panes join and
184+
---corrupt the user's review. Such cases are routed to a dedicated tab instead
185+
---(issue #277). At diff-setup time claudecode has not created its own diff
186+
---windows yet, so any match here is a foreign diff.
187+
---@param tabpage integer Tabpage handle (0 = current)
188+
---@return boolean
189+
local function tabpage_has_diff_window(tabpage)
190+
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
191+
if vim.api.nvim_win_get_option(win, "diff") then
192+
return true
193+
end
194+
end
195+
return false
196+
end
197+
198+
-- Exposed for testing the foreign-diff-tab detection.
199+
M._tabpage_has_diff_window = tabpage_has_diff_window
200+
173201
---Find the Claude Code terminal window to keep focus there.
174202
---Uses the terminal provider to get the active terminal buffer, then finds its window.
175203
---@return number? win_id Window ID of the Claude Code terminal window, or nil if not found
@@ -1289,7 +1317,10 @@ function M._setup_blocking_diff(params, resolution_callback)
12891317

12901318
if existing_buffer then
12911319
for _, win in ipairs(vim.api.nvim_list_wins()) do
1292-
if vim.api.nvim_win_get_buf(win) == existing_buffer then
1320+
-- Don't reuse a window that is already in diff mode (e.g. the old
1321+
-- file shown inside the user's diffview/vimdiff): diffing into it
1322+
-- would join and corrupt that diff (issue #277).
1323+
if vim.api.nvim_win_get_buf(win) == existing_buffer and not vim.api.nvim_win_get_option(win, "diff") then
12931324
target_window = win
12941325
break
12951326
end
@@ -1300,11 +1331,31 @@ function M._setup_blocking_diff(params, resolution_callback)
13001331
if not target_window then
13011332
target_window = find_main_editor_window()
13021333
end
1334+
1335+
-- A Neovim diff is scoped to its tabpage: all &diff windows in a tab share
1336+
-- one diff set. If the tab that would host Claude's diff already contains a
1337+
-- foreign diff (vimdiff/diffview.nvim/fugitive), running `diffthis` there
1338+
-- makes Claude's panes join and corrupt it -- even when a usable non-diff
1339+
-- window exists in that tab. So check the destination tab (the candidate
1340+
-- window's tab, or the current tab if none was found) and isolate the diff
1341+
-- in a dedicated tab when it already hosts a diff (issue #277). Mirrors the
1342+
-- open_in_new_tab path, including the issue #262 hoisting so a failure
1343+
-- before registration can still close the new tab.
1344+
local dest_tab = (target_window and vim.api.nvim_win_is_valid(target_window))
1345+
and vim.api.nvim_win_get_tabpage(target_window)
1346+
or vim.api.nvim_get_current_tabpage()
1347+
if tabpage_has_diff_window(dest_tab) then
1348+
original_tab_handle = vim.api.nvim_get_current_tabpage()
1349+
original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle =
1350+
display_terminal_in_new_tab()
1351+
created_new_tab = true
1352+
target_window = nil
1353+
end
13031354
end
13041355
-- If created_new_tab is true, target_window stays nil and will be created in the new tab.
13051356
-- Otherwise, if no editor window is suitable (e.g. the Claude terminal is the only window --
1306-
-- issue #231), create one by splitting the current window instead of erroring out, mirroring
1307-
-- the fallback in lua/claudecode/tools/open_file.lua.
1357+
-- issue #231) and the tab has no foreign diff to corrupt, create one by splitting the current
1358+
-- window instead of erroring out, mirroring the fallback in lua/claudecode/tools/open_file.lua.
13081359
if not target_window and not created_new_tab then
13091360
create_split()
13101361
local scratch_buf = vim.api.nvim_create_buf(false, true) -- unlisted, scratch

lua/claudecode/tools/close_all_diff_tabs.lua

Lines changed: 13 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -13,79 +13,20 @@ local schema = {
1313
---Closes all diff tabs/windows in the editor.
1414
---@return table response MCP-compliant response with content array indicating number of closed tabs.
1515
local function handler(params)
16-
local closed_count = 0
17-
18-
-- Tear down tracked diffs first (resolving their pending coroutines); the
19-
-- window/buffer scan below would otherwise leak that diff state (issue #248).
16+
-- Tear down only the diffs this plugin created, resolving their pending
17+
-- coroutines (issue #248). claudecode.diff.active_diffs is the authoritative
18+
-- record of every diff claudecode opened, so it is the complete and safe scope
19+
-- for this tool.
20+
--
21+
-- We deliberately do NOT scan for "any window with &diff set" or buffers named
22+
-- like *.diff / diff:// / fugitive://: those belong to the user's own diff
23+
-- tools (diffview.nvim, fugitive, native :diffsplit). Claude's CLI invokes
24+
-- closeAllDiffTabs at the START OF EVERY TURN while an IDE is connected, so an
25+
-- unscoped sweep silently destroyed unrelated diffs on each prompt (issue
26+
-- #277). This also matches the official VS Code extension, which closes only
27+
-- tabs it labelled "[Claude Code] ..." -- i.e. its own diffs.
2028
local diff = require("claudecode.diff")
21-
closed_count = closed_count + diff.close_all_diffs("closeAllDiffTabs tool")
22-
23-
-- Get all windows (catches any untracked diff windows, e.g. fugitive)
24-
local windows = vim.api.nvim_list_wins()
25-
local windows_to_close = {} -- Use set to avoid duplicates
26-
27-
for _, win in ipairs(windows) do
28-
local buf = vim.api.nvim_win_get_buf(win)
29-
local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
30-
local diff_mode = vim.api.nvim_win_get_option(win, "diff")
31-
local should_close = false
32-
33-
-- Check if this is a diff window
34-
if diff_mode then
35-
should_close = true
36-
end
37-
38-
-- Also check for diff-related buffer names or types
39-
local buf_name = vim.api.nvim_buf_get_name(buf)
40-
if buf_name:match("%.diff$") or buf_name:match("diff://") then
41-
should_close = true
42-
end
43-
44-
-- Check for special diff buffer types
45-
if buftype == "nofile" and buf_name:match("^fugitive://") then
46-
should_close = true
47-
end
48-
49-
-- Add to close set only once (prevents duplicates)
50-
if should_close then
51-
windows_to_close[win] = true
52-
end
53-
end
54-
55-
-- Close the identified diff windows
56-
for win, _ in pairs(windows_to_close) do
57-
if vim.api.nvim_win_is_valid(win) then
58-
local success = pcall(vim.api.nvim_win_close, win, false)
59-
if success then
60-
closed_count = closed_count + 1
61-
end
62-
end
63-
end
64-
65-
-- Also check for buffers that might be diff-related but not currently in windows
66-
local buffers = vim.api.nvim_list_bufs()
67-
for _, buf in ipairs(buffers) do
68-
if vim.api.nvim_buf_is_loaded(buf) then
69-
local buf_name = vim.api.nvim_buf_get_name(buf)
70-
local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
71-
72-
-- Check for diff-related buffers
73-
if
74-
buf_name:match("%.diff$")
75-
or buf_name:match("diff://")
76-
or (buftype == "nofile" and buf_name:match("^fugitive://"))
77-
then
78-
-- Delete the buffer if it's not in any window
79-
local buf_windows = vim.fn.win_findbuf(buf)
80-
if #buf_windows == 0 then
81-
local success = pcall(vim.api.nvim_buf_delete, buf, { force = true })
82-
if success then
83-
closed_count = closed_count + 1
84-
end
85-
end
86-
end
87-
end
88-
end
29+
local closed_count = diff.close_all_diffs("closeAllDiffTabs tool")
8930

9031
-- Return MCP-compliant format matching VS Code extension
9132
return {

0 commit comments

Comments
 (0)