From 00aa785b42b886819f1d3d63f7093fa52121b277 Mon Sep 17 00:00:00 2001 From: Andres Saemundsson Date: Thu, 3 Jul 2025 00:50:21 +0200 Subject: [PATCH 1/6] 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 --- lua/claudecode/init.lua | 1 + lua/claudecode/integrations.lua | 60 +++++++- lua/claudecode/tools/open_file.lua | 1 + lua/claudecode/visual_commands.lua | 23 ++- tests/unit/snacks_explorer_spec.lua | 225 ++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 tests/unit/snacks_explorer_spec.lua diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index f673899..ca46b06 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -611,6 +611,7 @@ function M._create_commands() local is_tree_buffer = current_ft == "NvimTree" or current_ft == "neo-tree" or current_ft == "oil" + or current_ft == "snacks_picker_list" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") diff --git a/lua/claudecode/integrations.lua b/lua/claudecode/integrations.lua index 2827aab..34acd57 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -1,6 +1,6 @@ --- -- Tree integration module for ClaudeCode.nvim --- Handles detection and selection of files from nvim-tree, neo-tree, and oil.nvim +-- Handles detection and selection of files from nvim-tree, neo-tree, oil.nvim and snacks.explorer -- @module claudecode.integrations local M = {} @@ -16,6 +16,8 @@ function M.get_selected_files_from_tree() return M._get_neotree_selection() elseif current_ft == "oil" then return M._get_oil_selection() + elseif current_ft == "snacks_picker_list" then + return M._get_snacks_explorer_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end @@ -261,4 +263,60 @@ function M._get_oil_selection() return {}, "No file found under cursor" end +--- Get selected files from snacks.explorer +--- Uses the picker API to get the current selection +--- @return table files List of file paths +--- @return string|nil error Error message if operation failed +function M._get_snacks_explorer_selection() + local snacks_ok, snacks = pcall(require, "snacks") + if not snacks_ok or not snacks.picker then + return {}, "snacks.nvim not available" + end + + -- Get the current explorer picker + local explorers = snacks.picker.get({ source = "explorer" }) + if not explorers or #explorers == 0 then + return {}, "No active snacks.explorer found" + end + + -- Get the first (and likely only) explorer instance + local explorer = explorers[1] + if not explorer then + return {}, "No active snacks.explorer found" + end + + local files = {} + + -- Check if there are selected items + local selected = explorer:selected({ fallback = false }) + if selected and #selected > 0 then + -- Process selected items + for _, item in ipairs(selected) do + -- Try different possible fields for file path + local file_path = item.file or item.path or (item.item and item.item.file) or (item.item and item.item.path) + if file_path and file_path ~= "" then + table.insert(files, file_path) + end + end + if #files > 0 then + return files, nil + end + end + + -- Fall back to current item under cursor + local current = explorer:current({ resolve = true }) + if current then + -- Try different possible fields for file path + local file_path = current.file + or current.path + or (current.item and current.item.file) + or (current.item and current.item.path) + if file_path and file_path ~= "" then + return { file_path }, nil + end + end + + return {}, "No file found under cursor" +end + return M diff --git a/lua/claudecode/tools/open_file.lua b/lua/claudecode/tools/open_file.lua index 855a28b..611dd9c 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -78,6 +78,7 @@ local function find_main_editor_window() or filetype == "oil" or filetype == "aerial" or filetype == "tagbar" + or filetype == "snacks_picker_list" ) then is_suitable = false diff --git a/lua/claudecode/visual_commands.lua b/lua/claudecode/visual_commands.lua index 29d5699..e97f226 100644 --- a/lua/claudecode/visual_commands.lua +++ b/lua/claudecode/visual_commands.lua @@ -135,7 +135,7 @@ function M.get_visual_range() end --- Check if we're in a tree buffer and get the tree state ---- @return table|nil, string|nil tree_state, tree_type ("neo-tree" or "nvim-tree") +--- @return table|nil, string|nil tree_state, tree_type ("neo-tree", "nvim-tree", "oil", or "snacks-explorer") function M.get_tree_state() local current_ft = "" -- Default fallback local current_win = 0 -- Default fallback @@ -181,6 +181,16 @@ function M.get_tree_state() end return oil, "oil" + elseif current_ft == "snacks_picker_list" then + local snacks_success, snacks = pcall(require, "snacks") + if not snacks_success or not snacks.picker then + return nil, nil + end + + local explorers = snacks.picker.get({ source = "explorer" }) + if explorers and #explorers > 0 then + return explorers[1], "snacks-explorer" + end else return nil, nil end @@ -381,6 +391,17 @@ function M.get_files_from_visual_selection(visual_data) end end end + elseif tree_type == "snacks-explorer" then + -- For snacks.explorer, we need to handle visual selection differently + -- since it's a picker and doesn't have a traditional tree structure + local integrations = require("claudecode.integrations") + local selected_files, error = integrations._get_snacks_explorer_selection() + + if not error and selected_files and #selected_files > 0 then + for _, file in ipairs(selected_files) do + table.insert(files, file) + end + end end return files, nil diff --git a/tests/unit/snacks_explorer_spec.lua b/tests/unit/snacks_explorer_spec.lua new file mode 100644 index 0000000..5b2dfdf --- /dev/null +++ b/tests/unit/snacks_explorer_spec.lua @@ -0,0 +1,225 @@ +local helpers = require("tests.helpers.setup") +local integrations = require("claudecode.integrations") + +describe("snacks.explorer integration", function() + before_each(function() + helpers.setup() + end) + + after_each(function() + helpers.cleanup() + end) + + describe("_get_snacks_explorer_selection", function() + it("should return error when snacks.nvim is not available", function() + -- Mock require to fail for snacks + local original_require = _G.require + _G.require = function(module) + if module == "snacks" then + error("Module not found") + end + return original_require(module) + end + + local files, err = integrations._get_snacks_explorer_selection() + assert.are.same({}, files) + assert.equals("snacks.nvim not available", err) + + -- Restore original require + _G.require = original_require + end) + + it("should return error when no explorer picker is active", function() + -- Mock snacks module + local mock_snacks = { + picker = { + get = function() + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.are.same({}, files) + assert.equals("No active snacks.explorer found", err) + + package.loaded["snacks"] = nil + end) + + it("should return selected files from snacks.explorer", function() + -- Mock snacks module with explorer picker + local mock_explorer = { + selected = function(self, opts) + return { + { file = "/path/to/file1.lua" }, + { file = "/path/to/file2.lua" }, + } + end, + current = function(self, opts) + return { file = "/path/to/current.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ "/path/to/file1.lua", "/path/to/file2.lua" }, files) + + package.loaded["snacks"] = nil + end) + + it("should fall back to current file when no selection", function() + -- Mock snacks module with explorer picker + local mock_explorer = { + selected = function(self, opts) + return {} + end, + current = function(self, opts) + return { file = "/path/to/current.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ "/path/to/current.lua" }, files) + + package.loaded["snacks"] = nil + end) + + it("should handle empty file paths", function() + -- Mock snacks module with empty file paths + local mock_explorer = { + selected = function(self, opts) + return { + { file = "" }, + { file = "/valid/path.lua" }, + { file = nil }, + } + end, + current = function(self, opts) + return { file = "" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ "/valid/path.lua" }, files) + + package.loaded["snacks"] = nil + end) + + it("should try alternative fields for file path", function() + -- Mock snacks module with different field names + local mock_explorer = { + selected = function(self, opts) + return { + { path = "/path/from/path.lua" }, + { item = { file = "/path/from/item.file.lua" } }, + { item = { path = "/path/from/item.path.lua" } }, + } + end, + current = function(self, opts) + return { path = "/current/from/path.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ + "/path/from/path.lua", + "/path/from/item.file.lua", + "/path/from/item.path.lua", + }, files) + + package.loaded["snacks"] = nil + end) + end) + + describe("get_selected_files_from_tree", function() + it("should detect snacks_picker_list filetype", function() + vim.bo.filetype = "snacks_picker_list" + + -- Mock snacks module + local mock_explorer = { + selected = function(self, opts) + return {} + end, + current = function(self, opts) + return { file = "/test/file.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations.get_selected_files_from_tree() + assert.is_nil(err) + assert.are.same({ "/test/file.lua" }, files) + + package.loaded["snacks"] = nil + end) + end) +end) \ No newline at end of file From fdce0689d3f70095d7adabca02b34992f75377dc Mon Sep 17 00:00:00 2001 From: Andres Saemundsson Date: Thu, 3 Jul 2025 01:26:24 +0200 Subject: [PATCH 2/6] 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 --- dev-config.lua | 2 +- lua/claudecode/integrations.lua | 71 +++++++++++-- lua/claudecode/visual_commands.lua | 5 +- tests/unit/snacks_explorer_spec.lua | 158 +++++++++++++++++++++++++++- 4 files changed, 220 insertions(+), 16 deletions(-) diff --git a/dev-config.lua b/dev-config.lua index ecb3489..616b931 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -23,7 +23,7 @@ return { "as", "ClaudeCodeTreeAdd", desc = "Add file from tree", - ft = { "NvimTree", "neo-tree", "oil" }, + ft = { "NvimTree", "neo-tree", "oil", "snacks_picker_list" }, -- snacks.explorer uses "snacks_picker_list" filetype }, -- Development helpers diff --git a/lua/claudecode/integrations.lua b/lua/claudecode/integrations.lua index 34acd57..a294860 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -265,9 +265,11 @@ end --- Get selected files from snacks.explorer --- Uses the picker API to get the current selection +--- @param visual_start number|nil Start line of visual selection (optional) +--- @param visual_end number|nil End line of visual selection (optional) --- @return table files List of file paths --- @return string|nil error Error message if operation failed -function M._get_snacks_explorer_selection() +function M._get_snacks_explorer_selection(visual_start, visual_end) local snacks_ok, snacks = pcall(require, "snacks") if not snacks_ok or not snacks.picker then return {}, "snacks.nvim not available" @@ -287,14 +289,61 @@ function M._get_snacks_explorer_selection() local files = {} - -- Check if there are selected items + -- Helper function to extract file path from various item structures + local function extract_file_path(item) + if not item then + return nil + end + local file_path = item.file or item.path or (item.item and item.item.file) or (item.item and item.item.path) + + -- Add trailing slash for directories + if file_path and file_path ~= "" and vim.fn.isdirectory(file_path) == 1 then + if not file_path:match("/$") then + file_path = file_path .. "/" + end + end + + return file_path + end + + -- Helper function to check if path is safe (not root-level) + local function is_safe_path(file_path) + if not file_path or file_path == "" then + return false + end + -- Not root-level file & this prevents selecting files like /etc/passwd, /usr/bin/vim, etc. + return not string.match(file_path, "^/[^/]*$") + end + + -- Handle visual mode selection if range is provided + if visual_start and visual_end and explorer.list then + -- Process each line in the visual selection + for row = visual_start, visual_end do + -- Convert row to picker index + local idx = explorer.list:row2idx(row) + if idx then + -- Get the item at this index + local item = explorer.list:get(idx) + if item then + local file_path = extract_file_path(item) + if file_path and file_path ~= "" and is_safe_path(file_path) then + table.insert(files, file_path) + end + end + end + end + if #files > 0 then + return files, nil + end + end + + -- Check if there are selected items (using toggle selection) local selected = explorer:selected({ fallback = false }) if selected and #selected > 0 then -- Process selected items for _, item in ipairs(selected) do - -- Try different possible fields for file path - local file_path = item.file or item.path or (item.item and item.item.file) or (item.item and item.item.path) - if file_path and file_path ~= "" then + local file_path = extract_file_path(item) + if file_path and file_path ~= "" and is_safe_path(file_path) then table.insert(files, file_path) end end @@ -306,13 +355,13 @@ function M._get_snacks_explorer_selection() -- Fall back to current item under cursor local current = explorer:current({ resolve = true }) if current then - -- Try different possible fields for file path - local file_path = current.file - or current.path - or (current.item and current.item.file) - or (current.item and current.item.path) + local file_path = extract_file_path(current) if file_path and file_path ~= "" then - return { file_path }, nil + if is_safe_path(file_path) then + return { file_path }, nil + else + return {}, "Cannot add root-level file. Please select a file in a subdirectory." + end end end diff --git a/lua/claudecode/visual_commands.lua b/lua/claudecode/visual_commands.lua index e97f226..342dee0 100644 --- a/lua/claudecode/visual_commands.lua +++ b/lua/claudecode/visual_commands.lua @@ -392,10 +392,9 @@ function M.get_files_from_visual_selection(visual_data) end end elseif tree_type == "snacks-explorer" then - -- For snacks.explorer, we need to handle visual selection differently - -- since it's a picker and doesn't have a traditional tree structure + -- For snacks.explorer, pass the visual range to handle multi-selection local integrations = require("claudecode.integrations") - local selected_files, error = integrations._get_snacks_explorer_selection() + local selected_files, error = integrations._get_snacks_explorer_selection(start_pos, end_pos) if not error and selected_files and #selected_files > 0 then for _, file in ipairs(selected_files) do diff --git a/tests/unit/snacks_explorer_spec.lua b/tests/unit/snacks_explorer_spec.lua index 5b2dfdf..a1cf74b 100644 --- a/tests/unit/snacks_explorer_spec.lua +++ b/tests/unit/snacks_explorer_spec.lua @@ -186,6 +186,162 @@ describe("snacks.explorer integration", function() package.loaded["snacks"] = nil end) + + it("should handle visual mode selection with range parameters", function() + -- Mock snacks module with explorer picker that has list + local mock_list = { + row2idx = function(self, row) + return row -- Simple 1:1 mapping for test + end, + get = function(self, idx) + local items = { + [1] = { file = "/path/to/file1.lua" }, + [2] = { file = "/path/to/file2.lua" }, + [3] = { file = "/path/to/file3.lua" }, + [4] = { file = "/path/to/file4.lua" }, + [5] = { file = "/path/to/file5.lua" }, + } + return items[idx] + end, + } + + local mock_explorer = { + list = mock_list, + selected = function(self, opts) + return {} -- No marked selection + end, + current = function(self, opts) + return { file = "/path/to/current.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + -- Test visual selection from lines 2 to 4 + local files, err = integrations._get_snacks_explorer_selection(2, 4) + assert.is_nil(err) + assert.are.same({ + "/path/to/file2.lua", + "/path/to/file3.lua", + "/path/to/file4.lua", + }, files) + + package.loaded["snacks"] = nil + end) + + it("should handle visual mode with missing items and empty paths", function() + -- Mock snacks module with some problematic items + local mock_list = { + row2idx = function(self, row) + -- Some rows don't have corresponding indices + if row == 3 then + return nil + end + return row + end, + get = function(self, idx) + local items = { + [1] = { file = "" }, -- Empty path + [2] = { file = "/valid/file.lua" }, + [4] = { path = "/path/based/file.lua" }, -- Using path field + [5] = nil, -- nil item + } + return items[idx] + end, + } + + local mock_explorer = { + list = mock_list, + selected = function(self, opts) + return {} + end, + current = function(self, opts) + return { file = "/current.lua" } + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + -- Test visual selection from lines 1 to 5 + local files, err = integrations._get_snacks_explorer_selection(1, 5) + assert.is_nil(err) + -- Should only get the valid files + assert.are.same({ + "/valid/file.lua", + "/path/based/file.lua", + }, files) + + package.loaded["snacks"] = nil + end) + + it("should add trailing slashes to directories", function() + -- Mock vim.fn.isdirectory to return true for directory paths + local original_isdirectory = vim.fn.isdirectory + vim.fn.isdirectory = function(path) + return path:match("/directory") and 1 or 0 + end + + -- Mock snacks module with directory items + local mock_explorer = { + selected = function(self, opts) + return { + { file = "/path/to/file.lua" }, -- file + { file = "/path/to/directory" }, -- directory (no trailing slash) + { file = "/path/to/another_directory/" }, -- directory (already has slash) + } + end, + current = function(self, opts) + return { file = "/current/directory" } -- directory + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ + "/path/to/file.lua", -- file unchanged + "/path/to/directory/", -- directory with added slash + "/path/to/another_directory/", -- directory with existing slash unchanged + }, files) + + -- Restore original function + vim.fn.isdirectory = original_isdirectory + package.loaded["snacks"] = nil + end) end) describe("get_selected_files_from_tree", function() @@ -222,4 +378,4 @@ describe("snacks.explorer integration", function() package.loaded["snacks"] = nil end) end) -end) \ No newline at end of file +end) From addff9e3eb647916c61f5058c564a450871d1e90 Mon Sep 17 00:00:00 2001 From: Andres Saemundsson Date: Thu, 3 Jul 2025 01:43:45 +0200 Subject: [PATCH 3/6] docs: add snacks.explorer to supported file explorers --- ARCHITECTURE.md | 29 +++++++++++++++++++++++++++++ README.md | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9398852..af7e82a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -184,6 +184,33 @@ vim.api.nvim_create_autocmd("VimLeavePre", { }) ``` +### 7. File Explorer Integrations (`integrations.lua`) + +Unified interface for popular file explorers: + +```lua +-- Supports nvim-tree, neo-tree, oil.nvim, and snacks.explorer +function M.get_selected_files_from_tree() + local current_ft = vim.bo.filetype + + if current_ft == "NvimTree" then + return M._get_nvim_tree_selection() + elseif current_ft == "neo-tree" then + return M._get_neotree_selection() + elseif current_ft == "oil" then + return M._get_oil_selection() + elseif current_ft == "snacks_picker_list" then + return M._get_snacks_explorer_selection() + end +end +``` + +Key features across all integrations: +- **Visual mode support**: Select multiple files using vim visual mode +- **Security protection**: Filters out root-level files (`/etc/passwd`, `/usr/bin/vim`) +- **Directory handling**: Adds trailing slashes to directories for consistency +- **Fallback behavior**: Selected items → current item → error + ## Module Structure ``` @@ -197,6 +224,8 @@ lua/claudecode/ │ ├── client.lua # Connection management │ └── utils.lua # Pure Lua SHA-1, base64 ├── tools/init.lua # MCP tool registry +├── integrations.lua # File explorer integrations +├── visual_commands.lua # Visual mode handling ├── diff.lua # Native diff support ├── selection.lua # Selection tracking ├── terminal.lua # Terminal management diff --git a/README.md b/README.md index b3c31c7..68caba6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. "as", "ClaudeCodeTreeAdd", desc = "Add file", - ft = { "NvimTree", "neo-tree", "oil" }, + ft = { "NvimTree", "neo-tree", "oil", "snacks_picker_list" }, }, -- Diff management { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, @@ -80,7 +80,7 @@ That's it! The plugin will auto-configure everything else. 1. **Launch Claude**: Run `:ClaudeCode` to open Claude in a split terminal 2. **Send context**: - Select text in visual mode and use `as` to send it to Claude - - In `nvim-tree`/`neo-tree`/`oil.nvim`, press `as` on a file to add it to Claude's context + - In `nvim-tree`/`neo-tree`/`oil.nvim`/`snacks.explorer`, press `as` on a file to add it to Claude's context 3. **Let Claude work**: Claude can now: - See your current file and selections in real-time - Open files in your editor From 0e1c8b7961b205b3cb04785f2e02d08935616500 Mon Sep 17 00:00:00 2001 From: Andres Saemundsson Date: Thu, 3 Jul 2025 05:48:00 +0200 Subject: [PATCH 4/6] test(integrations): expand on testing for snacks.explorer --- tests/unit/snacks_explorer_spec.lua | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/unit/snacks_explorer_spec.lua b/tests/unit/snacks_explorer_spec.lua index a1cf74b..9dbb924 100644 --- a/tests/unit/snacks_explorer_spec.lua +++ b/tests/unit/snacks_explorer_spec.lua @@ -342,6 +342,77 @@ describe("snacks.explorer integration", function() vim.fn.isdirectory = original_isdirectory package.loaded["snacks"] = nil end) + + it("should protect against root-level files", function() + -- Mock snacks module with root-level and safe files + local mock_explorer = { + selected = function(self, opts) + return { + { file = "/etc/passwd" }, -- root-level file (dangerous) + { file = "/home/user/file.lua" }, -- safe file + { file = "/usr/bin/vim" }, -- root-level file (dangerous) + { file = "/path/to/directory/" }, -- safe directory + } + end, + current = function(self, opts) + return { file = "/etc/hosts" } -- root-level file + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + -- Test selected items - should filter out root-level files + local files, err = integrations._get_snacks_explorer_selection() + assert.is_nil(err) + assert.are.same({ + "/home/user/file.lua", + "/path/to/directory/", + }, files) + + package.loaded["snacks"] = nil + end) + + it("should return error for root-level current file", function() + -- Mock snacks module with root-level current file and no selection + local mock_explorer = { + selected = function(self, opts) + return {} -- No selection + end, + current = function(self, opts) + return { file = "/etc/passwd" } -- root-level file + end, + } + + local mock_snacks = { + picker = { + get = function(opts) + if opts.source == "explorer" then + return { mock_explorer } + end + return {} + end, + }, + } + + package.loaded["snacks"] = mock_snacks + + local files, err = integrations._get_snacks_explorer_selection() + assert.are.same({}, files) + assert.equals("Cannot add root-level file. Please select a file in a subdirectory.", err) + + package.loaded["snacks"] = nil + end) end) describe("get_selected_files_from_tree", function() From 6fd3196dc0188b9b4b5d820c74653d83ec1ec66e Mon Sep 17 00:00:00 2001 From: Andres Saemundsson Date: Thu, 3 Jul 2025 06:07:57 +0200 Subject: [PATCH 5/6] fix: resolve test failures for snacks.explorer integration - Fix helpers module usage in snacks_explorer_spec.lua (return function, not table) - Add missing vim.fn.isDirectory() and vim.bo to test mocks - Fix is_safe_path() to properly filter system files (/etc/, /usr/, /bin/, /sbin/) - All 326 tests now pass successfully --- ARCHITECTURE.md | 3 ++- lua/claudecode/integrations.lua | 14 +++++++++++++- tests/mocks/vim.lua | 27 +++++++++++++++++++++++++++ tests/unit/snacks_explorer_spec.lua | 5 ++--- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af7e82a..376c7e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -192,7 +192,7 @@ Unified interface for popular file explorers: -- Supports nvim-tree, neo-tree, oil.nvim, and snacks.explorer function M.get_selected_files_from_tree() local current_ft = vim.bo.filetype - + if current_ft == "NvimTree" then return M._get_nvim_tree_selection() elseif current_ft == "neo-tree" then @@ -206,6 +206,7 @@ end ``` Key features across all integrations: + - **Visual mode support**: Select multiple files using vim visual mode - **Security protection**: Filters out root-level files (`/etc/passwd`, `/usr/bin/vim`) - **Directory handling**: Adds trailing slashes to directories for consistency diff --git a/lua/claudecode/integrations.lua b/lua/claudecode/integrations.lua index a294860..318e10d 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -312,7 +312,19 @@ function M._get_snacks_explorer_selection(visual_start, visual_end) return false end -- Not root-level file & this prevents selecting files like /etc/passwd, /usr/bin/vim, etc. - return not string.match(file_path, "^/[^/]*$") + -- Check for system directories and root-level files + if string.match(file_path, "^/[^/]*$") then + return false -- True root-level files like /etc, /usr, /bin + end + if + string.match(file_path, "^/etc/") + or string.match(file_path, "^/usr/") + or string.match(file_path, "^/bin/") + or string.match(file_path, "^/sbin/") + then + return false -- System directories + end + return true end -- Handle visual mode selection if range is provided diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index 39141c7..8c19596 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -457,6 +457,12 @@ local vim = { localtime = function() return os.time() end, + + isdirectory = function(path) + -- Mock implementation - return 1 for directories, 0 for files + -- For testing, we'll consider paths ending with '/' as directories + return path:match("/$") and 1 or 0 + end, }, cmd = function(command) @@ -601,6 +607,27 @@ local vim = { end, }), + bo = setmetatable({}, { + __index = function(_, key) + -- Return buffer option for current buffer + local current_buf = vim.api.nvim_get_current_buf() + if vim._buffers[current_buf] and vim._buffers[current_buf].options then + return vim._buffers[current_buf].options[key] + end + return nil + end, + __newindex = function(_, key, value) + -- Set buffer option for current buffer + local current_buf = vim.api.nvim_get_current_buf() + if vim._buffers[current_buf] then + if not vim._buffers[current_buf].options then + vim._buffers[current_buf].options = {} + end + vim._buffers[current_buf].options[key] = value + end + end, + }), + deepcopy = function(tbl) if type(tbl) ~= "table" then return tbl diff --git a/tests/unit/snacks_explorer_spec.lua b/tests/unit/snacks_explorer_spec.lua index 9dbb924..15b62de 100644 --- a/tests/unit/snacks_explorer_spec.lua +++ b/tests/unit/snacks_explorer_spec.lua @@ -1,13 +1,12 @@ -local helpers = require("tests.helpers.setup") local integrations = require("claudecode.integrations") describe("snacks.explorer integration", function() before_each(function() - helpers.setup() + require("tests.helpers.setup")() end) after_each(function() - helpers.cleanup() + -- No cleanup needed end) describe("_get_snacks_explorer_selection", function() From b1b97c3ec8069e9a7c6b74afc29019778f2954ab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 29 Jul 2025 13:48:55 +0200 Subject: [PATCH 6/6] feat: add snacks-explorer fixture configuration Change-Id: I6c28f07bace2e3236e6cc054a5e56dc49e6dc125 Signed-off-by: Thomas Kosiewski --- fixtures/snacks-explorer/init.lua | 1 + fixtures/snacks-explorer/lazy-lock.json | 6 + fixtures/snacks-explorer/lua/config/lazy.lua | 41 ++++ .../lua/plugins/dev-claudecode.lua | 1 + fixtures/snacks-explorer/lua/plugins/init.lua | 33 ++++ .../snacks-explorer/lua/plugins/snacks.lua | 183 ++++++++++++++++++ 6 files changed, 265 insertions(+) create mode 100644 fixtures/snacks-explorer/init.lua create mode 100644 fixtures/snacks-explorer/lazy-lock.json create mode 100644 fixtures/snacks-explorer/lua/config/lazy.lua create mode 120000 fixtures/snacks-explorer/lua/plugins/dev-claudecode.lua create mode 100644 fixtures/snacks-explorer/lua/plugins/init.lua create mode 100644 fixtures/snacks-explorer/lua/plugins/snacks.lua diff --git a/fixtures/snacks-explorer/init.lua b/fixtures/snacks-explorer/init.lua new file mode 100644 index 0000000..55b8979 --- /dev/null +++ b/fixtures/snacks-explorer/init.lua @@ -0,0 +1 @@ +require("config.lazy") diff --git a/fixtures/snacks-explorer/lazy-lock.json b/fixtures/snacks-explorer/lazy-lock.json new file mode 100644 index 0000000..667fcf3 --- /dev/null +++ b/fixtures/snacks-explorer/lazy-lock.json @@ -0,0 +1,6 @@ +{ + "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, + "mini.icons": { "branch": "main", "commit": "b8f6fa6f5a3fd0c56936252edcd691184e5aac0c" }, + "snacks.nvim": { "branch": "main", "commit": "bc0630e43be5699bb94dadc302c0d21615421d93" }, + "which-key.nvim": { "branch": "main", "commit": "370ec46f710e058c9c1646273e6b225acf47cbed" } +} diff --git a/fixtures/snacks-explorer/lua/config/lazy.lua b/fixtures/snacks-explorer/lua/config/lazy.lua new file mode 100644 index 0000000..2d86d18 --- /dev/null +++ b/fixtures/snacks-explorer/lua/config/lazy.lua @@ -0,0 +1,41 @@ +-- Bootstrap lazy.nvim +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not (vim.uv or vim.loop).fs_stat(lazypath) then + local lazyrepo = "https://github.com/folke/lazy.nvim.git" + local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) + if vim.v.shell_error ~= 0 then + vim.api.nvim_echo({ + { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, + { out, "WarningMsg" }, + { "\nPress any key to exit..." }, + }, true, {}) + vim.fn.getchar() + os.exit(1) + end +end +vim.opt.rtp:prepend(lazypath) + +-- Make sure to setup `mapleader` and `maplocalleader` before +-- loading lazy.nvim so that mappings are correct. +-- This is also a good place to setup other settings (vim.opt) +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- Setup lazy.nvim +require("lazy").setup({ + spec = { + -- import your plugins + { import = "plugins" }, + }, + -- Configure any other settings here. See the documentation for more details. + -- colorscheme that will be used when installing plugins. + install = { colorscheme = { "habamax" } }, + -- automatically check for plugin updates + checker = { enabled = true }, +}) + +-- Add keybind for Lazy plugin manager +vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) + +-- Terminal keybindings +vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) diff --git a/fixtures/snacks-explorer/lua/plugins/dev-claudecode.lua b/fixtures/snacks-explorer/lua/plugins/dev-claudecode.lua new file mode 120000 index 0000000..f609a1c --- /dev/null +++ b/fixtures/snacks-explorer/lua/plugins/dev-claudecode.lua @@ -0,0 +1 @@ +../../../../dev-config.lua \ No newline at end of file diff --git a/fixtures/snacks-explorer/lua/plugins/init.lua b/fixtures/snacks-explorer/lua/plugins/init.lua new file mode 100644 index 0000000..d0370ff --- /dev/null +++ b/fixtures/snacks-explorer/lua/plugins/init.lua @@ -0,0 +1,33 @@ +return { + -- Essential plugins for basic functionality + { + "folke/which-key.nvim", + event = "VeryLazy", + opts = {}, + keys = { + { + "?", + function() + require("which-key").show({ global = false }) + end, + desc = "Buffer Local Keymaps (which-key)", + }, + }, + }, + + -- Icon support for file explorers + { + "echasnovski/mini.icons", + opts = {}, + lazy = true, + specs = { + { "nvim-tree/nvim-web-devicons", enabled = false, optional = true }, + }, + init = function() + package.preload["nvim-web-devicons"] = function() + require("mini.icons").mock_nvim_web_devicons() + return package.loaded["nvim-web-devicons"] + end + end, + }, +} diff --git a/fixtures/snacks-explorer/lua/plugins/snacks.lua b/fixtures/snacks-explorer/lua/plugins/snacks.lua new file mode 100644 index 0000000..2502592 --- /dev/null +++ b/fixtures/snacks-explorer/lua/plugins/snacks.lua @@ -0,0 +1,183 @@ +return { + "folke/snacks.nvim", + priority = 1000, + lazy = false, + opts = { + -- Enable the explorer module + explorer = { + enabled = true, + replace_netrw = true, -- Replace netrw with snacks explorer + }, + -- Enable other useful modules for testing + bigfile = { enabled = true }, + notifier = { enabled = true }, + quickfile = { enabled = true }, + statuscolumn = { enabled = true }, + words = { enabled = true }, + }, + keys = { + -- Main explorer keybindings + { + "e", + function() + require("snacks").explorer() + end, + desc = "Explorer", + }, + { + "E", + function() + require("snacks").explorer.open() + end, + desc = "Explorer (open)", + }, + { + "fe", + function() + require("snacks").explorer.reveal() + end, + desc = "Explorer (reveal current file)", + }, + + -- Alternative keybindings for testing + { + "-", + function() + require("snacks").explorer() + end, + desc = "Open parent directory", + }, + { + "", + function() + require("snacks").explorer() + end, + desc = "File Explorer", + }, + + -- Snacks utility keybindings for testing + { + "un", + function() + require("snacks").notifier.dismiss() + end, + desc = "Dismiss All Notifications", + }, + { + "bd", + function() + require("snacks").bufdelete() + end, + desc = "Delete Buffer", + }, + { + "gg", + function() + require("snacks").lazygit() + end, + desc = "Lazygit", + }, + { + "gb", + function() + require("snacks").git.blame_line() + end, + desc = "Git Blame Line", + }, + { + "gB", + function() + require("snacks").gitbrowse() + end, + desc = "Git Browse", + }, + { + "gf", + function() + require("snacks").lazygit.log_file() + end, + desc = "Lazygit Current File History", + }, + { + "gl", + function() + require("snacks").lazygit.log() + end, + desc = "Lazygit Log (cwd)", + }, + { + "cR", + function() + require("snacks").rename.rename_file() + end, + desc = "Rename File", + }, + { + "", + function() + require("snacks").terminal() + end, + desc = "Toggle Terminal", + }, + { + "", + function() + require("snacks").terminal() + end, + desc = "which_key_ignore", + }, + }, + init = function() + vim.api.nvim_create_autocmd("User", { + pattern = "VeryLazy", + callback = function() + -- Setup some globals for easier testing + _G.Snacks = require("snacks") + _G.lazygit = _G.Snacks.lazygit + _G.explorer = _G.Snacks.explorer + end, + }) + end, + config = function(_, opts) + require("snacks").setup(opts) + + -- Additional explorer-specific keybindings that activate after setup + vim.api.nvim_create_autocmd("FileType", { + pattern = "snacks_picker_list", -- This is the filetype for snacks explorer + callback = function(event) + local buf = event.buf + -- Custom keybindings specifically for snacks explorer buffers + vim.keymap.set("n", "", function() + -- Toggle visual mode for multi-selection (this is what the PR adds support for) + vim.cmd("normal! V") + end, { buffer = buf, desc = "Toggle visual selection" }) + + vim.keymap.set("n", "v", function() + vim.cmd("normal! v") + end, { buffer = buf, desc = "Visual mode" }) + + vim.keymap.set("n", "V", function() + vim.cmd("normal! V") + end, { buffer = buf, desc = "Visual line mode" }) + + -- Additional testing keybindings + vim.keymap.set("n", "?", function() + require("which-key").show({ buffer = buf }) + end, { buffer = buf, desc = "Show keybindings" }) + end, + }) + + -- Set up some helpful defaults for testing + vim.opt.number = true + vim.opt.relativenumber = true + vim.opt.signcolumn = "yes" + vim.opt.wrap = false + + -- Print helpful message when starting + vim.defer_fn(function() + print("🍿 Snacks Explorer fixture loaded!") + print("Press e to open explorer, ? for help") + print("Use visual modes (v/V/) in explorer for multi-file selection") + end, 500) + end, +}