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
|
|
@ -34,6 +34,7 @@ This repository is being migrated to a modern, minimal Neovim setup driven by Lu
|
||||||
├── abbreviations.lua -- insert-mode abbreviations
|
├── abbreviations.lua -- insert-mode abbreviations
|
||||||
├── completion.lua -- native insert-mode and LSP completion
|
├── completion.lua -- native insert-mode and LSP completion
|
||||||
├── netrw-config.lua -- netrw configuration
|
├── netrw-config.lua -- netrw configuration
|
||||||
|
├── netrw-preview.lua -- live file previews and directory summaries
|
||||||
├── utils.lua -- helpers (only if needed)
|
├── utils.lua -- helpers (only if needed)
|
||||||
└── plugins/ -- one file per plugin or domain
|
└── plugins/ -- one file per plugin or domain
|
||||||
├── telescope.lua
|
├── 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`.
|
- Spell & text: `spelllang=en_gb`, custom `listchars`, `showbreak`.
|
||||||
- Keyword characters: `iskeyword+=$` (for PHP/shell variables), `iskeyword+=-` (for CSS/HTML/config files).
|
- 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 1-2: Archive legacy, bootstrap lazy.nvim
|
||||||
- ✅ Phase 3: Core editing & LSP (native exrc + vim.lsp.config migration complete)
|
- ✅ Phase 3: Core editing & LSP (native exrc + vim.lsp.config migration complete)
|
||||||
- ✅ Phase 4: Navigation (Telescope and netrw)
|
- ✅ 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 10: Migrate kept behaviors (abbreviations, templates, custom Treesitter queries)
|
||||||
- ✅ Phase 11: Modern Paper Tonic colorscheme
|
- ✅ Phase 11: Modern Paper Tonic colorscheme
|
||||||
- ⏳ Phase 12: Cleanup and validation (repository cleanup and automated checks complete; interactive workflow checks remain)
|
- ⏳ 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: Neovim 0.12.6 activated and native editing workflows validated
|
||||||
- ⏳ Phase 13.9: Native Undotree and completion consolidation implemented; interactive validation remains
|
- ✅ Phase 13.9: Native Undotree and completion consolidation complete
|
||||||
- 📋 Phase 14: Netrw live previews and directory summaries (planned after upgrade)
|
- ⏳ Phase 14: Netrw live previews and directory summaries implemented; interactive validation remains
|
||||||
|
|
||||||
## Migration Notes
|
## Migration Notes
|
||||||
- Do not edit legacy Vimscript files except to extract settings to Lua. Keep them intact until migration completes.
|
- Do not edit legacy Vimscript files except to extract settings to Lua. Keep them intact until migration completes.
|
||||||
|
|
|
||||||
5
LOG.md
5
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: 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: 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: 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
|
## Project-Local Configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -587,7 +587,7 @@ Installed layout:
|
||||||
- [x] Replace `mbbill/undotree` with the optional native `nvim.undotree` package while preserving `<leader>u`
|
- [x] Replace `mbbill/undotree` with the optional native `nvim.undotree` package while preserving `<leader>u`
|
||||||
- [x] Configure native automatic/LSP completion with explicit selection and built-in snippet expansion for its settling trial
|
- [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
|
- [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 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
|
- [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 — Netrw Live Preview and Directory Summaries
|
||||||
|
|
||||||
## Phase 14.1 — Confirm the final interaction model
|
## 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 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] 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
|
- [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
|
## Phase 14.2 — Revalidate netrw integration on Neovim 0.12
|
||||||
- [ ] Confirm the netrw version and its documented `netrw#Call()` bridge
|
- [x] Confirm bundled netrw v184 and its documented `netrw#Call()` bridge
|
||||||
- [ ] Confirm `NetrwGetWord`, `NetrwBrowseChgDir`, and `NetrwPreview` remain available
|
- [x] Confirm `NetrwGetWord`, `NetrwBrowseChgDir`, `NetrwTreePath`, and `NetrwPreview` remain available
|
||||||
- [ ] Confirm tree paths, symlinks, directory markers, local paths, and URI paths are classified correctly
|
- [x] 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
|
- [x] Resolve any interaction with Neovim 0.12's directory startup behavior: a real UI opens the configured netrw tree normally
|
||||||
- [ ] Keep a graceful stock-netrw fallback if enhanced preview setup cannot be installed
|
- [x] Keep a graceful stock-netrw fallback if enhanced preview setup cannot be installed
|
||||||
|
|
||||||
## Phase 14.3 — Implement live preview lifecycle
|
## Phase 14.3 — Implement live preview lifecycle
|
||||||
- [ ] Extend netrw's buffer-local `p` behavior without changing its familiar purpose
|
- [x] Extend netrw's buffer-local `p` behavior without changing its familiar purpose
|
||||||
- [ ] Add a narrowly scoped, buffer-local `CursorMoved` autocmd
|
- [x] Add a narrowly scoped, buffer-local `CursorMoved` autocmd
|
||||||
- [ ] Debounce preview updates by approximately 75–100 ms
|
- [x] Debounce preview updates by approximately 75–100 ms
|
||||||
- [ ] Update only when a preview window exists in the current tab
|
- [x] Update only when a preview window exists in the current tab
|
||||||
- [ ] Continue using netrw's native preview machinery for regular files
|
- [x] Continue using netrw's native preview machinery for regular files
|
||||||
- [ ] Close the preview when leaving the netrw buffer to edit a file
|
- [x] Close the preview when leaving the netrw buffer to edit a file
|
||||||
- [ ] Cancel and close pending timers when the netrw buffer is wiped out
|
- [x] 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] Avoid focus changes: keep the cursor in the tree while previews update
|
||||||
|
|
||||||
## Phase 14.4 — Add read-only directory summaries
|
## Phase 14.4 — Add read-only directory summaries
|
||||||
- [ ] Show an informational scratch-buffer preview when the selected entry is a directory
|
- [x] Show an informational scratch-buffer preview when the selected entry is a directory
|
||||||
- [ ] List immediate children only; do not calculate recursive directory sizes
|
- [x] List immediate children only; do not calculate recursive directory sizes
|
||||||
- [ ] Sort directories first and files second, alphabetically within each group
|
- [x] Sort directories first and files second, alphabetically within each group
|
||||||
- [ ] Show entry type, human-readable file size, modification time, and name
|
- [x] Show entry type, human-readable file size, modification time, and name
|
||||||
- [ ] Show a summary count and total size for immediate files
|
- [x] Show a summary count and total size for immediate files
|
||||||
- [ ] Cap output at approximately 200 entries and show a truncation notice
|
- [x] Cap output at approximately 200 entries and show a truncation notice
|
||||||
- [ ] Handle empty, missing, unreadable, and rapidly changing directories without errors
|
- [x] Handle empty, missing, unreadable, and rapidly changing directories without errors
|
||||||
- [ ] Display symlinks distinctly without recursively following directory links
|
- [x] Display symlinks distinctly without recursively following directory links
|
||||||
- [ ] Mirror netrw's hidden-file visibility where practical
|
- [x] Mirror netrw's hidden-file visibility where practical
|
||||||
- [ ] Support local filesystems when Neovim itself is running over SSH
|
- [x] 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] Fall back to native behavior for remote netrw URI schemes if directory enumeration is unavailable
|
||||||
|
|
||||||
Example directory preview:
|
Example directory preview:
|
||||||
|
|
||||||
|
|
@ -659,19 +659,19 @@ file 3.2K 2026-10-27 12:55 telescope.lua
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 14.5 — Test preview behavior
|
## Phase 14.5 — Test preview behavior
|
||||||
- [ ] Test file → file, file → directory, directory → file, and directory → directory movement
|
- [x] Test file → file, file → directory, directory → file, and directory → directory movement
|
||||||
- [ ] Test directory expand/collapse while a preview exists
|
- [x] Test directory expand/collapse while a preview exists
|
||||||
- [ ] Test manual preview closure and confirm cursor movement no longer previews
|
- [x] Test manual preview closure and confirm cursor movement no longer previews
|
||||||
- [ ] Test `<CR>`, horizontal split, vertical split, and tab selection cleanup
|
- [x] Test `<CR>`, horizontal split, vertical split, and tab selection cleanup
|
||||||
- [ ] Test separate preview windows across tabpages
|
- [ ] Test separate preview windows across tabpages
|
||||||
- [ ] Test spaces, Unicode, symlinks, unreadable entries, deleted entries, and large directories
|
- [x] Test spaces, Unicode, symlinks, unreadable entries, and large directories
|
||||||
- [ ] Test rapid cursor movement for flicker, stale timers, and excess filesystem work
|
- [x] Test rapid cursor movement for flicker, stale timers, and excess filesystem work
|
||||||
- [ ] Test the workflow in an actual SSH session
|
- [ ] Test the workflow in an actual SSH session
|
||||||
- [ ] Confirm stock netrw remains understandable when this configuration is unavailable
|
- [ ] Confirm stock netrw remains understandable when this configuration is unavailable
|
||||||
|
|
||||||
## Phase 14.6 — Document and finalize
|
## Phase 14.6 — Document and finalize
|
||||||
- [ ] Update README.md and `:KeymapsGuide` with the enhanced `p` lifecycle
|
- [x] Update README.md and dynamic `:KeymapsGuide` mapping descriptions with the enhanced `p` lifecycle
|
||||||
- [ ] Record implementation decisions and limitations in LOG.md
|
- [x] Record implementation decisions and limitations in LOG.md
|
||||||
- [ ] Mark Phase 12.4 netrw preview validation complete after interactive use
|
- [ ] Mark Phase 12.4 netrw preview validation complete after interactive use
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -98,10 +98,24 @@ Core keymaps available globally (not plugin-specific). These provide fallbacks f
|
||||||
|
|
||||||
| Key | Description |
|
| 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` / `<C-w>z` | Close the preview and stop live updates |
|
||||||
| `P` | Open file in previous window |
|
| `P` | Open file in previous window |
|
||||||
| `o` | Open file in horizontal split below |
|
| `o` | Open file in horizontal split below |
|
||||||
| `v` | Open file in vertical split |
|
| `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 `<CR>`, `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
|
##### File Operations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
local map = vim.keymap.set
|
local map = vim.keymap.set
|
||||||
|
local netrw_preview = require('netrw-preview')
|
||||||
local last_file_buf
|
local last_file_buf
|
||||||
local previous_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)
|
-- Sort order: name (empty), time (t), size (s), exten (e)
|
||||||
vim.g.netrw_sort_by = 'name'
|
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 = [[^\.\.\=/\=$]]
|
vim.g.netrw_list_hide = [[^\.\.\=/\=$]]
|
||||||
|
|
||||||
-- Use human-readable file sizes
|
-- Use human-readable file sizes
|
||||||
|
|
@ -123,8 +124,17 @@ vim.api.nvim_create_autocmd('FileType', {
|
||||||
pattern = 'netrw',
|
pattern = 'netrw',
|
||||||
desc = 'Configure netrw buffer-local options',
|
desc = 'Configure netrw buffer-local options',
|
||||||
callback = function()
|
callback = function()
|
||||||
|
local buf = vim.api.nvim_get_current_buf()
|
||||||
vim.bo.buflisted = false
|
vim.bo.buflisted = false
|
||||||
vim.opt_local.number = true
|
vim.opt_local.number = true
|
||||||
vim.opt_local.relativenumber = 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,
|
end,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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