diff --git a/README.md b/README.md index aaf71d1c..1574f183 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. "as", "ClaudeCodeTreeAdd", desc = "Add file", - ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" }, + ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw", "snacks_picker_list" }, }, -- Diff management { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, @@ -212,7 +212,8 @@ Configure the plugin with the detected path: 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`/`mini.nvim`, press `as` on a file to add it to Claude's context + - In `nvim-tree`/`neo-tree`/`oil.nvim`/`mini.nvim`, or a focused snacks picker list / the Snacks Explorer sidebar, press `as` on a file to add it to Claude's context + - For modal snacks pickers (`Snacks.picker.files()`/`grep()`), which keep focus in the input box, bind a picker action that calls `require("claudecode").send_at_mention(...)` for the selected item(s) — the [claude-fzf.nvim](#-claude-fzfnvim) community extension does the equivalent for `fzf-lua` 3. **Let Claude work**: Claude can now: - See your current file and selections in real-time - Open files in your editor diff --git a/fixtures/snacks-picker/README.md b/fixtures/snacks-picker/README.md new file mode 100644 index 00000000..af56b969 --- /dev/null +++ b/fixtures/snacks-picker/README.md @@ -0,0 +1,85 @@ +# snacks-picker fixture — triage for issue #192 + +Reproduces and explores [#192](https://github.com/coder/claudecode.nvim/issues/192): +make adding the **highlighted/selected file in a snacks.nvim picker** to Claude's +context work, instead of failing with: + +``` +[ClaudeCode] [command] [ERROR] ClaudeCodeTreeAdd: Not in a supported tree buffer (current filetype: snacks_picker_list) +``` + +Unlike the tree-explorer integrations (`nvim-tree`, `neo-tree`, `oil`, +`mini.files`, `netrw`), a snacks **picker** is a modal fuzzy finder. Its window +layout is: + +| window | filetype | buftype | usual focus | +| ------ | --------------------- | -------- | --------------------------------- | +| box | `snacks_layout_box` | `nofile` | — | +| list | `snacks_picker_list` | `nofile` | only when you ``/cycle to it | +| input | `snacks_picker_input` | `prompt` | **default (insert mode)** | + +`:ClaudeCodeTreeAdd` only sees `snacks_picker_list` when the **list** window is +focused — but the picker normally keeps focus in the **input** box. That is why +the idiomatic fix is an in-picker _action bound to a key_, not an ex-command. + +Note: `Snacks.explorer()` is built on the picker and also uses the +`snacks_picker_list` filetype, so the same code path covers both. + +## Run it + +```bash +source fixtures/nvim-aliases.sh +vv snacks-picker +``` + +Then `:ClaudeCodeStart` (or it auto-starts), and: + +- `ff` → files picker, `fg` → grep picker. + +### Path A — zero-change WORKAROUND (works on stock claudecode.nvim) + +`lua/plugins/snacks.lua` registers a custom picker action `claude_add` bound to +`` in both the input and list windows. From the picker (input box, insert +mode): + +1. Type to filter; optionally `` to multi-select several files. +2. Press `` → the selected files (or the one under the cursor) are sent to + Claude via the public `require("claudecode").send_at_mention()` API, and the + picker closes. + +This needs **no changes to claudecode.nvim**. It mirrors how the community +`claude-fzf.nvim` plugin integrates fzf-lua. + +### Path B — built-in command path + +The in-core `snacks_picker_list` handler (`integrations._get_snacks_picker_selection`) +makes `:ClaudeCodeTreeAdd` work when the list window is focused: + +1. Open a picker, `` to focus/cycle to the **list** window (filetype becomes + `snacks_picker_list`). +2. `:ClaudeCodeTreeAdd` (or `at`) → selected/cursor files are added. + +## Deterministic, headless proof (no Claude client needed) + +The behavior was validated headlessly: + +```text +# Without the snacks_picker_list handler (the issue #192 state), list focused: +focused_filetype: snacks_picker_list +dispatch_error: Not in a supported tree buffer (current filetype: snacks_picker_list) + +# With the in-core handler, 2 files multi-selected: +handler_basenames: alpha.lua,beta.lua +dispatch_files: 2 err: nil + +# Key-bound action (Path A), 2 files multi-selected: +send_at_mention_results: alpha.lua=true,beta.lua=true +``` + +To reproduce headlessly, a small throwaway harness (not committed) opens a picker +with `Snacks.picker.files({ cwd = ... })`, `vim.wait`s until +`Snacks.picker.get()` returns a picker with items, focuses the list window, then +calls `require("claudecode.integrations").get_selected_files_from_tree()` and +inspects the returned paths. The committed unit test +`tests/unit/snacks_picker_integration_spec.lua` covers the same logic +deterministically with mocked snacks. diff --git a/fixtures/snacks-picker/example/alpha.lua b/fixtures/snacks-picker/example/alpha.lua new file mode 100644 index 00000000..70b19575 --- /dev/null +++ b/fixtures/snacks-picker/example/alpha.lua @@ -0,0 +1,8 @@ +-- example/alpha.lua — a file to highlight/select in the snacks picker. +local M = {} + +function M.greet(name) + return "hello, " .. (name or "world") +end + +return M diff --git a/fixtures/snacks-picker/example/beta.lua b/fixtures/snacks-picker/example/beta.lua new file mode 100644 index 00000000..bced53f8 --- /dev/null +++ b/fixtures/snacks-picker/example/beta.lua @@ -0,0 +1,8 @@ +-- example/beta.lua — a second file, to test multi-select () in the picker. +local M = {} + +function M.add(a, b) + return a + b +end + +return M diff --git a/fixtures/snacks-picker/init.lua b/fixtures/snacks-picker/init.lua new file mode 100644 index 00000000..55b8979f --- /dev/null +++ b/fixtures/snacks-picker/init.lua @@ -0,0 +1 @@ +require("config.lazy") diff --git a/fixtures/snacks-picker/lazy-lock.json b/fixtures/snacks-picker/lazy-lock.json new file mode 100644 index 00000000..18105709 --- /dev/null +++ b/fixtures/snacks-picker/lazy-lock.json @@ -0,0 +1,5 @@ +{ + "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, + "snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }, + "tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" } +} diff --git a/fixtures/snacks-picker/lua/config/lazy.lua b/fixtures/snacks-picker/lua/config/lazy.lua new file mode 100644 index 00000000..a932814d --- /dev/null +++ b/fixtures/snacks-picker/lua/config/lazy.lua @@ -0,0 +1,37 @@ +-- 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) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- Resolve the claudecode.nvim checkout that owns this fixture. +-- XDG_CONFIG_HOME is set to the `fixtures/` dir by the `vv` launcher, so the +-- repository root is its parent. This makes the fixture load the local plugin +-- copy (including git worktrees) without relying on lazy's default dev path. +local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or vim.fn.getcwd(), ":h") +vim.g.claudecode_dev_dir = repo_root + +require("lazy").setup({ + spec = { + { import = "plugins" }, + }, + install = { colorscheme = { "habamax" } }, + checker = { enabled = false }, +}) + +vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) +vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) diff --git a/fixtures/snacks-picker/lua/plugins/dev-claudecode.lua b/fixtures/snacks-picker/lua/plugins/dev-claudecode.lua new file mode 100644 index 00000000..df0e753c --- /dev/null +++ b/fixtures/snacks-picker/lua/plugins/dev-claudecode.lua @@ -0,0 +1,22 @@ +-- Development configuration for claudecode.nvim with the snacks.nvim picker. +-- Loads the local plugin checkout (resolved in lua/config/lazy.lua) via `dir` +-- so it works from a normal checkout or a git worktree. +return { + "coder/claudecode.nvim", + dir = vim.g.claudecode_dev_dir, + dependencies = { "folke/snacks.nvim" }, + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "aS", "ClaudeCodeStart", desc = "Start Claude Server" }, + { "aQ", "ClaudeCodeStop", desc = "Stop Claude Server" }, + -- Built-in command path: focus the picker LIST window, then run this. + { "at", "ClaudeCodeTreeAdd", desc = "Tree/Picker Add" }, + }, + ---@type PartialClaudeCodeConfig + opts = { + -- Keep behavior predictable for reproduction; start the server explicitly. + log_level = "debug", + }, +} diff --git a/fixtures/snacks-picker/lua/plugins/init.lua b/fixtures/snacks-picker/lua/plugins/init.lua new file mode 100644 index 00000000..508732a2 --- /dev/null +++ b/fixtures/snacks-picker/lua/plugins/init.lua @@ -0,0 +1,11 @@ +-- Basic plugin configuration +return { + { + "folke/tokyonight.nvim", + lazy = false, + priority = 1000, + config = function() + vim.cmd([[colorscheme tokyonight]]) + end, + }, +} diff --git a/fixtures/snacks-picker/lua/plugins/snacks.lua b/fixtures/snacks-picker/lua/plugins/snacks.lua new file mode 100644 index 00000000..855f131c --- /dev/null +++ b/fixtures/snacks-picker/lua/plugins/snacks.lua @@ -0,0 +1,75 @@ +-- snacks.nvim picker fixture for issue #192. +-- +-- Demonstrates BOTH integration paths for adding a picker's selected/highlighted +-- file(s) to Claude Code's context: +-- +-- 1. WORKAROUND (works TODAY, no claudecode.nvim change): a custom snacks picker +-- `action` (`claude_add`) bound to in the input AND list windows. This +-- is the idiomatic snacks way and works from the input box in insert mode. +-- It mirrors how the community claude-fzf.nvim plugin integrates fzf-lua. +-- +-- 2. Built-in command path: `:ClaudeCodeTreeAdd` also works when the picker +-- LIST window is focused (vim.bo.filetype == "snacks_picker_list"), via the +-- in-core snacks_picker_list handler in lua/claudecode/integrations.lua. +return { + "folke/snacks.nvim", + priority = 1000, + lazy = false, + ---@type snacks.Config + opts = { + picker = { + enabled = true, + ---@type table + actions = { + -- WORKAROUND action: send the selected (Tab) items, or the item under + -- the cursor when nothing is selected, to Claude as @-mentions. + claude_add = function(picker) + local items = picker:selected({ fallback = true }) + local claudecode = require("claudecode") + local count = 0 + for _, item in ipairs(items) do + local path = Snacks.picker.util.path(item) + if path and path ~= "" then + local ok = claudecode.send_at_mention(path, nil, nil, "snacks-picker") + if ok then + count = count + 1 + end + end + end + picker:close() + vim.schedule(function() + vim.notify(("[claude_add] Added %d file(s) to Claude context"):format(count), vim.log.levels.INFO) + end) + end, + }, + win = { + input = { + keys = { + [""] = { "claude_add", mode = { "i", "n" }, desc = "Add to Claude Code" }, + }, + }, + list = { + keys = { + [""] = { "claude_add", desc = "Add to Claude Code" }, + }, + }, + }, + }, + }, + keys = { + { + "ff", + function() + Snacks.picker.files() + end, + desc = "Find Files (snacks picker)", + }, + { + "fg", + function() + Snacks.picker.grep() + end, + desc = "Grep (snacks picker)", + }, + }, +} diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 6934730f..d71ab7a6 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -734,6 +734,7 @@ function M._create_commands() or current_ft == "oil" or current_ft == "minifiles" or current_ft == "netrw" + or current_ft == "snacks_picker_list" 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 1713def6..3302a335 100644 --- a/lua/claudecode/integrations.lua +++ b/lua/claudecode/integrations.lua @@ -1,5 +1,5 @@ --- Tree integration module for ClaudeCode.nvim ---- Handles detection and selection of files from nvim-tree, neo-tree, mini.files, and oil.nvim +--- Handles detection and selection of files from nvim-tree, neo-tree, mini.files, oil.nvim, netrw, and snacks pickers ---@module 'claudecode.integrations' local M = {} local logger = require("claudecode.logger") @@ -20,11 +20,88 @@ function M.get_selected_files_from_tree() return M._get_mini_files_selection() elseif current_ft == "netrw" then return M._get_netrw_selection() + elseif current_ft == "snacks_picker_list" then + return M._get_snacks_picker_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end end +---Get selected files from a snacks.nvim picker +---Supports both Snacks.explorer() and modal files()/grep() pickers (they share +---the snacks_picker_list filetype), including multi-selection (Tab) and the +---item under the cursor. +---@return table files List of file paths +---@return string|nil error Error message if operation failed +function M._get_snacks_picker_selection() + local success, snacks = pcall(require, "snacks") + if not success then + return {}, "snacks.nvim picker not available" + end + + -- snacks.picker and snacks.picker.util are lazily required through a metatable, + -- so merely accessing them can raise on a partial install. Probe both under + -- pcall so the handler degrades gracefully instead of throwing. + -- probe_ok is true only if snacks.picker resolved AND snacks.picker.util.path + -- evaluated without raising, so a non-function util_path is the only remaining + -- "partial install" case to reject. + local probe_ok, picker_mod, util_path = pcall(function() + return snacks.picker, snacks.picker.util.path + end) + if not probe_ok or type(util_path) ~= "function" then + return {}, "snacks.nvim picker not available" + end + + -- snacks_picker_list is shared by the explorer and modal files()/grep() pickers. + -- Prefer the picker whose list window is currently focused (the buffer the user + -- ran the command in); fall back to the most-recently-opened picker on this tab. + local pickers = picker_mod.get({ tab = true }) + if not pickers or #pickers == 0 then + return {}, "No active snacks picker found" + end + local current_win = vim.api.nvim_get_current_win() + local picker = pickers[#pickers] + for _, p in ipairs(pickers) do + local lw = p.list and p.list.win and p.list.win.win + if lw == current_win then + picker = p + break + end + end + + -- selected({fallback=true}) returns explicitly selected (Tab) items, or the + -- item under the cursor when nothing is selected. + local ok_sel, items = pcall(function() + return picker:selected({ fallback = true }) + end) + if not ok_sel or type(items) ~= "table" then + return {}, "Failed to read snacks picker selection" + end + + local files = {} + for _, item in ipairs(items) do + -- Skip items without a string file path (registers/commands/unsaved buffers). + if item and type(item.file) == "string" then + -- Snacks.picker.util.path is the canonical resolver but undocumented; + -- guard it and fall back to a manual cwd/file join. An absolute item.file + -- with no cwd (e.g. the explorer) falls through to item.file unchanged. + local path_ok, path = pcall(util_path, item) + if not path_ok or not path then + path = (type(item.cwd) == "string") and (item.cwd .. "/" .. item.file) or item.file + end + if path and path ~= "" and (vim.fn.filereadable(path) == 1 or vim.fn.isdirectory(path) == 1) then + table.insert(files, path) + end + end + end + + if #files > 0 then + return files, nil + end + + return {}, "No file found in snacks picker selection" +end + ---Get selected files from nvim-tree ---Supports both multi-selection (marks) and single file under cursor ---@return table files List of file paths diff --git a/tests/unit/snacks_picker_integration_spec.lua b/tests/unit/snacks_picker_integration_spec.lua new file mode 100644 index 00000000..f8d30192 --- /dev/null +++ b/tests/unit/snacks_picker_integration_spec.lua @@ -0,0 +1,390 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("snacks picker integration", function() + local integrations + local mock_vim + + -- Builds a fake snacks picker object that mimics the public surface the + -- handler relies on: `picker.list.win.win` (the list window id, used for + -- focus matching) and `picker:selected({ fallback = true })`. + local function make_picker(win_id, items) + return { + list = { win = { win = win_id } }, + selected = function(_, opts) + expect(opts).to_be_table() + expect(opts.fallback).to_be_true() + return items + end, + } + end + + -- Installs the snacks mock. `pickers` is the list returned by + -- Snacks.picker.get({ tab = true }). + local function set_snacks(pickers) + package.loaded["snacks"] = { + picker = { + get = function(opts) + expect(opts).to_be_table() + expect(opts.tab).to_be_true() + return pickers + end, + util = { + -- Mirrors the real Snacks.picker.util.path: nil when no string file, + -- absolute item.file returned as-is, else joined to cwd. + path = function(item) + if not (item and type(item.file) == "string") then + return nil + end + if item.file:sub(1, 1) == "/" then + return item.file + end + return (type(item.cwd) == "string") and (item.cwd .. "/" .. item.file) or item.file + end, + }, + }, + } + end + + local function setup_mocks() + package.loaded["claudecode.integrations"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["snacks"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function() end, + error = function() end, + } + + mock_vim = { + fn = { + filereadable = function(path) + if path:match("/nonexistent/") or path:match("missing") 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$") then + return 1 + end + return 0 + end, + }, + bo = { filetype = "snacks_picker_list" }, + api = { + nvim_get_current_win = function() + return 1000 + end, + }, + } + + _G.vim = mock_vim + end + + before_each(function() + setup_mocks() + integrations = require("claudecode.integrations") + end) + + describe("_get_snacks_picker_selection", function() + it("should return error when snacks is not available", function() + package.loaded["snacks"] = nil + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_match("not available") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should return error when snacks has no picker module", function() + package.loaded["snacks"] = {} + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_match("not available") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should return error when no active picker is found", function() + set_snacks({}) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be("No active snacks picker found") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should return a single file from the cursor fallback", function() + local picker = make_picker(1000, { + { file = "single.lua", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/single.lua") + end) + + it("should return multiple files from a multi-selection in order", function() + local picker = make_picker(1000, { + { file = "first.lua", cwd = "/test/project" }, + { file = "second.txt", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(2) + expect(files[1]).to_be("/test/project/first.lua") + expect(files[2]).to_be("/test/project/second.txt") + end) + + it("should skip file-less items (registers/commands)", function() + local picker = make_picker(1000, { + { file = "real.lua", cwd = "/test/project" }, + { text = ":registers", cwd = "/test/project" }, -- no .file + { file = "also.md", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(2) + expect(files[1]).to_be("/test/project/real.lua") + expect(files[2]).to_be("/test/project/also.md") + end) + + it("should fall back to manual cwd/file join when util.path raises", function() + local picker = make_picker(1000, { + { file = "fallback.lua", cwd = "/test/project" }, + }) + set_snacks({ picker }) + -- Simulate the undocumented resolver blowing up. + package.loaded["snacks"].picker.util.path = function() + error("snacks.picker.util.path exploded") + end + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/fallback.lua") + end) + + it("should fall back to manual cwd/file join when util.path returns nil", function() + local picker = make_picker(1000, { + { file = "nilpath.lua", cwd = "/test/project" }, + }) + set_snacks({ picker }) + -- Resolver returns nil (e.g. an item shape it does not recognize). + package.loaded["snacks"].picker.util.path = function() + return nil + end + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/nilpath.lua") + end) + + it("should use an absolute item.file (no cwd) directly in the manual fallback", function() + -- Explorer-style item: absolute file, no cwd. When util.path fails, the + -- manual fallback must return item.file unchanged (no doubled prefix). + local picker = make_picker(1000, { + { file = "/test/project/abs.lua" }, + }) + set_snacks({ picker }) + package.loaded["snacks"].picker.util.path = function() + return nil + end + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/abs.lua") + end) + + it("should return error when picker:selected raises", function() + local picker = { + list = { win = { win = 1000 } }, + selected = function() + error("selected blew up") + end, + } + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be("Failed to read snacks picker selection") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should return error when picker:selected returns a non-table", function() + local picker = { + list = { win = { win = 1000 } }, + selected = function() + return nil + end, + } + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be("Failed to read snacks picker selection") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should accept a directory item (Snacks.explorer)", function() + -- Explorer directory nodes carry a file path that is a directory, not a + -- readable file; the isdirectory() branch must accept them. + local picker = make_picker(1000, { + { file = "src", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/src") + end) + + it("should skip an item whose file is not a string", function() + local picker = make_picker(1000, { + { file = 123, cwd = "/test/project" }, -- malformed/non-string file + { file = "valid.lua", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/valid.lua") + end) + + it("should filter out nonexistent paths", function() + local picker = make_picker(1000, { + { file = "exists.lua", cwd = "/test/project" }, + { file = "missing.lua", cwd = "/nonexistent" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/exists.lua") + end) + + it("should return error when selection yields no valid files", function() + local picker = make_picker(1000, { + { file = "missing.lua", cwd = "/nonexistent" }, + }) + set_snacks({ picker }) + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be("No file found in snacks picker selection") + expect(files).to_be_table() + expect(#files).to_be(0) + end) + + it("should pick the picker whose list window is focused", function() + -- Three pickers on the tab; the focused window matches the FIRST one's + -- list, which is deliberately neither the last element (the + -- pickers[#pickers] fallback) nor adjacent to it. A working focus-match + -- loop returns from_a.lua; a dead loop falls through to the fallback and + -- returns from_c.lua, failing this test. + local picker_a = make_picker(1000, { + { file = "from_a.lua", cwd = "/test/project" }, + }) + local picker_b = make_picker(2000, { + { file = "from_b.lua", cwd = "/test/project" }, + }) + local picker_c = make_picker(3000, { + { file = "from_c.lua", cwd = "/test/project" }, + }) + set_snacks({ picker_a, picker_b, picker_c }) + + mock_vim.api.nvim_get_current_win = function() + return 1000 + end + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + expect(files[1]).to_be("/test/project/from_a.lua") + end) + + it("should fall back to the most-recent picker when no window matches", function() + local picker_a = make_picker(1000, { + { file = "from_a.lua", cwd = "/test/project" }, + }) + local picker_b = make_picker(2000, { + { file = "from_b.lua", cwd = "/test/project" }, + }) + set_snacks({ picker_a, picker_b }) + + -- Current window matches neither picker's list window. + mock_vim.api.nvim_get_current_win = function() + return 9999 + end + + local files, err = integrations._get_snacks_picker_selection() + + expect(err).to_be_nil() + expect(files).to_be_table() + expect(#files).to_be(1) + -- Most-recent (last) picker is used as the fallback. + expect(files[1]).to_be("/test/project/from_b.lua") + end) + end) + + describe("get_selected_files_from_tree", function() + it("should detect snacks_picker_list filetype and delegate to the snacks handler", function() + mock_vim.bo.filetype = "snacks_picker_list" + + local picker = make_picker(1000, { + { file = "delegated.lua", cwd = "/test/project" }, + }) + set_snacks({ picker }) + + 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/delegated.lua") + end) + end) +end)