diff --git a/runtime/doc/editing.txt b/runtime/doc/editing.txt index 46f14e9afd..8916b30a90 100644 --- a/runtime/doc/editing.txt +++ b/runtime/doc/editing.txt @@ -1515,6 +1515,12 @@ focus. If you want to automatically reload a file when it has been changed outside of Vim, set the 'autoread' option. This doesn't work at the moment you write the file though, only when the file wasn't changed inside of Vim. + +Nvim uses file system watchers to detect external changes in real-time for +all loaded buffers when 'autoread' is set. This means files are reloaded +promptly without waiting for |FocusGained| or |:checktime|. The watchers +are managed automatically: started when a buffer is loaded, restarted after +|:write|, and stopped when the buffer is deleted or 'autoread' is disabled. *ignore-timestamp* If you do not want to be asked or automatically reload the file, you can use this: > diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 420e69dde6..ad8ca7ef2a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -166,6 +166,8 @@ EDITOR number of history lines kept above the prompt. • |v_al| and |v_il| text objects select the whole buffer and the current line without leading or trailing white space. +• 'autoread' uses file system watchers to detect external changes in + real-time, instead of only on |FocusGained|/|:checktime|. EVENTS diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index ca9c2e8b35..8cea4e2671 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -813,7 +813,8 @@ A jump table for the options with a short description can be found at |Q_op|. it has not been changed inside of Vim, automatically read it again. When the file has been deleted this is not done, so you have the text from before it was deleted. When it appears again then it is read. - |timestamp| + Nvim uses file system watchers to detect changes in real-time for all + loaded buffers; see |timestamp| for details. If this option has a local value, use this command to switch back to using the global value: >vim set autoread< diff --git a/runtime/lua/nvim/autoread.lua b/runtime/lua/nvim/autoread.lua new file mode 100644 index 0000000000..fc36d84218 --- /dev/null +++ b/runtime/lua/nvim/autoread.lua @@ -0,0 +1,149 @@ +--- 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. + +local uv = vim.uv +local watch = vim._watch +local nvim_on = require('vim._core.util').nvim_on + +local M = {} + +--- @type table bufnr -> cancel function +local watchers = {} + +--- @type table bufnr -> debounce timer +local timers = {} + +local DEBOUNCE_MS = 100 + +--- 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. +--- @param bufnr integer +--- @return boolean +local function buf_autoread(bufnr) + local local_val = vim.bo[bufnr].autoread + if local_val ~= nil then + return local_val + end + return vim.go.autoread +end + +--- Returns true if the buffer should be watched. +--- @param bufnr integer +--- @return boolean +local function should_watch(bufnr) + if not vim.api.nvim_buf_is_loaded(bufnr) then + return false + end + -- Skip special buffers (terminal, help, quickfix, etc.) + if vim.bo[bufnr].buftype ~= '' then + return false + end + -- Must have a file name that exists on disk + local name = vim.api.nvim_buf_get_name(bufnr) + if name == '' or not uv.fs_stat(name) then + return false + end + if not buf_autoread(bufnr) then + return false + end + return true +end + +--- Stops and cleans up the watcher for a buffer. +--- @param bufnr integer +local function stop_watcher(bufnr) + local cancel = watchers[bufnr] + if cancel then + cancel() + watchers[bufnr] = nil + end + local timer = timers[bufnr] + if timer then + timer:stop() + timer:close() + timers[bufnr] = nil + end +end + +--- Ensures the buffer has an active file watcher if appropriate, or stops +--- an existing one if the buffer should no longer be watched. +--- @param bufnr integer +local function ensure_watcher(bufnr) + stop_watcher(bufnr) + + if not should_watch(bufnr) then + return + end + + local name = vim.api.nvim_buf_get_name(bufnr) + local timer = assert(uv.new_timer()) + timers[bufnr] = timer + + local cancel = watch.watch(name, {}, function(_, change_type) + -- 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() + if not vim.api.nvim_buf_is_loaded(bufnr) or not buf_autoread(bufnr) then + return + end + vim.cmd.checktime(bufnr) + -- 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 + ensure_watcher(bufnr) + end + end) + end) + end) + + watchers[bufnr] = cancel +end + +--- @private +--- @param bufnr integer +--- @return boolean +function M._is_watching(bufnr) + return watchers[bufnr] ~= nil +end + +function M.enable() + local group = vim.api.nvim_create_augroup('nvim.autoread', { clear = true }) + + -- (Re)start watcher when a file is loaded or written. + nvim_on({ 'BufReadPost', 'BufWritePost' }, group, function(args) + ensure_watcher(args.buf) + end) + + -- Stop watcher when buffer is unloaded or wiped out. + nvim_on({ 'BufUnload', 'BufWipeout' }, group, function(args) + stop_watcher(args.buf) + end) + + -- Clean up all watchers on exit to avoid dangling handles in the event loop. + nvim_on('VimLeavePre', group, function() + for bufnr in pairs(watchers) do + stop_watcher(bufnr) + end + end) + + -- React to 'autoread' option changes. + nvim_on('OptionSet', group, { pattern = 'autoread' }, function() + if vim.v.option_type == 'global' then + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + ensure_watcher(bufnr) + end + else + ensure_watcher(vim.api.nvim_get_current_buf()) + end + end) + + -- Attach to buffers that were already loaded before enable() ran. + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + ensure_watcher(bufnr) + end +end + +return M diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index 0895207bd2..5bc33d2ede 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -172,7 +172,8 @@ vim.bo.ai = vim.bo.autoindent --- it has not been changed inside of Vim, automatically read it again. --- When the file has been deleted this is not done, so you have the text --- from before it was deleted. When it appears again then it is read. ---- `timestamp` +--- Nvim uses file system watchers to detect changes in real-time for all +--- loaded buffers; see `timestamp` for details. --- If this option has a local value, use this command to switch back to --- using the global value: --- diff --git a/runtime/plugin/autoread.lua b/runtime/plugin/autoread.lua new file mode 100644 index 0000000000..5105a6ab0c --- /dev/null +++ b/runtime/plugin/autoread.lua @@ -0,0 +1,12 @@ +-- File-watcher backing for the 'autoread' option. +-- Lives here (not in vim/_core/defaults.lua) so that `-u NONE` / `--noplugin` +-- skip it: the BufReadPost/BufWritePost autocmds it registers would otherwise +-- show up in every test that inspects the autocmd list. Matches the pattern +-- used by runtime/plugin/matchparen.lua. + +if vim.g.loaded_autoread ~= nil then + return +end +vim.g.loaded_autoread = 1 + +require('nvim.autoread').enable() diff --git a/src/nvim/options.lua b/src/nvim/options.lua index b0bdfaa21f..f02318a546 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -305,7 +305,8 @@ local options = { it has not been changed inside of Vim, automatically read it again. When the file has been deleted this is not done, so you have the text from before it was deleted. When it appears again then it is read. - |timestamp| + Nvim uses file system watchers to detect changes in real-time for all + loaded buffers; see |timestamp| for details. If this option has a local value, use this command to switch back to using the global value: >vim set autoread< diff --git a/test/functional/options/autoread_spec.lua b/test/functional/options/autoread_spec.lua new file mode 100644 index 0000000000..0491b90f43 --- /dev/null +++ b/test/functional/options/autoread_spec.lua @@ -0,0 +1,236 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() + +local clear = n.clear +local command = n.command +local eq = t.eq +local api = n.api +local retry = t.retry +local write_file = t.write_file +local sleep = vim.uv.sleep + +local testdir = 'Xtest-autoread' + +--- Returns true if the autoread module is watching the given buffer +--- (defaults to the current buffer). +local function is_watching(bufnr) + return n.exec_lua(function(b) + return require('nvim.autoread')._is_watching(b or vim.api.nvim_get_current_buf()) + end, bufnr) +end + +describe('autoread file watcher', function() + before_each(function() + n.mkdir_p(testdir) + clear({ args = { '--clean' } }) + end) + + after_each(function() + n.rmdir(testdir) + end) + + it('watches file opened on startup (nvim foo.txt)', function() + local path = testdir .. '/test_startup.txt' + write_file(path, 'startup original\n') + + -- Spawn nvim with the file passed on the command line. This exercises the + -- boot order: plugins must load before the initial file is read so that + -- the BufReadPost autocmd is registered in time to attach a watcher. + clear({ args = { '--clean', path } }) + + eq({ 'startup original' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Modify file externally; watcher should already be active. + write_file(path, 'startup changed\n') + + retry(nil, 3000, function() + eq({ 'startup changed' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) + + it('reloads buffer when file changes externally', function() + local path = testdir .. '/test_reload.txt' + write_file(path, 'original content\n') + + command('edit ' .. path) + eq({ 'original content' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Modify file externally + write_file(path, 'new content\n') + + -- The watcher + debounce should trigger checktime + retry(nil, 3000, function() + eq({ 'new content' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) + + it('does not reload when buffer has unsaved changes (conflict)', function() + local path = testdir .. '/test_conflict.txt' + write_file(path, 'original\n') + + command('edit ' .. path) + eq(true, is_watching()) + + -- Make a local change so the buffer is modified + api.nvim_buf_set_lines(0, 0, -1, true, { 'local change' }) + eq(true, api.nvim_get_option_value('modified', { buf = 0 })) + + -- Modify file externally + write_file(path, 'external change\n') + + -- Give watcher time to fire; buffer should NOT be reloaded + -- because it has unsaved changes (autoread only reloads unmodified buffers) + sleep(200) + -- 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)) + end) + + it('tracks autoread option changes', function() + local path = testdir .. '/test_reenable.txt' + write_file(path, 'original\n') + + command('edit ' .. path) + eq(true, is_watching()) + command('setlocal noautoread') + eq(false, is_watching()) + + -- Modify externally while noautoread + write_file(path, 'while disabled\n') + sleep(200) + eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) + + -- Re-enable autoread + command('setlocal autoread') + eq(true, is_watching()) + + -- Modify again + write_file(path, 'after reenable\n') + + retry(nil, 3000, function() + eq({ 'after reenable' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) + + it('stops watcher on bdelete', function() + local path = testdir .. '/test_bdelete.txt' + write_file(path, 'content\n') + + command('edit ' .. path) + local bufnr = api.nvim_get_current_buf() + eq(true, is_watching(bufnr)) + + command('enew') + command('bdelete ' .. bufnr) + eq(false, is_watching(bufnr)) + end) + + it('reloads hidden buffer when file changes', function() + local path = testdir .. '/test_hidden.txt' + write_file(path, 'original\n') + + command('set hidden') + command('edit ' .. path) + local bufnr = api.nvim_get_current_buf() + eq(true, is_watching(bufnr)) + + -- Switch to a different buffer (hides the first one) + command('enew') + eq(true, is_watching(bufnr)) + + -- Modify file externally + write_file(path, 'updated hidden\n') + + retry(nil, 3000, function() + eq({ 'updated hidden' }, api.nvim_buf_get_lines(bufnr, 0, -1, true)) + end) + end) + + it('handles file deletion gracefully', function() + local path = testdir .. '/test_delete.txt' + write_file(path, 'will be deleted\n') + + command('edit ' .. path) + eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Delete the file + os.remove(path) + + retry(nil, 3000, function() + eq(false, is_watching()) + end) + -- Buffer content should remain unchanged + eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + + it('handles rapid changes with debouncing', function() + local path = testdir .. '/test_debounce.txt' + write_file(path, 'v1\n') + + command('edit ' .. path) + eq({ 'v1' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Make several rapid changes + write_file(path, 'v2\n') + write_file(path, 'v3\n') + write_file(path, 'v4\n') + write_file(path, 'final\n') + + -- Should eventually settle on final content + retry(nil, 3000, function() + eq({ 'final' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) + + it('detects changes after atomic rename (external editor save)', function() + local path = testdir .. '/test_rename.txt' + write_file(path, 'original\n') + + command('edit ' .. path) + eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Simulate atomic save: write to temp file, rename over target + local tmp = path .. '.tmp' + write_file(tmp, 'after rename\n') + assert(vim.uv.fs_rename(tmp, path)) + + retry(nil, 3000, function() + eq({ 'after rename' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + -- Watcher should have been re-established on the new inode. + eq(true, is_watching()) + + -- Verify the watcher still works for subsequent plain writes + write_file(path, 'second change\n') + + retry(nil, 3000, function() + eq({ 'second change' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) + + it('auto-reload is undoable', function() + local path = testdir .. '/test_undo.txt' + write_file(path, 'original\n') + + command('edit ' .. path) + eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq(true, is_watching()) + + -- Modify externally + write_file(path, 'changed externally\n') + + retry(nil, 3000, function() + eq({ 'changed externally' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) + + -- Undo should restore original content + command('silent undo') + eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) +end)