Skip to content

Instantly share code, notes, and snippets.

@b0o
Last active April 28, 2024 05:35
Show Gist options
  • Save b0o/d45565c81dbc930a6e20513d05bfc69a to your computer and use it in GitHub Desktop.
Save b0o/d45565c81dbc930a6e20513d05bfc69a to your computer and use it in GitHub Desktop.
Nvim-Tree Preview

OUTDATED

The new plugin repo is here: https://github.com/b0o/nvim-tree-preview.lua


Preview NvimTree files in a floating window.

2024-04-27_20-50-13_region_re.mp4

Depends on plenary.nvim.

Usage:

local preview = require'nvim-tree-preview'

-- Default config:
preview.setup {
  -- Keymaps for the preview window (does not apply to the tree window)
  -- Keymaps can be a string (vimscript command), a function, or a table.
  -- If a table, it must contain either an 'action' or 'open' key.
  -- Valid actions are 'close' and 'toggle_focus'.
  -- Valid open modes are 'edit', 'tab', 'vertical', and 'horizontal'.
  -- To disable a default keymap, set it to false.
  -- All keymaps are set in normal mode.
  keymaps = {
    ['<Esc>'] = { action = 'close' },
    ['<Tab>'] = { action = 'toggle_focus' },
    ['<CR>'] = { open = 'edit' },
    ['<C-t>'] = { open = 'tab' },
    ['<C-v>'] = { open = 'vertical' },
    ['<C-x>'] = { open = 'horizontal' },
  },
  min_width = 10,
  min_height = 5,
  max_width = 85,
  max_height = 25,
  wrap = false, -- Whether to wrap lines in the preview window
  border = 'rounded', -- Border style for the preview window
}

In your nvim-tree on_attach function:

vim.keymap.set('n', 'P', preview.watch, opts 'Preview (Watch)')
vim.keymap.set('n', '<Esc>', preview.unwatch, opts 'Close Preview/Unwatch')

-- Simple tab behavior: Always preview
vim.keymap.set('n', '<Tab>', preview.node_under_cursor, opts 'Preview')

-- Smart tab behavior: Only preview files, expand/collapse directories.
vim.keymap.set('n', '<Tab>', function()
  local ok, node = pcall(api.tree.get_node_under_cursor)
  if ok and node then
    if node.type == 'directory' then
      api.node.open.edit()
    else
      preview.node(node, { toggle_focus = true })
    end
  end
end, opts 'Preview')

If using nvim-window-picker, it's recommended to ignore nvim-tree-preview windows:

require('nvim-tree').setup {
  actions = {
    open_file = {
      window_picker = {
        enable = true,
        picker = function()
          return require('window-picker').pick_window {
            filter_rules = {
              file_path_contains = { 'nvim-tree-preview://' },
            },
          }
        end,
      },
    },
  },
}

License

Copyright (C) 2024 Maddison Hellstrom

MIT License

