Skip to content

Commit 9952bd4

Browse files
committed
fix: implement proper nvim-tree visual selection support
- Replace fallback to single-file selection with line-by-line node mapping - Support visual line selection (V), character selection (v), and block selection (Ctrl-V) - Add comprehensive test suite for nvim-tree visual selection scenarios - Include deduplication and root-level file filtering - Maintain compatibility with existing nvim-tree marks functionality Fixes issue where multi-selection in nvim-tree only sent cursor file instead of all selected files. Change-Id: Ida7b3154f0b6749903ff0e847752d5c263a09de7 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent a35d422 commit 9952bd4

File tree

2 files changed

+298
-8
lines changed

2 files changed

+298
-8
lines changed

lua/claudecode/visual_commands.lua

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,69 @@ function M.get_files_from_visual_selection(visual_data)
275275
end
276276
end
277277
elseif tree_type == "nvim-tree" then
278-
-- For nvim-tree, we'll fall back to using the integrations module
279-
-- since nvim-tree doesn't have the same line-to-node mapping as neo-tree
280-
local integrations = require("claudecode.integrations")
281-
local tree_files, tree_err = integrations._get_nvim_tree_selection()
282-
283-
if tree_err then
284-
return {}, tree_err
278+
-- For nvim-tree, we need to manually map visual lines to tree nodes
279+
-- since nvim-tree doesn't have direct line-to-node mapping like neo-tree
280+
require("claudecode.logger").debug(
281+
"visual_commands",
282+
"Processing nvim-tree visual selection from line",
283+
start_pos,
284+
"to",
285+
end_pos
286+
)
287+
288+
local nvim_tree_api = tree_state
289+
local current_buf = vim.api.nvim_get_current_buf()
290+
291+
-- Get all lines in the visual selection
292+
local lines = vim.api.nvim_buf_get_lines(current_buf, start_pos - 1, end_pos, false)
293+
294+
require("claudecode.logger").debug("visual_commands", "Found", #lines, "lines in visual selection")
295+
296+
-- For each line in the visual selection, try to get the corresponding node
297+
for i, line_content in ipairs(lines) do
298+
local line_num = start_pos + i - 1
299+
300+
-- Set cursor to this line to get the node
301+
pcall(vim.api.nvim_win_set_cursor, 0, { line_num, 0 })
302+
303+
-- Get node under cursor for this line
304+
local node_success, node = pcall(nvim_tree_api.tree.get_node_under_cursor)
305+
if node_success and node then
306+
require("claudecode.logger").debug(
307+
"visual_commands",
308+
"Line",
309+
line_num,
310+
"node type:",
311+
node.type,
312+
"path:",
313+
node.absolute_path
314+
)
315+
316+
if node.type == "file" and node.absolute_path and node.absolute_path ~= "" then
317+
-- Check if it's not a root-level file (basic protection)
318+
if not string.match(node.absolute_path, "^/[^/]*$") then
319+
table.insert(files, node.absolute_path)
320+
end
321+
elseif node.type == "directory" and node.absolute_path and node.absolute_path ~= "" then
322+
table.insert(files, node.absolute_path)
323+
end
324+
else
325+
require("claudecode.logger").debug("visual_commands", "No valid node found for line", line_num)
326+
end
285327
end
286328

287-
files = tree_files
329+
require("claudecode.logger").debug("visual_commands", "Extracted", #files, "files from nvim-tree visual selection")
330+
331+
-- Remove duplicates while preserving order
332+
local seen = {}
333+
local unique_files = {}
334+
for _, file_path in ipairs(files) do
335+
if not seen[file_path] then
336+
seen[file_path] = true
337+
table.insert(unique_files, file_path)
338+
end
339+
end
340+
files = unique_files
288341
end
289342

290343
return files, nil
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
-- luacheck: globals expect
2+
require("tests.busted_setup")
3+
4+
describe("NvimTree Visual Selection", function()
5+
local visual_commands
6+
local mock_vim
7+
8+
local function setup_mocks()
9+
package.loaded["claudecode.visual_commands"] = nil
10+
package.loaded["claudecode.logger"] = nil
11+
12+
-- Mock logger
13+
package.loaded["claudecode.logger"] = {
14+
debug = function() end,
15+
warn = function() end,
16+
error = function() end,
17+
}
18+
19+
mock_vim = {
20+
fn = {
21+
mode = function()
22+
return "V" -- Visual line mode
23+
end,
24+
getpos = function(mark)
25+
if mark == "'<" then
26+
return { 0, 2, 0, 0 } -- Start at line 2
27+
elseif mark == "'>" then
28+
return { 0, 4, 0, 0 } -- End at line 4
29+
elseif mark == "v" then
30+
return { 0, 2, 0, 0 } -- Anchor at line 2
31+
end
32+
return { 0, 0, 0, 0 }
33+
end,
34+
},
35+
api = {
36+
nvim_get_current_win = function()
37+
return 1002
38+
end,
39+
nvim_get_mode = function()
40+
return { mode = "V" }
41+
end,
42+
nvim_get_current_buf = function()
43+
return 1
44+
end,
45+
nvim_win_get_cursor = function()
46+
return { 4, 0 } -- Cursor at line 4
47+
end,
48+
nvim_buf_get_lines = function(buf, start, end_line, strict)
49+
-- Return mock buffer lines for the visual selection
50+
return {
51+
" 📁 src/",
52+
" 📄 init.lua",
53+
" 📄 config.lua",
54+
}
55+
end,
56+
nvim_win_set_cursor = function(win, pos)
57+
-- Mock cursor setting
58+
end,
59+
nvim_replace_termcodes = function(keys, from_part, do_lt, special)
60+
return keys
61+
end,
62+
},
63+
bo = { filetype = "NvimTree" },
64+
schedule = function(fn)
65+
fn()
66+
end,
67+
}
68+
69+
_G.vim = mock_vim
70+
end
71+
72+
before_each(function()
73+
setup_mocks()
74+
end)
75+
76+
describe("nvim-tree visual selection handling", function()
77+
before_each(function()
78+
visual_commands = require("claudecode.visual_commands")
79+
end)
80+
81+
it("should extract files from visual selection in nvim-tree", function()
82+
-- Create a stateful mock that tracks cursor position
83+
local cursor_positions = {}
84+
local expected_nodes = {
85+
[2] = { type = "directory", absolute_path = "/Users/test/project/src" },
86+
[3] = { type = "file", absolute_path = "/Users/test/project/init.lua" },
87+
[4] = { type = "file", absolute_path = "/Users/test/project/config.lua" },
88+
}
89+
90+
mock_vim.api.nvim_win_set_cursor = function(win, pos)
91+
cursor_positions[#cursor_positions + 1] = pos[1]
92+
end
93+
94+
local mock_nvim_tree_api = {
95+
tree = {
96+
get_node_under_cursor = function()
97+
local current_line = cursor_positions[#cursor_positions] or 2
98+
return expected_nodes[current_line]
99+
end,
100+
},
101+
}
102+
103+
local visual_data = {
104+
tree_state = mock_nvim_tree_api,
105+
tree_type = "nvim-tree",
106+
start_pos = 2,
107+
end_pos = 4,
108+
}
109+
110+
local files, err = visual_commands.get_files_from_visual_selection(visual_data)
111+
112+
expect(err).to_be_nil()
113+
expect(files).to_be_table()
114+
expect(#files).to_be(3)
115+
expect(files[1]).to_be("/Users/test/project/src")
116+
expect(files[2]).to_be("/Users/test/project/init.lua")
117+
expect(files[3]).to_be("/Users/test/project/config.lua")
118+
end)
119+
120+
it("should handle empty visual selection in nvim-tree", function()
121+
local mock_nvim_tree_api = {
122+
tree = {
123+
get_node_under_cursor = function()
124+
return nil -- No node found
125+
end,
126+
},
127+
}
128+
129+
local visual_data = {
130+
tree_state = mock_nvim_tree_api,
131+
tree_type = "nvim-tree",
132+
start_pos = 2,
133+
end_pos = 2,
134+
}
135+
136+
local files, err = visual_commands.get_files_from_visual_selection(visual_data)
137+
138+
expect(err).to_be_nil()
139+
expect(files).to_be_table()
140+
expect(#files).to_be(0)
141+
end)
142+
143+
it("should filter out root-level files in nvim-tree", function()
144+
local mock_nvim_tree_api = {
145+
tree = {
146+
get_node_under_cursor = function()
147+
return {
148+
type = "file",
149+
absolute_path = "/root_file.txt", -- Root-level file should be filtered
150+
}
151+
end,
152+
},
153+
}
154+
155+
local visual_data = {
156+
tree_state = mock_nvim_tree_api,
157+
tree_type = "nvim-tree",
158+
start_pos = 1,
159+
end_pos = 1,
160+
}
161+
162+
local files, err = visual_commands.get_files_from_visual_selection(visual_data)
163+
164+
expect(err).to_be_nil()
165+
expect(files).to_be_table()
166+
expect(#files).to_be(0) -- Root-level file should be filtered out
167+
end)
168+
169+
it("should remove duplicate files in visual selection", function()
170+
local call_count = 0
171+
local mock_nvim_tree_api = {
172+
tree = {
173+
get_node_under_cursor = function()
174+
call_count = call_count + 1
175+
-- Return the same file path twice to test deduplication
176+
return {
177+
type = "file",
178+
absolute_path = "/Users/test/project/duplicate.lua",
179+
}
180+
end,
181+
},
182+
}
183+
184+
local visual_data = {
185+
tree_state = mock_nvim_tree_api,
186+
tree_type = "nvim-tree",
187+
start_pos = 1,
188+
end_pos = 2, -- Two lines, same file
189+
}
190+
191+
local files, err = visual_commands.get_files_from_visual_selection(visual_data)
192+
193+
expect(err).to_be_nil()
194+
expect(files).to_be_table()
195+
expect(#files).to_be(1) -- Should have only one instance
196+
expect(files[1]).to_be("/Users/test/project/duplicate.lua")
197+
end)
198+
199+
it("should handle mixed file and directory selection", function()
200+
local cursor_positions = {}
201+
local expected_nodes = {
202+
[1] = { type = "directory", absolute_path = "/Users/test/project/lib" },
203+
[2] = { type = "file", absolute_path = "/Users/test/project/main.lua" },
204+
[3] = { type = "directory", absolute_path = "/Users/test/project/tests" },
205+
}
206+
207+
mock_vim.api.nvim_win_set_cursor = function(win, pos)
208+
cursor_positions[#cursor_positions + 1] = pos[1]
209+
end
210+
211+
local mock_nvim_tree_api = {
212+
tree = {
213+
get_node_under_cursor = function()
214+
local current_line = cursor_positions[#cursor_positions] or 1
215+
return expected_nodes[current_line]
216+
end,
217+
},
218+
}
219+
220+
local visual_data = {
221+
tree_state = mock_nvim_tree_api,
222+
tree_type = "nvim-tree",
223+
start_pos = 1,
224+
end_pos = 3,
225+
}
226+
227+
local files, err = visual_commands.get_files_from_visual_selection(visual_data)
228+
229+
expect(err).to_be_nil()
230+
expect(files).to_be_table()
231+
expect(#files).to_be(3)
232+
expect(files[1]).to_be("/Users/test/project/lib")
233+
expect(files[2]).to_be("/Users/test/project/main.lua")
234+
expect(files[3]).to_be("/Users/test/project/tests")
235+
end)
236+
end)
237+
end)

0 commit comments

Comments
 (0)