fix: handle concurrent updates from multiple sessions
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
# loc.nvim
|
||||
|
||||
> Entirely GPT 5.5 generated
|
||||
|
||||
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.
|
||||
|
||||
@@ -82,9 +80,8 @@ Defaults:
|
||||
```lua
|
||||
require("loc").setup({
|
||||
auto_enable = true,
|
||||
persist = true,
|
||||
data_path = nil,
|
||||
save_delay_ms = 1000,
|
||||
flush_interval_ms = 10000,
|
||||
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
|
||||
Neovim process.
|
||||
Local edits are applied immediately in the UI, then flushed into `stats.json` every `flush_interval_ms`.
|
||||
|
||||
## API
|
||||
|
||||
@@ -121,6 +117,8 @@ require("loc").stats()
|
||||
require("loc").metrics()
|
||||
```
|
||||
|
||||
`save()` flushes this process's pending deltas immediately.
|
||||
|
||||
`stats()` returns today's counters:
|
||||
|
||||
```lua
|
||||
|
||||
+302
-113
@@ -2,22 +2,19 @@ local M = {}
|
||||
|
||||
local defaults = {
|
||||
auto_enable = true,
|
||||
persist = true,
|
||||
data_path = nil,
|
||||
save_delay_ms = 1000,
|
||||
flush_interval_ms = 10000,
|
||||
statusline_prefix = "LOC",
|
||||
}
|
||||
|
||||
local state = {
|
||||
config = nil,
|
||||
loaded = false,
|
||||
loaded_path = nil,
|
||||
enabled = false,
|
||||
augroup = nil,
|
||||
snapshots = {},
|
||||
save_pending = false,
|
||||
dirty = false,
|
||||
days = {},
|
||||
base_days = {},
|
||||
pending_days = {},
|
||||
flush_timer = nil,
|
||||
metrics_render = 0,
|
||||
}
|
||||
|
||||
@@ -50,6 +47,9 @@ local metrics_cell_width = 7
|
||||
local metrics_cell_height = 3
|
||||
local metrics_column_gap = 2
|
||||
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()
|
||||
return (state.config and state.config.data_path)
|
||||
@@ -63,9 +63,194 @@ local function today_key()
|
||||
return vim.fn.strftime("%Y-%m-%d")
|
||||
end
|
||||
|
||||
local function day_stats(date)
|
||||
state.days[date] = state.days[date] or { added = 0, deleted = 0 }
|
||||
return state.days[date]
|
||||
local function redraw_statusline()
|
||||
vim.schedule(function()
|
||||
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
|
||||
|
||||
local function char_count(value)
|
||||
@@ -158,46 +343,11 @@ local function apply_change(added, deleted)
|
||||
return
|
||||
end
|
||||
|
||||
local day = day_stats(today_key())
|
||||
local day = ensure_day(state.pending_days, today_key())
|
||||
|
||||
day.added = day.added + added
|
||||
day.deleted = day.deleted + deleted
|
||||
state.dirty = true
|
||||
|
||||
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)
|
||||
redraw_statusline()
|
||||
end
|
||||
|
||||
local function update_snapshot(bufnr)
|
||||
@@ -212,7 +362,6 @@ local function update_snapshot(bufnr)
|
||||
if previous then
|
||||
local added, deleted = measure_change(previous, current)
|
||||
apply_change(added, deleted)
|
||||
schedule_save()
|
||||
end
|
||||
|
||||
state.snapshots[bufnr] = current
|
||||
@@ -331,20 +480,7 @@ local function metrics_display_date(epoch)
|
||||
end
|
||||
|
||||
local function stored_day_stats(date)
|
||||
local day = state.days[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,
|
||||
}
|
||||
return merged_day_stats(date)
|
||||
end
|
||||
|
||||
local function display_day_stats(stats)
|
||||
@@ -600,47 +736,77 @@ local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net)
|
||||
end
|
||||
end
|
||||
|
||||
local function load_stats()
|
||||
local path = data_path()
|
||||
|
||||
if state.loaded and state.loaded_path == path then
|
||||
return
|
||||
local function flush_pending_now()
|
||||
if not state.config or not has_pending_days() then
|
||||
return true
|
||||
end
|
||||
|
||||
if not state.config.persist then
|
||||
state.days = {}
|
||||
state.loaded = true
|
||||
state.loaded_path = path
|
||||
return
|
||||
end
|
||||
local flushed = copy_days(state.pending_days)
|
||||
local ok, result = with_stats_lock(function(path)
|
||||
local merged = merge_days(read_days_from_disk(path), flushed)
|
||||
|
||||
if vim.fn.filereadable(path) ~= 1 then
|
||||
state.days = {}
|
||||
state.loaded = true
|
||||
state.loaded_path = path
|
||||
return
|
||||
end
|
||||
|
||||
local ok, parsed = pcall(function()
|
||||
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
|
||||
write_days_to_disk(path, merged)
|
||||
return merged
|
||||
end)
|
||||
|
||||
state.days = {}
|
||||
state.dirty = false
|
||||
|
||||
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
|
||||
if not ok then
|
||||
return false, result
|
||||
end
|
||||
|
||||
state.loaded = true
|
||||
state.loaded_path = path
|
||||
reload_base_days()
|
||||
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
|
||||
|
||||
local function ensure_setup()
|
||||
@@ -653,15 +819,27 @@ end
|
||||
|
||||
function M.setup(opts)
|
||||
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.flush_interval_ms = math.max(1, tonumber(state.config.flush_interval_ms) or defaults.flush_interval_ms)
|
||||
|
||||
if opts.data_path == nil then
|
||||
state.config.data_path = nil
|
||||
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()
|
||||
end
|
||||
|
||||
@@ -697,9 +875,17 @@ function M.enable()
|
||||
callback = function(args)
|
||||
update_snapshot(args.buf)
|
||||
state.snapshots[args.buf] = nil
|
||||
save_now()
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = state.augroup,
|
||||
callback = function()
|
||||
flush_pending_now()
|
||||
end,
|
||||
})
|
||||
|
||||
start_flush_timer()
|
||||
end
|
||||
|
||||
function M.disable()
|
||||
@@ -707,6 +893,8 @@ function M.disable()
|
||||
return
|
||||
end
|
||||
|
||||
stop_flush_timer()
|
||||
|
||||
if state.augroup then
|
||||
pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
|
||||
end
|
||||
@@ -714,31 +902,32 @@ function M.disable()
|
||||
state.enabled = false
|
||||
state.augroup = nil
|
||||
state.snapshots = {}
|
||||
save_now()
|
||||
flush_pending_now()
|
||||
end
|
||||
|
||||
function M.reset()
|
||||
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()
|
||||
end
|
||||
|
||||
function M.stats()
|
||||
ensure_setup()
|
||||
|
||||
local day = day_stats(today_key())
|
||||
|
||||
return {
|
||||
added = day.added,
|
||||
deleted = day.deleted,
|
||||
net = day.added - day.deleted,
|
||||
}
|
||||
return merged_day_stats(today_key())
|
||||
end
|
||||
|
||||
function M.display_stats()
|
||||
@@ -784,7 +973,7 @@ end
|
||||
|
||||
function M.save()
|
||||
ensure_setup()
|
||||
save_now()
|
||||
flush_pending_now()
|
||||
end
|
||||
|
||||
M._measure_change = measure_change
|
||||
|
||||
+16
-6
@@ -63,7 +63,7 @@ write_json(daily_path, {
|
||||
[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()
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
@@ -161,7 +161,7 @@ write_json(metrics_path, {
|
||||
["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)
|
||||
|
||||
@@ -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(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()
|
||||
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")
|
||||
@@ -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, 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("04/19") ~= nil, true, "metrics renders date label")
|
||||
assert_equal(rendered:match("04/20") ~= 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[1].epoch)) ~= nil, true, "metrics renders 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(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(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")
|
||||
|
||||
Reference in New Issue
Block a user