diff --git a/README.md b/README.md index 8da67ee3..073fcbae 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", "minifiles" }, + ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" }, }, -- Diff management { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, diff --git a/dev-config.lua b/dev-config.lua index 6939f929..d0e1a1c2 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", "minifiles" }, + ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" }, }, -- Development helpers diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 2355ecf2..d590710f 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -70,6 +70,7 @@ local function find_main_editor_window() or filetype == "NvimTree" or filetype == "oil" or filetype == "minifiles" + or filetype == "netrw" or filetype == "aerial" or filetype == "tagbar" ) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 2f858c5c..c4b7744e 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -656,6 +656,7 @@ function M._create_commands() or current_ft == "neo-tree" or current_ft == "oil" or current_ft == "minifiles" + or current_ft == "netrw" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") or string.match(current_bufname, "minifiles://") @@ -710,6 +711,7 @@ function M._create_commands() or current_ft == "neo-tree" or current_ft == "oil" or current_ft == "minifiles" + or current_ft == "netrw" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") or string.match(current_bufname, "minifiles://") diff --git a/lua/claudecode/integrations.lua b/lua/claudecode/integrations.lua index a1c6076d..1713def6 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -18,6 +18,8 @@ function M.get_selected_files_from_tree() return M._get_oil_selection() elseif current_ft == "minifiles" then return M._get_mini_files_selection() + elseif current_ft == "netrw" then + return M._get_netrw_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end @@ -406,4 +408,73 @@ function M._get_mini_files_selection() return {}, "No file found under cursor" end +--- Get selected files from netrw +--- Supports both marked files and single file under cursor +--- Reference: :help netrw-mf, :help markfilelist +--- @return table files List of file paths +--- @return string|nil error Error message if operation failed +function M._get_netrw_selection() + local has_call = (vim.fn.exists("*netrw#Call") == 1) + local has_expose = (vim.fn.exists("*netrw#Expose") == 1) + if not (has_call and has_expose) then + return {}, "netrw not available" + end + + -- function to resolve a 'word' (filename in netrw listing) to an absolute path using b:netrw_curdir + local function resolve_word_to_path(word) + if type(word) ~= "string" or word == "" then + return nil + end + if word == "." or word == ".." or word == "../" then + return nil + end + local curdir = vim.b.netrw_curdir or vim.fn.getcwd() + local joined = curdir .. "/" .. word + return vim.fn.fnamemodify(joined, ":p") + end + + -- 1. Check for marked files + do + local mf_ok, mf_result = pcall(function() + if has_expose then + return vim.fn.call("netrw#Expose", { "netrwmarkfilelist" }) + end + return nil + end) + + local marked_files = {} + if mf_ok and type(mf_result) == "table" and #mf_result > 0 then + for _, file_path in ipairs(mf_result) do + if vim.fn.filereadable(file_path) == 1 or vim.fn.isdirectory(file_path) == 1 then + table.insert(marked_files, vim.fn.fnamemodify(file_path, ":p")) + end + end + end + + if #marked_files > 0 then + return marked_files, nil + end + end + + -- 2. No marked files. Check for a file or dir under cursor + local path_ok, path_result = pcall(function() + if has_call then + local word = vim.fn.call("netrw#Call", { "NetrwGetWord" }) + local p = resolve_word_to_path(word) + return p + end + return nil + end) + + if not path_ok or not path_result or path_result == "" then + return {}, "Failed to get path from netrw" + end + + if vim.fn.filereadable(path_result) == 1 or vim.fn.isdirectory(path_result) == 1 then + return { path_result }, nil + end + + return {}, "Invalid file or directory path: " .. path_result +end + return M diff --git a/lua/claudecode/tools/open_file.lua b/lua/claudecode/tools/open_file.lua index 34083285..ab56ea3d 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -81,6 +81,7 @@ local function find_main_editor_window() or filetype == "NvimTree" or filetype == "oil" or filetype == "minifiles" + or filetype == "netrw" or filetype == "aerial" or filetype == "tagbar" ) diff --git a/lua/claudecode/visual_commands.lua b/lua/claudecode/visual_commands.lua index 3f2bc25b..18328af6 100644 --- a/lua/claudecode/visual_commands.lua +++ b/lua/claudecode/visual_commands.lua @@ -201,6 +201,8 @@ function M.get_tree_state() end return oil, "oil" + elseif current_ft == "netrw" then + return { curdir = vim.b.netrw_curdir }, "netrw" else return nil, nil end @@ -432,6 +434,40 @@ function M.get_files_from_visual_selection(visual_data) end end end + elseif tree_type == "netrw" then + local netrw = tree_state + + local function resolve_word_to_path(word) + if type(word) ~= "string" or word == "" then + return nil + end + if word == "." or word == ".." or word == "../" then + return nil + end + local curdir = netrw.curdir or vim.b.netrw_curdir or vim.fn.getcwd() + local joined = curdir .. "/" .. word + return vim.fn.fnamemodify(joined, ":p") + end + + if start_pos and end_pos then + local cursor_pos = vim.api.nvim_win_get_cursor(0) + + -- Move cursor to each line and do NetrwGetWord + for lnum = start_pos, end_pos do + pcall(vim.api.nvim_win_set_cursor, 0, { lnum, 0 }) + local ok, word = pcall(function() + return vim.fn.call("netrw#Call", { "NetrwGetWord" }) + end) + if ok then + local path = resolve_word_to_path(word) + if path and (vim.fn.filereadable(path) == 1 or vim.fn.isdirectory(path) == 1) then + table.insert(files, path) + end + end + end + + pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) + end end return files, nil diff --git a/tests/unit/netrw_integration_spec.lua b/tests/unit/netrw_integration_spec.lua new file mode 100644 index 00000000..aed651d9 --- /dev/null +++ b/tests/unit/netrw_integration_spec.lua @@ -0,0 +1,354 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("netrw integration", function() + local integrations + local mock_vim + + local function setup_mocks() + package.loaded["claudecode.integrations"] = nil + package.loaded["claudecode.logger"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function() end, + error = function() end, + } + + mock_vim = { + fn = { + exists = function(func_name) + if func_name == "*netrw#Call" or func_name == "*netrw#Expose" then + return 1 + end + return 0 + end, + call = function(func_name, args) + -- Default behavior - will be overridden in individual tests + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "test_file.lua" + end + return "" + end, + filereadable = function(path) + if path:match("/nonexistent/") or path:match("invalid_file") then + return 0 + elseif path:match("%.lua$") or path:match("%.txt$") or path:match("%.md$") then + return 1 + end + return 0 + end, + isdirectory = function(path) + if path:match("/nonexistent/") then + return 0 + elseif path:match("/$") or path:match("/src$") or path:match("/docs$") or path:match("/subdir$") then + return 1 + end + return 0 + end, + fnamemodify = function(path, modifier) + if modifier == ":p" then + if path:sub(1, 1) == "/" then + return path + else + return "/test/project/" .. path + end + end + return path + end, + getcwd = function() + return "/test/project" + end, + }, + bo = { filetype = "netrw" }, + b = { netrw_curdir = "/test/project/subdir" }, + api = { + nvim_get_current_buf = function() + return 1 + end, + }, + } + + _G.vim = mock_vim + end + + before_each(function() + setup_mocks() + integrations = require("claudecode.integrations") + end) + + describe("_get_netrw_selection", function() + it("should return marked files when available", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return { + "/test/project/file1.lua", + "/test/project/file2.txt", + "/test/project/src/", + } + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(3) + expect(files[1]).to_be("/test/project/file1.lua") + expect(files[2]).to_be("/test/project/file2.txt") + expect(files[3]).to_be("/test/project/src/") + end) + + it("should filter out invalid files from marked list", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return { + "/test/project/valid.lua", + "/nonexistent/invalid.txt", + "/test/project/src/", + "/nonexistent/invalid_dir/", + } + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(2) -- Only valid.lua and src/ + expect(files[1]).to_be("/test/project/valid.lua") + expect(files[2]).to_be("/test/project/src/") + end) + + it("should fall back to cursor selection when no marked files", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "cursor_file.lua" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/subdir/cursor_file.lua") + end) + + it("should handle directory under cursor", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "docs" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/subdir/docs") + end) + + it("should prefer marked files over cursor selection", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return { "/test/project/marked.lua" } + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "cursor.lua" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/marked.lua") + end) + + it("should use b:netrw_curdir for path resolution", function() + mock_vim.b.netrw_curdir = "/custom/netrw/dir" + + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "relative.lua" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/custom/netrw/dir/relative.lua") + end) + + it("should return error when netrw functions are not available", function() + mock_vim.fn.exists = function() + return 0 + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be("netrw not available") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle empty word from NetrwGetWord", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be("Failed to get path from netrw") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle nil word from NetrwGetWord", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return nil + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be("Failed to get path from netrw") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle special navigation entries", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return ".." + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be("Failed to get path from netrw") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle invalid file path", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "invalid_file" + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_match("Invalid file or directory path:") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle netrw#Call pcall failure", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" then + error("netrw#Call failed") + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be("Failed to get path from netrw") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should handle mixed valid and invalid marked files", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return { + "/test/project/valid1.lua", + "/nonexistent/invalid1.txt", + "/test/project/src/", + "/nonexistent/invalid2/", + "/test/project/valid2.md", + } + end + return "" + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(3) + expect(files[1]).to_be("/test/project/valid1.lua") + expect(files[2]).to_be("/test/project/src/") + expect(files[3]).to_be("/test/project/valid2.md") + end) + end) + + describe("get_selected_files_from_tree", function() + it("should detect netrw filetype and delegate to _get_netrw_selection", function() + mock_vim.bo.filetype = "netrw" + + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "integrated_test.lua" + end + return "" + end + + 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("/test/project/subdir/integrated_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() + + expect(err).to_match("Not in a supported tree buffer") + expect(files).to_be_nil() + end) + end) +end)