--- Neo-tree file explorer (replaces the mini.files explorer)
--- @module plugins.neotree
---
--- This layers on top of AstroNvim's own neo-tree spec instead of replacing it:
--- `opts` takes the table AstroNvim built and only overrides what differs, so
--- `close_if_last_window`, `auto_clean_after_session_restore` (needed by the
--- resession dirsessions), `hijack_netrw_behavior` (`nvim
`), the AstroUI
--- icon set and the `system_open`/`copy_selector`/`parent_or_close` commands
--- all stay in place.
---
--- Filtering comes from two sources:
--- * static globs in `config.ignore_patterns` -> `hide_by_pattern` /
--- `never_show*` (neo-tree compiles globs to Lua patterns in `setup()`)
--- * neo-tree's own gitignore-style `ignore_files` reader for per-repo
--- `.rgignore` files, which replaces the old DirChanged / `set_root`
--- "pattern refresh" hooks. Those injected raw globs *after* setup() had
--- already compiled the list, so they silently matched nothing.
local ignore_patterns = require "config.ignore_patterns"
--- neo-tree rewrites these lists in place at `setup()` time (glob -> Lua
--- pattern), so hand it copies: the shared module keeps serving raw globs to
--- the snacks picker filter in astrocore.lua.
--- @param list string[]
--- @return string[]
local function copy(list) return vim.list_extend({}, list) end
--- Open a hidden snacks terminal running `cmd`.
--- @param cmd string
--- @param opts? table extra snacks.terminal options
local function term(cmd, opts)
return function()
require("snacks").terminal(cmd, vim.tbl_extend("force", { hidden = true, interactive = true }, opts or {}))
end
end
---@type LazySpec
return {
{
"nvim-neo-tree/neo-tree.nvim",
keys = {
-- kept from the mini.files era; AstroNvim's e is overridden by
-- the git files picker in astrocore.lua, o still focuses the tree
{ "", "Neotree toggle", mode = { "n", "v" }, desc = "Toggle Explorer" },
},
opts = function(_, opts)
opts = require("astrocore").extend_tbl(opts, {
popup_border_style = "rounded",
enable_diagnostics = true,
git_status_async = true, -- asynchronous git status, improves performance
hide_root_node = true,
retain_hidden_root_indent = true, -- keep the indent so expanders line up
sort_case_insensitive = false,
source_selector = { winbar = false, statusline = false },
window = {
position = "right",
width = 36,
-- window option, not a top level one (it used to be set top level,
-- where neo-tree ignored it)
auto_expand_width = false,
-- keys must be spelled exactly like neo-tree's own defaults:
-- setup() does not case-normalize them, so `` would sit next to
-- the default `` and whichever `pairs()` reached last would win
mappings = {
["H"] = "navigate_up",
["L"] = "set_root",
[""] = "toggle_hidden",
["."] = "toggle_hidden",
["/"] = "fuzzy_finder",
["f"] = "filter_on_submit",
[""] = "clear_filter",
[""] = "open_and_close_neotree",
[""] = "open",
-- Preview image under cursor via kitty's icat kitten
["p"] = "image_kitty",
-- Git keybindings, mirroring astrocore.lua
["o"] = function() vim.cmd "!gh repo view --web" end,
["h"] = term("gh dash", { auto_close = false }),
["g"] = function() require("snacks").lazygit() end,
["d"] = function() require("snacks").picker.git_diff() end,
["b"] = function() require("snacks").picker.git_branches() end,
-- Terminals (toggleterm and config.terminals are both gone)
["l"] = term("lazydocker", { auto_close = true, start_in_insert = true }),
["."] = term("yazi", { auto_close = true, start_in_insert = true }),
["t"] = function() require("snacks").terminal() end,
-- Open yazi in a detached kitty OS window at the node's directory
["N"] = function(state)
local node = state.tree:get_node()
local path = node.type == "directory" and node.path or vim.fs.dirname(node.path)
vim.fn.jobstart { "kitty", "--detach", "--directory", path, "yazi" }
end,
},
},
default_component_configs = {
container = {
enable_character_fade = true,
},
indent = {
indent_size = 2,
padding = 1, -- extra padding on left hand side
with_markers = true,
indent_marker = "│",
last_indent_marker = "└",
highlight = "NeoTreeIndentMarker",
expander_collapsed = "",
expander_expanded = "",
expander_highlight = "NeoTreeExpander",
},
name = {
trailing_slash = false,
use_git_status_colors = true,
highlight = "NeoTreeFileName",
},
git_status = {
symbols = {
-- change type: empty where the name already carries git colors
added = "",
modified = "",
deleted = "✖",
renamed = "",
-- status type
untracked = "",
ignored = "",
unstaged = "",
staged = "",
conflict = "",
},
},
-- extra columns, each only rendered once the window is wide enough
file_size = { enabled = true, width = 12, required_width = 48 },
type = { enabled = true, width = 10, required_width = 122 },
last_modified = { enabled = true, width = 20, required_width = 88 },
created = { enabled = true, width = 20, required_width = 110 },
symlink_target = { enabled = false },
},
filesystem = {
commands = {
-- Close neo-tree after opening a file (directories just expand).
-- The node has to be read before `open`: afterwards the tree
-- window may be gone and `tree:get_node()` errors.
open_and_close_neotree = function(state)
local node = state.tree:get_node()
require("neo-tree.sources.filesystem.commands").open(state)
if node and node.type == "file" then
require("neo-tree.command").execute { action = "close" }
end
end,
-- Preview an image with kitty's icat kitten in a snacks terminal
image_kitty = function(state)
local node = state.tree:get_node()
if node and node.type == "file" then
term("kitten icat --hold " .. vim.fn.shellescape(node.path), { auto_close = false })()
else
vim.notify("Not a file", vim.log.levels.WARN, { title = "Neo-tree" })
end
end,
},
bind_to_cwd = true,
follow_current_file = { enabled = true, leave_dirs_open = false },
use_libuv_file_watcher = true, -- filesystem option, not a top level one
filtered_items = {
force_visible_in_empty_folder = false,
show_hidden_count = false,
hide_dotfiles = false,
hide_gitignored = true,
-- gitignore-style ignore files, read per directory by neo-tree
hide_ignored = true,
ignore_files = { ".neotreeignore", ".ignore", ".rgignore" },
-- hide_by_pattern / never_show / never_show_by_pattern are seeded
-- from config.ignore_patterns in `config` below
},
},
})
-- deep merge of list values is index wise, so AstroNvim's extra sources
-- survive a `sources = { "filesystem" }` override: assign instead.
-- (`:checkhealth neo-tree` then reports config.buffers/git_status
-- `renderers: expected ?, got nil` - stock neo-tree does the same with a
-- single source, those sections are simply never loaded.)
opts.sources = { "filesystem" }
-- git reports ignored paths in a second pass, and neo-tree's own
-- GIT_STATUS_CHANGED handler only redraws the nodes it already has, so
-- `!!` entries that land after the first scan stay visible until
-- something rescans. Rescan once per worktree (the flag also keeps this
-- from looping on the status the rescan itself kicks off) - this is what
-- the old BufEnter/DirChanged "refresh patterns" autocmd was papering
-- over.
local rescanned = {} ---@type table
table.insert(opts.event_handlers, {
event = "git_status_changed",
handler = function(args)
local root = args and args.git_root
if root == nil or rescanned[root] then return end
rescanned[root] = true
require("neo-tree.sources.manager").refresh "filesystem"
end,
})
return opts
end,
config = function(_, opts)
-- setup() compiles the glob lists into Lua patterns (and turns
-- `never_show` into a lookup table) in place, so re-seed them from the
-- shared module on every setup: `:Lazy reload neo-tree.nvim` re-runs
-- this with the same cached opts table, and compiling already compiled
-- patterns a second time silently breaks all pattern filtering.
local filtered = opts.filesystem.filtered_items
filtered.hide_by_pattern = copy(ignore_patterns.patterns)
filtered.never_show = copy(ignore_patterns.never_show)
filtered.never_show_by_pattern = copy(ignore_patterns.never_show_patterns)
require("neo-tree").setup(opts)
end,
},
}