|
| 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 |
0 commit comments