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: 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: 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

View file

@ -666,6 +666,7 @@ file 3.2K 2026-10-27 12:55 telescope.lua
- [ ] Test separate preview windows across tabpages
- [x] Test spaces, Unicode, symlinks, unreadable entries, and large directories
- [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
- [ ] 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
total for immediate files. Directories appear first, output is capped at 200
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
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 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)
@ -53,6 +55,46 @@ local function close_timer(state, key)
state[key] = nil
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)
if not state then
return
@ -61,6 +103,7 @@ local function close_preview(state)
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
@ -395,7 +438,48 @@ 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,
} 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,
}
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
pcall(vim.api.nvim_set_current_win, state.win)
end
@ -445,7 +529,12 @@ local function preview_selected(state, allow_create)
end
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
end
@ -459,6 +548,26 @@ local function schedule_preview(state)
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