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
113local 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+ -- Create augroup once (clear existing autocmds from previous loads)
6+ local augroup = vim .api .nvim_create_augroup (" CodeMeTracker" , { clear = true })
7+
8+ local last_heartbeat_time = {}
9+ local last_git_diff_lines = {}
10+ local last_non_git_save_time = {}
11+ local active_file = nil
12+ local presence_timer = nil
13+ local PRESENCE_INTERVAL = 120
14+ local COOLDOWN_SAME_FILE = 60
15+ local NON_GIT_COOLDOWN = 60
16+
17+ local function should_track (bufnr , filepath )
18+ if filepath == " " or not vim .api .nvim_buf_is_valid (bufnr ) then
19+ return false
20+ end
21+
22+ if vim .bo [bufnr ].buftype ~= " " then
23+ return false
24+ end
25+
26+ local filetype = vim .bo [bufnr ].filetype
27+ local skip_fts = { " NvimTree" , " neo-tree" , " dashboard" , " help" , " qf" , " TelescopePrompt" , " oil" , " noice" , " notify" }
28+
29+ if vim .tbl_contains (skip_fts , filetype ) then
30+ return false
31+ end
32+
33+ return true
34+ end
1835
19- --- Calculate git diff for file (returns lines changed, or nil if not in git)
2036local function get_git_lines_changed (filepath )
2137 local dir = vim .fn .fnamemodify (filepath , " :h" )
2238 local filename = vim .fn .fnamemodify (filepath , " :t" )
@@ -43,114 +59,48 @@ local function get_git_lines_changed(filepath)
4359 return nil
4460end
4561
46- --- Send heartbeat
47- function M .send_heartbeat (opts )
62+ local function send_to_backend (filepath , lines_changed , opts )
4863 opts = opts or {}
4964
50- 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
65+ local bufnr = vim .fn .bufnr (filepath )
66+ if bufnr == - 1 or not vim .api .nvim_buf_is_valid (bufnr ) then
6567 return
6668 end
6769
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
127- end
128-
12970 local line_count = vim .api .nvim_buf_line_count (bufnr )
130- local language = filetype ~= " " and filetype or " unknown"
71+ local language = vim . bo [ bufnr ]. filetype ~= " " and vim . bo [ bufnr ]. filetype or " unknown"
13172
132- -- Send to backend
133- local cmd = string.format (
134- " %s track --file %s --lang %s --lines %d --total %d" ,
73+ local cmd = {
13574 opts .codeme_bin or " codeme" ,
136- vim .fn .shellescape (filepath ),
75+ " track" ,
76+ " --file" ,
77+ filepath ,
78+ " --lang" ,
13779 language ,
138- lines_changed ,
139- line_count
140- )
80+ " --lines" ,
81+ tostring (lines_changed ),
82+ " --total" ,
83+ tostring (line_count ),
84+ }
14185
14286 vim .fn .jobstart (cmd , {
14387 detach = true ,
14488 on_exit = function (_ , code )
14589 if opts .verbose then
90+ local heartbeat_type = opts .heartbeat_type or " unknown"
14691 if code == 0 then
14792 vim .notify (
148- string.format (" CodeMe: ✓ %s (%d lines)" , vim .fn .fnamemodify (filepath , " :t" ), lines_changed ),
93+ string.format (
94+ " codeme: ✓ %s (%d lines) [%s]" ,
95+ vim .fn .fnamemodify (filepath , " :t" ),
96+ lines_changed ,
97+ heartbeat_type
98+ ),
14999 vim .log .levels .INFO
150100 )
151101 else
152102 vim .notify (
153- string.format (" CodeMe : ✗ Failed to track %s" , vim .fn .fnamemodify (filepath , " :t" )),
103+ string.format (" codeme : ✗ failed to track %s" , vim .fn .fnamemodify (filepath , " :t" )),
154104 vim .log .levels .WARN
155105 )
156106 end
@@ -159,15 +109,187 @@ function M.send_heartbeat(opts)
159109 })
160110end
161111
112+ local function calculate_lines_changed (filepath )
113+ local current_diff = get_git_lines_changed (filepath )
114+
115+ if current_diff ~= nil then
116+ -- GIT-TRACKED FILE
117+ local last_diff = last_git_diff_lines [filepath ] or 0
118+ local delta = current_diff - last_diff
119+
120+ -- Update baseline
121+ last_git_diff_lines [filepath ] = current_diff
122+
123+ if delta > 0 then
124+ return delta , " save" -- productivity signal
125+ end
126+
127+ -- File was reset (commit, checkout, stash, etc.)
128+ -- Still send heartbeat with 0 lines to preserve time tracking
129+ if current_diff == 0 and last_diff > 0 then
130+ return 0 , " save_reset" -- still counts as time spent
131+ end
132+
133+ -- No new changes since last save (delta <= 0)
134+ -- Don't send redundant heartbeat
135+ return nil , nil
136+ else
137+ -- NON-GIT-TRACKED FILE (with NON_GIT_COOLDOWN throttling)
138+ local now = os.time ()
139+ local last_save = last_non_git_save_time [filepath ]
140+
141+ if not last_save then
142+ -- First save of this non-git file
143+ last_non_git_save_time [filepath ] = now
144+ return 0 , " save_new" -- new file, track time only
145+ elseif (now - last_save ) < NON_GIT_COOLDOWN then
146+ -- Too soon since last save, skip to prevent spam
147+ -- This implements the NON_GIT_COOLDOWN throttling
148+ return nil , nil
149+ else
150+ -- Enough time passed, send heartbeat and reset timer
151+ last_non_git_save_time [filepath ] = now
152+ return 0 , " save_untracked" -- untracked file, time only
153+ end
154+ end
155+ end
156+
157+ function M .send_heartbeat (opts )
158+ opts = opts or {}
159+ local bufnr = opts .bufnr or vim .api .nvim_get_current_buf ()
160+ local filepath = opts .filepath or 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
177+ local heartbeat_type
178+
179+ if is_save then
180+ local delta , hb_type = calculate_lines_changed (filepath )
181+ if delta == nil then
182+ return
183+ end
184+ lines_changed = delta
185+ heartbeat_type = hb_type or " save"
186+ else
187+ -- Presence / periodic heartbeat: time tracking only
188+ lines_changed = 0
189+ heartbeat_type = opts .is_periodic and " periodic" or (opts .heartbeat_type or " presence" )
190+ end
191+
192+ send_to_backend (filepath , lines_changed , {
193+ codeme_bin = opts .codeme_bin ,
194+ verbose = opts .verbose ,
195+ heartbeat_type = heartbeat_type ,
196+ })
197+
198+ last_heartbeat_time [filepath ] = now
199+ end
200+
201+ local function start_periodic_heartbeat ()
202+ -- Stop existing timer if any
203+ if presence_timer then
204+ vim .fn .timer_stop (presence_timer )
205+ end
206+
207+ -- Create new timer: fire every PRESENCE_INTERVAL seconds
208+ presence_timer = vim .fn .timer_start (PRESENCE_INTERVAL * 1000 , function ()
209+ if active_file then
210+ M .send_heartbeat ({
211+ is_save = false ,
212+ is_periodic = true ,
213+ filepath = active_file ,
214+ })
215+ end
216+ end , { repeats = - 1 })
217+ end
218+
219+ --- Wire into BufEnter: file opened or switched
220+ vim .api .nvim_create_autocmd (" BufEnter" , {
221+ group = augroup ,
222+ callback = function ()
223+ local bufnr = vim .api .nvim_get_current_buf ()
224+ local filepath = vim .fn .expand (" %:p" )
225+
226+ if should_track (bufnr , filepath ) then
227+ -- Track active file for periodic heartbeats
228+ active_file = filepath
229+
230+ -- Send immediate presence heartbeat on file change (Layer 1)
231+ M .send_heartbeat ({
232+ is_save = false ,
233+ })
234+
235+ -- Start/restart periodic timer
236+ start_periodic_heartbeat ()
237+ end
238+ end ,
239+ })
240+
241+ --- Wire into BufWritePost: file saved
242+ vim .api .nvim_create_autocmd (" BufWritePost" , {
243+ group = augroup ,
244+ callback = function ()
245+ local bufnr = vim .api .nvim_get_current_buf ()
246+ local filepath = vim .fn .expand (" %:p" )
247+
248+ if should_track (bufnr , filepath ) then
249+ -- Send save heartbeat with productivity metadata (Layer 3)
250+ M .send_heartbeat ({
251+ is_save = true ,
252+ })
253+ end
254+ end ,
255+ })
256+
257+ --- Wire into BufLeave: user switched away
258+ vim .api .nvim_create_autocmd (" BufLeave" , {
259+ group = augroup ,
260+ callback = function ()
261+ if presence_timer then
262+ vim .fn .timer_stop (presence_timer )
263+ presence_timer = nil
264+ end
265+ active_file = nil
266+ end ,
267+ })
268+
162269function M .clear_state ()
163- last_heartbeat = {}
164- last_git_diff = {}
270+ last_heartbeat_time = {}
271+ last_git_diff_lines = {}
272+ last_non_git_save_time = {}
273+ active_file = nil
274+
275+ if presence_timer then
276+ vim .fn .timer_stop (presence_timer )
277+ presence_timer = nil
278+ end
165279end
166280
281+ --- Get current tracking state (for debugging)
167282function M .get_state ()
168283 return {
169- heartbeats = vim .deepcopy (last_heartbeat ),
170- git_diffs = vim .deepcopy (last_git_diff ),
284+ last_heartbeat_time = vim .deepcopy (last_heartbeat_time ),
285+ last_git_diff_lines = vim .deepcopy (last_git_diff_lines ),
286+ last_non_git_save_time = vim .deepcopy (last_non_git_save_time ),
287+ active_file = active_file ,
288+ config = {
289+ PRESENCE_INTERVAL = PRESENCE_INTERVAL ,
290+ COOLDOWN_SAME_FILE = COOLDOWN_SAME_FILE ,
291+ NON_GIT_COOLDOWN = NON_GIT_COOLDOWN ,
292+ },
171293 }
172294end
173295
0 commit comments