diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 9bbfed9..9eb5e3c 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -5,6 +5,8 @@ local M = {} local logger = require("claudecode.logger") local terminal = require("claudecode.terminal") +local uv = vim.uv or vim.loop + M.state = { latest_selection = nil, tracking_enabled = false, @@ -45,10 +47,33 @@ function M.disable() M.state.latest_selection = nil M.server = nil - if M.state.debounce_timer then - vim.loop.timer_stop(M.state.debounce_timer) - M.state.debounce_timer = nil + M._cancel_debounce_timer() + + if M.state.demotion_timer then + local demotion_timer = M.state.demotion_timer + M.state.demotion_timer = nil + + demotion_timer:stop() + demotion_timer:close() + end +end + +---Cancels and closes the current debounce timer, if any. +---@local +function M._cancel_debounce_timer() + local timer = M.state.debounce_timer + if not timer then + return end + + -- Clear state before stopping/closing so any already-scheduled callback is a no-op. + M.state.debounce_timer = nil + + assert(timer.stop, "Expected debounce timer to have :stop()") + assert(timer.close, "Expected debounce timer to have :close()") + + timer:stop() + timer:close() end ---Creates autocommands for tracking selections. @@ -107,14 +132,36 @@ end ---Ensures that `update_selection` is not called too frequently by deferring ---its execution. function M.debounce_update() - if M.state.debounce_timer then - vim.loop.timer_stop(M.state.debounce_timer) - end + M._cancel_debounce_timer() + + assert(type(M.state.debounce_ms) == "number", "Expected debounce_ms to be a number") + + local timer = uv.new_timer() + assert(timer, "Expected uv.new_timer() to return a timer handle") + assert(timer.start, "Expected debounce timer to have :start()") + assert(timer.stop, "Expected debounce timer to have :stop()") + assert(timer.close, "Expected debounce timer to have :close()") + + M.state.debounce_timer = timer - M.state.debounce_timer = vim.defer_fn(function() - M.update_selection() - M.state.debounce_timer = nil - end, M.state.debounce_ms) + timer:start( + M.state.debounce_ms, + 0, -- 0 repeat = one-shot + vim.schedule_wrap(function() + -- Ignore stale timers (e.g., cancelled and replaced before callback runs) + if M.state.debounce_timer ~= timer then + return + end + + -- Clear state before stopping/closing so cancellation is idempotent. + M.state.debounce_timer = nil + + timer:stop() + timer:close() + + M.update_selection() + end) + ) end ---Updates the current selection state. diff --git a/tests/selection_test.lua b/tests/selection_test.lua index ee59383..98a5893 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -1,4 +1,6 @@ if not _G.vim then + local next_timer_id = 0 + _G.vim = { ---@type vim_global_api schedule_wrap = function(fn) return fn @@ -192,7 +194,49 @@ if not _G.vim then end, loop = { - timer_stop = function(_timer) -- Prefix unused param with underscore + now = function() + return 0 + end, + new_timer = function() + next_timer_id = next_timer_id + 1 + + local timer = { + _id = next_timer_id, + _start_calls = 0, + _stop_calls = 0, + _close_calls = 0, + _callback = nil, + } + + function timer:start(timeout, repeat_interval, callback) + self._start_calls = self._start_calls + 1 + self._timeout = timeout + self._repeat_interval = repeat_interval + self._callback = callback + return true + end + + function timer:stop() + self._stop_calls = self._stop_calls + 1 + return true + end + + function timer:close() + self._close_calls = self._close_calls + 1 + return true + end + + function timer:fire() + assert(self._callback, "Timer has no callback; did you call :start()?") + return self._callback() + end + + return timer + end, + timer_stop = function(timer) + if timer and timer.stop then + timer:stop() + end return true end, }, @@ -362,6 +406,76 @@ describe("Selection module", function() assert(selection.state.latest_selection == nil) end) + describe("debounce_update", function() + it("should cancel and close previous debounce timer when re-debouncing", function() + local update_calls = 0 + local old_update_selection = selection.update_selection + + selection.update_selection = function() + update_calls = update_calls + 1 + end + + selection.debounce_update() + local timer1 = selection.state.debounce_timer + assert(timer1 ~= nil) + + selection.debounce_update() + local timer2 = selection.state.debounce_timer + assert(timer2 ~= nil) + assert.are_not.equal(timer1, timer2) + + assert.are.equal(1, timer1._stop_calls) + assert.are.equal(1, timer1._close_calls) + + -- Clean up the active timer + timer2:fire() + assert.are.equal(1, update_calls) + + selection.update_selection = old_update_selection + end) + + it("should ignore stale debounce timer callbacks", function() + local update_calls = 0 + local old_update_selection = selection.update_selection + + selection.update_selection = function() + update_calls = update_calls + 1 + end + + selection.debounce_update() + local timer1 = selection.state.debounce_timer + assert(timer1 ~= nil) + + selection.debounce_update() + local timer2 = selection.state.debounce_timer + assert(timer2 ~= nil) + + -- A callback from a cancelled timer should be ignored. + timer1:fire() + assert.are.equal(0, update_calls) + + timer2:fire() + assert.are.equal(1, update_calls) + assert(selection.state.debounce_timer == nil) + assert.are.equal(1, timer2._stop_calls) + assert.are.equal(1, timer2._close_calls) + + selection.update_selection = old_update_selection + end) + + it("disable() should cancel an active debounce timer", function() + selection.enable(mock_server) + selection.debounce_update() + local timer = selection.state.debounce_timer + assert(timer ~= nil) + + selection.disable() + assert(selection.state.debounce_timer == nil) + assert.are.equal(1, timer._stop_calls) + assert.are.equal(1, timer._close_calls) + end) + end) + it("should get cursor position in normal mode", function() local old_win_get_cursor = _G.vim.api.nvim_win_get_cursor _G.vim.api.nvim_win_get_cursor = function()