Skip to content

Commit 00aa785

Browse files
committed
feat(integrations): add snacks.explorer file explorer support (file-send)
This PR aims to add an equivalent "ClaudeCodeSend" integration for snacks.explorer, as what already exists for nvim-tree, neo-tree and oil.nvim Snacks.explorer seems to have become the default LazyVim file explorer, and has gained a fair bit of traction. - Add snacks_picker_list filetype detection across the codebase - Implement _get_snacks_explorer_selection() to handle file selection - Support both individual selection and current file fallback - Handle visual mode for snacks.explorer in visual commands - Add comprehensive test coverage for the new integration
1 parent 91357d8 commit 00aa785

File tree

5 files changed

+308
-2
lines changed

5 files changed

+308
-2
lines changed

lua/claudecode/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,7 @@ function M._create_commands()
611611
local is_tree_buffer = current_ft == "NvimTree"
612612
or current_ft == "neo-tree"
613613
or current_ft == "oil"
614+
or current_ft == "snacks_picker_list"
614615
or string.match(current_bufname, "neo%-tree")
615616
or string.match(current_bufname, "NvimTree")
616617

lua/claudecode/integrations.lua

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
-- Tree integration module for ClaudeCode.nvim
3-
-- Handles detection and selection of files from nvim-tree, neo-tree, and oil.nvim
3+
-- Handles detection and selection of files from nvim-tree, neo-tree, oil.nvim and snacks.explorer
44
-- @module claudecode.integrations
55
local M = {}
66

@@ -16,6 +16,8 @@ function M.get_selected_files_from_tree()
1616
return M._get_neotree_selection()
1717
elseif current_ft == "oil" then
1818
return M._get_oil_selection()
19+
elseif current_ft == "snacks_picker_list" then
20+
return M._get_snacks_explorer_selection()
1921
else
2022
return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")"
2123
end
@@ -261,4 +263,60 @@ function M._get_oil_selection()
261263
return {}, "No file found under cursor"
262264
end
263265

266+
--- Get selected files from snacks.explorer
267+
--- Uses the picker API to get the current selection
268+
--- @return table files List of file paths
269+
--- @return string|nil error Error message if operation failed
270+
function M._get_snacks_explorer_selection()
271+
local snacks_ok, snacks = pcall(require, "snacks")
272+
if not snacks_ok or not snacks.picker then
273+
return {}, "snacks.nvim not available"
274+
end
275+
276+
-- Get the current explorer picker
277+
local explorers = snacks.picker.get({ source = "explorer" })
278+
if not explorers or #explorers == 0 then
279+
return {}, "No active snacks.explorer found"
280+
end
281+
282+
-- Get the first (and likely only) explorer instance
283+
local explorer = explorers[1]
284+
if not explorer then
285+
return {}, "No active snacks.explorer found"
286+
end
287+
288+
local files = {}
289+
290+
-- Check if there are selected items
291+
local selected = explorer:selected({ fallback = false })
292+
if selected and #selected > 0 then
293+
-- Process selected items
294+
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
298+
table.insert(files, file_path)
299+
end
300+
end
301+
if #files > 0 then
302+
return files, nil
303+
end
304+
end
305+
306+
-- Fall back to current item under cursor
307+
local current = explorer:current({ resolve = true })
308+
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)
314+
if file_path and file_path ~= "" then
315+
return { file_path }, nil
316+
end
317+
end
318+
319+
return {}, "No file found under cursor"
320+
end
321+
264322
return M

lua/claudecode/tools/open_file.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ local function find_main_editor_window()
7878
or filetype == "oil"
7979
or filetype == "aerial"
8080
or filetype == "tagbar"
81+
or filetype == "snacks_picker_list"
8182
)
8283
then
8384
is_suitable = false

lua/claudecode/visual_commands.lua

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ function M.get_visual_range()
135135
end
136136

