diff --git a/runtime/lua/nvim/autoread.lua b/runtime/lua/nvim/autoread.lua index 229e2f6d26..2691e8ccc2 100644 --- a/runtime/lua/nvim/autoread.lua +++ b/runtime/lua/nvim/autoread.lua @@ -1,6 +1,5 @@ ---- Watches buffer files for external changes using vim._watch. ---- When 'autoread' is set, external changes are detected in real-time ---- instead of only on FocusGained/:checktime. +--- Provides 'autoread' via OS filewatchers: watches 'autoread' buffer files for external changes +--- using vim._watch. Complements the existing FocusGained/:checktime approach. local uv = vim.uv local watch = vim._watch @@ -8,13 +7,16 @@ local nvim_on = require('vim._core.util').nvim_on local M = {} +local debounce_ms = 100 --- @type table bufnr -> cancel function local watchers = {} - --- @type table bufnr -> debounce timer local timers = {} - -local debounce_ms = 100 +--- @type table bufnr -> true. Tracks pending autoreads (debounce window, or :checktime in flight), +--- so we can surface activity via the 'busy' flag. +local pending = {} +--- @type table bufnr -> true. Tracks which `pending` buffers have set 'busy'. +local pending_busy = {} --- @private --- Test-only: override the debounce window so tests can run faster. @@ -30,6 +32,35 @@ function M._is_watching(bufnr) return watchers[bufnr] ~= nil end +--- Sets the 'busy' option on a `pending` buffer. Idempotent: if `pending` and `pending_busy` +--- already agree, it's a no-op. Must run on main thread. +--- +--- @param bufnr integer +local function sync_busy(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + pending_busy[bufnr] = nil + return + end + local want = pending[bufnr] ~= nil + local have = pending_busy[bufnr] ~= nil + if want == have then + return + end + vim.bo[bufnr].busy = math.max(0, vim.bo[bufnr].busy + (want and 1 or -1)) + pending_busy[bufnr] = want or nil +end + +--- Sends `pending` state for `bufnr`. +--- +--- @param bufnr integer +--- @param is_pending boolean +local function set_pending(bufnr, is_pending) + pending[bufnr] = is_pending or nil + vim.schedule(function() + sync_busy(bufnr) + end) +end + --- Returns the effective 'autoread' value for a buffer. --- 'autoread' is global-local: vim.bo[bufnr].autoread is nil when not set locally, --- so we must fall back to the global value. @@ -68,6 +99,7 @@ end --- Stops and cleans up the watcher for a buffer. --- @param bufnr integer local function stop_watcher(bufnr) + set_pending(bufnr, false) local cancel = watchers[bufnr] if cancel then cancel() @@ -96,14 +128,20 @@ local function ensure_watcher(bufnr) timers[bufnr] = timer local cancel = watch.watch(name, {}, function(_, change_type) + -- Set the 'busy' buffer option for the duration of the pending cycle. This is a small, "best + -- effort" UX hint, not intended to be noticeable except when filewatcher activity is "noisy". + set_pending(bufnr, true) -- Debounce: restart the same timer on each event, so only the last -- event in a rapid series (e.g. truncate + write) triggers checktime. timer:start(debounce_ms, 0, function() vim.schedule(function() + sync_busy(bufnr) if not vim.api.nvim_buf_is_loaded(bufnr) or not buf_autoread(bufnr) then + set_pending(bufnr, false) return end vim.cmd.checktime(bufnr) + set_pending(bufnr, false) -- On rename events (e.g. atomic save by another editor), the watcher -- is now stale (watching the old inode). Re-establish it. if change_type ~= watch.FileChangeType.Changed then diff --git a/test/functional/options/autoread_spec.lua b/test/functional/options/autoread_spec.lua index 841719c13e..ad9bc13d2c 100644 --- a/test/functional/options/autoread_spec.lua +++ b/test/functional/options/autoread_spec.lua @@ -99,7 +99,7 @@ describe('autoread file watcher', function() -- Give the watcher time to fire; the buffer must NOT be reloaded because -- it has unsaved changes (autoread only reloads unmodified buffers). sleep(50) - -- Also do a manual checktime to be sure + -- Also do a manual :checktime to be sure command('silent! checktime') -- Buffer should still have local changes (autoread doesn't override modified buffers) eq({ 'local change' }, api.nvim_buf_get_lines(0, 0, -1, true)) @@ -111,7 +111,7 @@ describe('autoread file watcher', function() command('setlocal noautoread') eq(false, is_watching()) - -- Modify externally while noautoread + -- Modify externally while 'noautoread'. write_file(path, 'while disabled\n') sleep(50) eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) @@ -167,6 +167,38 @@ describe('autoread file watcher', function() eq(1, n.exec_lua('return _G.reloads')) end) + it("bumps 'busy' on each watched buffer while a reload is pending", function() + -- Use a longer debounce so we can sample 'busy' during pending autoreads. + n.exec_lua([[require('nvim.autoread')._set_debounce(100)]]) + + local path1 = open_watched('a1\n') + local buf1 = api.nvim_get_current_buf() + command('enew') + local path2 = open_watched('a2\n') + local buf2 = api.nvim_get_current_buf() + + eq(0, api.nvim_get_option_value('busy', { buf = buf1 })) + eq(0, api.nvim_get_option_value('busy', { buf = buf2 })) + + -- Trigger external changes on both watched files concurrently. + write_file(path1, 'b1\n') + write_file(path2, 'b2\n') + + -- Confirm busy=1 during the debounce window. + retry(nil, 1000, function() + eq(1, api.nvim_get_option_value('busy', { buf = buf1 })) + eq(1, api.nvim_get_option_value('busy', { buf = buf2 })) + end) + + -- Confirm busy=0 after the autoread. + retry(nil, 3000, function() + eq({ 'b1' }, api.nvim_buf_get_lines(buf1, 0, -1, true)) + eq({ 'b2' }, api.nvim_buf_get_lines(buf2, 0, -1, true)) + eq(0, api.nvim_get_option_value('busy', { buf = buf1 })) + eq(0, api.nvim_get_option_value('busy', { buf = buf2 })) + end) + end) + it('detects changes after atomic rename (external editor save)', function() local path = open_watched('original\n')