Skip to content
Open
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
67 changes: 57 additions & 10 deletions lua/claudecode/selection.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
116 changes: 115 additions & 1 deletion tests/selection_test.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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()
Expand Down