137137
--- Check if we're in a tree buffer and get the tree state
138-
--- @return table|nil, string|nil tree_state, tree_type ("neo-tree" or "nvim-tree")
138+
--- @return table|nil, string|nil tree_state, tree_type ("neo-tree", "nvim-tree", "oil", or "snacks-explorer")
139139
function M.get_tree_state()
140140
local current_ft = "" -- Default fallback
141141
local current_win = 0 -- Default fallback
@@ -181,6 +181,16 @@ function M.get_tree_state()
181181
end
182182

183183
return oil, "oil"
184+
elseif current_ft == "snacks_picker_list" then
185+
local snacks_success, snacks = pcall(require, "snacks")
186+
if not snacks_success or not snacks.picker then
187+
return nil, nil
188+
end
189+
190+
local explorers = snacks.picker.get({ source = "explorer" })
191+
if explorers and #explorers > 0 then
192+
return explorers[1], "snacks-explorer"
193+
end
184194
else
185195
return nil, nil
186196
end
@@ -381,6 +391,17 @@ function M.get_files_from_visual_selection(visual_data)
381391
end
382392
end
383393
end
394+
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
397+
local integrations = require("claudecode.integrations")
398+
local selected_files, error = integrations._get_snacks_explorer_selection()
399+
400+
if not error and selected_files and #selected_files > 0 then
401+
for _, file in ipairs(selected_files) do
402+
table.insert(files, file)
403+
end
404+
end
384405
end
385406

386407
return files, nil

