A minimal tree-view engine for Neovim. Handles rendering, expand/collapse, keyboard and mouse events, inline color and area tags. Does not provide any backend — file system, LSP symbols, DAP, etc. are implemented by the caller.
- Multi-line nodes
- Lazy children (populate on first open)
- Ghost nodes (invisible containers for multi-root layouts)
- Inline
<c:name>color tags and<a:name>clickable area tags, with nesting - Per-class keymaps, symbols and colors — hot-patched on live views
- Per-symbol highlight groups for tree prefix characters
- Cursor line via extmarks (colors always visible on top)
- Multiple views on the same tree with independent open state
- Mouse support via
<LeftRelease> - Debounced rendering with per-node parse cache
goto_nodefor programmatic scroll-to:TreeInspectbuffer command for debugging
Using lazy.nvim:
{ "hl037/treeasy.nvim" }Using packer.nvim:
use "hl037/treeasy.nvim"Using vim-plug:
Plug 'hl037/treeasy.nvim'local treeasy = require("treeasy")
local tree_m = treeasy.tree
local node_m = treeasy.node
-- Always check before setting so user config is not overwritten.
if not treeasy.get_keymap("myclass") then
treeasy.set_keymap("myclass", {
toggle_collapse = { "<CR>", "<2-LeftMouse>" },
click = { "<LeftRelease>" },
open_rec = { "e" },
collapse_rec = { "c" },
})
end
if not treeasy.get_colors("myclass") then
treeasy.set_colors("myclass", {
dir = "#7aa2f7",
leaf = "#9ece6a",
})
end
local function open_text(n)
return { "<c:dir>" .. n.label .. "</c> <a:toggle>[-]</a>" }
end
local function collapsed_text(n)
return { "<c:dir>" .. n.label .. "</c> <a:toggle>[+]</a>" }
end
local function on_open(node, view, ctx)
if node.children ~= nil then return end
node.children = {}
-- populate node.children here (lazy loading)
end
local function on_click(node, view, ctx)
for _, area in ipairs(ctx.areas) do
if area == "toggle" then
view:_handle_event("toggle_collapse", node, ctx)
end
end
end
local root = node_m.new()
root.label = "src/"
root.open_text = open_text
root.collapsed_text = collapsed_text
root.handler["open"] = on_open
root.handler["click"] = on_click
local t = tree_m.new({ class = "myclass", root = root })
vim.cmd("topleft 35vsplit")
local view = treeasy.attach_tree(vim.api.nvim_get_current_win(), t)
view:set_open(root, true)treeasy/
├── init.lua public API: attach_tree, set_keymap, set_colors, set_symbols
├── tree.lua tree manipulation: replace_node, insert_node, update_node
├── node.lua node constructor, default open_rec/collapse_rec handlers
└── view.lua rendering engine, tag parser, event dispatch
A tree owns a root node and a list of views. When tree.replace_node, tree.insert_node or tree.update_node is called, all attached views are notified and schedule a debounced re-render automatically.
A node is a plain Lua table. The engine reads text, children, parent, index, ghost, open_text, collapsed_text, handler and internal. Everything else is userdata — add whatever fields you need.
A node can be freely mutated before it is added to the tree. Once part of a live tree, all structural changes must go through tree_m.* so views are notified. The one exception is lazy loading inside an open handler — see below.
Node validity:
- Valid non-root:
node.parent ~= nil - Valid root:
node.parent == nilandnode.tree ~= nil - Invalid (detached):
node.parent == nilandnode.tree == nil
Set node.children = nil initially. Populate in the open handler — it fires before re-render, so children will be picked up automatically. The children == nil guard is important: it ensures the node has not been opened by another view already.
node.handler["open"] = function(n, view, ctx)
if n.children ~= nil then return end
n.children = {}
for i, item in ipairs(fetch(n)) do
local child = node_m.new({ parent = n, index = i })
child.handler["open"] = on_open
n.children[i] = child
end
endThis direct mutation of node.children is a narrow exception — outside of this lazy-loading pattern, always use tree_m.insert_node / tree_m.replace_node.
A node with ghost = true is never rendered. Its children appear at the same depth without any connector symbol — useful for multi-root layouts (header + tree root as siblings):
local ghost = node_m.new(); ghost.ghost = true
local header = node_m.new({ text = { "My Tree" } })
local root = node_m.new({ ... })
ghost.children = { header, root }
header.parent = ghost; header.index = 1
root.parent = ghost; root.index = 2
local t = tree_m.new({ class = "myclass", root = ghost })For each node, displayed text is resolved in order:
node.open_text(node, view)/node.collapsed_text(node, view)tree.open_text(node, view)/tree.collapsed_text(node, view)node.text{ "" }
<c:colorname>text</c> apply a named color
<a:areaname>text</a> define a clickable area
Tags may be nested. Inner <c:> tags get higher extmark priority. All <a:> tags matching the cursor position are collected in ctx.areas.
All renders are debounced — view:schedule_render() batches multiple modifications into a single render via vim.schedule. view:_render() is private and must never be called directly. Parse results are cached per node; tree_m.update_node invalidates the cache.
view:schedule_render(cb) accepts an optional callback executed after the render, with the view as argument. Used internally by goto_node.
view:goto_node(node) -- opens all ancestors, then scrolls to the nodeRun :TreeInspect in the view buffer to dump the full tree structure into a scratch split. Circular references (parent, tree) are shown as <circular ref>.
| Group | Default | Description |
|---|---|---|
TreeasyCursorLine |
links to CursorLine |
Cursor line (extmark-based) |
treeasy_{class}_{name} |
from set_colors |
Named label colors |
treeasy_{class}_sym_mid |
empty | ├─ symbol |
treeasy_{class}_sym_last |
empty | └─ symbol |
treeasy_{class}_sym_vert |
empty | │ continuation |
treeasy_{class}_sym_space |
empty | blank continuation |
See :help treeasy for the full reference.