nvim/lua/netrw-preview.lua

723 lines
20 KiB
Lua

local M = {}
local uv = vim.uv
local states = {}
local preview_buffers = {}
local preview_group = vim.api.nvim_create_augroup('netrw_live_preview', { clear = true })
local DEBOUNCE_MS = 90
local MAX_ENTRIES = 200
local PREVIEW_BUFFER_CLEANUP_MS = 2000
local function is_valid_buffer(buf)
return type(buf) == 'number' and buf > 0 and vim.api.nvim_buf_is_valid(buf)
end
local function is_valid_window(win)
return type(win) == 'number' and win > 0 and vim.api.nvim_win_is_valid(win)
end
local function is_valid_tab(tab)
return type(tab) == 'number' and tab > 0 and vim.api.nvim_tabpage_is_valid(tab)
end
local function is_remote_path(path)
return type(path) == 'string' and path:match('^%a[%w+.-]*://') ~= nil
end
local function netrw_call(name, ...)
return pcall(vim.fn['netrw#Call'], name, ...)
end
local function find_preview_window(tab)
if not is_valid_tab(tab) then
return nil
end
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do
if is_valid_window(win) and vim.wo[win].previewwindow then
return win
end
end
end
local function close_timer(state, key)
key = key or 'timer'
local timer = state and state[key]
if not timer then
return
end
pcall(timer.stop, timer)
if not timer:is_closing() then
pcall(timer.close, timer)
end
state[key] = nil
end
local function indent_guides_enabled(buf)
local ok, config = pcall(require, 'ibl.config')
if not ok or not config.config then
return nil
end
return config.get_config(buf).enabled
end
local function set_indent_guides(buf, enabled)
if enabled == nil then
return
end
local ok, ibl = pcall(require, 'ibl')
if ok and ibl.initialized then
ibl.setup_buffer(buf, { enabled = enabled })
end
end
local function restore_preview_buffer(buf, for_editing)
local metadata = preview_buffers[buf]
if not metadata or not is_valid_buffer(buf) then
return
end
vim.b[buf].netrw_live_preview_buffer = nil
vim.bo[buf].bufhidden = metadata.bufhidden
vim.bo[buf].buflisted = for_editing or metadata.buflisted
vim.bo[buf].syntax = metadata.syntax
vim.diagnostic.enable(metadata.diagnostics_enabled, { bufnr = buf })
set_indent_guides(buf, metadata.indent_guides_enabled)
if metadata.treesitter_active then
pcall(vim.treesitter.start, buf)
end
preview_buffers[buf] = nil
end
local function schedule_preview_buffer_cleanup(state)
if not state or state.preview_cleanup_scheduled then
return
end
state.preview_cleanup_scheduled = true
vim.defer_fn(function()
state.preview_cleanup_scheduled = false
for buf, metadata in pairs(preview_buffers) do
local is_preview = is_valid_buffer(buf) and vim.b[buf].netrw_live_preview_buffer == true
local is_visible = is_valid_buffer(buf) and #vim.fn.win_findbuf(buf) > 0
if not is_preview or not is_visible then
if is_preview and metadata.loaded then
restore_preview_buffer(buf, false)
end
if not is_preview then
preview_buffers[buf] = nil
end
end
end
end, PREVIEW_BUFFER_CLEANUP_MS)
end
local function close_preview(state)
if not state then
return
end
local preview = find_preview_window(state.tab)
if preview then
pcall(vim.api.nvim_win_close, preview, false)
schedule_preview_buffer_cleanup(state)
end
end
local function normalize_local_path(path)
if type(path) ~= 'string' or path == '' then
return nil
end
local trimmed = path:gsub('/+$', '')
if trimmed == '' and path:sub(1, 1) == '/' then
trimmed = '/'
end
return vim.fs.normalize(trimmed)
end
local function selected_entry(buf, win)
if not is_valid_buffer(buf) or not is_valid_window(win) then
return nil, 'netrw buffer or window is no longer valid'
end
return vim.api.nvim_win_call(win, function()
local curdir = vim.b[buf].netrw_curdir
local ok_word, word = netrw_call('NetrwGetWord')
if not ok_word or type(word) ~= 'string' or word == '' then
return nil, 'netrw could not identify the selected entry'
end
if is_remote_path(curdir) then
return {
remote = true,
word = word,
}
end
local path
if vim.w[win].netrw_liststyle == 3 and type(vim.w[win].netrw_treetop) == 'string' then
local ok_tree, tree_path = netrw_call('NetrwTreePath', vim.w[win].netrw_treetop)
if not ok_tree or type(tree_path) ~= 'string' or tree_path == '' then
return nil, 'netrw could not resolve the selected tree path'
end
if word == '../' then
path = vim.fs.dirname(normalize_local_path(tree_path))
elseif word:sub(-1) == '/' then
path = tree_path
else
path = vim.fs.joinpath(tree_path, (word:gsub('[@*]$', '')))
end
else
local name = word:gsub('/$', ''):gsub('[@*]$', '')
path = vim.fs.isabs(name) and name or vim.fs.joinpath(curdir, name)
end
return {
path = normalize_local_path(path),
remote = false,
word = word,
}
end)
end
local function human_size(bytes)
if type(bytes) ~= 'number' then
return '—'
end
local units = { 'B', 'K', 'M', 'G', 'T' }
local value = bytes
local unit = 1
while value >= 1024 and unit < #units do
value = value / 1024
unit = unit + 1
end
if unit == 1 then
return ('%d%s'):format(value, units[unit])
end
local formatted = ('%.1f'):format(value):gsub('%.0$', '')
return formatted .. units[unit]
end
local function modified_time(stat)
local seconds = stat and stat.mtime and stat.mtime.sec
if type(seconds) ~= 'number' then
return '—'
end
return os.date('%Y-%m-%d %H:%M', seconds)
end
local function visible_in_netrw(name, entry_type)
local hide_mode = tonumber(vim.g.netrw_hide) or 0
local pattern = vim.g.netrw_list_hide
if hide_mode == 0 or type(pattern) ~= 'string' or pattern == '' then
return true
end
local candidate = name .. (entry_type == 'directory' and '/' or '')
local hidden = vim.fn.match(candidate, pattern) >= 0
return (hide_mode == 1 and not hidden) or (hide_mode == 2 and hidden)
end
local function entry_details(directory, name, hinted_type)
local path = vim.fs.joinpath(directory, name)
local stat = uv.fs_lstat(path)
local entry_type = stat and stat.type or hinted_type or 'unknown'
local display_name = name
local size = '—'
if entry_type == 'directory' then
display_name = display_name .. '/'
elseif entry_type == 'file' then
size = human_size(stat and stat.size)
elseif entry_type == 'link' then
local target = uv.fs_readlink(path)
display_name = display_name .. '@'
if target then
display_name = display_name .. ' -> ' .. target
end
end
return {
name = name,
display_name = display_name,
path = path,
type = entry_type,
size = size,
bytes = entry_type == 'file' and stat and stat.size or 0,
modified = modified_time(stat),
}
end
local function plural(count, singular, plural_form)
return ('%d %s'):format(count, count == 1 and singular or plural_form)
end
local function directory_lines(path)
local scan, scan_error = uv.fs_scandir(path)
if not scan then
return {
path .. '/',
'',
'Unable to read directory:',
tostring(scan_error or 'unknown error'),
}
end
local entries = {}
local counts = { directory = 0, file = 0, link = 0, other = 0 }
local total_file_size = 0
while true do
local name, hinted_type = uv.fs_scandir_next(scan)
if not name then
break
end
if visible_in_netrw(name, hinted_type) then
local entry = entry_details(path, name, hinted_type)
if entry.type == 'directory' then
counts.directory = counts.directory + 1
elseif entry.type == 'file' then
counts.file = counts.file + 1
total_file_size = total_file_size + (entry.bytes or 0)
elseif entry.type == 'link' then
counts.link = counts.link + 1
else
counts.other = counts.other + 1
end
table.insert(entries, entry)
end
end
local ranks = { directory = 1, file = 2, link = 3 }
table.sort(entries, function(a, b)
local a_rank = ranks[a.type] or 4
local b_rank = ranks[b.type] or 4
if a_rank ~= b_rank then
return a_rank < b_rank
end
return a.name:lower() < b.name:lower()
end)
local lines = {
path == '/' and '/' or path .. '/',
'',
('%-5s %8s %-16s %s'):format('Type', 'Size', 'Modified', 'Name'),
('%-5s %8s %-16s %s'):format('-----', '--------', '----------------', '----'),
}
for index, entry in ipairs(entries) do
if index > MAX_ENTRIES then
break
end
local label = entry.type == 'directory' and 'dir'
or entry.type == 'file' and 'file'
or entry.type == 'link' and 'link'
or 'other'
table.insert(lines, ('%-5s %8s %-16s %s'):format(
label,
entry.size,
entry.modified,
entry.display_name
))
end
if #entries == 0 then
table.insert(lines, '(empty)')
elseif #entries > MAX_ENTRIES then
table.insert(lines, ('… showing first %d of %d entries'):format(MAX_ENTRIES, #entries))
end
table.insert(lines, '')
local summary = {
plural(counts.directory, 'directory', 'directories'),
plural(counts.file, 'file', 'files'),
}
if counts.link > 0 then
table.insert(summary, plural(counts.link, 'link', 'links'))
end
if counts.other > 0 then
table.insert(summary, plural(counts.other, 'other entry', 'other entries'))
end
table.insert(lines, table.concat(summary, ', ') .. ', ' .. human_size(total_file_size) .. ' in immediate files')
return lines
end
local function symlink_lines(path, stat)
local target, read_error = uv.fs_readlink(path)
return {
path .. '@',
'',
'Type: symbolic link',
'Target: ' .. tostring(target or read_error or 'unavailable'),
'Modified: ' .. modified_time(stat),
'',
'The target is not traversed in directory previews.',
}
end
local function unavailable_lines(path, error_message)
return {
tostring(path or '(unknown entry)'),
'',
'Preview unavailable:',
tostring(error_message or 'entry no longer exists'),
}
end
local function ensure_summary_buffer(state)
if is_valid_buffer(state.summary_buf) then
return state.summary_buf
end
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].bufhidden = 'wipe'
vim.bo[buf].swapfile = false
vim.bo[buf].filetype = 'netrw-preview'
vim.b[buf].netrw_directory_preview = true
state.summary_buf = buf
return buf
end
local function set_summary_lines(buf, lines)
vim.bo[buf].readonly = false
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modified = false
vim.bo[buf].modifiable = false
vim.bo[buf].readonly = true
end
local function configure_summary_window(win)
vim.wo[win].previewwindow = true
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].cursorline = false
vim.wo[win].wrap = false
vim.wo[win].list = false
vim.wo[win].spell = false
vim.wo[win].signcolumn = 'no'
end
local function show_summary(state, netrw_win, lines)
local buf = ensure_summary_buffer(state)
set_summary_lines(buf, lines)
local preview = find_preview_window(state.tab)
if preview then
local ok = pcall(vim.api.nvim_win_set_buf, preview, buf)
if not ok then
return false
end
else
local ok, created = pcall(vim.api.nvim_open_win, buf, false, {
split = 'below',
win = netrw_win,
noautocmd = true,
})
if not ok then
return false
end
preview = created
end
configure_summary_window(preview)
vim.api.nvim_win_set_cursor(preview, { 1, 0 })
return true
end
local function native_preview(entry)
local path = entry.path
if entry.remote then
-- Match netrw's own `p` mapping arguments for local and remote listings.
local ok_path, resolved = netrw_call('NetrwBrowseChgDir', 1, entry.word, 1, 1)
if not ok_path then
return false
end
path = resolved
end
if type(path) ~= 'string' or path == '' then
return false
end
local ok = netrw_call('NetrwPreview', path)
return ok
end
local function show_native_preview(state, entry)
state.native_preview_active = true
local existing_buf = entry.path and vim.fn.bufnr(entry.path) or -1
local existing = is_valid_buffer(existing_buf) and {
buf = existing_buf,
loaded = vim.api.nvim_buf_is_loaded(existing_buf),
buflisted = vim.bo[existing_buf].buflisted,
bufhidden = vim.bo[existing_buf].bufhidden,
syntax = vim.bo[existing_buf].syntax,
treesitter_active = vim.treesitter.highlighter.active[existing_buf] ~= nil,
diagnostics_enabled = vim.diagnostic.is_enabled({ bufnr = existing_buf }),
indent_guides_enabled = indent_guides_enabled(existing_buf),
} or nil
local ok, shown = pcall(native_preview, entry)
local preview = find_preview_window(state.tab)
if preview then
local preview_buf = vim.api.nvim_win_get_buf(preview)
local preview_only = #vim.fn.win_findbuf(preview_buf) == 1
if vim.bo[preview_buf].buftype == '' and preview_only then
preview_buffers[preview_buf] = preview_buffers[preview_buf] or {
loaded = existing ~= nil and existing.buf == preview_buf and existing.loaded,
buflisted = existing ~= nil and existing.buf == preview_buf and existing.buflisted or false,
bufhidden = existing ~= nil and existing.buf == preview_buf and existing.bufhidden or '',
syntax = existing ~= nil and existing.buf == preview_buf and existing.syntax or '',
treesitter_active = existing ~= nil
and existing.buf == preview_buf
and existing.loaded
and existing.treesitter_active
or vim.treesitter.highlighter.active[preview_buf] ~= nil,
diagnostics_enabled = existing ~= nil
and existing.buf == preview_buf
and existing.diagnostics_enabled
or vim.diagnostic.is_enabled({ bufnr = preview_buf }),
indent_guides_enabled = existing ~= nil
and existing.buf == preview_buf
and existing.indent_guides_enabled
or indent_guides_enabled(preview_buf),
}
vim.bo[preview_buf].bufhidden = 'hide'
vim.bo[preview_buf].buflisted = preview_buffers[preview_buf].buflisted
vim.b[preview_buf].netrw_live_preview_buffer = true
pcall(vim.treesitter.stop, preview_buf)
vim.diagnostic.enable(false, { bufnr = preview_buf })
vim.bo[preview_buf].syntax = ''
set_indent_guides(preview_buf, false)
end
end
if is_valid_window(state.win) then
pcall(vim.api.nvim_set_current_win, state.win)
end
state.native_preview_active = false
return ok and shown
end
local function preview_selected(state, allow_create)
if not state or not is_valid_buffer(state.buf) or not is_valid_window(state.win) then
return
end
if vim.api.nvim_win_get_buf(state.win) ~= state.buf then
return
end
if not find_preview_window(state.tab) and not allow_create then
return
end
local entry, resolve_error = selected_entry(state.buf, state.win)
if not entry then
show_summary(state, state.win, unavailable_lines(nil, resolve_error))
return
end
if entry.remote then
show_native_preview(state, entry)
return
end
local stat, stat_error = uv.fs_lstat(entry.path)
if not stat then
show_summary(state, state.win, unavailable_lines(entry.path, stat_error))
return
end
if stat.type == 'directory' then
show_summary(state, state.win, directory_lines(entry.path))
elseif stat.type == 'link' then
show_summary(state, state.win, symlink_lines(entry.path, stat))
elseif stat.type == 'file' then
show_native_preview(state, entry)
else
show_summary(state, state.win, unavailable_lines(entry.path, 'unsupported entry type: ' .. stat.type))
end
end
local function schedule_preview(state)
if not state then
return
end
if not find_preview_window(state.tab) then
schedule_preview_buffer_cleanup(state)
return
end
if not state.timer then
state.timer = uv.new_timer()
end
state.timer:stop()
state.timer:start(DEBOUNCE_MS, 0, vim.schedule_wrap(function()
preview_selected(state, false)
end))
end
vim.api.nvim_create_autocmd('BufEnter', {
group = preview_group,
desc = 'Restore normal editing behavior for a file previously used as a netrw preview',
callback = function(args)
local buf = args.buf
if not is_valid_buffer(buf) or vim.b[buf].netrw_live_preview_buffer ~= true then
return
end
local win = vim.api.nvim_get_current_win()
if is_valid_window(win) and vim.wo[win].previewwindow then
return
end
if preview_buffers[buf] then
restore_preview_buffer(buf, true)
end
end,
})
local function state_for(buf)
local state = states[buf]
if not state then
state = { buf = buf }
states[buf] = state
end
return state
end
local function schedule_leave_cleanup(state)
close_timer(state, 'leave_timer')
state.leave_timer = uv.new_timer()
state.leave_timer:start(20, 0, vim.schedule_wrap(function()
close_timer(state, 'leave_timer')
if state.native_preview_active then
return
end
local current_win = vim.api.nvim_get_current_win()
local still_in_explorer = is_valid_window(current_win)
and current_win == state.win
and vim.api.nvim_win_get_buf(current_win) == state.buf
if not still_in_explorer then
close_preview(state)
end
end))
end
local function scroll_preview(direction, count)
local preview = find_preview_window(vim.api.nvim_get_current_tabpage())
if not preview then
return
end
local key = vim.keycode(direction > 0 and '<C-e>' or '<C-y>')
pcall(vim.api.nvim_win_call, preview, function()
vim.cmd.normal({ args = { tostring(count) .. key }, bang = true })
end)
end
function M.setup(buf)
if vim.fn.has('nvim-0.12') == 0 or not is_valid_buffer(buf) then
return false
end
if vim.b[buf].netrw_live_preview_configured then
return true
end
if vim.fn.exists('*netrw#Call') ~= 1 then
return false
end
vim.b[buf].netrw_live_preview_configured = true
local state = state_for(buf)
vim.keymap.set('n', 'p', function()
state.win = vim.api.nvim_get_current_win()
state.tab = vim.api.nvim_get_current_tabpage()
preview_selected(state, true)
end, {
buffer = buf,
desc = 'Netrw: Preview selection and enable live updates',
nowait = true,
silent = true,
})
vim.keymap.set('n', '<M-j>', function()
scroll_preview(1, vim.v.count1)
end, {
buffer = buf,
desc = 'Netrw: Scroll preview down',
nowait = true,
silent = true,
})
vim.keymap.set('n', '<M-k>', function()
scroll_preview(-1, vim.v.count1)
end, {
buffer = buf,
desc = 'Netrw: Scroll preview up',
nowait = true,
silent = true,
})
vim.api.nvim_create_autocmd('CursorMoved', {
group = preview_group,
buffer = buf,
desc = 'Update an existing netrw preview after cursor movement',
callback = function()
state.win = vim.api.nvim_get_current_win()
state.tab = vim.api.nvim_get_current_tabpage()
schedule_preview(state)
end,
})
vim.api.nvim_create_autocmd({ 'BufLeave', 'WinLeave' }, {
group = preview_group,
buffer = buf,
desc = 'Close the netrw preview when leaving the explorer',
callback = function()
close_timer(state)
schedule_leave_cleanup(state)
end,
})
vim.api.nvim_create_autocmd('BufWipeout', {
group = preview_group,
buffer = buf,
once = true,
desc = 'Release netrw preview resources',
callback = function()
close_timer(state)
close_timer(state, 'leave_timer')
close_preview(state)
states[buf] = nil
end,
})
return true
end
return M