Skip to content

Commit fdce068

Browse files
committed
feat(integrations): add visual mode multi-file selection for snacks.explorer
Enable selecting multiple files in snacks.explorer using vim's visual mode. This brings the snacks.explorer integration up to par with others, such as oil, nvim-tree and NvimTree - Pass visual range parameters to _get_snacks_explorer_selection() - Use picker's list API to convert row numbers to items (row2idx/get) - Handle edge cases like nil items and empty file paths - Add comprehensive test coverage for visual selection scenarios
1 parent 00aa785 commit fdce068

File tree

4 files changed

+220
-16
lines changed

4 files changed

+220
-16
lines changed

dev-config.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ return {
2323
"<leader>as",
2424
"<cmd>ClaudeCodeTreeAdd<cr>",
2525
desc = "Add file from tree",
26-
ft = { "NvimTree", "neo-tree", "oil" },
26+
ft = { "NvimTree", "neo-tree", "oil", "snacks_picker_list" }, -- snacks.explorer uses "snacks_picker_list" filetype
2727
},
2828

2929
-- Development helpers

lua/claudecode/integrations.lua

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,11 @@ end
265265

266266
--- Get selected files from snacks.explorer
267267
--- Uses the picker API to get the current selection
268+
--- @param visual_start number|nil Start line of visual selection (optional)
269+
--- @param visual_end number|nil End line of visual selection (optional)
268270
--- @return table files List of file paths
269271
--- @return string|nil error Error message if operation failed
270-
function M._get_snacks_explorer_selection()
272+
function M._get_snacks_explorer_selection(visual_start, visual_end)
271273
local snacks_ok, snacks = pcall(require, "snacks")
272274
if not snacks_ok or not snacks.picker then
273275
return {}, "snacks.nvim not available"
@@ -287,14 +289,61 @@ function M._get_snacks_explorer_selection()
287289

288290
local files = {}
289291

290-
-- Check if there are selected items
292+
-- Helper function to extract file path from various item structures
293+
local function extract_file_path(item)
294+
if not item then
295+
return nil
296+
end
297+
local file_path = item.file or item.path or (item.item and item.item.file) or (item.item and item.item.path)
298+
299+
-- Add trailing slash for directories
300+
if file_path and file_path ~= "" and vim.fn.isdirectory(file_path) == 1 then
301+
if not file_path:match("/$") then
302+
file_path = file_path .. "/"
303+
end
304+
end
305+
306+
return file_path
307+
end
308+
309+
-- Helper function to check if path is safe (not root-level)
310+
local function is_safe_path(file_path)
311+
if not file_path or file_path == "" then
312+
return false
313+
end
314+
-- Not root-level file & this prevents selecting files like /etc/passwd, /usr/bin/vim, etc.
315+
return not string.match(file_path, "^/[^/]*$")
316+
end
317+
318+
-- Handle visual mode selection if range is provided
319+
if visual_start and visual_end and explorer.list then
320+
-- Process each line in the visual selection
321+
for row = visual_start, visual_end do
322+
-- Convert row to picker index
323+
local idx = explorer.list:row2idx(row)
324+
if idx then
325+
-- Get the item at this index
326+
local item = explorer.list:get(idx)
327+
if item then
328+
local file_path = extract_file_path(item)
329+
if file_path and file_path ~= "" and is_safe_path(file_path) then
330+
table.insert(files, file_path)
331+
end
332+
end
333+
end
334+
end
335+
if #files > 0 then
336+
return files, nil
337+
end
338+
end
339+
340+
-- Check if there are selected items (using toggle selection)
291341
local selected = explorer:selected({ fallback = false })
292342
if selected and #selected > 0 then
293343
-- Process selected items
294344
for _, item in ipairs(selected) do
295-
-- Try different possible fields for file path
296-
local file_path = item.file or item.path or (item.item and item.item.file) or (item.item and item.item.path)
297-
if file_path and file_path ~= "" then
345+
local file_path = extract_file_path(item)
346+
if file_path and file_path ~= "" and is_safe_path(file_path) then
298347
table.insert(files, file_path)
299348
end
300349
end
@@ -306,13 +355,13 @@ function M._get_snacks_explorer_selection()
306355
-- Fall back to current item under cursor
307356
local current = explorer:current({ resolve = true })
308357
if current then
309-
-- Try different possible fields for file path
310-
local file_path = current.file
311-
or current.path
312-
or (current.item and current.item.file)
313-
or (current.item and current.item.path)
358+
local file_path = extract_file_path(current)
314359
if file_path and file_path ~= "" then
315-
return { file_path }, nil
360+
if is_safe_path(file_path) then
361+
return { file_path }, nil
362+
else
363+
return {}, "Cannot add root-level file. Please select a file in a subdirectory."
364+
end
316365
end
317366
end
318367

lua/claudecode/visual_commands.lua

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,9 @@ function M.get_files_from_visual_selection(visual_data)
392392
end
393393
end
394394
elseif tree_type == "snacks-explorer" then
395-
-- For snacks.explorer, we need to handle visual selection differently
396-
-- since it's a picker and doesn't have a traditional tree structure
395+
-- For snacks.explorer, pass the visual range to handle multi-selection
397396
local integrations = require("claudecode.integrations")
398-
local selected_files, error = integrations._get_snacks_explorer_selection()
397+
local selected_files, error = integrations._get_snacks_explorer_selection(start_pos, end_pos)
399398

