fix: handle concurrent updates from multiple sessions

This commit is contained in:
2026-06-03 19:56:58 +00:00
parent 106641748c
commit 9a6b0e1dd8
3 changed files with 322 additions and 125 deletions
+4 -6
View File
@@ -1,7 +1,5 @@
# loc.nvim # loc.nvim
> Entirely GPT 5.5 generated
A tiny Neovim plugin that tracks characters added and deleted while you are in A tiny Neovim plugin that tracks characters added and deleted while you are in
insert mode, then displays estimated lines of code by dividing characters by 35. insert mode, then displays estimated lines of code by dividing characters by 35.
@@ -82,9 +80,8 @@ Defaults:
```lua ```lua
require("loc").setup({ require("loc").setup({
auto_enable = true, auto_enable = true,
persist = true,
data_path = nil, data_path = nil,
save_delay_ms = 1000, flush_interval_ms = 10000,
statusline_prefix = "LOC", statusline_prefix = "LOC",
}) })
``` ```
@@ -106,8 +103,7 @@ Stats are stored by local date:
} }
``` ```
With `persist = false`, daily counters stay in memory only for the current Local edits are applied immediately in the UI, then flushed into `stats.json` every `flush_interval_ms`.
Neovim process.
## API ## API
@@ -121,6 +117,8 @@ require("loc").stats()
require("loc").metrics() require("loc").metrics()
``` ```
`save()` flushes this process's pending deltas immediately.
`stats()` returns today's counters: `stats()` returns today's counters:
```lua ```lua
+302 -113
View File
@@ -2,22 +2,19 @@ local M = {}
local defaults = { local defaults = {
auto_enable = true, auto_enable = true,
persist = true,
data_path = nil, data_path = nil,
save_delay_ms = 1000, flush_interval_ms = 10000,
statusline_prefix = "LOC", statusline_prefix = "LOC",
} }
local state = { local state = {
config = nil, config = nil,
loaded = false,
loaded_path = nil,
enabled = false, enabled = false,
augroup = nil, augroup = nil,
snapshots = {}, snapshots = {},
save_pending = false, base_days = {},
dirty = false, pending_days = {},
days = {}, flush_timer = nil,
metrics_render = 0, metrics_render = 0,
} }
@@ -50,6 +47,9 @@ local metrics_cell_width = 7
local metrics_cell_height = 3 local metrics_cell_height = 3
local metrics_column_gap = 2 local metrics_column_gap = 2
local metrics_row_gap = 1 local metrics_row_gap = 1
local uv = vim.uv or vim.loop
local lock_retry_count = 40
local lock_retry_delay_ms = 50
local function data_path() local function data_path()
return (state.config and state.config.data_path) return (state.config and state.config.data_path)
@@ -63,9 +63,194 @@ local function today_key()
return vim.fn.strftime("%Y-%m-%d") return vim.fn.strftime("%Y-%m-%d")
end end
local function day_stats(date) local function redraw_statusline()
state.days[date] = state.days[date] or { added = 0, deleted = 0 } vim.schedule(function()
return state.days[date] vim.cmd.redrawstatus()
end)
end
local function copy_days(days)
local copy = {}
for date, stats in pairs(days or {}) do
if type(date) == "string" and type(stats) == "table" then
copy[date] = {
added = tonumber(stats.added) or 0,
deleted = tonumber(stats.deleted) or 0,
}
end
end
return copy
end
local function ensure_day(days, date)
days[date] = days[date] or { added = 0, deleted = 0 }
return days[date]
end
local function merged_day_stats(date)
local base = state.base_days[date]
local pending = state.pending_days[date]
local added = (tonumber(base and base.added) or 0) + (tonumber(pending and pending.added) or 0)
local deleted = (tonumber(base and base.deleted) or 0) + (tonumber(pending and pending.deleted) or 0)
return {
added = added,
deleted = deleted,
net = added - deleted,
}
end
local function has_pending_days()
return next(state.pending_days) ~= nil
end
local function merge_days(base_days, pending_days)
local merged = copy_days(base_days)
for date, stats in pairs(pending_days or {}) do
local day = ensure_day(merged, date)
day.added = day.added + (tonumber(stats.added) or 0)
day.deleted = day.deleted + (tonumber(stats.deleted) or 0)
end
return merged
end
local function stats_lock_path(path)
return path .. ".lock"
end
local function lock_info_path(lock_path)
return lock_path .. "/info.json"
end
local function stale_lock_age_seconds()
local interval = (state.config and state.config.flush_interval_ms) or defaults.flush_interval_ms
return math.max(math.ceil(interval / 1000) * 6, 30)
end
local function normalize_days(days)
return copy_days(days)
end
local function read_days_from_disk(path)
if vim.fn.filereadable(path) ~= 1 then
return {}
end
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
end)
if not ok or type(parsed) ~= "table" then
return {}
end
return normalize_days(parsed)
end
local function reload_base_days()
state.base_days = read_days_from_disk(data_path())
end
local function write_days_to_disk(path, days)
local dir = vim.fn.fnamemodify(path, ":h")
local temp_path = string.format("%s.tmp.%d.%d", path, vim.fn.getpid(), os.time())
local payload = json_encode(days)
vim.fn.mkdir(dir, "p")
vim.fn.writefile({ payload }, temp_path)
local ok, err = uv.fs_rename(temp_path, path)
if not ok then
vim.fn.delete(temp_path)
error(err or "failed to rename stats file")
end
end
local function read_lock_timestamp(lock_path)
local info_path = lock_info_path(lock_path)
if vim.fn.filereadable(info_path) == 1 then
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(info_path), "\n"))
end)
if ok and type(parsed) == "table" and type(parsed.timestamp) == "number" then
return parsed.timestamp
end
end
local stat = uv.fs_stat(lock_path)
if stat and stat.mtime and type(stat.mtime.sec) == "number" then
return stat.mtime.sec
end
return nil
end
local function lock_is_stale(lock_path)
local timestamp = read_lock_timestamp(lock_path)
if not timestamp then
return false
end
return os.difftime(os.time(), timestamp) >= stale_lock_age_seconds()
end
local function release_lock(lock_path)
pcall(vim.fn.delete, lock_path, "rf")
end
local function write_lock_metadata(lock_path)
local metadata = {
pid = vim.fn.getpid(),
timestamp = os.time(),
}
pcall(vim.fn.writefile, { json_encode(metadata) }, lock_info_path(lock_path))
end
local function with_stats_lock(fn)
local path = data_path()
local dir = vim.fn.fnamemodify(path, ":h")
local lock_path = stats_lock_path(path)
vim.fn.mkdir(dir, "p")
for _ = 1, lock_retry_count do
local ok, created = pcall(vim.fn.mkdir, lock_path)
if ok and created == 1 then
write_lock_metadata(lock_path)
local success, result, extra = xpcall(function()
return fn(path)
end, debug.traceback)
release_lock(lock_path)
if not success then
return false, result
end
return true, result, extra
end
if lock_is_stale(lock_path) then
release_lock(lock_path)
else
vim.wait(lock_retry_delay_ms, function() return false end)
end
end
return false, string.format("failed to acquire stats lock: %s", lock_path)
end end
local function char_count(value) local function char_count(value)
@@ -158,46 +343,11 @@ local function apply_change(added, deleted)
return return
end end
local day = day_stats(today_key()) local day = ensure_day(state.pending_days, today_key())
day.added = day.added + added day.added = day.added + added
day.deleted = day.deleted + deleted day.deleted = day.deleted + deleted
state.dirty = true redraw_statusline()
vim.schedule(function()
vim.cmd.redrawstatus()
end)
end
local function save_now()
if not state.config or not state.config.persist or not state.dirty then
return
end
local path = data_path()
local payload = json_encode(state.days)
local ok = pcall(function()
vim.fn.mkdir(vim.fn.fnamemodify(path, ":h"), "p")
vim.fn.writefile({ payload }, path)
end)
if ok then
state.dirty = false
end
end
local function schedule_save()
if not state.config or not state.config.persist or state.save_pending then
return
end
state.save_pending = true
vim.defer_fn(function()
state.save_pending = false
save_now()
end, state.config.save_delay_ms)
end end
local function update_snapshot(bufnr) local function update_snapshot(bufnr)
@@ -212,7 +362,6 @@ local function update_snapshot(bufnr)
if previous then if previous then
local added, deleted = measure_change(previous, current) local added, deleted = measure_change(previous, current)
apply_change(added, deleted) apply_change(added, deleted)
schedule_save()
end end
state.snapshots[bufnr] = current state.snapshots[bufnr] = current
@@ -331,20 +480,7 @@ local function metrics_display_date(epoch)
end end
local function stored_day_stats(date) local function stored_day_stats(date)
local day = state.days[date] return merged_day_stats(date)
if type(day) ~= "table" then
return { added = 0, deleted = 0, net = 0 }
end
local added = tonumber(day.added) or 0
local deleted = tonumber(day.deleted) or 0
return {
added = added,
deleted = deleted,
net = added - deleted,
}
end end
local function display_day_stats(stats) local function display_day_stats(stats)
@@ -600,47 +736,77 @@ local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net)
end end
end end
local function load_stats() local function flush_pending_now()
local path = data_path() if not state.config or not has_pending_days() then
return true
if state.loaded and state.loaded_path == path then
return
end end
if not state.config.persist then local flushed = copy_days(state.pending_days)
state.days = {} local ok, result = with_stats_lock(function(path)
state.loaded = true local merged = merge_days(read_days_from_disk(path), flushed)
state.loaded_path = path
return
end
if vim.fn.filereadable(path) ~= 1 then write_days_to_disk(path, merged)
state.days = {} return merged
state.loaded = true
state.loaded_path = path
return
end
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
end) end)
state.days = {} if not ok then
state.dirty = false return false, result
if ok and type(parsed) == "table" then
for date, stats in pairs(parsed) do
if type(date) == "string" and type(stats) == "table" then
state.days[date] = {
added = tonumber(stats.added) or 0,
deleted = tonumber(stats.deleted) or 0,
}
end
end
end end
state.loaded = true reload_base_days()
state.loaded_path = path state.pending_days = {}
redraw_statusline()
return true, result
end
local function periodic_sync()
if not state.config then
return
end
if has_pending_days() then
local ok = flush_pending_now()
if not ok then
return
end
return
end
reload_base_days()
redraw_statusline()
end
local function stop_flush_timer()
if not state.flush_timer then
return
end
pcall(function()
state.flush_timer:stop()
end)
if not state.flush_timer:is_closing() then
state.flush_timer:close()
end
state.flush_timer = nil
end
local function start_flush_timer()
stop_flush_timer()
if not state.enabled or not state.config then
return
end
state.flush_timer = assert(uv.new_timer())
state.flush_timer:start(
state.config.flush_interval_ms,
state.config.flush_interval_ms,
vim.schedule_wrap(periodic_sync)
)
end end
local function ensure_setup() local function ensure_setup()
@@ -653,15 +819,27 @@ end
function M.setup(opts) function M.setup(opts)
opts = opts or {} opts = opts or {}
local was_enabled = state.enabled
if state.config then
flush_pending_now()
stop_flush_timer()
end
state.config = vim.tbl_deep_extend("force", defaults, opts) state.config = vim.tbl_deep_extend("force", defaults, opts)
state.config.flush_interval_ms = math.max(1, tonumber(state.config.flush_interval_ms) or defaults.flush_interval_ms)
if opts.data_path == nil then if opts.data_path == nil then
state.config.data_path = nil state.config.data_path = nil
end end
load_stats() state.base_days = {}
state.pending_days = {}
reload_base_days()
if state.config.auto_enable then if was_enabled then
start_flush_timer()
elseif state.config.auto_enable then
M.enable() M.enable()
end end
@@ -697,9 +875,17 @@ function M.enable()
callback = function(args) callback = function(args)
update_snapshot(args.buf) update_snapshot(args.buf)
state.snapshots[args.buf] = nil state.snapshots[args.buf] = nil
save_now()
end, end,
}) })
vim.api.nvim_create_autocmd("VimLeavePre", {
group = state.augroup,
callback = function()
flush_pending_now()
end,
})
start_flush_timer()
end end
function M.disable() function M.disable()
@@ -707,6 +893,8 @@ function M.disable()
return return
end end
stop_flush_timer()
if state.augroup then if state.augroup then
pcall(vim.api.nvim_del_augroup_by_id, state.augroup) pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
end end
@@ -714,31 +902,32 @@ function M.disable()
state.enabled = false state.enabled = false
state.augroup = nil state.augroup = nil
state.snapshots = {} state.snapshots = {}
save_now() flush_pending_now()
end end
function M.reset() function M.reset()
ensure_setup() ensure_setup()
local day = day_stats(today_key()) local today = today_key()
local ok = with_stats_lock(function(path)
local days = read_days_from_disk(path)
days[today] = { added = 0, deleted = 0 }
write_days_to_disk(path, days)
end)
if ok then
reload_base_days()
state.pending_days = {}
end
day.added = 0
day.deleted = 0
state.dirty = true
save_now()
vim.cmd.redrawstatus() vim.cmd.redrawstatus()
end end
function M.stats() function M.stats()
ensure_setup() ensure_setup()
local day = day_stats(today_key()) return merged_day_stats(today_key())
return {
added = day.added,
deleted = day.deleted,
net = day.added - day.deleted,
}
end end
function M.display_stats() function M.display_stats()
@@ -784,7 +973,7 @@ end
function M.save() function M.save()
ensure_setup() ensure_setup()
save_now() flush_pending_now()
end end
M._measure_change = measure_change M._measure_change = measure_change
+16 -6
View File
@@ -63,7 +63,7 @@ write_json(daily_path, {
[old_day] = { added = 4, deleted = 1 }, [old_day] = { added = 4, deleted = 1 },
}) })
loc.setup({ auto_enable = false, data_path = daily_path, save_delay_ms = 1 }) loc.setup({ auto_enable = false, data_path = daily_path, flush_interval_ms = 10000 })
local loaded_stats = loc.stats() local loaded_stats = loc.stats()
assert_equal(loaded_stats.added, 7, "loaded daily added") assert_equal(loaded_stats.added, 7, "loaded daily added")
@@ -85,7 +85,7 @@ assert_equal(daily[old_day].added, 4, "reset preserves old day added")
assert_equal(daily[old_day].deleted, 1, "reset preserves old day deleted") assert_equal(daily[old_day].deleted, 1, "reset preserves old day deleted")
local change_path = vim.fn.tempname() local change_path = vim.fn.tempname()
loc.setup({ auto_enable = false, data_path = change_path, save_delay_ms = 1 }) loc.setup({ auto_enable = false, data_path = change_path, flush_interval_ms = 10000 })
loc.enable() loc.enable()
local bufnr = vim.api.nvim_create_buf(false, true) local bufnr = vim.api.nvim_create_buf(false, true)
@@ -161,7 +161,7 @@ write_json(metrics_path, {
["2026-05-10"] = { added = 350, deleted = 0 }, ["2026-05-10"] = { added = 350, deleted = 0 },
}) })
loc.setup({ auto_enable = false, data_path = metrics_path, save_delay_ms = 1 }) loc.setup({ auto_enable = false, data_path = metrics_path, flush_interval_ms = 10000 })
local entries, column_count, max_abs_net, total_net = loc._metrics_entries(metrics_epoch) local entries, column_count, max_abs_net, total_net = loc._metrics_entries(metrics_epoch)
@@ -181,6 +181,16 @@ assert_equal(entries[28].loc_net, 0, "metrics future date loc net is zero")
assert_equal(max_abs_net, 10, "metrics max abs net") assert_equal(max_abs_net, 10, "metrics max abs net")
assert_equal(total_net, 14, "metrics total net") assert_equal(total_net, 14, "metrics total net")
local render_path = vim.fn.tempname()
local render_dates = loc._metrics_dates()
write_json(render_path, {
[render_dates[1].date] = { added = 245, deleted = 0 },
[render_dates[10].date] = { added = 0, deleted = 105 },
[today] = { added = 350, deleted = 0 },
})
loc.setup({ auto_enable = false, data_path = render_path, flush_interval_ms = 10000 })
local render = loc.metrics() local render = loc.metrics()
assert_equal(vim.api.nvim_win_is_valid(render.winid), true, "metrics window is valid") assert_equal(vim.api.nvim_win_is_valid(render.winid), true, "metrics window is valid")
assert_equal(vim.api.nvim_buf_is_valid(render.bufnr), true, "metrics buffer is valid") assert_equal(vim.api.nvim_buf_is_valid(render.bufnr), true, "metrics buffer is valid")
@@ -192,9 +202,9 @@ assert_equal(metrics_lines[2]:match("LOC metrics") ~= nil, true, "metrics title"
assert_equal(metrics_lines[#metrics_lines - 1]:match("4%-week net: %+14") ~= nil, true, "metrics summary") assert_equal(metrics_lines[#metrics_lines - 1]:match("4%-week net: %+14") ~= nil, true, "metrics summary")
assert_equal(#metrics_lines, 21, "metrics renders transposed three-line week rows with row gaps and outer padding") assert_equal(#metrics_lines, 21, "metrics renders transposed three-line week rows with row gaps and outer padding")
assert_equal(rendered:match("%+7") ~= nil, true, "metrics renders net value") assert_equal(rendered:match("%+7") ~= nil, true, "metrics renders net value")
assert_equal(rendered:match("04/19") ~= nil, true, "metrics renders date label") assert_equal(rendered:match(loc._metrics_display_date(render_dates[1].epoch)) ~= nil, true, "metrics renders date label")
assert_equal(rendered:match("04/20") ~= nil, true, "metrics renders zero tile date label") assert_equal(rendered:match(loc._metrics_display_date(render_dates[2].epoch)) ~= nil, true, "metrics renders zero tile date label")
assert_equal(rendered:match("05/16") ~= nil, true, "metrics renders future date label") assert_equal(rendered:match(loc._metrics_display_date(render_dates[28].epoch)) ~= nil, true, "metrics renders future date label")
assert_equal(rendered:match("Sun") == nil, true, "metrics omits weekday labels") assert_equal(rendered:match("Sun") == nil, true, "metrics omits weekday labels")
assert_equal(vim.wo[render.winid].winhl:match("LocMetricsNormal") ~= nil, true, "metrics window uses custom normal highlight") assert_equal(vim.wo[render.winid].winhl:match("LocMetricsNormal") ~= nil, true, "metrics window uses custom normal highlight")
assert_equal(vim.wo[render.winid].winhl:match("FloatBorder") == nil, true, "metrics window does not set a border highlight") assert_equal(vim.wo[render.winid].winhl:match("FloatBorder") == nil, true, "metrics window does not set a border highlight")