-- Sane handling of files that change on disk while loaded in neovim. -- -- Neovim already has `autoread` on by default, but it only ever looks at the -- filesystem after a shell command, after `:checktime` and on `FocusGained` -- ( see `:help timestamp` ). So a formatter, a git checkout or an agent can -- rewrite a file and neovim happily keeps showing stale text for minutes. -- -- Three layers, from fastest to most reliable: -- -- 1. `uv.fs_event` on the *parent directory* of every loaded file -- -> push notification, millisecond latency, but unreliable -- ( network shares, WSL interop, watcher limits ). -- 2. a plain repeating timer -- -> the floor. always works, independent of terminal focus reporting -- and of `updatetime`. -- 3. `FocusGained` and friends -- -> zero latency for the common "alt tab back into neovim" case. -- -- All three funnel into `:checktime`, which decides nothing by itself. The -- actual policy lives in the `FileChangedShell` handler below. -- -- Note on the directory watch: tools write files by creating a temporary file -- and renaming it over the target. That replaces the inode, so an `fs_event` -- registered on the *file* goes deaf after the first write. Watching the -- containing directory survives this. local auto = require 'std.auto' local uv = vim.uv -- reading a file again must stay undoable, also for big files. -- see `:help 'undoreload'` vim.opt.undoreload = 100000 -- global value. plugins may still set a local one, that is their business. vim.opt.autoread = true --[[ POLICY ]] -- `FileChangedShell` only fires for the cases neovim can not decide on its -- own. A clean buffer whose file changed is reloaded silently by `autoread` -- and never reaches this handler. -- -- Defining this autocommand suppresses the built in W11/W12/W16/E211 warnings -- and dialogs entirely, so it owns the whole user experience. --- remembers which buffers we already complained about, to not repeat --- ourselves on every check. keyed by buffer number. --- deliberately not a buffer local variable: `FileChangedShell` runs with the --- buffer list locked. ---@type table local warned = {} local relative_name = function ( path ) return vim.fn.fnamemodify( path, ':~:.' ) end local warn_once = function ( buffer, reason, message ) if warned[buffer] == reason then return end warned[buffer] = reason vim.notify( message, vim.log.levels.WARN, { title = 'file changed', } ) end auto 'file_changed_policy' { { description = 'decide what to do with a file that changed outside of neovim', events = 'FileChangedShell', command = function ( event ) local reason = vim.v.fcs_reason local name = relative_name( event.file ) if reason == 'mode' or reason == 'time' then -- permissions or timestamp only, contents are identical. -- typical for version control checkouts. nothing to lose. vim.v.fcs_choice = 'reload' elseif reason == 'changed' then -- contents differ and the buffer is clean. -- 'edit' instead of 'reload' so that 'fileformat', 'fileencoding' and -- 'binary' are detected again. a formatter or a `dos2unix` run may -- have changed them. vim.v.fcs_choice = 'edit' elseif reason == 'conflict' then -- changed on disk AND in the buffer. never pick a winner. vim.v.fcs_choice = '' warn_once( event.buf, reason, ( '`%s` changed on disk **and** in the buffer.\n' .. '`:DiffDisk` compare · `:e!` take disk · `:w!` take buffer' ):format( name ) ) elseif reason == 'deleted' then -- keep the text. it may be the only copy left. vim.v.fcs_choice = '' warn_once( event.buf, reason, ( '`%s` is gone from disk.' ):format( name ) ) end end, }, { description = 'report a file that was reloaded from disk', events = 'FileChangedShellPost', command = function ( event ) warned[event.buf] = nil if vim.wo.diff then vim.cmd.diffupdate() end vim.notify( ( 'reloaded `%s`' ):format( relative_name( event.file ) ), vim.log.levels.INFO, { title = 'file changed', } ) end, }, { -- writing, or reading the file again ( `:e!`, `:edit` ), means buffer and -- disk agree again. without this the next conflict would be swallowed by -- the latch of the previous one. description = 'forget complaints once the buffer is in sync again', events = { 'BufWritePost', 'BufReadPost', 'BufWipeout', }, command = function ( event ) warned[event.buf] = nil end, }, } --[[ BUFFERS ]] --- the file a buffer is backed by, or nil if it is not a plain file buffer. ---@param buffer integer ---@return string? path, string? directory, string? file local file_of = function ( buffer ) if not vim.api.nvim_buf_is_valid( buffer ) then return end if vim.bo[buffer].buftype ~= '' then return end -- reloading these costs more than the staleness does if vim.bo[buffer].filetype == 'bigfile' then return end local name = vim.api.nvim_buf_get_name( buffer ) -- `oil://`, `fugitive://`, ... are not files if name == '' or name:match '^%a[%w+.-]*://' then return end local path = vim.fs.normalize( vim.fn.fnamemodify( name, ':p' ) ) return path, vim.fs.dirname( path ), vim.fs.basename( path ) end --[[ TRIGGERS ]] -- Everything funnels through one debounced, quiescence gated `:checktime`. -- -- The gate matters: a lot of tools truncate a file and only then write it, -- and on windows the gap between the two can be hundreds of milliseconds. -- Reading in that window loads an empty file, the cursor gets clamped to -- line 1, and the next read restores the contents but no longer knows where -- the cursor was. So wait until size and mtime of every watched file stopped -- moving before looking at them. local settle_delay = 150 local settle_attempts = 8 local timer = assert( uv.new_timer() ) ---@type table? local previous_fingerprint = nil local attempt = 0 --- size and modification time of every file a loaded buffer is backed by. --- The second return value marks a file that is almost certainly half written: --- it is empty while its buffer still holds text. Waiting for the fingerprint --- to stop moving does not catch this, because a truncated file sits there --- perfectly stable until the writer gets around to producing its bytes. ---@return table fingerprints, boolean half_written local fingerprint = function () local prints = {} local half_written = false for _, buffer in ipairs( vim.api.nvim_list_bufs() ) do if vim.api.nvim_buf_is_loaded( buffer ) then local path = file_of( buffer ) if path then local stat = uv.fs_stat( path ) prints[path] = stat and ( '%d:%d:%d' ):format( stat.size, stat.mtime.sec, stat.mtime.nsec ) or 'gone' if stat and stat.size == 0 and vim.api.nvim_buf_line_count( buffer ) > 1 then half_written = true end end end end return prints, half_written end local settle local function arm ( delay ) timer:stop() timer:start( delay, 0, vim.schedule_wrap( settle ) ) end settle = function () -- the reload is postponed by neovim until it is harmless anyway, but -- checking while typing or selecting only produces noise. if vim.fn.getcmdwintype() ~= '' then return end -- E11 if vim.api.nvim_get_mode().mode ~= 'n' then return end local current, half_written = fingerprint() local moved = not vim.deep_equal( previous_fingerprint, current ) if attempt < settle_attempts and ( moved or half_written ) then -- somebody is still writing. look again in a moment. -- after `settle_attempts` we give up and read whatever is there: the file -- may really have been emptied on purpose. previous_fingerprint = current attempt = attempt + 1 arm( settle_delay ) return end previous_fingerprint = nil attempt = 0 pcall( vim.cmd.checktime ) end local check = function ( delay ) previous_fingerprint = nil attempt = 0 arm( delay ) end auto 'file_changed_triggers' { description = 'check timestamps whenever attention returns to neovim', events = { 'FocusGained', 'BufEnter', 'TermLeave', 'TermClose', 'VimResume', 'InsertLeave', }, command = function () check( 50 ) end, } -- The floor. Deliberately not `CursorHold`: that event is edge triggered, it -- fires once per idle period and never again until the cursor moves. Sitting -- still and reading is exactly when an external change would be missed. local poll_interval = 2000 local poll = assert( uv.new_timer() ) poll:start( poll_interval, poll_interval, vim.schedule_wrap( function () -- do not interrupt a debounce that is already running if timer:is_active() then return end check( 0 ) end ) ) --[[ DIRECTORY WATCHERS ]] -- one `uv_fs_event_t` per directory, shared by all buffers in it. local max_directories = 64 ---@type table }> local watchers = {} --- plain lua, no `vim.*`: this also runs inside libuv callbacks. local basename = function ( path ) return path:match '[^/\\]*$' end local watch = function ( buffer ) local _, directory, file = file_of( buffer ) if not directory or not file then return end local watcher = watchers[directory] if watcher then watcher.files[file] = true return end if vim.tbl_count( watchers ) >= max_directories then return end local handle = assert( uv.new_fs_event() ) local _, error_message = handle:start( directory, {}, function ( error, changed ) if error then return end local current = watchers[directory] if not current then return end -- `changed` is nil on some platforms. then we can not tell which file it -- was and have to check them all, which `:checktime` does anyway. if changed and not current.files[basename( changed )] then return end check( 100 ) end ) if error_message then -- unwatchable directory ( network share, permissions, ... ). -- the poll timer still covers this buffer. handle:close() return end watchers[directory] = { handle = handle, files = { [file] = true, }, } end local unwatch = function ( buffer ) local _, directory, file = file_of( buffer ) if not directory or not file then return end local watcher = watchers[directory] if not watcher then return end watcher.files[file] = nil if next( watcher.files ) == nil then watcher.handle:stop() watcher.handle:close() watchers[directory] = nil end end auto 'file_changed_watchers' { { description = 'watch the directory of every file backed buffer', events = { 'BufReadPost', 'BufNewFile', 'BufFilePost', 'BufWritePost', }, command = function ( event ) watch( event.buf ) end, }, { description = 'release the directory watcher when a buffer goes away', events = { 'BufWipeout', 'BufFilePre', }, command = function ( event ) unwatch( event.buf ) end, }, } -- buffers that were already loaded before this module ran for _, buffer in ipairs( vim.api.nvim_list_bufs() ) do if vim.api.nvim_buf_is_loaded( buffer ) then watch( buffer ) end end --[[ COMMANDS ]] vim.api.nvim_create_user_command( 'DiffDisk', function () local filetype = vim.bo.filetype vim.cmd 'vertical new' vim.opt_local.buftype = 'nofile' vim.opt_local.bufhidden = 'wipe' vim.opt_local.swapfile = false vim.cmd 'read ++edit #' vim.cmd '0d_' vim.bo.filetype = filetype vim.cmd 'diffthis' vim.cmd 'wincmd p' vim.cmd 'diffthis' end, { desc = 'diff the current buffer against the file on disk', } )