400399
if not error and selected_files and #selected_files > 0 then
401400
for _, file in ipairs(selected_files) do

tests/unit/snacks_explorer_spec.lua

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,162 @@ describe("snacks.explorer integration", function()
186186

187187
package.loaded["snacks"] = nil
188188
end)
189+
190+
it("should handle visual mode selection with range parameters", function()
191+
-- Mock snacks module with explorer picker that has list
192+
local mock_list = {
193+
row2idx = function(self, row)
194+
return row -- Simple 1:1 mapping for test
195+
end,
196+
get = function(self, idx)
197+
local items = {
198+
[1] = { file = "/path/to/file1.lua" },
199+
[2] = { file = "/path/to/file2.lua" },
200+
[3] = { file = "/path/to/file3.lua" },
201+
[4] = { file = "/path/to/file4.lua" },
202+
[5] = { file = "/path/to/file5.lua" },
203+
}
204+
return items[idx]
205+
end,
206+
}
207+
208+
local mock_explorer = {
209+
list = mock_list,
210+
selected = function(self, opts)
211+
return {} -- No marked selection
212+
end,
213+
current = function(self, opts)
214+
return { file = "/path/to/current.lua" }
215+
end,
216+
}
217+
218+
local mock_snacks = {
219+
picker = {
220+
get = function(opts)
221+
if opts.source == "explorer" then
222+
return { mock_explorer }
223+
end
224+
return {}
225+
end,
226+
},
227+
}
228+
229+
package.loaded["snacks"] = mock_snacks
230+
231+
-- Test visual selection from lines 2 to 4
232+
local files, err = integrations._get_snacks_explorer_selection(2, 4)
233+
assert.is_nil(err)
234+
assert.are.same({
235+
"/path/to/file2.lua",
236+
"/path/to/file3.lua",
237+
"/path/to/file4.lua",
238+
}, files)
239+
240+
package.loaded["snacks"] = nil
241+
end)
242+
243+
it("should handle visual mode with missing items and empty paths", function()
244+
-- Mock snacks module with some problematic items
245+
local mock_list = {
246+
row2idx = function(self, row)
247+
-- Some rows don't have corresponding indices
248+
if row == 3 then
249+
return nil
250+
end
251+
return row
252+
end,
253+
get = function(self, idx)
254+
local items = {
255+
[1] = { file = "" }, -- Empty path
256+
[2] = { file = "/valid/file.lua" },
257+
[4] = { path = "/path/based/file.lua" }, -- Using path field
258+
[5] = nil, -- nil item
259+
}
260+
return items[idx]
261+
end,
262+
}
263+
264+
local mock_explorer = {
265+
list = mock_list,
266+
selected = function(self, opts)
267+
return {}
268+
end,
269+
current = function(self, opts)
270+
return { file = "/current.lua" }
271+
end,
272+
}
273+
274+
local mock_snacks = {
275+
picker = {
276+
get = function(opts)
277+
if opts.source == "explorer" then
278+
return { mock_explorer }
279+
end
280+
return {}
281+
end,
282+
},
283+
}
284+
285+
package.loaded["snacks"] = mock_snacks
286+
287+
-- Test visual selection from lines 1 to 5
288+
local files, err = integrations._get_snacks_explorer_selection(1, 5)
289+
assert.is_nil(err)
290+
-- Should only get the valid files
291+
assert.are.same({
292+
"/valid/file.lua",
293+
"/path/based/file.lua",
294+
}, files)
295+
296+
package.loaded["snacks"] = nil
297+
end)
298+
299+
it("should add trailing slashes to directories", function()
300+
-- Mock vim.fn.isdirectory to return true for directory paths
301+
local original_isdirectory = vim.fn.isdirectory
302+
vim.fn.isdirectory = function(path)
303+
return path:match("/directory") and 1 or 0
304+
end
305+
306+
-- Mock snacks module with directory items
307+
local mock_explorer = {
308+
selected = function(self, opts)
309+
return {
310+
{ file = "/path/to/file.lua" }, -- file
311+
{ file = "/path/to/directory" }, -- directory (no trailing slash)
312+
{ file = "/path/to/another_directory/" }, -- directory (already has slash)
313+
}
314+
end,
315+
current = function(self, opts)
316+
return { file = "/current/directory" } -- directory
317+
end,
318+
}
319+
320+
local mock_snacks = {
321+
picker = {
322+
get = function(opts)
323+
if opts.source == "explorer" then
324+
return { mock_explorer }
325+
end
326+
return {}
327+
end,
328+
},
329+
}
330+
331+
package.loaded["snacks"] = mock_snacks
332+
333+
local files, err = integrations._get_snacks_explorer_selection()
334+
assert.is_nil(err)
335+
assert.are.same({
336+
"/path/to/file.lua", -- file unchanged
337+
"/path/to/directory/", -- directory with added slash
338+
"/path/to/another_directory/", -- directory with existing slash unchanged
339+
}, files)
340+
341+
-- Restore original function
342+
vim.fn.isdirectory = original_isdirectory
343+
package.loaded["snacks"] = nil
344+
end)
189345
end)
190346

191347
describe("get_selected_files_from_tree", function()
@@ -222,4 +378,4 @@ describe("snacks.explorer integration", function()
222378
package.loaded["snacks"] = nil
223379
end)
224380
end)
225-
end)
381+
end)

0 commit comments

Comments
 (0)