Skip to content

Commit 298a3e6

Browse files
ldelossaThomasK33claude
authored
fix(diff): wipe empty buffer when terminal provider is none (#223)
Extract the marking of the current buffer to ephemeral when tabnew is applied. Use this function at every early return site where `vim.cmd(tabnew)` is called. Fixes: #208 --------- Signed-off-by: ldelossa <louis.delos@gmail.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f64c307 commit 298a3e6

3 files changed

Lines changed: 552 additions & 14 deletions

File tree

fixtures/issue-208/init.lua

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
-- Fixture for issue #208:
2+
-- "[BUG] Leftover [No Name] tab after diff resolve with open_in_new_tab and
3+
-- terminal.provider = none"
4+
-- https://github.com/coder/claudecode.nvim/issues/208
5+
--
6+
-- Repro config from the report:
7+
-- terminal = { provider = "none" } -- Claude runs in an EXTERNAL terminal
8+
-- diff_opts = { open_in_new_tab = true } -- each diff opens in its own tab
9+
--
10+
-- With provider = "none" there is no in-Neovim terminal buffer, so the new-tab
11+
-- helper (display_terminal_in_new_tab) early-returns right after `:tabnew`
12+
-- WITHOUT marking the bare `[No Name]` buffer ephemeral, and reports "no terminal
13+
-- window". choose_original_window() then treats the diff as NOT in a new tab and
14+
-- REUSES that empty buffer as the diff's original side. On a NEW-file diff that
15+
-- reused buffer is never deleted on cleanup (original_buffer_created_by_plugin is
16+
-- false), so it leaks -- "collecting empty buffers on every new diff tab".
17+
--
18+
-- This fixture drives the diff through the exact functions the openDiff /
19+
-- close_tab MCP path uses, so NO external Claude is required to see the leak.
20+
--
21+
-- Usage (from repo root):
22+
-- source fixtures/nvim-aliases.sh && vv issue-208
23+
-- Watch the tabline counter "noname=N". Then:
24+
-- <leader>x open+ACCEPT a NEW-file diff -> noname count GROWS (BUG)
25+
-- :Repro208NewReject open+REJECT a NEW-file diff -> noname count GROWS (BUG)
26+
-- :Repro208Existing open+accept an EXISTING-file diff-> noname count steady (control)
27+
-- :Repro208Buffers print the leaked [No Name] buffers (:ls-style)
28+
-- :Repro208Reset collapse to one tab + wipe stray no-name buffers
29+
30+
local config_dir = vim.fn.stdpath("config")
31+
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
32+
vim.opt.rtp:prepend(repo_root)
33+
34+
vim.g.mapleader = " "
35+
vim.g.maplocalleader = "\\"
36+
37+
-- Count valid, listed buffers with an empty name -> the leaked `[No Name]` buffers.
38+
local function noname_count()
39+
local n = 0
40+
for _, b in ipairs(vim.api.nvim_list_bufs()) do
41+
if vim.api.nvim_buf_is_valid(b) and vim.api.nvim_buf_get_name(b) == "" and vim.bo[b].buflisted then
42+
n = n + 1
43+
end
44+
end
45+
return n
46+
end
47+
48+
-- Always show the tabline with a live [No Name] buffer counter, so the leak is
49+
-- visible without typing any command.
50+
vim.o.showtabline = 2
51+
vim.o.laststatus = 2
52+
function _G.Repro208Tabline()
53+
local s = {}
54+
for i = 1, vim.fn.tabpagenr("$") do
55+
local active = (i == vim.fn.tabpagenr())
56+
local winnr = vim.fn.tabpagewinnr(i)
57+
local buflist = vim.fn.tabpagebuflist(i)
58+
local bufname = vim.fn.bufname(buflist[winnr])
59+
local label = (bufname == "" and "[No Name]" or vim.fn.fnamemodify(bufname, ":t"))
60+
s[#s + 1] = (active and "%#TabLineSel#" or "%#TabLine#")
61+
s[#s + 1] = (" TAB %d%s: %s "):format(i, active and " (active)" or "", label)
62+
end
63+
s[#s + 1] = "%#TabLineFill#"
64+
s[#s + 1] = "%=%#WarningMsg# noname=" .. noname_count() .. " tabs=" .. vim.fn.tabpagenr("$") .. " "
65+
return table.concat(s)
66+
end
67+
vim.o.tabline = "%!v:lua.Repro208Tabline()"
68+
69+
local ok, claudecode = pcall(require, "claudecode")
70+
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
71+
72+
claudecode.setup({
73+
auto_start = false,
74+
log_level = "info",
75+
terminal = {
76+
provider = "none", -- the path under test (#208): Claude runs externally
77+
},
78+
diff_opts = {
79+
layout = "vertical",
80+
open_in_new_tab = true, -- the path under test (#208)
81+
keep_terminal_focus = false,
82+
on_new_file_reject = "keep_empty",
83+
},
84+
})
85+
86+
local diff = require("claudecode.diff")
87+
88+
-- Drive one diff through the real MCP code path with no external Claude:
89+
-- open -> M._setup_blocking_diff (what the openDiff tool runs)
90+
-- accept-> M._resolve_diff_as_saved (what BufWriteCmd / :w runs)
91+
-- reject-> M._resolve_diff_as_rejected (what the reject keymap runs)
92+
-- close -> M.close_diff_by_tab_name (what Claude's close_tab notification runs)
93+
---@param is_new_file boolean
94+
---@param mode "accept"|"reject"
95+
local function run_one(is_new_file, mode)
96+
local before = noname_count()
97+
98+
local tag = (is_new_file and "new" or "existing") .. "_" .. mode
99+
local tab_name = ("✻ [Claude Code] issue208_%s ⧉"):format(tag)
100+
101+
local old_file
102+
if is_new_file then
103+
old_file = vim.fn.tempname() .. "_issue208_" .. tag .. "_NEW.md" -- not created -> is_new_file
104+
else
105+
old_file = vim.fn.tempname() .. "_issue208_" .. tag .. ".md"
106+
local fh = io.open(old_file, "w")
107+
fh:write("# original\n\nline one\nline two\n")
108+
fh:close()
109+
end
110+
111+
pcall(function()
112+
diff._setup_blocking_diff({
113+
old_file_path = old_file,
114+
new_file_path = old_file,
115+
new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n",
116+
tab_name = tab_name,
117+
}, function() end)
118+
local active = diff._get_active_diffs()[tab_name]
119+
if mode == "accept" then
120+
if active and active.new_buffer then
121+
diff._resolve_diff_as_saved(tab_name, active.new_buffer)
122+
end
123+
else
124+
diff._resolve_diff_as_rejected(tab_name)
125+
end
126+
diff.close_diff_by_tab_name(tab_name)
127+
end)
128+
129+
-- close_diff_by_tab_name's saved branch defers a reload by 100ms.
130+
vim.wait(250, function()
131+
return false
132+
end)
133+
if not is_new_file then
134+
os.remove(old_file)
135+
end
136+
137+
local after = noname_count()
138+
local delta = after - before
139+
vim.api.nvim_echo({
140+
{
141+
("issue208 [%s]: [No Name] bufs %d -> %d (delta=%+d)%s"):format(
142+
tag,
143+
before,
144+
after,
145+
delta,
146+
delta > 0 and " <<< LEAKED" or " (clean)"
147+
),
148+
delta > 0 and "ErrorMsg" or "MoreMsg",
149+
},
150+
}, false, {})
151+
end
152+
153+
vim.api.nvim_create_user_command("Repro208New", function()
154+
run_one(true, "accept")
155+
end, { desc = "#208: open+ACCEPT a NEW-file diff (leaks a [No Name] buffer)" })
156+
157+
vim.api.nvim_create_user_command("Repro208NewReject", function()
158+
run_one(true, "reject")
159+
end, { desc = "#208: open+REJECT a NEW-file diff (leaks a [No Name] buffer)" })
160+
161+
vim.api.nvim_create_user_command("Repro208Existing", function()
162+
run_one(false, "accept")
163+
end, { desc = "#208: open+accept an EXISTING-file diff (control, clean)" })
164+
165+
vim.api.nvim_create_user_command("Repro208Buffers", function()
166+
local lines = { "Leaked [No Name] listed buffers:" }
167+
for _, b in ipairs(vim.api.nvim_list_bufs()) do
168+
if vim.api.nvim_buf_is_valid(b) and vim.api.nvim_buf_get_name(b) == "" and vim.bo[b].buflisted then
169+
local loaded = vim.api.nvim_buf_is_loaded(b)
170+
lines[#lines + 1] = (" buf %d loaded=%s lines=%d"):format(
171+
b,
172+
tostring(loaded),
173+
loaded and vim.api.nvim_buf_line_count(b) or -1
174+
)
175+
end
176+
end
177+
lines[#lines + 1] = ("total noname=%d tabs=%d"):format(noname_count(), vim.fn.tabpagenr("$"))
178+
vim.api.nvim_echo({ { table.concat(lines, "\n"), "MoreMsg" } }, true, {})
179+
end, { desc = "#208: list leaked [No Name] buffers" })
180+
181+
vim.api.nvim_create_user_command("Repro208Reset", function()
182+
diff._cleanup_all_active_diffs("repro reset")
183+
vim.cmd("silent! tabonly!")
184+
vim.cmd("silent! only!")
185+
vim.cmd("silent! enew!")
186+
local cur = vim.api.nvim_get_current_buf()
187+
for _, b in ipairs(vim.api.nvim_list_bufs()) do
188+
if b ~= cur and vim.api.nvim_buf_is_valid(b) and vim.api.nvim_buf_get_name(b) == "" and vim.bo[b].buflisted then
189+
pcall(vim.api.nvim_buf_delete, b, { force = true })
190+
end
191+
end
192+
vim.api.nvim_echo(
193+
{ { ("Repro208Reset: noname=%d tabs=%d"):format(noname_count(), vim.fn.tabpagenr("$")), "MoreMsg" } },
194+
false,
195+
{}
196+
)
197+
end, { desc = "#208: reset layout + wipe stray no-name buffers" })
198+
199+
vim.keymap.set("n", "<leader>x", function()
200+
run_one(true, "accept")
201+
end, { desc = "#208 repro: open+accept a NEW-file diff" })
202+
203+
-- A normal editor buffer in the first tab so the layout looks like real usage.
204+
local banner = {
205+
"claudecode.nvim -- issue #208 reproduction fixture",
206+
"",
207+
"terminal.provider = none (Claude runs in an external terminal)",
208+
"diff_opts.open_in_new_tab = true (each diff opens in its own tab)",
209+
"",
210+
"Watch the tabline (top-right): noname=N tabs=M",
211+
"",
212+
" <leader>x open+ACCEPT a NEW-file diff -> noname GROWS (BUG #208)",
213+
" :Repro208NewReject open+REJECT a NEW-file diff -> noname GROWS (BUG #208)",
214+
" :Repro208Existing open+accept EXISTING file -> noname steady (control)",
215+
" :Repro208Buffers print the leaked [No Name] buffers",
216+
" :Repro208Reset collapse to one tab + wipe stray buffers",
217+
"",
218+
"Each NEW-file diff leaves one extra unnamed buffer behind (both accept and",
219+
"reject). Existing-file diffs are clean because the reused empty buffer is",
220+
"`:edit`-ed over and auto-wiped.",
221+
}
222+
vim.api.nvim_buf_set_lines(0, 0, -1, false, banner)
223+
vim.bo.modifiable = false
224+
vim.bo.modified = false

lua/claudecode/diff.lua

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,21 @@ local function get_default_terminal_options()
310310
}
311311
end
312312

313+
---Mark the current buffer (just created by tabnew) as ephemeral so it auto-wipes when hidden.
314+
local function mark_tabnew_buffer_ephemeral()
315+
local buf = vim.api.nvim_get_current_buf()
316+
local ok_name, name = pcall(vim.api.nvim_buf_get_name, buf)
317+
local ok_mod, modified = pcall(vim.api.nvim_buf_get_option, buf, "modified")
318+
local ok_lc, linecount = pcall(function()
319+
return vim.api.nvim_buf_line_count(buf)
320+
end)
321+
if ok_name and ok_mod and ok_lc then
322+
if (name == nil or name == "") and modified == false and linecount <= 1 then
323+
pcall(vim.api.nvim_buf_set_option, buf, "bufhidden", "wipe")
324+
end
325+
end
326+
end
327+
313328
---Display existing Claude Code terminal in new tab
314329
---@return number original_tab The original tab number
315330
---@return number? terminal_win Terminal window in new tab
@@ -322,13 +337,15 @@ local function display_terminal_in_new_tab()
322337
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
323338
if not terminal_ok then
324339
vim.cmd("tabnew")
340+
mark_tabnew_buffer_ephemeral()
325341
local new_tab = vim.api.nvim_get_current_tabpage()
326342
return original_tab, nil, false, new_tab
327343
end
328344

329345
local terminal_bufnr = terminal_module.get_active_terminal_bufnr()
330346
if not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then
331347
vim.cmd("tabnew")
348+
mark_tabnew_buffer_ephemeral()
332349
local new_tab = vim.api.nvim_get_current_tabpage()
333350
return original_tab, nil, false, new_tab
334351
end
@@ -343,22 +360,9 @@ local function display_terminal_in_new_tab()
343360
end
344361

345362
vim.cmd("tabnew")
363+
mark_tabnew_buffer_ephemeral()
346364
local new_tab = vim.api.nvim_get_current_tabpage()
347365

348-
-- Mark the initial, unnamed buffer in the new tab as ephemeral to avoid leaks
349-
-- When this buffer gets hidden (replaced or tab closed), wipe it automatically.
350-
local initial_buf = vim.api.nvim_get_current_buf()
351-
local name_ok, initial_name = pcall(vim.api.nvim_buf_get_name, initial_buf)
352-
local mod_ok, initial_modified = pcall(vim.api.nvim_buf_get_option, initial_buf, "modified")
353-
local linecount_ok, initial_linecount = pcall(function()
354-
return vim.api.nvim_buf_line_count(initial_buf)
355-
end)
356-
if name_ok and mod_ok and linecount_ok then
357-
if (initial_name == nil or initial_name == "") and initial_modified == false and initial_linecount <= 1 then
358-
pcall(vim.api.nvim_buf_set_option, initial_buf, "bufhidden", "wipe")
359-
end
360-
end
361-
362366
local terminal_config = config.terminal or {}
363367
local split_side = terminal_config.split_side or "right"
364368

0 commit comments

Comments
 (0)