Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions lua/shelltime/heartbeat.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ local PLUGIN_VERSION = '0.0.4' -- x-release-please-version
-- Pending heartbeats queue
local pending_heartbeats = {}

-- Upper bound for the queue while the daemon is unreachable
local MAX_PENDING = 5000

-- Last heartbeat time per file (for debouncing)
local last_heartbeat_time = {}

Expand Down Expand Up @@ -55,7 +58,7 @@ local function is_valid_buffer(bufnr)
end

-- Skip .git directory files
if file_path:match('/.git/') then
if file_path:match('[/\\]%.git[/\\]') then
return false
end

Expand Down Expand Up @@ -158,10 +161,19 @@ local function create_heartbeat(bufnr, is_write)
}
end

--- Drop the oldest heartbeats once the queue exceeds MAX_PENDING
local function trim_queue()
local overflow = #pending_heartbeats - MAX_PENDING
if overflow > 0 then
pending_heartbeats = vim.list_slice(pending_heartbeats, overflow + 1)
end
end

--- Add heartbeat to pending queue
---@param heartbeat table Heartbeat data
local function add_heartbeat(heartbeat)
table.insert(pending_heartbeats, heartbeat)
trim_queue()

if config.get('debug') then
vim.notify(
Expand All @@ -173,7 +185,8 @@ end

--- Handle editor event
---@param is_write boolean Whether this is a write event
local function on_event(is_write)
---@param is_navigation boolean Whether this is a navigation event (BufEnter, cursor moves)
local function on_event(is_write, is_navigation)
if not config.is_enabled() then
return
end
Expand All @@ -191,8 +204,9 @@ local function on_event(is_write)
local line_number = cursor[1]
local cursor_position = cursor[2]

-- Skip duplicate events (same file and cursor position)
if is_duplicate_activity(file_path, line_number, cursor_position, is_write) then
-- Skip repeated navigation events (same file and cursor position).
-- Edits always count, even when the cursor stays in place (x, dd).
if is_navigation and is_duplicate_activity(file_path, line_number, cursor_position, is_write) then
return
end

Expand Down Expand Up @@ -222,31 +236,31 @@ function M.start()
vim.api.nvim_create_autocmd('BufEnter', {
group = augroup,
callback = function()
on_event(false)
on_event(false, true)
end,
})

-- Text changed
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
group = augroup,
callback = function()
on_event(false)
on_event(false, false)
end,
})

-- File saved
vim.api.nvim_create_autocmd('BufWritePost', {
group = augroup,
callback = function()
on_event(true)
on_event(true, false)
end,
})

-- Cursor moved
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
group = augroup,
callback = function()
on_event(false)
on_event(false, true)
end,
})
end
Expand All @@ -267,6 +281,16 @@ function M.flush()
return heartbeats
end

--- Put heartbeats that could not be delivered back at the front of the queue
---@param heartbeats table[] Heartbeats to retry
function M.requeue(heartbeats)
if #heartbeats == 0 then
return
end
pending_heartbeats = vim.list_extend(vim.list_extend({}, heartbeats), pending_heartbeats)
trim_queue()
end

--- Get pending heartbeat count
---@return number Count
function M.get_pending_count()
Expand Down
39 changes: 39 additions & 0 deletions lua/shelltime/sender.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ local M = {}
-- Flush timer
local flush_timer = nil

-- Autocmd group for the exit flush
local augroup = nil

-- How long Neovim may wait on exit for pending heartbeats to be sent
local EXIT_FLUSH_TIMEOUT = 1500

-- Connection status
local is_connected = false

Expand All @@ -28,6 +34,11 @@ local function send_heartbeats(callback)
socket.send_heartbeats(heartbeats, function(success, err)
is_connected = success

if not success then
-- Keep them for the next flush instead of dropping them
heartbeat.requeue(heartbeats)
end

if config.get('debug') then
if success then
vim.notify(
Expand Down Expand Up @@ -64,6 +75,16 @@ function M.start()
end)
end)

-- Sessions are often shorter than the flush interval, so send what is
-- pending before Neovim exits.
augroup = vim.api.nvim_create_augroup('ShellTimeSender', { clear = true })
vim.api.nvim_create_autocmd('VimLeavePre', {
group = augroup,
callback = function()
M.flush_sync(EXIT_FLUSH_TIMEOUT)
end,
})

