diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3ce317..4874ae6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -246,6 +246,7 @@ source fixtures/nvim-aliases.sh vv nvim-tree # Test with nvim-tree integration vv oil # Test with oil.nvim integration vv netrw # Test with built-in netrw +vv mini-files # Test with built-in mini.files # Each fixture provides: # - Complete Neovim configuration diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index de17b42..4e4b85b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -9,6 +9,7 @@ claudecode.nvim/ ├── .github/workflows/ # CI workflow definitions ├── fixtures/ # Test Neovim configurations for integration testing │ ├── bin/ # Helper scripts (vv, vve, list-configs) +│ ├── mini-files/ # Neovim config testing with mini.files │ ├── netrw/ # Neovim config testing with built-in file explorer │ ├── nvim-tree/ # Neovim config testing with nvim-tree.lua │ ├── oil/ # Neovim config testing with oil.nvim diff --git a/fixtures/mini-files/init.lua b/fixtures/mini-files/init.lua new file mode 100644 index 0000000..55b8979 --- /dev/null +++ b/fixtures/mini-files/init.lua @@ -0,0 +1 @@ +require("config.lazy") diff --git a/fixtures/mini-files/lazy-lock.json b/fixtures/mini-files/lazy-lock.json new file mode 100644 index 0000000..b8b4afd --- /dev/null +++ b/fixtures/mini-files/lazy-lock.json @@ -0,0 +1,5 @@ +{ + "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, + "mini.files": { "branch": "main", "commit": "5b9431cf5c69b8e69e5a67d2d12338a3ac2e1541" }, + "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } +} diff --git a/fixtures/mini-files/lua/config/lazy.lua b/fixtures/mini-files/lua/config/lazy.lua new file mode 100644 index 0000000..2d86d18 --- /dev/null +++ b/fixtures/mini-files/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/mini-files/lua/plugins/dev-claudecode.lua b/fixtures/mini-files/lua/plugins/dev-claudecode.lua new file mode 120000 index 0000000..f609a1c --- /dev/null +++ b/fixtures/mini-files/lua/plugins/dev-claudecode.lua @@ -0,0 +1 @@ +../../../../dev-config.lua \ No newline at end of file diff --git a/fixtures/mini-files/lua/plugins/init.lua b/fixtures/mini-files/lua/plugins/init.lua new file mode 100644 index 0000000..e911afe --- /dev/null +++ b/fixtures/mini-files/lua/plugins/init.lua @@ -0,0 +1,12 @@ +-- Basic plugin configuration +return { + -- Example: add a colorscheme + { + "folke/tokyonight.nvim", + lazy = false, + priority = 1000, + config = function() + vim.cmd([[colorscheme tokyonight]]) + end, + }, +} diff --git a/fixtures/mini-files/lua/plugins/mini-files.lua b/fixtures/mini-files/lua/plugins/mini-files.lua new file mode 100644 index 0000000..e69748e --- /dev/null +++ b/fixtures/mini-files/lua/plugins/mini-files.lua @@ -0,0 +1,176 @@ +return { + "echasnovski/mini.files", + version = false, + config = function() + require("mini.files").setup({ + -- Customization of shown content + content = { + -- Predicate for which file system entries to show + filter = nil, + -- What prefix to show to the left of file system entry + prefix = nil, + -- In which order to show file system entries + sort = nil, + }, + + -- Module mappings created only inside explorer. + -- Use `''` (empty string) to not create one. + mappings = { + close = "q", + go_in = "l", + go_in_plus = "L", + go_out = "h", + go_out_plus = "H", + reset = "", + reveal_cwd = "@", + show_help = "g?", + synchronize = "=", + trim_left = "<", + trim_right = ">", + }, + + -- General options + options = { + -- Whether to delete permanently or move into module-specific trash + permanent_delete = true, + -- Whether to use for editing directories + use_as_default_explorer = true, + }, + + -- Customization of explorer windows + windows = { + -- Maximum number of windows to show side by side + max_number = math.huge, + -- Whether to show preview of file/directory under cursor + preview = false, + -- Width of focused window + width_focus = 50, + -- Width of non-focused window + width_nofocus = 15, + -- Width of preview window + width_preview = 25, + }, + }) + + -- Global keybindings for mini.files + vim.keymap.set("n", "e", function() + require("mini.files").open() + end, { desc = "Open mini.files (current dir)" }) + + vim.keymap.set("n", "E", function() + require("mini.files").open(vim.api.nvim_buf_get_name(0)) + end, { desc = "Open mini.files (current file)" }) + + vim.keymap.set("n", "-", function() + require("mini.files").open() + end, { desc = "Open parent directory" }) + + -- Mini.files specific keybindings and autocommands + vim.api.nvim_create_autocmd("User", { + pattern = "MiniFilesBufferCreate", + callback = function(args) + local buf_id = args.data.buf_id + + -- Add buffer-local keybindings + vim.keymap.set("n", "", function() + -- Split window and open file + local cur_target = require("mini.files").get_fs_entry() + if cur_target and cur_target.fs_type == "file" then + require("mini.files").close() + vim.cmd("split " .. cur_target.path) + end + end, { buffer = buf_id, desc = "Split and open file" }) + + vim.keymap.set("n", "", function() + -- Vertical split and open file + local cur_target = require("mini.files").get_fs_entry() + if cur_target and cur_target.fs_type == "file" then + require("mini.files").close() + vim.cmd("vsplit " .. cur_target.path) + end + end, { buffer = buf_id, desc = "Vertical split and open file" }) + + vim.keymap.set("n", "", function() + -- Open in new tab + local cur_target = require("mini.files").get_fs_entry() + if cur_target and cur_target.fs_type == "file" then + require("mini.files").close() + vim.cmd("tabnew " .. cur_target.path) + end + end, { buffer = buf_id, desc = "Open in new tab" }) + + -- Create new file/directory + vim.keymap.set("n", "a", function() + local cur_target = require("mini.files").get_fs_entry() + local path = cur_target and cur_target.path or require("mini.files").get_explorer_state().cwd + local new_name = vim.fn.input("Create: " .. path .. "/") + if new_name and new_name ~= "" then + if new_name:sub(-1) == "/" then + -- Create directory + vim.fn.mkdir(path .. "/" .. new_name, "p") + else + -- Create file + local new_file = io.open(path .. "/" .. new_name, "w") + if new_file then + new_file:close() + end + end + require("mini.files").refresh() + end + end, { buffer = buf_id, desc = "Create new file/directory" }) + + -- Rename file/directory + vim.keymap.set("n", "r", function() + local cur_target = require("mini.files").get_fs_entry() + if cur_target then + local old_name = vim.fn.fnamemodify(cur_target.path, ":t") + local new_name = vim.fn.input("Rename to: ", old_name) + if new_name and new_name ~= "" and new_name ~= old_name then + local new_path = vim.fn.fnamemodify(cur_target.path, ":h") .. "/" .. new_name + os.rename(cur_target.path, new_path) + require("mini.files").refresh() + end + end + end, { buffer = buf_id, desc = "Rename file/directory" }) + + -- Delete file/directory + vim.keymap.set("n", "d", function() + local cur_target = require("mini.files").get_fs_entry() + if cur_target then + local confirm = vim.fn.confirm("Delete " .. cur_target.path .. "?", "&Yes\n&No", 2) + if confirm == 1 then + if cur_target.fs_type == "directory" then + vim.fn.delete(cur_target.path, "rf") + else + vim.fn.delete(cur_target.path) + end + require("mini.files").refresh() + end + end + end, { buffer = buf_id, desc = "Delete file/directory" }) + end, + }) + + -- Auto-close mini.files when it's the last window + vim.api.nvim_create_autocmd("User", { + pattern = "MiniFilesBufferUpdate", + callback = function() + if vim.bo.filetype == "minifiles" then + -- Check if this is the only window left + local windows = vim.api.nvim_list_wins() + local minifiles_windows = 0 + for _, win in ipairs(windows) do + local buf = vim.api.nvim_win_get_buf(win) + if vim.api.nvim_buf_get_option(buf, "filetype") == "minifiles" then + minifiles_windows = minifiles_windows + 1 + end + end + + if #windows == minifiles_windows then + vim.cmd("quit") + end + end + end, + }) + end, +} diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 852e8d8..631ac36 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -770,8 +770,8 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t end -- Set up blocking diff operation - local co = coroutine.running() - if not co then + local co, is_main = coroutine.running() + if not co or is_main then error({ code = -32000, message = "Internal server error", diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index dcaf16f..1cacd83 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -614,8 +614,10 @@ function M._create_commands() local is_tree_buffer = current_ft == "NvimTree" or current_ft == "neo-tree" or current_ft == "oil" + or current_ft == "minifiles" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") + or string.match(current_bufname, "minifiles://") if is_tree_buffer then local integrations = require("claudecode.integrations") @@ -659,7 +661,53 @@ function M._create_commands() end local function handle_send_visual(visual_data, _opts) - -- Try tree file selection first + -- Check if we're in a tree buffer first + local current_ft = (vim.bo and vim.bo.filetype) or "" + local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or "" + + local is_tree_buffer = current_ft == "NvimTree" + or current_ft == "neo-tree" + or current_ft == "oil" + or current_ft == "minifiles" + or string.match(current_bufname, "neo%-tree") + or string.match(current_bufname, "NvimTree") + or string.match(current_bufname, "minifiles://") + + if is_tree_buffer then + local integrations = require("claudecode.integrations") + local files, error + + -- For mini.files, try to get the range from visual marks + if current_ft == "minifiles" or string.match(current_bufname, "minifiles://") then + local start_line = vim.fn.line("'<") + local end_line = vim.fn.line("'>") + + if start_line > 0 and end_line > 0 and start_line <= end_line then + -- Use range-based selection for mini.files + files, error = integrations._get_mini_files_selection_with_range(start_line, end_line) + else + -- Fall back to regular method + files, error = integrations.get_selected_files_from_tree() + end + else + files, error = integrations.get_selected_files_from_tree() + end + + if error then + logger.error("command", "ClaudeCodeSend_visual->TreeAdd: " .. error) + return + end + + if not files or #files == 0 then + logger.warn("command", "ClaudeCodeSend_visual->TreeAdd: No files selected") + return + end + + add_paths_to_claude(files, { context = "ClaudeCodeSend_visual->TreeAdd" }) + return + end + + -- Fall back to old visual selection logic for non-tree buffers if visual_data then local visual_commands = require("claudecode.visual_commands") local files, error = visual_commands.get_files_from_visual_selection(visual_data) diff --git a/lua/claudecode/integrations.lua b/lua/claudecode/integrations.lua index 2827aab..c4646a8 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -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 == "minifiles" then + return M._get_mini_files_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end @@ -261,4 +263,79 @@ function M._get_oil_selection() return {}, "No file found under cursor" end +--- Get selected files from mini.files +--- Supports both visual selection and single file under cursor +--- Reference: mini.files API MiniFiles.get_fs_entry() +--- @return table files List of file paths +--- @return string|nil error Error message if operation failed + +-- Helper function to get mini.files selection using explicit range +function M._get_mini_files_selection_with_range(start_line, end_line) + local success, mini_files = pcall(require, "mini.files") + if not success then + return {}, "mini.files not available" + end + + local files = {} + local bufnr = vim.api.nvim_get_current_buf() + + -- Process each line in the range + for line = start_line, end_line do + local entry_ok, entry = pcall(mini_files.get_fs_entry, bufnr, line) + + if entry_ok and entry and entry.path and entry.path ~= "" then + -- Extract real filesystem path from mini.files buffer path + local real_path = entry.path + -- Remove mini.files buffer protocol prefix if present + if real_path:match("^minifiles://") then + real_path = real_path:gsub("^minifiles://[^/]*/", "") + end + + -- Validate that the path exists + if vim.fn.filereadable(real_path) == 1 or vim.fn.isdirectory(real_path) == 1 then + table.insert(files, real_path) + end + end + end + + if #files > 0 then + return files, nil + else + return {}, "No files found in range" + end +end + +function M._get_mini_files_selection() + local success, mini_files = pcall(require, "mini.files") + if not success then + return {}, "mini.files not available" + end + + local bufnr = vim.api.nvim_get_current_buf() + + -- Normal mode: get file under cursor + local entry_ok, entry = pcall(mini_files.get_fs_entry, bufnr) + if not entry_ok or not entry then + return {}, "Failed to get entry from mini.files" + end + + if entry.path and entry.path ~= "" then + -- Extract real filesystem path from mini.files buffer path + local real_path = entry.path + -- Remove mini.files buffer protocol prefix if present + if real_path:match("^minifiles://") then + real_path = real_path:gsub("^minifiles://[^/]*/", "") + end + + -- Validate that the path exists + if vim.fn.filereadable(real_path) == 1 or vim.fn.isdirectory(real_path) == 1 then + return { real_path }, nil + else + return {}, "Invalid file or directory path: " .. real_path + end + end + + return {}, "No file found under cursor" +end + return M diff --git a/lua/claudecode/tools/open_diff.lua b/lua/claudecode/tools/open_diff.lua index 18525a9..86145d5 100644 --- a/lua/claudecode/tools/open_diff.lua +++ b/lua/claudecode/tools/open_diff.lua @@ -52,8 +52,8 @@ local function handler(params) end -- Ensure we're running in a coroutine context for blocking operation - local co = coroutine.running() - if not co then + local co, is_main = coroutine.running() + if not co or is_main then error({ code = -32000, message = "Internal server error", diff --git a/tests/unit/mini_files_integration_spec.lua b/tests/unit/mini_files_integration_spec.lua new file mode 100644 index 0000000..a54b4fd --- /dev/null +++ b/tests/unit/mini_files_integration_spec.lua @@ -0,0 +1,265 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("mini.files integration", function() + local integrations + local mock_vim + + local function setup_mocks() + package.loaded["claudecode.integrations"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.visual_commands"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function() end, + error = function() end, + } + + -- Mock visual_commands + package.loaded["claudecode.visual_commands"] = { + get_visual_range = function() + return 1, 3 -- Return lines 1-3 by default + end, + } + + mock_vim = { + fn = { + mode = function() + return "n" -- Normal mode by default + end, + filereadable = function(path) + if path:match("%.lua$") or path:match("%.txt$") then + return 1 + end + return 0 + end, + isdirectory = function(path) + if path:match("/$") or path:match("/src$") then + return 1 + end + return 0 + end, + }, + api = { + nvim_get_current_buf = function() + return 1 -- Mock buffer ID + end, + }, + bo = { filetype = "minifiles" }, + } + + _G.vim = mock_vim + end + + before_each(function() + setup_mocks() + integrations = require("claudecode.integrations") + end) + + describe("_get_mini_files_selection", function() + it("should get single file under cursor", function() + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function(buf_id) + -- Verify buffer ID is passed correctly + if buf_id ~= 1 then + return nil + end + return { path = "/Users/test/project/main.lua" } + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/Users/test/project/main.lua") + end) + + it("should get directory under cursor", function() + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function(buf_id) + -- Verify buffer ID is passed correctly + if buf_id ~= 1 then + return nil + end + return { path = "/Users/test/project/src" } + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/Users/test/project/src") + end) + + it("should handle mini.files buffer path format", function() + -- Mock mini.files module that returns buffer-style paths + local mock_mini_files = { + get_fs_entry = function(buf_id) + if buf_id ~= 1 then + return nil + end + return { path = "minifiles://42//Users/test/project/buffer_file.lua" } + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/Users/test/project/buffer_file.lua") + end) + + it("should handle various mini.files buffer path formats", function() + -- Test different buffer path formats that could occur + local test_cases = { + { input = "minifiles://42/Users/test/file.lua", expected = "Users/test/file.lua" }, + { input = "minifiles://42//Users/test/file.lua", expected = "/Users/test/file.lua" }, + { input = "minifiles://123///Users/test/file.lua", expected = "//Users/test/file.lua" }, + { input = "/Users/test/normal_path.lua", expected = "/Users/test/normal_path.lua" }, + } + + for i, test_case in ipairs(test_cases) do + local mock_mini_files = { + get_fs_entry = function(buf_id) + if buf_id ~= 1 then + return nil + end + return { path = test_case.input } + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be(test_case.expected) + end + end) + + it("should handle empty entry under cursor", function() + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function() + return nil -- No entry + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be("Failed to get entry from mini.files") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle entry with empty path", function() + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function() + return { path = "" } -- Empty path + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be("No file found under cursor") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle invalid file path", function() + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function() + return { path = "/Users/test/project/invalid_file" } + end, + } + package.loaded["mini.files"] = mock_mini_files + + mock_vim.fn.filereadable = function() + return 0 -- File not readable + end + mock_vim.fn.isdirectory = function() + return 0 -- Not a directory + end + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be("Invalid file or directory path: /Users/test/project/invalid_file") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle mini.files not available", function() + -- Don't mock mini.files module (will cause require to fail) + package.loaded["mini.files"] = nil + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be("mini.files not available") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle pcall errors gracefully", function() + -- Mock mini.files module that throws errors + local mock_mini_files = { + get_fs_entry = function() + error("mini.files get_fs_entry failed") + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations._get_mini_files_selection() + + expect(err).to_be("Failed to get entry from mini.files") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + end) + + describe("get_selected_files_from_tree", function() + it("should detect minifiles filetype and delegate to _get_mini_files_selection", function() + mock_vim.bo.filetype = "minifiles" + + -- Mock mini.files module + local mock_mini_files = { + get_fs_entry = function() + return { path = "/path/test.lua" } + end, + } + package.loaded["mini.files"] = mock_mini_files + + local files, err = integrations.get_selected_files_from_tree() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/path/test.lua") + end) + + it("should return error for unsupported filetype", function() + mock_vim.bo.filetype = "unsupported" + + local files, err = integrations.get_selected_files_from_tree() + + assert_contains(err, "Not in a supported tree buffer") + expect(files).to_be_nil() + end) + end) +end) diff --git a/tests/unit/opendiff_blocking_spec.lua b/tests/unit/opendiff_blocking_spec.lua index 854c639..6c103fe 100644 --- a/tests/unit/opendiff_blocking_spec.lua +++ b/tests/unit/opendiff_blocking_spec.lua @@ -18,6 +18,13 @@ describe("openDiff blocking behavior", function() package.loaded["claudecode.logger"] = mock_logger + -- Mock diff module to prevent loading issues + package.loaded["claudecode.diff"] = { + open_diff_blocking = function() + error("This should not be called in coroutine context test") + end, + } + -- Load the module under test open_diff_module = require("claudecode.tools.open_diff") end) @@ -121,7 +128,11 @@ describe("openDiff blocking behavior", function() assert.is_table(err) assert.equals(-32000, err.code) -- Error gets wrapped by open_diff_blocking -- The exact error message may vary depending on where it fails in the test environment - assert.is_true(err.message == "Error setting up diff" or err.message == "Internal server error") + assert.is_true( + err.message == "Error setting up diff" + or err.message == "Internal server error" + or err.message == "Error opening blocking diff" + ) end) it("should validate required parameters", function()