From 101d8db19d9dec1ca4986266232af085d35af42f Mon Sep 17 00:00:00 2001 From: Makoto Dejima Date: Sun, 22 Jun 2025 23:12:00 +0200 Subject: [PATCH] feat: support netrw file selection --- README.md | 4 +- dev-config.lua | 2 +- lua/claudecode/diff.lua | 1 + lua/claudecode/init.lua | 1 + lua/claudecode/integrations.lua | 52 +++++ lua/claudecode/tools/open_file.lua | 1 + tests/unit/netrw_integration_spec.lua | 298 ++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 tests/unit/netrw_integration_spec.lua diff --git a/README.md b/README.md index b3c31c7..f7cf4ee 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", "netrw" }, }, -- 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`/`netrw`, 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 ecb3489..85a4ed6 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", "netrw" }, }, -- Development helpers diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 852e8d8..51e2f2c 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -50,6 +50,7 @@ local function find_main_editor_window() or filetype == "ClaudeCode" or filetype == "NvimTree" or filetype == "oil" + or filetype == "netrw" or filetype == "aerial" or filetype == "tagbar" ) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index f673899..90cc67c 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 == "netrw" 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..9cfa003 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 == "netrw" then + return M._get_netrw_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end @@ -261,4 +263,54 @@ function M._get_oil_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() + -- 1. Check for marked files + local mf_ok, mf_result = pcall(function() + if vim.fn.exists("*netrw#Expose") == 1 then + return vim.fn.call("netrw#Expose", { "netrwmarkfilelist" }) + end + return nil + end) + + local marked_files = {} + + if mf_ok and mf_result 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, file_path) + end + end + end + + if #marked_files > 0 then + return marked_files, nil + end + + -- 2. No marked files. Check for a file or dir under cursor + local path_ok, path_result = pcall(function() + if vim.fn.exists("*netrw#Call") == 1 then + local word = vim.fn.call("netrw#Call", { "NetrwGetWord" }) + if word ~= "" then + return vim.fn.call("netrw#Call", { "NetrwFile", word }) + end + 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 855a28b..327f8c6 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -76,6 +76,7 @@ local function find_main_editor_window() or filetype == "ClaudeCode" or filetype == "NvimTree" or filetype == "oil" + or filetype == "netrw" or filetype == "aerial" or filetype == "tagbar" ) diff --git a/tests/unit/netrw_integration_spec.lua b/tests/unit/netrw_integration_spec.lua new file mode 100644 index 0000000..ef807ca --- /dev/null +++ b/tests/unit/netrw_integration_spec.lua @@ -0,0 +1,298 @@ +-- 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#Expose" or func_name == "*netrw#Call" then + return 1 + end + return 0 + end, + call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} -- No marked files by default + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "test_file.lua" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/Users/test/project/test_file.lua" + end + return "" + 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, + }, + bo = { filetype = "netrw" }, + } + + _G.vim = mock_vim + end + + before_each(function() + setup_mocks() + integrations = require("claudecode.integrations") + end) + + describe("_get_netrw_selection", function() + it("should get single file under cursor", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} -- No marked files + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "main.lua" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/Users/test/project/main.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("/Users/test/project/main.lua") + end) + + it("should get directory under cursor", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} -- No marked files + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "src" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/Users/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(1) + expect(files[1]).to_be("/Users/test/project/src") + end) + + it("should get marked files when available", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return { + "/Users/test/project/file1.lua", + "/Users/test/project/file2.lua", + "/Users/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("/Users/test/project/file1.lua") + expect(files[2]).to_be("/Users/test/project/file2.lua") + expect(files[3]).to_be("/Users/test/project/src/") + 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 { "/Users/test/project/marked_file.lua" } + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "cursor_file.lua" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/Users/test/project/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("/Users/test/project/marked_file.lua") + 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 { + "/Users/test/project/valid_file.lua", + "/Users/test/project/invalid_file.xyz", -- This won't pass filereadable/isdirectory + "/Users/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(2) -- Only valid_file.lua and src/ + expect(files[1]).to_be("/Users/test/project/valid_file.lua") + expect(files[2]).to_be("/Users/test/project/src/") + end) + + it("should handle empty word under cursor", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" and args[1] == "netrwmarkfilelist" then + return {} -- No marked files + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "" -- Empty word + 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 {} -- No marked files + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "invalid_file" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/Users/test/project/invalid_file" + end + return "" + end + + 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_netrw_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 netrw function not available", function() + mock_vim.fn.exists = function() + return 0 -- Functions not available + 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 pcall errors gracefully", function() + mock_vim.fn.call = function(func_name, args) + if func_name == "netrw#Expose" then + error("netrw#Expose 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 { + "/Users/test/project/valid1.lua", + "/nonexistent/invalid.txt", + "/Users/test/project/src/", + "/another/invalid/path", + } + end + return "" + end + + mock_vim.fn.filereadable = function(path) + return path:match("/Users/test/project/") and 1 or 0 + end + + mock_vim.fn.isdirectory = function(path) + return path:match("/Users/test/project/src") and 1 or 0 + end + + local files, err = integrations._get_netrw_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(2) + expect(files[1]).to_be("/Users/test/project/valid1.lua") + expect(files[2]).to_be("/Users/test/project/src/") + 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 {} -- No marked files + elseif func_name == "netrw#Call" and args[1] == "NetrwGetWord" then + return "test.lua" + elseif func_name == "netrw#Call" and args[1] == "NetrwFile" then + return "/path/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("/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)