-- Check initial connection status and CLI version
vim.schedule(function()
is_connected = socket.is_connected_sync()
Expand All @@ -86,6 +107,11 @@ function M.stop()
flush_timer:close()
flush_timer = nil
end

if augroup then
vim.api.nvim_del_augroup_by_id(augroup)
augroup = nil
end
end

--- Force flush pending heartbeats
Expand All @@ -94,6 +120,19 @@ function M.flush(callback)
send_heartbeats(callback)
end

--- Flush pending heartbeats and block until sent or timed out
---@param timeout number Maximum wait in milliseconds
---@return boolean True if the send finished within the timeout
function M.flush_sync(timeout)
local done = false
send_heartbeats(function()
done = true
end)
return vim.wait(timeout, function()
return done
end, 10)
end

--- Get connection status
---@return boolean
function M.is_connected()
Expand Down
38 changes: 32 additions & 6 deletions lua/shelltime/utils/system.lua
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,41 @@ function M.get_project_name(project_root)
return tail
end

-- Whether the math.random fallback has been seeded
local seeded = false

--- Get 16 random bytes, from the OS when possible
---@return number[] Byte values (0-255)
local function random_bytes()
local uv = vim.uv or vim.loop
local ok, bytes = pcall(uv.random, 16)
if ok and type(bytes) == 'string' and #bytes == 16 then
return { bytes:byte(1, 16) }
end

-- Seed once: reseeding on every call from the clock repeats sequences
-- across calls and Neovim instances, and heartbeat ids must be unique.
if not seeded then
math.randomseed(uv.hrtime() + uv.os_getpid())
seeded = true
end
local result = {}
for i = 1, 16 do
result[i] = math.random(0, 255)
end
return result
end

--- Generate UUID v4
---@return string UUID string
function M.uuid()
math.randomseed(os.time() + os.clock() * 1000000)
local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
return string.gsub(template, '[xy]', function(c)
local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
return string.format('%x', v)
end)
local b = random_bytes()
b[7] = (b[7] % 16) + 0x40 -- version 4
b[9] = (b[9] % 64) + 0x80 -- RFC 4122 variant
return string.format(
'%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x',
unpack(b)
)
end

--- Get current Unix timestamp in seconds
Expand Down
92 changes: 92 additions & 0 deletions tests/heartbeat_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,98 @@ describe('shelltime.heartbeat', function()
end)
end)

describe('requeue', function()
it('should put heartbeats back ahead of newer ones', function()
heartbeat.requeue({ { entity = 'newer' } })
heartbeat.requeue({ { entity = 'older' } })

local pending = heartbeat.flush()
assert.equals(2, #pending)
assert.equals('older', pending[1].entity)
assert.equals('newer', pending[2].entity)
end)

it('should ignore an empty list', function()
heartbeat.requeue({})
assert.equals(0, heartbeat.get_pending_count())
end)

it('should cap the queue and drop the oldest heartbeats', function()
local list = {}
for i = 1, 5001 do
list[i] = { entity = 'file-' .. i }
end

heartbeat.requeue(list)

local pending = heartbeat.flush()
assert.equals(5000, #pending)
assert.equals('file-2', pending[1].entity)
assert.equals('file-5001', pending[5000].entity)
end)
end)

describe('autocmd events (integration)', function()
local buffers = {}

-- Open a named file buffer in the current window, dropping its BufEnter heartbeat
local function open_buffer(path)
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(bufnr, path)
table.insert(buffers, bufnr)
vim.api.nvim_set_current_buf(bufnr)
heartbeat.flush()
return bufnr
end

before_each(function()
config._set_for_testing({ debounce_interval = 0 })
heartbeat.start()
end)

after_each(function()
heartbeat.stop()
for _, bufnr in ipairs(buffers) do
pcall(vim.api.nvim_buf_delete, bufnr, { force = true })
end
buffers = {}
end)

it('should skip files inside .git', function()
local bufnr = open_buffer('/tmp/shelltime-test/.git/COMMIT_EDITMSG')

vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr })

assert.equals(0, heartbeat.get_pending_count())
end)

it('should track directories that only look like .git', function()
local bufnr = open_buffer('/tmp/shelltime-test/egit/main.lua')

vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr })

assert.equals(1, heartbeat.get_pending_count())
end)

it('should count edits that leave the cursor in place', function()
local bufnr = open_buffer('/tmp/shelltime-test/edit.lua')

vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr })
vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr })

assert.equals(2, heartbeat.get_pending_count())
end)

it('should skip repeated cursor events at the same position', function()
local bufnr = open_buffer('/tmp/shelltime-test/nav.lua')

vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr })
vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr })

assert.equals(0, heartbeat.get_pending_count())
end)
end)

describe('buffer validation (integration)', function()
-- These tests verify buffer validation through behavior

Expand Down
Loading
Loading