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 '' or '') 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', '', function() scroll_preview(1, vim.v.count1) end, { buffer = buf, desc = 'Netrw: Scroll preview down', nowait = true, silent = true, }) vim.keymap.set('n', '', 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