Skip to content

Commit 6b4cabc

Browse files
ThomasK33claude
andauthored
fix(send): classify tree buffers by filetype only (#289) (#292)
## Summary Fixes #289. `ClaudeCodeSend` classified the current buffer as a file-explorer ("tree") buffer using a substring match against the **buffer name** (the file's absolute path) in addition to the filetype, in both `handle_send_normal` and `handle_send_visual`: ```lua or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") or string.match(current_bufname, "minifiles://") ``` So an ordinary `filetype=lua` file whose path merely contained `neo-tree` or `NvimTree` (e.g. a plugin spec named `_neo-tree_.lua`) was treated as a tree buffer and the send was misrouted into tree-extraction, failing with: - visual path (`<leader>as` → `<cmd>ClaudeCodeSend<cr>`): `ClaudeCodeSend_visual->TreeAdd: Not in visual mode (current mode: n)` - range path (`:'<,'>ClaudeCodeSend`): `ClaudeCodeSend->TreeAdd: Not in a supported tree buffer (current filetype: lua)` ## Why this is the right fix The two downstream extractors — `integrations.get_selected_files_from_tree()` and `visual_commands.get_tree_state()` — already classify by **filetype only**. A buffer matched as a tree purely via its name therefore could never extract anything; the substring checks only ever diverted a normal selection into a failing branch. Every supported explorer sets a distinctive filetype that those extractors key on (`neo-tree`, `NvimTree`, `oil`, `minifiles`, `netrw`), so removing the name checks does not regress any of them. In particular, mini.files sets `filetype=minifiles` in addition to its `minifiles://` buffer name, so the `minifiles://` branch is redundant (and was never a cause of this bug — a real path cannot contain `://`). ## Changes - `lua/claudecode/init.lua`: classify by filetype only in both `handle_send_normal` and `handle_send_visual`; drop the now-unused `current_bufname` locals and the redundant `minifiles://` discriminator. - `tests/unit/tree_buffer_classification_spec.lua`: new regression spec that drives the **real** predicate (the existing `tree_send_visual_spec` mocks the wrapper, so the predicate was untested). Verified two-sided: fails on the old code, passes on the fix. - `scripts/repro_issue_289.lua`: headless reproduction (exit 1 = bug present, 0 = fixed). - `fixtures/issue-289/`: interactive reproduction fixture (affected + control files, `:ReproState`/`:ReproDump` helpers). ## Verification - `mise run all` — format + lint + 663 tests pass. - `nvim --headless -u NONE -l scripts/repro_issue_289.lua` — exits 0 on this branch (1 on `main`). - Reproduced live with the README-default `<leader>as` mapping before/after the fix. ## Follow-ups (out of scope, noted for tracking) - `snacks_picker_list` is present in `handle_send_normal` but missing from `handle_send_visual`, and there is no snacks branch in `get_tree_state`/`get_files_from_visual_selection` — visual-mode snacks-picker send is currently unsupported (needs end-to-end work). - Tree-buffer classification is duplicated across four sites; a shared helper would prevent future drift. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bcb3fe2 commit 6b4cabc

8 files changed

Lines changed: 756 additions & 10 deletions

File tree

fixtures/issue-289/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Fixture: issue #289 — path-substring tree misclassification
2+
3+
Reproduces [#289](https://github.com/coder/claudecode.nvim/issues/289):
4+
5+
> `ClaudeCodeSend` misclassifies a regular file buffer as a "tree buffer" when
6+
> the **file path** (not filetype) contains the substring `neo-tree`,
7+
> `NvimTree`, or `minifiles://`.
8+
9+
## Root cause
10+
11+
`handle_send_normal` and `handle_send_visual` in `lua/claudecode/init.lua`
12+
decide whether the current buffer is a file-explorer ("tree") buffer using both
13+
the filetype **and** a substring match against the buffer **name**:
14+
15+
```lua
16+
local is_tree_buffer = current_ft == "NvimTree"
17+
or current_ft == "neo-tree"
18+
or current_ft == "oil"
19+
or current_ft == "minifiles"
20+
or current_ft == "netrw"
21+
or current_ft == "snacks_picker_list" -- (normal handler only)
22+
or string.match(current_bufname, "neo%-tree") -- ← false positive
23+
or string.match(current_bufname, "NvimTree") -- ← false positive
24+
or string.match(current_bufname, "minifiles://")
25+
```
26+
27+
A perfectly ordinary `lua` file whose **path** merely contains one of those
28+
substrings (e.g. a plugin spec named `_neo-tree_.lua`, or anything under a
29+
`nvim-tree-config/` directory) is therefore treated as a tree buffer.
30+
31+
## Files
32+
33+
| File | Path contains | Classified as tree? |
34+
| ---------------------------- | ------------- | ------------------- |
35+
| `lua/plugins/_neo-tree_.lua` | `neo-tree` | **yes (bug)** |
36+
| `lua/NvimTree_settings.lua` | `NvimTree` | **yes (bug)** |
37+
| `lua/regular_plugin.lua` | _(none)_ | no (control) |
38+
39+
All three are plain `filetype=lua` files. Only the path differs.
40+
41+
## Reproduce manually
42+
43+
```bash
44+
source fixtures/nvim-aliases.sh
45+
vv issue-289 'lua/plugins/_neo-tree_.lua' # AFFECTED
46+
# (or) vv issue-289 'lua/regular_plugin.lua' # CONTROL
47+
```
48+
49+
or directly:
50+
51+
```bash
52+
NVIM_APPNAME=issue-289 XDG_CONFIG_HOME=fixtures \
53+
nvim fixtures/issue-289/lua/plugins/_neo-tree_.lua
54+
```
55+
56+
Then, in the buffer:
57+
58+
1. **Visual path** (the README-default `<leader>as`, which maps to
59+
`<cmd>ClaudeCodeSend<cr>` and keeps visual mode): visually select a few lines
60+
(`Vjj`) and press `<leader>as`.
61+
`ClaudeCode Error [ClaudeCode] [command] [ERROR] ClaudeCodeSend_visual->TreeAdd: Not in visual mode (current mode: n)`
62+
2. **Range path**: visually select a few lines, then `:'<,'>ClaudeCodeSend`.
63+
`ClaudeCode Error [ClaudeCode] [command] [ERROR] ClaudeCodeSend->TreeAdd: Not in a supported tree buffer (current filetype: lua)`
64+
65+
Doing the same in `regular_plugin.lua` sends the selection with no error.
66+
67+
## Helper commands (provided by this fixture's `init.lua`)
68+
69+
- `:ReproState [path]` — write the current buffer's tree-classification state
70+
(`filetype`, `bufname`, `matches_filetype`, `legacy_path_substring_match`,
71+
`is_tree_buffer`, …) as JSON. `is_tree_buffer` mirrors the plugin's current
72+
(filetype-only) predicate. On the affected files you will see
73+
`legacy_path_substring_match=true` (the pre-#289 root cause) while
74+
`matches_filetype=false`; on **unfixed** code `is_tree_buffer` is `true`
75+
(the bug), and on **fixed** code it is `false` (correctly a normal buffer).
76+
- `:ReproDump [path]` — write all captured ClaudeCode notifications as JSON.
77+
- `:ReproClear` — clear the captured-notification buffer.
78+
79+
## Headless / automated reproduction
80+
81+
For a deterministic, CI-style check that drives the real `:ClaudeCodeSend`
82+
command and exits non-zero when the bug is present:
83+
84+
```bash
85+
nvim --headless -u NONE -l scripts/repro_issue_289.lua; echo "exit=$?"
86+
```
87+
88+
`exit=1` ⇒ reproduced (a `lua` buffer whose path contains `neo-tree` is
89+
misrouted into tree extraction while the control buffer sends correctly).
90+
`exit=0` ⇒ fixed.

fixtures/issue-289/init.lua

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
-- Repro fixture for issue #289:
2+
-- "[BUG] ClaudeCodeSend misclassifies regular buffers as tree buffers when
3+
-- file path contains 'neo-tree' or 'NvimTree'"
4+
--
5+
-- Root cause (lua/claudecode/init.lua, handle_send_normal + handle_send_visual):
6+
-- a buffer is classified as a file-explorer ("tree") buffer not only by its
7+
-- FILETYPE but also by a substring match against its BUFFER NAME:
8+
--
9+
-- local is_tree_buffer = current_ft == "NvimTree"
10+
-- or current_ft == "neo-tree"
11+
-- or ...
12+
-- or string.match(current_bufname, "neo%-tree") -- false positive
13+
-- or string.match(current_bufname, "NvimTree") -- false positive
14+
-- or string.match(current_bufname, "minifiles://")
15+
--
16+
-- So a perfectly ordinary file whose PATH happens to contain one of those
17+
-- substrings (e.g. a Neovim plugin spec at lua/plugins/_neo-tree_.lua, or any
18+
-- file under a directory called nvim-tree-config/) is mistaken for a tree.
19+
--
20+
-- Symptom, visual path (the README-default `<leader>as` keymap uses
21+
-- `<cmd>ClaudeCodeSend<cr>`, which KEEPS the buffer in visual mode):
22+
-- 1. wrapper sees mode == "v" -> exit_visual_and_schedule(visual_handler)
23+
-- 2. capture_visual_selection_data() returns nil (get_tree_state() is nil for
24+
-- a real `lua` buffer) and <Esc> is fed, dropping us into normal mode
25+
-- 3. handle_send_visual: is_tree_buffer == true (bufname match) -> takes the
26+
-- tree branch -> get_files_from_visual_selection(nil) -> validate_visual_mode()
27+
-- now fails because the mode is "n" -> logs:
28+
-- ClaudeCodeSend_visual->TreeAdd: Not in visual mode (current mode: n)
29+
-- ...and nothing is ever sent to Claude.
30+
--
31+
-- Symptom, normal/range path (`:'<,'>ClaudeCodeSend`): mode is "n", so
32+
-- handle_send_normal runs; is_tree_buffer is still true, so it calls
33+
-- integrations.get_selected_files_from_tree(), which fails with:
34+
-- ClaudeCodeSend->TreeAdd: Not in a supported tree buffer (current filetype: lua)
35+
--
36+
-- A control file whose path has NONE of those substrings works correctly.
37+
--
38+
-- Usage (from repo root):
39+
-- source fixtures/nvim-aliases.sh
40+
-- vv issue-289 'lua/plugins/_neo-tree_.lua' # AFFECTED (path has 'neo-tree')
41+
-- vv issue-289 'lua/regular_plugin.lua' # CONTROL (no substring)
42+
-- or directly:
43+
-- NVIM_APPNAME=issue-289 XDG_CONFIG_HOME=fixtures \
44+
-- nvim fixtures/issue-289/lua/plugins/_neo-tree_.lua
45+
--
46+
-- Commands provided for automation:
47+
-- :ReproDump [path] write captured ClaudeCode notifications as JSON to `path`
48+
-- (defaults to stdpath('cache')/issue289_notifications.json)
49+
-- :ReproClear clear the captured-notification buffer
50+
51+
local config_dir = vim.fn.stdpath("config")
52+
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
53+
vim.opt.rtp:prepend(repo_root)
54+
55+
vim.g.mapleader = " "
56+
vim.g.maplocalleader = "\\"
57+
58+
-- A little extra command height keeps long error notifications from triggering
59+
-- the blocking hit-enter prompt while driving Neovim with agent-tty.
60+
vim.o.cmdheight = 3
61+
vim.o.more = false
62+
63+
-- Capture every ClaudeCode notification so automation can assert on it without
64+
-- scraping the screen. We still forward to the original handler so the error is
65+
-- also visible in a screenshot.
66+
_G._repro_notifications = {}
67+
local original_notify = vim.notify
68+
vim.notify = function(msg, level, opts)
69+
table.insert(_G._repro_notifications, {
70+
message = tostring(msg),
71+
level = level,
72+
title = opts and opts.title or nil,
73+
})
74+
return original_notify(msg, level, opts)
75+
end
76+
77+
local ok, claudecode = pcall(require, "claudecode")
78+
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
79+
80+
claudecode.setup({
81+
auto_start = false,
82+
-- ERROR notifications fire regardless of log_level; "info" keeps the rest quiet.
83+
log_level = "info",
84+
-- No in-editor terminal needed to reproduce the misclassification.
85+
terminal = {
86+
provider = "none",
87+
},
88+
})
89+
90+
-- Best-effort: start the server so the CONTROL path can actually queue an
91+
-- at-mention. The bug itself does NOT require a running server or a connected
92+
-- client -- the misclassification happens before any server interaction.
93+
pcall(function()
94+
claudecode.start(false)
95+
end)
96+
97+
-- README-default visual keymap: this is the exact mapping the docs recommend
98+
-- and the one the reporter uses. `<cmd>...<cr>` PRESERVES visual mode, which is
99+
-- what routes the request into the (buggy) visual tree-extraction path.
100+
vim.keymap.set("v", "<leader>as", "<cmd>ClaudeCodeSend<cr>", { desc = "Send to Claude" })
101+
102+
vim.api.nvim_create_user_command("ReproDump", function(opts)
103+
local path = opts.args ~= "" and opts.args or (vim.fn.stdpath("cache") .. "/issue289_notifications.json")
104+
vim.fn.writefile({ vim.json.encode(_G._repro_notifications) }, path)
105+
original_notify("ReproDump -> " .. path .. " (" .. #_G._repro_notifications .. " notifications)", vim.log.levels.INFO)
106+
end, { nargs = "?", desc = "Write captured ClaudeCode notifications as JSON" })
107+
108+
vim.api.nvim_create_user_command("ReproClear", function()
109+
_G._repro_notifications = {}
110+
end, { desc = "Clear captured ClaudeCode notifications" })
111+
112+
-- Diagnostic snapshot: records how the CURRENT buffer would be classified so
113+
-- automation can assert on the misclassification directly (no screen-scraping).
114+
vim.api.nvim_create_user_command("ReproState", function(opts)
115+
local path = opts.args ~= "" and opts.args or (vim.fn.stdpath("cache") .. "/issue289_state.json")
116+
local buf = 0
117+
local ft = vim.bo[buf].filetype
118+
local bufname = vim.api.nvim_buf_get_name(buf)
119+
-- `is_tree_buffer` mirrors the plugin's CURRENT predicate (post-#289):
120+
-- filetype only. On the fixed plugin, running this in `_neo-tree_.lua` reports
121+
-- is_tree_buffer=false, i.e. the file is correctly treated as a normal buffer.
122+
local matches_filetype = ft == "NvimTree" or ft == "neo-tree" or ft == "oil" or ft == "minifiles" or ft == "netrw"
123+
-- Legacy pre-#289 signal: the buffer-NAME substring match that USED to also
124+
-- flip is_tree_buffer to true (the root cause of #289). Reported for
125+
-- diagnostics so the fixture still shows why ordinary files misfired before
126+
-- the fix: legacy_path_substring_match=true while is_tree_buffer=false means
127+
-- "this file would have been misclassified by the old code".
128+
local legacy_path_substring_match = (string.match(bufname, "neo%-tree") ~= nil)
129+
or (string.match(bufname, "NvimTree") ~= nil)
130+
or (string.match(bufname, "minifiles://") ~= nil)
131+
local state = {
132+
filetype = ft,
133+
bufname = bufname,
134+
matches_filetype = matches_filetype,
135+
legacy_path_substring_match = legacy_path_substring_match,
136+
is_tree_buffer = matches_filetype,
137+
has_send_command = vim.fn.exists(":ClaudeCodeSend") == 2,
138+
server_running = (function()
139+
local ok_cc, cc = pcall(require, "claudecode")
140+
return ok_cc and cc.state and cc.state.server ~= nil or false
141+
end)(),
142+
}
143+
vim.fn.writefile({ vim.json.encode(state) }, path)
144+
original_notify("ReproState -> " .. path, vim.log.levels.INFO)
145+
end, { nargs = "?", desc = "Write current-buffer tree-classification state as JSON" })
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- AFFECTED FILE (NvimTree variant) for issue #289.
2+
--
3+
-- Same bug as `_neo-tree_.lua`, but triggered by the
4+
-- `string.match(current_bufname, "NvimTree")` substring check. Filetype is
5+
-- still `lua`; only the path contains "NvimTree".
6+
7+
local M = {}
8+
9+
M.opts = {
10+
sort = { sorter = "case_sensitive" },
11+
view = { width = 30 },
12+
renderer = { group_empty = true },
13+
filters = { dotfiles = true },
14+
}
15+
16+
return M
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- AFFECTED FILE for issue #289.
2+
--
3+
-- This is an utterly ordinary Lua file. Its FILETYPE is `lua`. The ONLY reason
4+
-- ClaudeCodeSend misbehaves here is that its PATH contains the substring
5+
-- "neo-tree" (this file is named `_neo-tree_.lua`), which trips the
6+
-- `string.match(current_bufname, "neo%-tree")` false positive.
7+
--
8+
-- Visually select a few of these lines and press <leader>as (or run
9+
-- :'<,'>ClaudeCodeSend) -> you get a TreeAdd error and nothing is sent.
10+
11+
return {
12+
"nvim-neo-tree/neo-tree.nvim",
13+
branch = "v3.x",
14+
dependencies = {
15+
"nvim-lua/plenary.nvim",
16+
"nvim-tree/nvim-web-devicons",
17+
"MunifTanjim/nui.nvim",
18+
},
19+
config = function()
20+
require("neo-tree").setup({
21+
close_if_last_window = true,
22+
filesystem = {
23+
follow_current_file = { enabled = true },
24+
},
25+
})
26+
end,
27+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CONTROL FILE for issue #289.
2+
--
3+
-- Identical in spirit to `_neo-tree_.lua`, but its path contains NONE of the
4+
-- magic substrings (no "neo-tree", "NvimTree", or "minifiles://"). Visually
5+
-- selecting lines here and pressing <leader>as works correctly: the selection
6+
-- is sent (or queued) with no TreeAdd error.
7+
8+
return {
9+
"some-author/some-plugin.nvim",
10+
dependencies = {
11+
"nvim-lua/plenary.nvim",
12+
},
13+
config = function()
14+
require("some-plugin").setup({
15+
enabled = true,
16+
option_one = "value",
17+
option_two = 42,
18+
})
19+
end,
20+
}

lua/claudecode/init.lua

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -727,17 +727,19 @@ function M._create_commands()
727727

728728
local function handle_send_normal(opts)
729729
local current_ft = (vim.bo and vim.bo.filetype) or ""
730-
local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or ""
731730

731+
-- Classify tree/explorer buffers by FILETYPE only. Matching the buffer name
732+
-- (an absolute path) misfires on ordinary files whose path merely contains a
733+
-- substring like "neo-tree"/"NvimTree" (issue #289). The downstream
734+
-- extractors (integrations.get_selected_files_from_tree /
735+
-- visual_commands.get_tree_state) are filetype-only, so a name-only match
736+
-- could never extract anyway.
732737
local is_tree_buffer = current_ft == "NvimTree"
733738
or current_ft == "neo-tree"
734739
or current_ft == "oil"
735740
or current_ft == "minifiles"
736741
or current_ft == "netrw"
737742
or current_ft == "snacks_picker_list"
738-
or string.match(current_bufname, "neo%-tree")
739-
or string.match(current_bufname, "NvimTree")
740-
or string.match(current_bufname, "minifiles://")
741743

742744
if is_tree_buffer then
743745
local integrations = require("claudecode.integrations")
@@ -781,26 +783,24 @@ function M._create_commands()
781783
end
782784

783785
local function handle_send_visual(visual_data, opts)
784-
-- Check if we're in a tree buffer first
786+
-- Check if we're in a tree buffer first. Classify by FILETYPE only; matching
787+
-- the buffer name (an absolute path) misfires on ordinary files whose path
788+
-- merely contains "neo-tree"/"NvimTree" (issue #289).
785789
local current_ft = (vim.bo and vim.bo.filetype) or ""
786-
local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or ""
787790

788791
local is_tree_buffer = current_ft == "NvimTree"
789792
or current_ft == "neo-tree"
790793
or current_ft == "oil"
791794
or current_ft == "minifiles"
792795
or current_ft == "netrw"
793-
or string.match(current_bufname, "neo%-tree")
794-
or string.match(current_bufname, "NvimTree")
795-
or string.match(current_bufname, "minifiles://")
796796

797797
if is_tree_buffer then
798798
local integrations = require("claudecode.integrations")
799799
local visual_cmd_module = require("claudecode.visual_commands")
800800
local files, error
801801

802802
-- For mini.files, try to get the range from visual marks for accuracy
803-
if current_ft == "minifiles" or string.match(current_bufname, "minifiles://") then
803+
if current_ft == "minifiles" then
804804
local start_line = vim.fn.line("'<")
805805
local end_line = vim.fn.line("'>")
806806

0 commit comments

Comments
 (0)