991 lines
24 KiB
Lua
991 lines
24 KiB
Lua
local M = {}
|
|
|
|
local defaults = {
|
|
auto_enable = true,
|
|
data_path = nil,
|
|
flush_interval_ms = 10000,
|
|
statusline_prefix = "LOC",
|
|
}
|
|
|
|
local state = {
|
|
config = nil,
|
|
enabled = false,
|
|
augroup = nil,
|
|
snapshots = {},
|
|
base_days = {},
|
|
pending_days = {},
|
|
flush_timer = nil,
|
|
metrics_render = 0,
|
|
}
|
|
|
|
local metrics_color_stops = {
|
|
{ at = 0.00, color = "#f0b6c1" },
|
|
{ at = 0.35, color = "#eba1ae" },
|
|
{ at = 0.65, color = "#df617a" },
|
|
{ at = 1.00, color = "#b90f36" },
|
|
}
|
|
|
|
local metrics_zero_style = {
|
|
bg = "#252628",
|
|
value_fg = "#e5e39a",
|
|
date_fg = "#8f9096",
|
|
}
|
|
|
|
local metrics_low_text_style = {
|
|
value_fg = "#2b2529",
|
|
date_fg = "#58565b",
|
|
}
|
|
|
|
local metrics_high_text_style = {
|
|
value_fg = "#ffd6df",
|
|
date_fg = "#f0aebb",
|
|
}
|
|
|
|
local weekday_names = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }
|
|
local chars_per_loc = 35
|
|
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)
|
|
or table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/")
|
|
end
|
|
|
|
local json_encode = (vim.json and vim.json.encode) or vim.fn.json_encode
|
|
local json_decode = (vim.json and vim.json.decode) or vim.fn.json_decode
|
|
|
|
local function today_key()
|
|
return vim.fn.strftime("%Y-%m-%d")
|
|
end
|
|
|
|
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)
|
|
if value == "" then return 0 end
|
|
local ok, count = pcall(vim.fn.strchars, value)
|
|
return ok and count or #value
|
|
end
|
|
|
|
local function is_continuation_byte(byte)
|
|
return byte and byte >= 0x80 and byte <= 0xbf
|
|
end
|
|
|
|
local function has_char_boundary_at(value, byte_index)
|
|
return byte_index <= 1 or byte_index > #value or not is_continuation_byte(value:byte(byte_index))
|
|
end
|
|
|
|
local function has_char_boundary_after(value, byte_index)
|
|
return has_char_boundary_at(value, byte_index + 1)
|
|
end
|
|
|
|
local function common_prefix_bytes(old, new)
|
|
local max_len = math.min(#old, #new)
|
|
local prefix = 0
|
|
|
|
while prefix < max_len and old:byte(prefix + 1) == new:byte(prefix + 1) do
|
|
prefix = prefix + 1
|
|
end
|
|
|
|
while prefix > 0 and (not has_char_boundary_after(old, prefix) or not has_char_boundary_after(new, prefix)) do
|
|
prefix = prefix - 1
|
|
end
|
|
|
|
return prefix
|
|
end
|
|
|
|
local function common_suffix_bytes(old, new, prefix)
|
|
local old_len = #old
|
|
local new_len = #new
|
|
local suffix = 0
|
|
|
|
while old_len - suffix > prefix
|
|
and new_len - suffix > prefix
|
|
and old:byte(old_len - suffix) == new:byte(new_len - suffix)
|
|
do
|
|
suffix = suffix + 1
|
|
end
|
|
|
|
while suffix > 0 do
|
|
local old_start = old_len - suffix + 1
|
|
local new_start = new_len - suffix + 1
|
|
|
|
if has_char_boundary_at(old, old_start) and has_char_boundary_at(new, new_start) then
|
|
break
|
|
end
|
|
|
|
suffix = suffix - 1
|
|
end
|
|
|
|
return suffix
|
|
end
|
|
|
|
local function measure_change(old, new)
|
|
if old == new then
|
|
return 0, 0
|
|
end
|
|
|
|
local prefix = common_prefix_bytes(old, new)
|
|
local suffix = common_suffix_bytes(old, new, prefix)
|
|
local old_changed = old:sub(prefix + 1, #old - suffix)
|
|
local new_changed = new:sub(prefix + 1, #new - suffix)
|
|
|
|
return char_count(new_changed), char_count(old_changed)
|
|
end
|
|
|
|
local function snapshot(bufnr)
|
|
if not vim.api.nvim_buf_is_valid(bufnr) then
|
|
return nil
|
|
end
|
|
|
|
local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, true)
|
|
if not ok then
|
|
return nil
|
|
end
|
|
|
|
return table.concat(lines, "\n")
|
|
end
|
|
|
|
local function apply_change(added, deleted)
|
|
if added == 0 and deleted == 0 then
|
|
return
|
|
end
|
|
|
|
local day = ensure_day(state.pending_days, today_key())
|
|
|
|
day.added = day.added + added
|
|
day.deleted = day.deleted + deleted
|
|
redraw_statusline()
|
|
end
|
|
|
|
local function update_snapshot(bufnr)
|
|
local previous = state.snapshots[bufnr]
|
|
local current = snapshot(bufnr)
|
|
|
|
if not current then
|
|
state.snapshots[bufnr] = nil
|
|
return
|
|
end
|
|
|
|
if previous then
|
|
local added, deleted = measure_change(previous, current)
|
|
apply_change(added, deleted)
|
|
end
|
|
|
|
state.snapshots[bufnr] = current
|
|
end
|
|
|
|
local function clamp(value, min, max)
|
|
return math.max(min, math.min(max, value))
|
|
end
|
|
|
|
local function chars_to_loc(chars)
|
|
return (chars < 0 and -1 or 1) * math.floor((math.abs(chars) / chars_per_loc) + 0.5)
|
|
end
|
|
|
|
local function hex_to_rgb(color)
|
|
return {
|
|
r = tonumber(color:sub(2, 3), 16),
|
|
g = tonumber(color:sub(4, 5), 16),
|
|
b = tonumber(color:sub(6, 7), 16),
|
|
}
|
|
end
|
|
|
|
local function rgb_to_hex(rgb)
|
|
return string.format("#%02x%02x%02x", rgb.r, rgb.g, rgb.b)
|
|
end
|
|
|
|
local function lerp_number(start, finish, t)
|
|
return math.floor(start + ((finish - start) * t) + 0.5)
|
|
end
|
|
|
|
local function lerp_color(start, finish, t)
|
|
local from = hex_to_rgb(start)
|
|
local to = hex_to_rgb(finish)
|
|
|
|
return rgb_to_hex({
|
|
r = lerp_number(from.r, to.r, t),
|
|
g = lerp_number(from.g, to.g, t),
|
|
b = lerp_number(from.b, to.b, t),
|
|
})
|
|
end
|
|
|
|
local function metrics_color(t)
|
|
t = clamp(t or 0, 0, 1)
|
|
|
|
for index = 1, #metrics_color_stops - 1 do
|
|
local left = metrics_color_stops[index]
|
|
local right = metrics_color_stops[index + 1]
|
|
|
|
if t <= right.at then
|
|
local span = right.at - left.at
|
|
local local_t = span == 0 and 0 or (t - left.at) / span
|
|
|
|
return lerp_color(left.color, right.color, local_t)
|
|
end
|
|
end
|
|
|
|
return metrics_color_stops[#metrics_color_stops].color
|
|
end
|
|
|
|
local function metrics_tile_style(net, max_abs_net)
|
|
local abs_net = math.abs(net or 0)
|
|
|
|
if abs_net == 0 or max_abs_net == 0 then
|
|
return vim.tbl_extend("force", {}, metrics_zero_style)
|
|
end
|
|
|
|
local t = clamp(abs_net / max_abs_net, 0, 1)
|
|
local text_style = t >= 0.75 and metrics_high_text_style or metrics_low_text_style
|
|
|
|
return {
|
|
bg = metrics_color(t),
|
|
value_fg = text_style.value_fg,
|
|
date_fg = text_style.date_fg,
|
|
}
|
|
end
|
|
|
|
local function highlight_color(name, key)
|
|
local ok, highlight = pcall(vim.api.nvim_get_hl, 0, { name = name, link = false })
|
|
|
|
if not ok or type(highlight) ~= "table" or type(highlight[key]) ~= "number" then
|
|
return nil
|
|
end
|
|
|
|
return highlight[key]
|
|
end
|
|
|
|
local function resolve_metrics_background()
|
|
return {
|
|
bg = highlight_color("StatusLine", "bg") or highlight_color("Normal", "bg"),
|
|
fg = highlight_color("StatusLine", "fg") or highlight_color("Normal", "fg"),
|
|
}
|
|
end
|
|
|
|
local function metrics_dates(now)
|
|
now = now or vim.fn.localtime()
|
|
|
|
local dates = {}
|
|
local today_weekday = tonumber(vim.fn.strftime("%w", now))
|
|
local current_week_start = now - (today_weekday * 86400)
|
|
local range_start = current_week_start - (21 * 86400)
|
|
|
|
for offset = 0, 27 do
|
|
local epoch = range_start + (offset * 86400)
|
|
|
|
dates[#dates + 1] = {
|
|
date = vim.fn.strftime("%Y-%m-%d", epoch),
|
|
epoch = epoch,
|
|
weekday = tonumber(vim.fn.strftime("%w", epoch)),
|
|
}
|
|
end
|
|
|
|
return dates
|
|
end
|
|
|
|
local function metrics_display_date(epoch)
|
|
return vim.fn.strftime("%m/%d", epoch)
|
|
end
|
|
|
|
local function stored_day_stats(date)
|
|
return merged_day_stats(date)
|
|
end
|
|
|
|
local function display_day_stats(stats)
|
|
local loc_added = chars_to_loc(stats.added)
|
|
local loc_deleted = chars_to_loc(stats.deleted)
|
|
local loc_net = chars_to_loc(stats.net)
|
|
|
|
return loc_added, loc_deleted, loc_net
|
|
end
|
|
|
|
local function collect_metrics_entries(now)
|
|
local dates = metrics_dates(now)
|
|
local entries = {}
|
|
local max_abs_net = 0
|
|
local total_net = 0
|
|
|
|
for index, item in ipairs(dates) do
|
|
local stats = stored_day_stats(item.date)
|
|
local loc_added, loc_deleted, loc_net = display_day_stats(stats)
|
|
local loc_abs_net = math.abs(loc_net)
|
|
local entry = {
|
|
index = index,
|
|
date = item.date,
|
|
display_date = metrics_display_date(item.epoch),
|
|
weekday = item.weekday,
|
|
column = math.floor((index - 1) / 7) + 1,
|
|
added = stats.added,
|
|
deleted = stats.deleted,
|
|
net = stats.net,
|
|
abs_net = math.abs(stats.net),
|
|
loc_added = loc_added,
|
|
loc_deleted = loc_deleted,
|
|
loc_net = loc_net,
|
|
loc_abs_net = loc_abs_net,
|
|
}
|
|
|
|
max_abs_net = math.max(max_abs_net, loc_abs_net)
|
|
total_net = total_net + loc_net
|
|
entries[#entries + 1] = entry
|
|
end
|
|
|
|
return entries, entries[#entries].column, max_abs_net, total_net
|
|
end
|
|
|
|
local function max_display_width(lines)
|
|
local width = 0
|
|
|
|
for _, line in ipairs(lines) do
|
|
width = math.max(width, vim.fn.strdisplaywidth(line))
|
|
end
|
|
|
|
return width
|
|
end
|
|
|
|
local function centered_text(value, width)
|
|
local display_width = vim.fn.strdisplaywidth(value)
|
|
if display_width >= width then return value end
|
|
local left = math.floor((width - display_width) / 2)
|
|
return string.rep(" ", left) .. value .. string.rep(" ", width - display_width - left)
|
|
end
|
|
|
|
local function clamped_metric_value(value)
|
|
value = clamp(value, -9999, 9999)
|
|
return (value > 0 and "+" or "") .. value
|
|
end
|
|
|
|
local function build_metrics_lines(column_count, total_net)
|
|
local horizontal_padding = 4
|
|
local vertical_padding = 1
|
|
local lines = {
|
|
"LOC metrics",
|
|
"",
|
|
}
|
|
local cells = {}
|
|
|
|
for week = 1, column_count do
|
|
local row_lines = {}
|
|
local row_cells = {}
|
|
|
|
for _ = 1, metrics_cell_height do
|
|
row_lines[#row_lines + 1] = ""
|
|
end
|
|
|
|
for weekday = 0, 6 do
|
|
local start_col = #row_lines[1]
|
|
|
|
for row = 1, metrics_cell_height do
|
|
row_lines[row] = row_lines[row] .. string.rep(" ", metrics_cell_width)
|
|
end
|
|
|
|
row_cells[weekday] = {
|
|
line = #lines,
|
|
value_line = #lines + 1,
|
|
date_line = #lines + 2,
|
|
start_col = start_col,
|
|
end_col = #row_lines[1],
|
|
}
|
|
|
|
if weekday < 6 then
|
|
local gap = string.rep(" ", metrics_column_gap)
|
|
|
|
for row = 1, metrics_cell_height do
|
|
row_lines[row] = row_lines[row] .. gap
|
|
end
|
|
end
|
|
end
|
|
|
|
cells[week] = row_cells
|
|
|
|
for _, line in ipairs(row_lines) do
|
|
lines[#lines + 1] = line
|
|
end
|
|
|
|
if week < column_count then
|
|
for _ = 1, metrics_row_gap do
|
|
lines[#lines + 1] = ""
|
|
end
|
|
end
|
|
end
|
|
|
|
lines[#lines + 1] = ""
|
|
lines[#lines + 1] = string.format("4-week net: %+d", total_net)
|
|
|
|
local padded_lines = {}
|
|
|
|
for _ = 1, vertical_padding do
|
|
padded_lines[#padded_lines + 1] = ""
|
|
end
|
|
|
|
for _, line in ipairs(lines) do
|
|
padded_lines[#padded_lines + 1] = string.rep(" ", horizontal_padding) .. line .. string.rep(" ", horizontal_padding)
|
|
end
|
|
|
|
for _ = 1, vertical_padding do
|
|
padded_lines[#padded_lines + 1] = ""
|
|
end
|
|
|
|
for _, row_cells in pairs(cells) do
|
|
for _, cell in pairs(row_cells) do
|
|
cell.line = cell.line + vertical_padding
|
|
cell.value_line = cell.value_line + vertical_padding
|
|
cell.date_line = cell.date_line + vertical_padding
|
|
cell.start_col = cell.start_col + horizontal_padding
|
|
cell.end_col = cell.end_col + horizontal_padding
|
|
end
|
|
end
|
|
|
|
return padded_lines, cells
|
|
end
|
|
|
|
local function set_metrics_keymaps(bufnr, winid)
|
|
local function close()
|
|
if vim.api.nvim_win_is_valid(winid) then
|
|
vim.api.nvim_win_close(winid, true)
|
|
end
|
|
end
|
|
|
|
local opts = { buffer = bufnr, nowait = true, silent = true }
|
|
vim.keymap.set("n", "q", close, opts)
|
|
vim.keymap.set("n", "<Esc>", close, opts)
|
|
|
|
-- Disable scrolling and movement
|
|
local nop = function() end
|
|
local keys = {
|
|
"<PageUp>", "<PageDown>", "<ScrollWheelUp>", "<ScrollWheelDown>",
|
|
"<Up>", "<Down>", "<Left>", "<Right>", "k", "j", "h", "l",
|
|
"<C-f>", "<C-b>", "<C-d>", "<C-u>", "gg", "G"
|
|
}
|
|
|
|
for _, key in ipairs(keys) do
|
|
vim.keymap.set("n", key, nop, opts)
|
|
end
|
|
end
|
|
|
|
local function open_metrics_window(lines)
|
|
local width = max_display_width(lines)
|
|
local height = #lines
|
|
local columns = vim.o.columns
|
|
local rows = vim.o.lines
|
|
|
|
width = math.min(width, math.max(1, columns - 4))
|
|
height = math.min(height, math.max(1, rows - 4))
|
|
|
|
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
|
|
vim.bo[bufnr].buftype = "nofile"
|
|
vim.bo[bufnr].bufhidden = "wipe"
|
|
vim.bo[bufnr].swapfile = false
|
|
vim.bo[bufnr].filetype = "locmetrics"
|
|
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
vim.bo[bufnr].modifiable = false
|
|
|
|
local winid = vim.api.nvim_open_win(bufnr, true, {
|
|
relative = "editor",
|
|
width = width,
|
|
height = height,
|
|
row = math.max(0, math.floor((rows - height) / 2) - 1),
|
|
col = math.max(0, math.floor((columns - width) / 2)),
|
|
style = "minimal",
|
|
})
|
|
|
|
vim.wo[winid].wrap = false
|
|
vim.wo[winid].cursorline = false
|
|
vim.wo[winid].number = false
|
|
vim.wo[winid].relativenumber = false
|
|
vim.wo[winid].scrolloff = 0
|
|
vim.wo[winid].sidescrolloff = 0
|
|
set_metrics_keymaps(bufnr, winid)
|
|
|
|
return bufnr, winid
|
|
end
|
|
|
|
local function set_metrics_window_highlights(winid)
|
|
local normal_group = "LocMetricsNormal" .. state.metrics_render
|
|
vim.api.nvim_set_hl(0, normal_group, resolve_metrics_background())
|
|
vim.wo[winid].winhl = "Normal:" .. normal_group .. ",NormalFloat:" .. normal_group
|
|
end
|
|
|
|
local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net)
|
|
local namespace = vim.api.nvim_create_namespace("loc_metrics")
|
|
local title_group = "LocMetricsTitle" .. state.metrics_render
|
|
|
|
vim.api.nvim_set_hl(0, title_group, { italic = true })
|
|
vim.api.nvim_buf_add_highlight(bufnr, namespace, title_group, 1, 4, 15)
|
|
|
|
for _, entry in ipairs(entries) do
|
|
local style = metrics_tile_style(entry.loc_net, max_abs_net)
|
|
local group = string.format("LocMetricsCell%d_%02d", state.metrics_render, entry.index)
|
|
local date_group = string.format("LocMetricsDate%d_%02d", state.metrics_render, entry.index)
|
|
local cell = cells[entry.column][entry.weekday]
|
|
local date = centered_text(entry.display_date, metrics_cell_width)
|
|
|
|
vim.api.nvim_set_hl(0, group, { bg = style.bg, fg = style.value_fg })
|
|
vim.api.nvim_set_hl(0, date_group, { bg = style.bg, fg = style.date_fg, italic = true })
|
|
|
|
if entry.loc_net == 0 then
|
|
vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { date })
|
|
else
|
|
local value = centered_text(clamped_metric_value(entry.loc_net), metrics_cell_width)
|
|
|
|
vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { value })
|
|
vim.api.nvim_buf_set_text(bufnr, cell.date_line, cell.start_col, cell.date_line, cell.end_col, { date })
|
|
end
|
|
|
|
for row = 0, metrics_cell_height - 1 do
|
|
vim.api.nvim_buf_add_highlight(bufnr, namespace, group, cell.line + row, cell.start_col, cell.end_col)
|
|
end
|
|
|
|
local date_line = entry.loc_net == 0 and cell.value_line or cell.date_line
|
|
|
|
vim.api.nvim_buf_add_highlight(bufnr, namespace, date_group, date_line, cell.start_col, cell.end_col)
|
|
end
|
|
end
|
|
|
|
local function flush_pending_now()
|
|
if not state.config or not has_pending_days() then
|
|
return true
|
|
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)
|
|
|
|
write_days_to_disk(path, merged)
|
|
return merged
|
|
end)
|
|
|
|
if not ok then
|
|
return false, result
|
|
end
|
|
|
|
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()
|
|
if state.config then
|
|
return
|
|
end
|
|
|
|
M.setup({ auto_enable = false })
|
|
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
|
|
|
|
state.base_days = {}
|
|
state.pending_days = {}
|
|
reload_base_days()
|
|
|
|
if was_enabled then
|
|
start_flush_timer()
|
|
elseif state.config.auto_enable then
|
|
M.enable()
|
|
end
|
|
|
|
return M
|
|
end
|
|
|
|
function M.enable()
|
|
ensure_setup()
|
|
|
|
if state.enabled then
|
|
return
|
|
end
|
|
|
|
state.enabled = true
|
|
state.augroup = vim.api.nvim_create_augroup("loc_nvim_tracker", { clear = true })
|
|
|
|
vim.api.nvim_create_autocmd("InsertEnter", {
|
|
group = state.augroup,
|
|
callback = function(args)
|
|
state.snapshots[args.buf] = snapshot(args.buf)
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd("TextChangedI", {
|
|
group = state.augroup,
|
|
callback = function(args)
|
|
update_snapshot(args.buf)
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd("InsertLeave", {
|
|
group = state.augroup,
|
|
callback = function(args)
|
|
update_snapshot(args.buf)
|
|
state.snapshots[args.buf] = nil
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd("VimLeavePre", {
|
|
group = state.augroup,
|
|
callback = function()
|
|
flush_pending_now()
|
|
end,
|
|
})
|
|
|
|
start_flush_timer()
|
|
end
|
|
|
|
function M.disable()
|
|
if not state.enabled then
|
|
return
|
|
end
|
|
|
|
stop_flush_timer()
|
|
|
|
if state.augroup then
|
|
pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
|
|
end
|
|
|
|
state.enabled = false
|
|
state.augroup = nil
|
|
state.snapshots = {}
|
|
flush_pending_now()
|
|
end
|
|
|
|
function M.reset()
|
|
ensure_setup()
|
|
|
|
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
|
|
|
|
vim.cmd.redrawstatus()
|
|
end
|
|
|
|
function M.stats()
|
|
ensure_setup()
|
|
|
|
return merged_day_stats(today_key())
|
|
end
|
|
|
|
function M.display_stats()
|
|
local stats = M.stats()
|
|
local added, deleted, net = display_day_stats(stats)
|
|
|
|
return {
|
|
added = added,
|
|
deleted = deleted,
|
|
net = net,
|
|
}
|
|
end
|
|
|
|
function M.statusline()
|
|
local stats = M.display_stats()
|
|
local prefix = state.config.statusline_prefix
|
|
|
|
return string.format("%s %+d", prefix, stats.net)
|
|
end
|
|
|
|
function M.metrics()
|
|
ensure_setup()
|
|
|
|
local entries, column_count, max_abs_net, total_net = collect_metrics_entries()
|
|
local lines, cells = build_metrics_lines(column_count, total_net)
|
|
|
|
local bufnr, winid = open_metrics_window(lines)
|
|
|
|
state.metrics_render = state.metrics_render + 1
|
|
set_metrics_window_highlights(winid)
|
|
vim.bo[bufnr].modifiable = true
|
|
apply_metrics_highlights(bufnr, cells, entries, max_abs_net)
|
|
vim.bo[bufnr].modifiable = false
|
|
|
|
return {
|
|
bufnr = bufnr,
|
|
winid = winid,
|
|
entries = entries,
|
|
total_net = total_net,
|
|
max_abs_net = max_abs_net,
|
|
}
|
|
end
|
|
|
|
function M.save()
|
|
ensure_setup()
|
|
flush_pending_now()
|
|
end
|
|
|
|
M._measure_change = measure_change
|
|
M._metrics_color = metrics_color
|
|
M._metrics_dates = metrics_dates
|
|
M._metrics_entries = collect_metrics_entries
|
|
M._metrics_background = resolve_metrics_background
|
|
M._metrics_centered_text = centered_text
|
|
M._metrics_value = clamped_metric_value
|
|
M._metrics_tile_style = metrics_tile_style
|
|
M._metrics_display_date = metrics_display_date
|
|
M._chars_to_loc = chars_to_loc
|
|
|
|
return M
|