tests/unit/snacks_explorer_spec.lua

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
local helpers = require("tests.helpers.setup")
2+
local integrations = require("claudecode.integrations")
3+
4+
describe("snacks.explorer integration", function()
5+
before_each(function()
6+
helpers.setup()
7+
end)
8+
9+
after_each(function()
10+
helpers.cleanup()
11+
end)
12+
13+
describe("_get_snacks_explorer_selection", function()
14+
it("should return error when snacks.nvim is not available", function()
15+
-- Mock require to fail for snacks
16+
local original_require = _G.require
17+
_G.require = function(module)
18+
if module == "snacks" then
19+
error("Module not found")
20+
end
21+
return original_require(module)
22+
end
23+
24+
local files, err = integrations._get_snacks_explorer_selection()
25+
assert.are.same({}, files)
26+
assert.equals("snacks.nvim not available", err)
27+
28+
-- Restore original require
29+
_G.require = original_require
30+
end)
31+
32+
it("should return error when no explorer picker is active", function()
33+
-- Mock snacks module
34+
local mock_snacks = {
35+
picker = {
36+
get = function()
37+
return {}
38+
end,
39+
},
40+
}
41+
42+
package.loaded["snacks"] = mock_snacks
43+
44+
local files, err = integrations._get_snacks_explorer_selection()
45+
assert.are.same({}, files)
46+
assert.equals("No active snacks.explorer found", err)
47+
48+
package.loaded["snacks"] = nil
49+
end)
50+
51+
it("should return selected files from snacks.explorer", function()
52+
-- Mock snacks module with explorer picker
53+
local mock_explorer = {
54+
selected = function(self, opts)
55+
return {
56+
{ file = "/path/to/file1.lua" },
57+
{ file = "/path/to/file2.lua" },
58+
}
59+
end,
60+
current = function(self, opts)
61+
return { file = "/path/to/current.lua" }
62+
end,
63+
}
64+
65+
local mock_snacks = {
66+
picker = {
67+
get = function(opts)
68+
if opts.source == "explorer" then
69+
return { mock_explorer }
70+
end
71+
return {}
72+
end,
73+
},
74+
}
75+
76+
package.loaded["snacks"] = mock_snacks
77+
78+
local files, err = integrations._get_snacks_explorer_selection()
79+
assert.is_nil(err)
80+
assert.are.same({ "/path/to/file1.lua", "/path/to/file2.lua" }, files)
81+
82+
package.loaded["snacks"] = nil
83+
end)
84+
85+
it("should fall back to current file when no selection", function()
86+
-- Mock snacks module with explorer picker
87+
local mock_explorer = {
88+
selected = function(self, opts)
89+
return {}
90+
end,
91+
current = function(self, opts)
92+
return { file = "/path/to/current.lua" }
93+
end,
94+
}
95+
96+
local mock_snacks = {
97+
picker = {
98+
get = function(opts)
99+
if opts.source == "explorer" then
100+
return { mock_explorer }
101+
end
102+
return {}
103+
end,
104+
},
105+
}
106+
107+
package.loaded["snacks"] = mock_snacks
108+
109+
local files, err = integrations._get_snacks_explorer_selection()
110+
assert.is_nil(err)
111+
assert.are.same({ "/path/to/current.lua" }, files)
112+
113+
package.loaded["snacks"] = nil
114+
end)
115+
116+
it("should handle empty file paths", function()
117+
-- Mock snacks module with empty file paths
118+
local mock_explorer = {
119+
selected = function(self, opts)
120+
return {
121+
{ file = "" },
122+
{ file = "/valid/path.lua" },
123+
{ file = nil },
124+
}
125+
end,
126+
current = function(self, opts)
127+
return { file = "" }
128+
end,
129+
}
130+
131+
local mock_snacks = {
132+
picker = {
133+
get = function(opts)
134+
if opts.source == "explorer" then
135+
return { mock_explorer }
136+
end
137+
return {}
138+
end,
139+
},
140+
}
141+
142+
package.loaded["snacks"] = mock_snacks
143+
144+
local files, err = integrations._get_snacks_explorer_selection()
145+
assert.is_nil(err)
146+
assert.are.same({ "/valid/path.lua" }, files)
147+
148+
package.loaded["snacks"] = nil
149+
end)
150+
151+
it("should try alternative fields for file path", function()
152+
-- Mock snacks module with different field names
153+
local mock_explorer = {
154+
selected = function(self, opts)
155+
return {
156+
{ path = "/path/from/path.lua" },
157+
{ item = { file = "/path/from/item.file.lua" } },
158+
{ item = { path = "/path/from/item.path.lua" } },
159+
}
160+
end,
161+
current = function(self, opts)
162+
return { path = "/current/from/path.lua" }
163+
end,
164+
}
165+
166+
local mock_snacks = {
167+
picker = {
168+
get = function(opts)
169+
if opts.source == "explorer" then
170+
return { mock_explorer }
171+
end
172+
return {}
173+
end,
174+
},
175+
}
176+
177+
package.loaded["snacks"] = mock_snacks
178+
179+
local files, err = integrations._get_snacks_explorer_selection()
180+
assert.is_nil(err)
181+
assert.are.same({
182+
"/path/from/path.lua",
183+
"/path/from/item.file.lua",
184+
"/path/from/item.path.lua",
185+
}, files)
186+
187+
package.loaded["snacks"] = nil
188+
end)
189+
end)
190+
191+
describe("get_selected_files_from_tree", function()
192+
it("should detect snacks_picker_list filetype", function()
193+
vim.bo.filetype = "snacks_picker_list"
194+
195+
-- Mock snacks module
196+
local mock_explorer = {
197+
selected = function(self, opts)
198+
return {}
199+
end,
200+
current = function(self, opts)
201+
return { file = "/test/file.lua" }
202+
end,
203+
}
204+
205+
local mock_snacks = {
206+
picker = {
207+
get = function(opts)
208+
if opts.source == "explorer" then
209+
return { mock_explorer }
210+
end
211+
return {}
212+
end,
213+
},
214+
}
215+
216+
package.loaded["snacks"] = mock_snacks
217+
218+
local files, err = integrations.get_selected_files_from_tree()
219+
assert.is_nil(err)
220+
assert.are.same({ "/test/file.lua" }, files)
221+
222+
package.loaded["snacks"] = nil
223+
end)
224+
end)
225+
end)

0 commit comments

Comments
 (0)