From f31a9b7d74d36bd378d0c9f14cdcfa774d07cc52 Mon Sep 17 00:00:00 2001 From: ray Date: Wed, 28 Oct 2026 16:29:18 +0000 Subject: [PATCH] add netrw live previews and directory summaries --- AGENTS.md | 9 +- LOG.md | 5 + MIGRATION_PLAN.md | 68 +++--- README.md | 16 +- lua/netrw-config.lua | 12 +- lua/netrw-preview.lua | 555 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 625 insertions(+), 40 deletions(-) create mode 100644 lua/netrw-preview.lua diff --git a/AGENTS.md b/AGENTS.md index 6d83828..2c383e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ This repository is being migrated to a modern, minimal Neovim setup driven by Lu ├── abbreviations.lua -- insert-mode abbreviations ├── completion.lua -- native insert-mode and LSP completion ├── netrw-config.lua -- netrw configuration + ├── netrw-preview.lua -- live file previews and directory summaries ├── utils.lua -- helpers (only if needed) └── plugins/ -- one file per plugin or domain ├── telescope.lua @@ -106,7 +107,7 @@ This repository is being migrated to a modern, minimal Neovim setup driven by Lu - Spell & text: `spelllang=en_gb`, custom `listchars`, `showbreak`. - Keyword characters: `iskeyword+=$` (for PHP/shell variables), `iskeyword+=-` (for CSS/HTML/config files). -## Current Status (Phase 13 In Progress) +## Current Status (Phase 14 In Progress) - ✅ Phase 1-2: Archive legacy, bootstrap lazy.nvim - ✅ Phase 3: Core editing & LSP (native exrc + vim.lsp.config migration complete) - ✅ Phase 4: Navigation (Telescope and netrw) @@ -118,9 +119,9 @@ This repository is being migrated to a modern, minimal Neovim setup driven by Lu - ✅ Phase 10: Migrate kept behaviors (abbreviations, templates, custom Treesitter queries) - ✅ Phase 11: Modern Paper Tonic colorscheme - ⏳ Phase 12: Cleanup and validation (repository cleanup and automated checks complete; interactive workflow checks remain) -- ⏳ Phase 13: Neovim 0.12.6 activated; native completion settling checks in progress -- ⏳ Phase 13.9: Native Undotree and completion consolidation implemented; interactive validation remains -- 📋 Phase 14: Netrw live previews and directory summaries (planned after upgrade) +- ✅ Phase 13: Neovim 0.12.6 activated and native editing workflows validated +- ✅ Phase 13.9: Native Undotree and completion consolidation complete +- ⏳ Phase 14: Netrw live previews and directory summaries implemented; interactive validation remains ## Migration Notes - Do not edit legacy Vimscript files except to extract settings to Lua. Keep them intact until migration completes. diff --git a/LOG.md b/LOG.md index da9a1dc..f8b080a 100644 --- a/LOG.md +++ b/LOG.md @@ -210,6 +210,11 @@ Record every decision here with a short rationale. Append new entries; do not re - 2026-10-28: Final smoke testing found that loading `nvim.undotree` before lazy.nvim setup left its command registered but removed its Lua module path. Moved `packadd nvim.undotree` after lazy.nvim finalizes `runtimepath`. The same test showed nvim-ts-autotag attaching to Markdown despite the intended filetype restriction; global autotag actions are now disabled and explicitly enabled only for HTML/PHP/JS/TS/Vue tag-bearing filetypes. - 2026-10-28: Active-runtime validation passed through the normal `nvim` selector: native Undotree opens, netrw and Telescope load, PHP native completion offers and accepts `greetExternal()` with snippet syntax, Copilot's inline client is authenticated, Markdown no longer runs autotag, and HTML autotag exits Insert mode without errors. `vim.deprecated` is clean. Remaining health warnings are optional missing language runtimes and the harmless unknown `markdown.mdx` filetype hint. - 2026-10-28: Marked `neovim-migration-guide.md` as historical planning input. Its original Neo-tree, cmp, and plugin assumptions are retained for provenance but no longer compete with the active architecture documents. +- 2026-10-28: User normal-use checks passed for the 0.12 native completion, snippet, autopairs, and inline Copilot workflow, closing the Phase 13.9 settling gate. Began Phase 14 with the confirmed netrw-only model: `p` starts previewing, cursor movement updates an existing preview, directories show read-only immediate-child summaries, and leaving netrw closes the preview. +- 2026-10-28: Revalidated bundled netrw v184. Its documented `netrw#Call()` bridge exposes `NetrwGetWord`, `NetrwTreePath`, `NetrwBrowseChgDir`, and `NetrwPreview`. In tree view, `NetrwTreePath` returns the selected directory path or the selected file's parent, allowing local paths to be resolved without parsing indentation or mutating netrw's expand/collapse state. Remote URI targets will retain native fallback behavior. +- 2026-10-28: Implemented Phase 14's plugin-free netrw preview extension in `lua/netrw-preview.lua`. Pressing `p` previews files through netrw's native machinery or shows a read-only immediate-child directory summary; while that preview window exists, a 90 ms buffer-local cursor debounce replaces it with the current selection. Manual `:pclose` stops updates, and leaving or wiping the explorer releases its preview and timer. Directory summaries are local-filesystem only, mirror netrw's active hide pattern, cap output at 200 entries, and never traverse symbolic links; remote URI schemes fall back to netrw's native preview path. +- 2026-10-28: Automated Phase 14 checks passed for file/directory transitions, manual close and reopen, all four file-open actions, tree expansion and collapse, rapid cursor movement, spaces, Unicode, symlinks, empty and unreadable directories, and the 200-entry cap. A PTY-backed launch on a directory confirmed Neovim 0.12 enters the configured netrw tree, so no special `filetype=directory` handling is required. Separate-tab, deleted-entry, and real SSH checks remain as interactive validation. +- 2026-10-28: An isolated lifecycle rerun exposed netrw's focus hop into an existing preview window while replacing a directory summary through its native preview function. The extension now explicitly restores the netrw window after that call, preserving tree-focused `j`/`k` navigation. Leave cleanup waits 20 ms before checking the settled current window and ignores callbacks raised while the native preview call is active, then closes only after a real explorer exit. The transient re-entrancy guard is not preview-mode state, and the cleanup timer is released when the netrw buffer is wiped. ## Project-Local Configuration diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index cc2a896..b44e2dc 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -587,7 +587,7 @@ Installed layout: - [x] Replace `mbbill/undotree` with the optional native `nvim.undotree` package while preserving `u` - [x] Configure native automatic/LSP completion with explicit selection and built-in snippet expansion for its settling trial - [x] Enable Copilot inline suggestions with a distinct accept key and hide them while the native completion menu is visible -- [ ] Validate PHP, HTML, JavaScript/TypeScript, Markdown, Lua, paths, snippets, autopairs, and Copilot interactively +- [x] Validate PHP, HTML, JavaScript/TypeScript, Markdown, Lua, paths, snippets, autopairs, and Copilot interactively - [x] Remove nvim-cmp, cmp-nvim-lsp, cmp-buffer, cmp-path, LuaSnip, and copilot-cmp from the 0.12 candidate - [x] Remove obsolete completion integrations, lockfile entries, highlights, and documentation @@ -599,7 +599,7 @@ Installed layout: ## Phase 14 — Netrw Live Preview and Directory Summaries ## Phase 14.1 — Confirm the final interaction model -- [ ] Confirm scope and priorities for this phase after the 0.12 upgrade +- [x] Confirm scope and priorities for this phase after the 0.12 upgrade - [x] Keep netrw as the sole tree browser and file explorer - [x] Keep `p` as the preview entry point so stock-netrw muscle memory remains useful on remote systems - [x] Once a native preview window exists, update it automatically as the netrw cursor moves @@ -616,34 +616,34 @@ j / k replace preview with the selected entry ``` ## Phase 14.2 — Revalidate netrw integration on Neovim 0.12 -- [ ] Confirm the netrw version and its documented `netrw#Call()` bridge -- [ ] Confirm `NetrwGetWord`, `NetrwBrowseChgDir`, and `NetrwPreview` remain available -- [ ] Confirm tree paths, symlinks, directory markers, local paths, and URI paths are classified correctly -- [ ] Resolve any interaction with Neovim 0.12's built-in `filetype=directory` buffers -- [ ] Keep a graceful stock-netrw fallback if enhanced preview setup cannot be installed +- [x] Confirm bundled netrw v184 and its documented `netrw#Call()` bridge +- [x] Confirm `NetrwGetWord`, `NetrwBrowseChgDir`, `NetrwTreePath`, and `NetrwPreview` remain available +- [x] Confirm tree paths, symlinks, directory markers, local paths, and URI paths are classified correctly +- [x] Resolve any interaction with Neovim 0.12's directory startup behavior: a real UI opens the configured netrw tree normally +- [x] Keep a graceful stock-netrw fallback if enhanced preview setup cannot be installed ## Phase 14.3 — Implement live preview lifecycle -- [ ] Extend netrw's buffer-local `p` behavior without changing its familiar purpose -- [ ] Add a narrowly scoped, buffer-local `CursorMoved` autocmd -- [ ] Debounce preview updates by approximately 75–100 ms -- [ ] Update only when a preview window exists in the current tab -- [ ] Continue using netrw's native preview machinery for regular files -- [ ] Close the preview when leaving the netrw buffer to edit a file -- [ ] Cancel and close pending timers when the netrw buffer is wiped out -- [ ] Avoid focus changes: keep the cursor in the tree while previews update +- [x] Extend netrw's buffer-local `p` behavior without changing its familiar purpose +- [x] Add a narrowly scoped, buffer-local `CursorMoved` autocmd +- [x] Debounce preview updates by approximately 75–100 ms +- [x] Update only when a preview window exists in the current tab +- [x] Continue using netrw's native preview machinery for regular files +- [x] Close the preview when leaving the netrw buffer to edit a file +- [x] Cancel and close pending timers when the netrw buffer is wiped out +- [x] Avoid focus changes: keep the cursor in the tree while previews update ## Phase 14.4 — Add read-only directory summaries -- [ ] Show an informational scratch-buffer preview when the selected entry is a directory -- [ ] List immediate children only; do not calculate recursive directory sizes -- [ ] Sort directories first and files second, alphabetically within each group -- [ ] Show entry type, human-readable file size, modification time, and name -- [ ] Show a summary count and total size for immediate files -- [ ] Cap output at approximately 200 entries and show a truncation notice -- [ ] Handle empty, missing, unreadable, and rapidly changing directories without errors -- [ ] Display symlinks distinctly without recursively following directory links -- [ ] Mirror netrw's hidden-file visibility where practical -- [ ] Support local filesystems when Neovim itself is running over SSH -- [ ] Fall back to native behavior for remote netrw URI schemes if directory enumeration is unavailable +- [x] Show an informational scratch-buffer preview when the selected entry is a directory +- [x] List immediate children only; do not calculate recursive directory sizes +- [x] Sort directories first and files second, alphabetically within each group +- [x] Show entry type, human-readable file size, modification time, and name +- [x] Show a summary count and total size for immediate files +- [x] Cap output at approximately 200 entries and show a truncation notice +- [x] Handle empty, missing, unreadable, and rapidly changing directories without errors +- [x] Display symlinks distinctly without recursively following directory links +- [x] Mirror netrw's hidden-file visibility where practical +- [x] Support local filesystems when Neovim itself is running over SSH +- [x] Fall back to native behavior for remote netrw URI schemes if directory enumeration is unavailable Example directory preview: @@ -659,19 +659,19 @@ file 3.2K 2026-10-27 12:55 telescope.lua ``` ## Phase 14.5 — Test preview behavior -- [ ] Test file → file, file → directory, directory → file, and directory → directory movement -- [ ] Test directory expand/collapse while a preview exists -- [ ] Test manual preview closure and confirm cursor movement no longer previews -- [ ] Test ``, horizontal split, vertical split, and tab selection cleanup +- [x] Test file → file, file → directory, directory → file, and directory → directory movement +- [x] Test directory expand/collapse while a preview exists +- [x] Test manual preview closure and confirm cursor movement no longer previews +- [x] Test ``, horizontal split, vertical split, and tab selection cleanup - [ ] Test separate preview windows across tabpages -- [ ] Test spaces, Unicode, symlinks, unreadable entries, deleted entries, and large directories -- [ ] Test rapid cursor movement for flicker, stale timers, and excess filesystem work +- [x] Test spaces, Unicode, symlinks, unreadable entries, and large directories +- [x] Test rapid cursor movement for flicker, stale timers, and excess filesystem work - [ ] Test the workflow in an actual SSH session - [ ] Confirm stock netrw remains understandable when this configuration is unavailable ## Phase 14.6 — Document and finalize -- [ ] Update README.md and `:KeymapsGuide` with the enhanced `p` lifecycle -- [ ] Record implementation decisions and limitations in LOG.md +- [x] Update README.md and dynamic `:KeymapsGuide` mapping descriptions with the enhanced `p` lifecycle +- [x] Record implementation decisions and limitations in LOG.md - [ ] Mark Phase 12.4 netrw preview validation complete after interactive use --- diff --git a/README.md b/README.md index 84840d2..c68b6c4 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,24 @@ Core keymaps available globally (not plugin-specific). These provide fallbacks f | Key | Description | | --- | --- | -| `p` | Preview file in horizontal split (50% window size) | +| `p` | Preview the selected file or directory below and enable live cursor updates | +| `j` / `k` | Update an existing preview to the newly selected entry | +| `:pclose` / `z` | Close the preview and stop live updates | | `P` | Open file in previous window | | `o` | Open file in horizontal split below | | `v` | Open file in vertical split | +| `t` | Open file in a new tab | + +Regular files use netrw's native preview. Directories show a read-only summary of +their immediate children, including type, file size, modification time, and a +total for immediate files. Directories appear first, output is capped at 200 +entries, and symbolic links are displayed without following them recursively. + +Opening a file with ``, `o`, `v`, or `t`, or otherwise leaving netrw, closes +the preview. Closing it manually also disables live updates until `p` is pressed +again. Enhanced directory summaries apply to local paths (including local paths +when Neovim runs over SSH); netrw URI browsing retains its native preview +behavior. ##### File Operations diff --git a/lua/netrw-config.lua b/lua/netrw-config.lua index 0790a02..3956620 100644 --- a/lua/netrw-config.lua +++ b/lua/netrw-config.lua @@ -1,4 +1,5 @@ local map = vim.keymap.set +local netrw_preview = require('netrw-preview') local last_file_buf local previous_file_buf @@ -78,7 +79,7 @@ vim.g.netrw_browse_split = 0 -- Sort order: name (empty), time (t), size (s), exten (e) vim.g.netrw_sort_by = 'name' --- Hide dotfiles by default (toggle with 'gh' in netrw) +-- Preserve netrw's normal listing filter; toggle dotfile visibility with 'gh' vim.g.netrw_list_hide = [[^\.\.\=/\=$]] -- Use human-readable file sizes @@ -123,8 +124,17 @@ vim.api.nvim_create_autocmd('FileType', { pattern = 'netrw', desc = 'Configure netrw buffer-local options', callback = function() + local buf = vim.api.nvim_get_current_buf() vim.bo.buflisted = false vim.opt_local.number = true vim.opt_local.relativenumber = true + + -- netrw installs its own buffer mappings while building the listing. + -- Apply the preview extension after that setup has finished. + vim.schedule(function() + if is_valid_buffer(buf) and vim.bo[buf].filetype == 'netrw' then + netrw_preview.setup(buf) + end + end) end, }) diff --git a/lua/netrw-preview.lua b/lua/netrw-preview.lua new file mode 100644 index 0000000..1fab484 --- /dev/null +++ b/lua/netrw-preview.lua @@ -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