add netrw live previews and directory summaries
This commit is contained in:
parent
282e5e0f0b
commit
f31a9b7d74
6 changed files with 625 additions and 40 deletions
555
lua/netrw-preview.lua
Normal file
555
lua/netrw-preview.lua
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
local M = {}
|
||||
|
||||
local uv = vim.uv
|
||||
local states = {}
|
||||
local preview_group = vim.api.nvim_create_augroup('netrw_live_preview', { clear = true })
|
||||
|
||||
local DEBOUNCE_MS = 90
|
||||
local MAX_ENTRIES = 200
|
||||
|
||||
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 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)
|
||||
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 ok, shown = pcall(native_preview, entry)
|
||||
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 or not find_preview_window(state.tab) then
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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.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
|
||||
Loading…
Add table
Add a link
Reference in a new issue