Skip to content

Commit 86a8ff5

Browse files
committed
fix: send better the heartbeat of buffer actions
1 parent 82f8490 commit 86a8ff5

File tree

1 file changed

+228
-102
lines changed

1 file changed

+228
-102
lines changed

lua/codeme/tracker.lua

Lines changed: 228 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
11
-- CodeMe Tracker
2-
--
3-
-- Key principles:
4-
-- 1. On SAVE: Calculate git diff DELTA (changes since last save), send only new changes
5-
-- - Git-tracked files: Track line count delta using git diff
6-
-- - Non-git files: Track activity (time) but not line counts (can't calculate without git)
7-
-- 2. On OPEN: Send heartbeat with file info (backend logs activity time)
8-
-- 3. Use 2-min cooldown to prevent spam on file opens
9-
-- 4. Track last git diff per file to avoid counting same changes multiple times
102

113
local M = {}
124

13-
-- Last heartbeat time per file
14-
local last_heartbeat = {}
15-
-- Last git diff value per file (for delta tracking)
16-
local last_git_diff = {}
17-
local COOLDOWN_SECONDS = 120
5+
local last_heartbeat_time = {}
6+
local last_git_diff_lines = {}
7+
local last_non_git_save_time = {}
8+
local active_file = nil
9+
local presence_timer = nil
10+
local PRESENCE_INTERVAL = 120
11+
local COOLDOWN_SAME_FILE = 60
12+
local NON_GIT_COOLDOWN = 60
13+
14+
local function should_track(bufnr, filepath)
15+
if filepath == "" or not vim.api.nvim_buf_is_valid(bufnr) then
16+
return false
17+
end
18+
19+
if vim.bo[bufnr].buftype ~= "" then
20+
return false
21+
end
22+
23+
local filetype = vim.bo[bufnr].filetype
24+
local skip_fts = { "NvimTree", "neo-tree", "dashboard", "help", "qf", "TelescopePrompt", "oil", "noice", "notify" }
25+
26+
if vim.tbl_contains(skip_fts, filetype) then
27+
return false
28+
end
29+
30+
return true
31+
end
1832

19-
--- Calculate git diff for file (returns lines changed, or nil if not in git)
2033
local function get_git_lines_changed(filepath)
2134
local dir = vim.fn.fnamemodify(filepath, ":h")
2235
local filename = vim.fn.fnamemodify(filepath, ":t")
@@ -43,100 +56,31 @@ local function get_git_lines_changed(filepath)
4356
return nil
4457
end
4558

46-
--- Send heartbeat
47-
function M.send_heartbeat(opts)
59+
--- Send heartbeat to backend
60+
local function send_to_backend(filepath, lines_changed, opts)
4861
opts = opts or {}
4962

5063
local bufnr = vim.api.nvim_get_current_buf()
51-
local filepath = vim.fn.expand("%:p")
52-
53-
-- Validation
54-
if filepath == "" or not vim.api.nvim_buf_is_valid(bufnr) then
55-
return
56-
end
57-
58-
if vim.bo[bufnr].buftype ~= "" then
59-
return
60-
end
61-
62-
local filetype = vim.bo[bufnr].filetype
63-
local skip_fts = { "NvimTree", "neo-tree", "dashboard", "help", "qf", "TelescopePrompt", "oil", "noice", "notify" }
64-
if vim.tbl_contains(skip_fts, filetype) then
64+
if not vim.api.nvim_buf_is_valid(bufnr) then
6565
return
6666
end
6767

68-
-- Cooldown check (skip for save events)
69-
local now = os.time()
70-
local is_save = opts.is_save or false
71-
72-
if not is_save then
73-
local last_time = last_heartbeat[filepath]
74-
if last_time and (now - last_time) < COOLDOWN_SECONDS then
75-
return -- Too soon
76-
end
77-
end
78-
79-
-- Update heartbeat time
80-
last_heartbeat[filepath] = now
81-
82-
-- Calculate lines changed (DELTA, not total)
83-
local lines_changed = 0
84-
if is_save then
85-
local current_diff = get_git_lines_changed(filepath)
86-
87-
if current_diff ~= nil then
88-
-- File is git-tracked, use delta tracking
89-
local last_diff = last_git_diff[filepath] or 0
90-
local delta = current_diff - last_diff
91-
92-
-- Only track positive deltas (new changes)
93-
if delta > 0 then
94-
lines_changed = delta
95-
last_git_diff[filepath] = current_diff
96-
elseif current_diff == 0 and last_diff > 0 then
97-
-- File was committed (git diff is now 0)
98-
-- Reset tracking but don't send a heartbeat
99-
last_git_diff[filepath] = 0
100-
return
101-
else
102-
-- No new changes since last save (delta <= 0)
103-
return
104-
end
105-
else
106-
-- File is NOT git-tracked (non-git file or new untracked file)
107-
-- For non-git files, we can't use git diff, so we track file modifications
108-
-- using Neovim's modified flag and a simple change detection
109-
110-
-- Check if this is the first save or if file was modified
111-
local last_modified = last_git_diff[filepath]
112-
113-
if not last_modified then
114-
-- First save of this session, count it as activity but don't track lines
115-
-- (we don't know what changed without git)
116-
lines_changed = 0
117-
last_git_diff[filepath] = now -- Use timestamp instead of line count
118-
elseif (now - last_modified) > 60 then
119-
-- More than 1 minute since last save, count as new activity
120-
lines_changed = 0
121-
last_git_diff[filepath] = now
122-
else
123-
-- Saved recently, skip to avoid spam
124-
return
125-
end
126-
end
68+
local line_count = vim.api.nvim_buf_line_count(bufnr)
69+
local language = vim.bo[bufnr].filetype
70+
if language == "" then
71+
language = "unknown"
12772
end
12873

129-
local line_count = vim.api.nvim_buf_line_count(bufnr)
130-
local language = filetype ~= "" and filetype or "unknown"
74+
local heartbeat_type = opts.heartbeat_type or "presence"
13175

132-
-- Send to backend
13376
local cmd = string.format(
134-
"%s track --file %s --lang %s --lines %d --total %d",
77+
"%s track --file %s --lang %s --lines %d --total %d --type %s",
13578
opts.codeme_bin or "codeme",
13679
vim.fn.shellescape(filepath),
13780
language,
13881
lines_changed,
139-
line_count
82+
line_count,
83+
heartbeat_type
14084
)
14185

14286
vim.fn.jobstart(cmd, {
@@ -145,29 +89,211 @@ function M.send_heartbeat(opts)
14589
if opts.verbose then
14690
if code == 0 then
14791
vim.notify(
148-
string.format("CodeMe: ✓ %s (%d lines)", vim.fn.fnamemodify(filepath, ":t"), lines_changed),
149-
vim.log.levels.INFO
92+
string.format(
93+
"codeme: ✓ %s (%d lines) [%s]",
94+
vim.fn.fnamemodify(filepath, ":t"),
95+
lines_changed,
96+
heartbeat_type
97+
),
98+
vim.log.levels.info
15099
)
151100
else
152101
vim.notify(
153-
string.format("CodeMe: ✗ Failed to track %s", vim.fn.fnamemodify(filepath, ":t")),
154-
vim.log.levels.WARN
102+
string.format("codeme: ✗ failed to track %s", vim.fn.fnamemodify(filepath, ":t")),
103+
vim.log.levels.warn
155104
)
156105
end
157106
end
158107
end,
159108
})
160109
end
161110

111+
local function calculate_lines_changed(filepath)
112+
local current_diff = get_git_lines_changed(filepath)
113+
114+
if current_diff ~= nil then
115+
-- GIT-TRACKED FILE
116+
local last_diff = last_git_diff_lines[filepath] or 0
117+
local delta = current_diff - last_diff
118+
119+
-- Update baseline
120+
last_git_diff_lines[filepath] = current_diff
121+
122+
if delta > 0 then
123+
return delta, "save" -- productivity signal
124+
end
125+
126+
-- File was reset (commit, checkout, stash, etc.)
127+
-- Still send heartbeat with 0 lines to preserve time tracking
128+
if current_diff == 0 and last_diff > 0 then
129+
return 0, "save_reset" -- still counts as time spent
130+
end
131+
132+
-- No new changes since last save (delta <= 0)
133+
-- Don't send redundant heartbeat
134+
return nil, nil
135+
else
136+
-- NON-GIT-TRACKED FILE (with NON_GIT_COOLDOWN throttling)
137+
local now = os.time()
138+
local last_save = last_non_git_save_time[filepath]
139+
140+
if not last_save then
141+
-- First save of this non-git file
142+
last_non_git_save_time[filepath] = now
143+
return 0, "save_new" -- new file, track time only
144+
elseif (now - last_save) < NON_GIT_COOLDOWN then
145+
-- Too soon since last save, skip to prevent spam
146+
-- This implements the NON_GIT_COOLDOWN throttling
147+
return nil, nil
148+
else
149+
-- Enough time passed, send heartbeat and reset timer
150+
last_non_git_save_time[filepath] = now
151+
return 0, "save_untracked" -- untracked file, time only
152+
end
153+
end
154+
end
155+
156+
function M.send_heartbeat(opts)
157+
opts = opts or {}
158+
159+
local bufnr = vim.api.nvim_get_current_buf()
160+
local filepath = vim.fn.expand("%:p")
161+
162+
if not should_track(bufnr, filepath) then
163+
return
164+
end
165+
166+
local now = os.time()
167+
local is_save = opts.is_save or false
168+
169+
if not is_save and not opts.is_periodic then
170+
local last_time = last_heartbeat_time[filepath]
171+
if last_time and (now - last_time) < COOLDOWN_SAME_FILE then
172+
return
173+
end
174+
end
175+
176+
local lines_changed = 0
177+
local heartbeat_type = "presence" -- default
178+
179+
if is_save then
180+
local delta, hb_type = calculate_lines_changed(filepath)
181+
if delta == nil then
182+
-- No new changes, skip this heartbeat
183+
return
184+
end
185+
lines_changed = delta
186+
heartbeat_type = hb_type or "save"
187+
else
188+
lines_changed = 0
189+
heartbeat_type = opts.heartbeat_type or "presence"
190+
if opts.is_periodic then
191+
heartbeat_type = "periodic"
192+
end
193+
end
194+
195+
send_to_backend(filepath, lines_changed, {
196+
codeme_bin = opts.codeme_bin,
197+
verbose = opts.verbose,
198+
heartbeat_type = heartbeat_type,
199+
})
200+
201+
last_heartbeat_time[filepath] = now
202+
end
203+
204+
local function start_periodic_heartbeat()
205+
-- Stop existing timer if any
206+
if presence_timer then
207+
vim.fn.timer_stop(presence_timer)
208+
end
209+
210+
-- Create new timer: fire every PRESENCE_INTERVAL seconds
211+
presence_timer = vim.fn.timer_start(PRESENCE_INTERVAL * 1000, function()
212+
if active_file and active_file ~= "" then
213+
vim.api.nvim_set_current_buf(vim.fn.bufnr(active_file))
214+
-- Send periodic heartbeat while user is active
215+
M.send_heartbeat({
216+
is_save = false,
217+
is_periodic = true,
218+
})
219+
end
220+
end, { repeats = -1 }) -- Repeat forever
221+
end
222+
223+
--- Wire into BufEnter: file opened or switched
224+
vim.api.nvim_create_autocmd("BufEnter", {
225+
group = vim.api.nvim_create_augroup("CodeMeTracker", { clear = true }),
226+
callback = function()
227+
local bufnr = vim.api.nvim_get_current_buf()
228+
local filepath = vim.fn.expand("%:p")
229+
230+
if should_track(bufnr, filepath) then
231+
-- Track active file for periodic heartbeats
232+
active_file = filepath
233+
234+
-- Send immediate presence heartbeat on file change (Layer 1)
235+
M.send_heartbeat({
236+
is_save = false,
237+
})
238+
239+
-- Start/restart periodic timer
240+
start_periodic_heartbeat()
241+
end
242+
end,
243+
})
244+
245+
--- Wire into BufWritePost: file saved
246+
vim.api.nvim_create_autocmd("BufWritePost", {
247+
group = vim.api.nvim_create_augroup("CodeMeTracker", { clear = true }),
248+
callback = function()
249+
local bufnr = vim.api.nvim_get_current_buf()
250+
local filepath = vim.fn.expand("%:p")
251+
252+
if should_track(bufnr, filepath) then
253+
-- Send save heartbeat with productivity metadata (Layer 3)
254+
M.send_heartbeat({
255+
is_save = true,
256+
})
257+
end
258+
end,
259+
})
260+
261+
--- Wire into BufLeave: user switched away
262+
vim.api.nvim_create_autocmd("BufLeave", {
263+
group = vim.api.nvim_create_augroup("CodeMeTracker", { clear = true }),
264+
callback = function()
265+
if presence_timer then
266+
vim.fn.timer_stop(presence_timer)
267+
presence_timer = nil
268+
end
269+
active_file = nil
270+
end,
271+
})
272+
162273
function M.clear_state()
163-
last_heartbeat = {}
164-
last_git_diff = {}
274+
last_heartbeat_time = {}
275+
last_git_diff_lines = {}
276+
last_non_git_save_time = {}
277+
active_file = nil
278+
279+
if presence_timer then
280+
vim.fn.timer_stop(presence_timer)
281+
presence_timer = nil
282+
end
165283
end
166284

285+
--- Get current tracking state (for debugging)
167286
function M.get_state()
168287
return {
169-
heartbeats = vim.deepcopy(last_heartbeat),
170-
git_diffs = vim.deepcopy(last_git_diff),
288+
last_heartbeat_time = vim.deepcopy(last_heartbeat_time),
289+
last_git_diff_lines = vim.deepcopy(last_git_diff_lines),
290+
last_non_git_save_time = vim.deepcopy(last_non_git_save_time),
291+
active_file = active_file,
292+
config = {
293+
PRESENCE_INTERVAL = PRESENCE_INTERVAL,
294+
COOLDOWN_SAME_FILE = COOLDOWN_SAME_FILE,
295+
NON_GIT_COOLDOWN = NON_GIT_COOLDOWN,
296+
},
171297
}
172298
end
173299

0 commit comments

Comments
 (0)