-- Preview NvimTree files in a floating window
-- Copyright (C) 2024 Maddison Hellstrom <github.com/b0o>
-- MIT License
-- Source: https://gist.github.com/b0o/d45565c81dbc930a6e20513d05bfc69a
--
-- Usage:
--
-- local preview = require'nvim-tree-preview'
--
-- -- Default config:
-- preview.setup {
-- -- Keymaps for the preview window (does not apply to the tree window)
-- -- Keymaps can be a string (vimscript command), a function, or a table.
-- -- If a table, it must contain either an 'action' or 'open' key.
-- -- Valid actions are 'close' and 'toggle_focus'.
-- -- Valid open modes are 'edit', 'tab', 'vertical', and 'horizontal'.
-- -- To disable a default keymap, set it to false.
-- -- All keymaps are set in normal mode.
-- keymaps = {
-- ['<Esc>'] = { action = 'close' },
-- ['<Tab>'] = { action = 'toggle_focus' },
-- ['<CR>'] = { open = 'edit' },
-- ['<C-t>'] = { open = 'tab' },
-- ['<C-v>'] = { open = 'vertical' },
-- ['<C-x>'] = { open = 'horizontal' },
-- },
-- min_width = 10,
-- min_height = 5,
-- max_width = 85,
-- max_height = 25,
-- wrap = false, -- Whether to wrap lines in the preview window
-- border = 'rounded', -- Border style for the preview window
-- }
--
-- In your nvim-tree on_attach function:
--
-- vim.keymap.set('n', 'P', preview.node_under_cursor, opts 'Preview')
-- vim.keymap.set('n', '<M-p>', preview.watch, opts 'Preview (Watch)')
-- vim.keymap.set('n', '<Esc>', preview.unwatch, opts 'Close/Unwatch Preview')
local Path = require 'plenary.path'
local api = require 'nvim-tree.api'
---@alias PreviewKeymapAction 'close'|'toggle_focus'
---@alias PreviewKeymapOpenAction 'edit'|'tab'|'vertical'|'horizontal'
---@alias PreviewKeymap string|function|{action: PreviewKeymapAction}|{open: PreviewKeymapOpenAction}
---@alias PreviewKeymapSpec {[1]: string, [2]: PreviewKeymap}
---@class PreviewConfig
---@field keymaps? Map<string, PreviewKeymap>
---@field min_width? number
---@field min_height? number
---@field max_width? number
---@field max_height? number
---@field wrap? boolean
---@field border? any
---@class PreviewModule
---@field config PreviewConfig
---@field instance? Preview
---@field watch_augroup? number
---@field watch_tree_buf? number
local M = {
config = {
keymaps = {
['<Esc>'] = { action = 'close' },
['<Tab>'] = { action = 'toggle_focus' },
['<CR>'] = { open = 'edit' },
['<C-t>'] = { open = 'tab' },
['<C-v>'] = { open = 'vertical' },
['<C-x>'] = { open = 'horizontal' },
},
min_width = 10,
min_height = 5,
max_width = 85,
max_height = 25,
wrap = false,
border = 'rounded',
},
instance = nil,
watch_augroup = nil,
tree_buf = nil,
}
---@class NvimTreeNode
---@field absolute_path string
---@field executable boolean
---@field extension string
---@field filetype string
---@field link_to string
---@field name string
---@field type 'file' | 'directory' | 'link'
---@class Preview
---@field augroup number?
---@field preview_win number?
---@field preview_buf number?
---@field tree_win number?
---@field tree_buf number?
---@field tree_node NvimTreeNode?
local Preview = {}
Preview.create = function()
return setmetatable({
augroup = nil,
preview_win = nil,
preview_buf = nil,
tree_win = nil,
tree_buf = nil,
tree_node = nil,
}, { __index = Preview })
end
function Preview:is_open()
return self.preview_win ~= nil
end
---@param opts? {focus_tree?: boolean}
function Preview:close(opts)
opts = vim.tbl_extend('force', { focus_tree = true }, opts or {})
if self.preview_win ~= nil then
if vim.api.nvim_win_is_valid(self.preview_win) then
vim.api.nvim_win_close(self.preview_win, true)
end
if opts.focus_tree and self.tree_win then
vim.api.nvim_set_current_win(self.tree_win)
end
end
if self.augroup ~= nil then
vim.api.nvim_del_augroup_by_id(self.augroup)
end
self.augroup = nil
self.preview_win = nil
self.preview_buf = nil
self.tree_win = nil
self.tree_buf = nil
self.tree_node = nil
end
function Preview:setup_autocmds()
vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter' }, {
group = self.augroup,
callback = function()
local buf = vim.api.nvim_get_current_buf()
if not (buf == self.tree_buf or buf == self.preview_buf) then
self:close { focus_tree = false }
end
end,
})
vim.api.nvim_create_autocmd({ 'CursorMoved' }, {
group = self.augroup,
buffer = self.tree_buf,
callback = function()
if M.is_watching() then
-- Re-use the preview window if it's already open
return
end
local ok, node = pcall(api.tree.get_node_under_cursor)
if not ok or not node or node.absolute_path ~= self.tree_node.absolute_path then
self:close()
end
end,
})
end
local function noop()
-- noop
end
function Preview:setup_keymaps()
local opts = { buffer = self.preview_buf, noremap = true, silent = true }
---@param key PreviewKeymapAction
---@param ... any
---@return function
local action = function(key, ...)
if not vim.tbl_contains({ 'close', 'toggle_focus' }, key) then
vim.notify('nvim-tree preview: Invalid keymap action ' .. key, vim.log.levels.ERROR)
return noop
end
local args = { ... }
return function()
vim.schedule(function()
self[key](self, unpack(args))
end)
end
end
---@param mode PreviewKeymapOpenAction
---@return function
local open = function(mode)
if not vim.tbl_contains({ 'edit', 'tab', 'vertical', 'horizontal' }, mode) then
vim.notify('nvim-tree preview: Invalid keymap open mode ' .. mode, vim.log.levels.ERROR)
return noop
end
return function()
vim.schedule(function()
self:close { focus_tree = true }
api.node.open[mode]()
end)
end
end
for key, spec in pairs(M.config.keymaps) do
if type(spec) == 'string' or type(spec) == 'function' then
vim.keymap.set('n', key, spec, opts)
elseif type(spec) == 'table' then
if spec.action then
vim.keymap.set('n', key, action(spec.action), opts)
elseif spec.open then
local open_mode = spec.open
---@cast open_mode PreviewKeymapOpenAction
vim.keymap.set('n', key, open(open_mode), opts)
else
vim.notify('nvim-tree preview: Invalid keymap spec for ' .. key, vim.log.levels.ERROR)
end
elseif spec == false then
-- pass
else
vim.notify('nvim-tree preview: Invalid keymap spec for ' .. key, vim.log.levels.ERROR)
end
end
end
---@param node NvimTreeNode
---@return string[]
local function read_directory(node)
local content = vim.fn.readdir(node.absolute_path)
if not content or #content == 0 then
return { 'Error reading directory' }
end
local files = vim
.iter(content)
:map(function(name)
return {
name = name,
is_dir = vim.fn.isdirectory(node.absolute_path .. '/' .. name) == 1,
}
end)
:totable()
table.sort(files, function(a, b)
if a.is_dir ~= b.is_dir then
return a.is_dir
end
return a.name < b.name
end)
content = {
'  ' .. node.name .. '/',
unpack(vim
.iter(ipairs(files))
:map(function(i, file)
local prefix = i == #content and ' └ ' or ' │ '
if file.is_dir then
return prefix .. file.name .. '/'
end
return prefix .. file.name
end)
:totable()),
}
return content
end
local noautocmd = function(cb, ...)
local eventignore = vim.opt.eventignore
vim.opt.eventignore:append 'BufEnter,BufWinEnter,BufAdd,BufNew,BufCreate,BufReadPost'
local res = cb(...)
vim.opt.eventignore = eventignore
return res
end
-- Adapted from telescope.nvim:
-- https://github.com/nvim-telescope/telescope.nvim/blob/35f94f0ef32d70e3664a703cefbe71bd1456d899/lua/telescope/previewers/buffer_previewer.lua#L199
function Preview:load_file_content(path)
local buf = self.preview_buf
Path:new(path):_read_async(vim.schedule_wrap(function(data)
if not buf or self.preview_buf ~= buf or not vim.api.nvim_buf_is_valid(buf) then
return
end
local processed_data = vim.split(data, '[\r]?\n')
if processed_data then
vim.bo[buf].modifiable = true
local ok = pcall(vim.api.nvim_buf_set_lines, buf, 0, -1, false, processed_data)
vim.bo[buf].modifiable = false
if not ok then
return
end
local ft = vim.filetype.match { buf = buf, filename = self.tree_node.absolute_path }
if ft and vim.bo[buf].filetype ~= ft then
vim.bo[buf].filetype = ft
end
end
end))
end
function Preview:load_buf_content()
if not self.tree_node or not self.preview_buf then
return
end
if self.tree_node.type == 'file' then
self:load_file_content(self.tree_node.absolute_path)
return
end
---@type string[]?
local content
if self.tree_node.type == 'directory' then
content = read_directory(self.tree_node)
else
content = { self.tree_node.name .. ' → ' .. self.tree_node.link_to }
end
local buf = self.preview_buf
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(self.preview_buf, 0, -1, false, content)
vim.bo[buf].modifiable = false
end
function Preview:get_win()
local width = vim.api.nvim_get_option_value('columns', {})
local height = vim.api.nvim_get_option_value('lines', {})
local opts = {
width = math.min(M.config.max_width, math.max(M.config.min_width, math.ceil(width / 2))),
height = math.min(M.config.max_height, math.max(M.config.min_height, math.ceil(height / 2))),
row = math.max(0, vim.fn.screenrow() - 1),
col = vim.fn.winwidth(0) + 1,
relative = 'win',
}
if self.preview_win and vim.api.nvim_win_is_valid(self.preview_win) then
vim.api.nvim_win_set_config(self.preview_win, opts)
return self.preview_win
end
opts = vim.tbl_extend('force', opts, {
noautocmd = true,
focusable = false,
border = M.config.border,
})
local win = noautocmd(vim.api.nvim_open_win, self.preview_buf, false, opts)
vim.wo[win].wrap = M.config.wrap
self.preview_win = win
return win
end
---@param node NvimTreeNode
function Preview:open(node)
if not self.tree_node or self.tree_node.absolute_path ~= node.absolute_path then
self.preview_buf = nil
end
self.tree_win = vim.api.nvim_get_current_win()
self.tree_buf = vim.api.nvim_get_current_buf()
self.tree_node = node
---@type number?
local preview_buf = nil
if not self.preview_buf or not vim.api.nvim_buf_is_valid(self.preview_buf) then
preview_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(preview_buf, 'nvim-tree-preview://' .. node.absolute_path)
self.preview_buf = preview_buf
end
local win = self:get_win()
vim.wo[win].number = node.type == 'file'
if preview_buf then
self.augroup = vim.api.nvim_create_augroup('nvim_tree_preview', { clear = true })
noautocmd(vim.api.nvim_win_set_buf, win, preview_buf)
vim.bo[preview_buf].bufhidden = 'delete'
vim.bo[preview_buf].buftype = 'nofile'
vim.bo[preview_buf].swapfile = false
vim.bo[preview_buf].buflisted = false
vim.bo[preview_buf].modifiable = false
vim.schedule(function()
self:setup_autocmds()
self:setup_keymaps()
self:load_buf_content()
end)
end
end
function Preview:toggle_focus()
if not self:is_open() then
return
end
local win = vim.api.nvim_get_current_win()
if win == self.preview_win then
vim.schedule(function()
if self.tree_win and vim.api.nvim_win_is_valid(self.tree_win) then
vim.api.nvim_set_current_win(self.tree_win)
end
end)
else
vim.schedule(function()
if self.preview_win and vim.api.nvim_win_is_valid(self.preview_win) then
vim.api.nvim_set_current_win(self.preview_win)
end
end)
end
end
---@param config? PreviewConfig
function M.setup(config)
config = config or {}
M.config = vim.tbl_deep_extend('force', M.config, config)
end
---@param node NvimTreeNode
---@param opts? {toggle_focus?: boolean}
function M.node(node, opts)
opts = vim.tbl_extend('force', { toggle_focus = false }, opts or {})
if not M.instance then
M.instance = Preview.create()
end
if not node.type then
M.instance:close()
return
end
if M.instance:is_open() then
if M.instance.tree_node.absolute_path == node.absolute_path then
if opts.toggle_focus then
M.instance:toggle_focus()
end
return
end
end
M.instance:open(node)
end
---@param opts? {toggle_focus?: boolean}
function M.node_under_cursor(opts)
opts = vim.tbl_extend('force', { toggle_focus = true }, opts or {})
local ok, node = pcall(api.tree.get_node_under_cursor)
if not ok then
if M.instance and M.instance:is_open() then
M.instance:close()
end
return
end
M.node(node, opts)
end
function M.close()
if M.instance then
M.instance:close()
end
end
function M.is_watching()
return M.watch_augroup ~= nil
end
function M.watch()
if M.watch_augroup then
M.unwatch()
return
end
if vim.bo.ft ~= 'NvimTree' then
vim.notify('Cannot watch preview: current buffer is not NvimTree', vim.log.levels.ERROR)
return
end
M.watch_augroup = vim.api.nvim_create_augroup('nvim_tree_preview_watch', { clear = true })
M.watch_tree_buf = vim.api.nvim_get_current_buf()
if not M.instance or not M.instance:is_open() then
M.node_under_cursor()
end
vim.api.nvim_create_autocmd({ 'CursorMoved' }, {
group = M.watch_augroup,
buffer = 0,
callback = function()
local ok, node = pcall(api.tree.get_node_under_cursor)
if not ok or not node then
M.close()
else
vim.schedule(function()
M.node(node, { toggle_focus = false })
end)
end
end,
})
vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter' }, {
group = M.watch_augroup,
callback = function()
local buf = vim.api.nvim_get_current_buf()
if buf == M.watch_tree_buf then
local ok, node = pcall(api.tree.get_node_under_cursor)
if not ok or not node then
M.close()
end
elseif M.instance and buf == M.instance.preview_buf then
return
else
M.unwatch()
end
end,
})
end
function M.unwatch()
if M.watch_augroup then
vim.api.nvim_del_augroup_by_id(M.watch_augroup)
M.watch_augroup = nil
end
M.close()
end
return M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment