mirror of
https://github.com/neovim/neovim.git
synced 2026-06-15 08:13:45 +00:00
feat(autoread): use filewatchers for OS-driven change detection #37971
Problem: The 'autoread' option only checks for file changes reactively — on FocusGained, :checktime, CmdlineEnter, etc. — by polling timestamps. External changes are not detected until the user interacts with Neovim. Solution: Add a core module (runtime/lua/nvim/autoread.lua) enabled from runtime/plugin/autoread.lua that watches each buffer's file using vim._watch.watch() (libuv fs_event). On change detection it calls :checktime, which invokes the existing buf_check_timestamp() logic for reload/prompt handling. Watchers are managed via autocmds tied to buffer lifecycle events and respect the 'autoread' option (global and buffer-local).
This commit is contained in:
committed by
GitHub
parent
c622b454b5
commit
400f247397
@@ -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: >
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
149
runtime/lua/nvim/autoread.lua
Normal file
149
runtime/lua/nvim/autoread.lua
Normal file
@@ -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<integer, fun()> bufnr -> cancel function
|
||||
local watchers = {}
|
||||
|
||||
--- @type table<integer, uv.uv_timer_t> 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
|
||||
3
runtime/lua/vim/_meta/options.gen.lua
generated
3
runtime/lua/vim/_meta/options.gen.lua
generated
@@ -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:
|
||||
---
|
||||
|
||||
12
runtime/plugin/autoread.lua
Normal file
12
runtime/plugin/autoread.lua
Normal file
@@ -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()
|
||||
@@ -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<
|
||||
|
||||
236
test/functional/options/autoread_spec.lua
Normal file
236
test/functional/options/autoread_spec.lua
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user