diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3ce317..1a3ddad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -184,6 +184,34 @@ 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 +225,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 b698beb..ffc4eef 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,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" }, @@ -76,7 +76,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 diff --git a/dev-config.lua b/dev-config.lua index 2fd2cae..f37b4f9 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -24,7 +24,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/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, +} diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index dcaf16f..ca056cc 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -614,6 +614,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..318e10d 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,121 @@ 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 +--- @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(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" + 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 = {} + + -- 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. + -- 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 + 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 + 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 + 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 + local file_path = extract_file_path(current) + if file_path and file_path ~= "" then + 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 + + 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 81c9ce8..9588f67 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -93,6 +93,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..342dee0 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,16 @@ function M.get_files_from_visual_selection(visual_data) end end end + elseif tree_type == "snacks-explorer" then + -- 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(start_pos, end_pos) + + 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/mocks/vim.lua b/tests/mocks/vim.lua index 9c83de9..aa3c036 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) @@ -617,6 +623,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 new file mode 100644 index 0000000..15b62de --- /dev/null +++ b/tests/unit/snacks_explorer_spec.lua @@ -0,0 +1,451 @@ +local integrations = require("claudecode.integrations") + +describe("snacks.explorer integration", function() + before_each(function() + require("tests.helpers.setup")() + end) + + after_each(function() + -- No cleanup needed + 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) + + 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) + + 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() + 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)