diff --git a/LOG.md b/LOG.md index f8b080a..5a38366 100644 --- a/LOG.md +++ b/LOG.md @@ -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 diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index b44e2dc..5b894e6 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -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 diff --git a/README.md b/README.md index c68b6c4..7656dfa 100644 --- a/README.md +++ b/README.md @@ -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 ``, `o`, `v`, or `t`, or otherwise leaving netrw, closes the preview. Closing it manually also disables live updates until `p` is pressed diff --git a/lua/netrw-preview.lua b/lua/netrw-preview.lua index 1fab484..74758df 100644 --- a/lua/netrw-preview.lua +++ b/lua/netrw-preview.lua @@ -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