repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/lua/bugabinga/file_changed.lua
Raw-- 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<integer, string>
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<string, string>?
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<string, string> 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<string, { handle: uv.uv_fs_event_t, files: table<string, true> }>
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', } )