avoid treesitter races in netrw previews

This commit is contained in:
Ray Elliott 2026-10-28 16:46:48 +00:00
commit fe94b30c31
4 changed files with 117 additions and 1 deletions

1
LOG.md
View file

@ -215,6 +215,7 @@ Record every decision here with a short rationale. Append new entries; do not re
- 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: 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: 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. - 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.
- 2026-10-28: Fixed a reproducible Neovim 0.12 Tree-sitter race during rapid cross-language netrw previews. Native `:pedit` unloads the previous preview buffer while its asynchronous parser may still be running, producing `TSNode:range()` errors. Preview-only file buffers are now kept loaded but unlisted for the rest of the Neovim session, with Tree-sitter highlighting suspended in favor of Vim syntax; avoiding parser detachment is necessary because delayed unloading reproduces the same race. Pre-existing buffer state is preserved, while entering a previewed file for real editing restores listed-buffer behavior and Tree-sitter immediately. An 80-step Markdown/Lua/JSON preview stress test, edit restoration check, retained-buffer check, and pre-existing-buffer check all pass.
## Project-Local Configuration ## Project-Local Configuration

View file

@ -666,6 +666,7 @@ file 3.2K 2026-10-27 12:55 telescope.lua
- [ ] Test separate preview windows across tabpages - [ ] Test separate preview windows across tabpages
- [x] Test spaces, Unicode, symlinks, unreadable entries, and large directories - [x] Test spaces, Unicode, symlinks, unreadable entries, and large directories
- [x] 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
- [x] Prevent Tree-sitter parser races during rapid cross-language file preview replacement and restore highlighting on edit
- [ ] 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

View file

@ -110,6 +110,11 @@ Regular files use netrw's native preview. Directories show a read-only summary o
their immediate children, including type, file size, modification time, and a their immediate children, including type, file size, modification time, and a
total for immediate files. Directories appear first, output is capped at 200 total for immediate files. Directories appear first, output is capped at 200
entries, and symbolic links are displayed without following them recursively. entries, and symbolic links are displayed without following them recursively.
File previews use Vim's syntax highlighting while Tree-sitter is suspended to
avoid asynchronous parser races during rapid replacement; normal Tree-sitter
highlighting resumes automatically when a previewed file is opened for editing.
Preview-only file buffers remain loaded but unlisted until Neovim exits, avoiding
the parser detachment that triggers this Neovim 0.12 race.
Opening a file with `<CR>`, `o`, `v`, or `t`, or otherwise leaving netrw, closes 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 the preview. Closing it manually also disables live updates until `p` is pressed

View file

@ -2,10 +2,12 @@ local M = {}
local uv = vim.uv local uv = vim.uv
local states = {} local states = {}
local preview_buffers = {}
local preview_group = vim.api.nvim_create_augroup('netrw_live_preview', { clear = true }) local preview_group = vim.api.nvim_create_augroup('netrw_live_preview', { clear = true })
local DEBOUNCE_MS = 90 local DEBOUNCE_MS = 90
local MAX_ENTRIES = 200 local MAX_ENTRIES = 200
local PREVIEW_BUFFER_CLEANUP_MS = 2000
local function is_valid_buffer(buf) local function is_valid_buffer(buf)
return type(buf) == 'number' and buf > 0 and vim.api.nvim_buf_is_valid(buf) return type(buf) == 'number' and buf > 0 and vim.api.nvim_buf_is_valid(buf)
@ -53,6 +55,46 @@ local function close_timer(state, key)
state[key] = nil state[key] = nil
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
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) local function close_preview(state)
if not state then if not state then
return return
@ -61,6 +103,7 @@ local function close_preview(state)
local preview = find_preview_window(state.tab) local preview = find_preview_window(state.tab)
if preview then if preview then
pcall(vim.api.nvim_win_close, preview, false) pcall(vim.api.nvim_win_close, preview, false)
schedule_preview_buffer_cleanup(state)
end end
end end
@ -395,7 +438,48 @@ end
local function show_native_preview(state, entry) local function show_native_preview(state, entry)
state.native_preview_active = true 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,
} or nil
local ok, shown = pcall(native_preview, entry) 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,
}
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)
local filetype = vim.bo[preview_buf].filetype
if filetype ~= '' then
vim.bo[preview_buf].syntax = filetype
end
end
end
if is_valid_window(state.win) then if is_valid_window(state.win) then
pcall(vim.api.nvim_set_current_win, state.win) pcall(vim.api.nvim_set_current_win, state.win)
end end
@ -445,7 +529,12 @@ local function preview_selected(state, allow_create)
end end
local function schedule_preview(state) local function schedule_preview(state)
if not state or not find_preview_window(state.tab) then if not state then
return
end
if not find_preview_window(state.tab) then
schedule_preview_buffer_cleanup(state)
return return
end end
@ -459,6 +548,26 @@ local function schedule_preview(state)
end)) end))
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 function state_for(buf)
local state = states[buf] local state = states[buf]
if not state then if not state then