From c55b67d57fef0e5ae5f3a88b327c2e9935d638ce Mon Sep 17 00:00:00 2001 From: Alexandros Alexiou Date: Thu, 18 Jun 2026 23:40:05 +0300 Subject: [PATCH 1/3] perf(picker): cache formatted rows to avoid re-rendering the whole list on scroll Each row caches its text/extmarks plus the inputs they depend on (matcher ticks, width, selection), so scroll only rebuilds rows entering the viewport. --- lua/snacks/picker/core/list.lua | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lua/snacks/picker/core/list.lua b/lua/snacks/picker/core/list.lua index f20ce0bf0..287c8cfdc 100644 --- a/lua/snacks/picker/core/list.lua +++ b/lua/snacks/picker/core/list.lua @@ -463,12 +463,22 @@ function M:unpause() self:update() end +-- PERF: cache each row's formatted output; on scroll only the new row is rebuilt. +-- Invalidated by the matcher ticks (search), self._width (resize) and sel (selection). ---@param item snacks.picker.Item function M:format(item) + local sc = #self.selected > 0 or self.picker.opts.formatters.selected.show_always + local sel = sc and (self:is_selected(item) and 2 or 1) or 0 + local mt, rt = self.matcher.tick, self.matcher_regex.tick + local c = item._fmt + if c and c.mt == mt and c.rt == rt and c.width == self._width and c.sel == sel then + return c.text, c.extmarks + end + Snacks.picker.util.resolve(item) -- Add selected and debug info local prefix = {} ---@type snacks.picker.Highlight[] - if #self.selected > 0 or self.picker.opts.formatters.selected.show_always then + if sc then vim.list_extend(prefix, Snacks.picker.format.selected(item, self.picker)) else prefix[#prefix + 1] = { " " } @@ -481,7 +491,7 @@ function M:format(item) -- Add the formatted item local line = self.picker.format(item, self.picker) - line = Snacks.picker.highlight.resolve(line, vim.api.nvim_win_get_width(self.win.win)) + line = Snacks.picker.highlight.resolve(line, self._width) while #line > 0 and type(line[#line][1]) == "string" and line[#line][1]:find("^%s*$") do table.remove(line) @@ -515,6 +525,8 @@ function M:format(item) vim.list_extend(positions, self.matcher_regex:positions(it).text or {}) end Snacks.picker.highlight.matches(extmarks, positions) + + item._fmt = { mt = mt, rt = rt, width = self._width, sel = sel, text = text, extmarks = extmarks } return text, extmarks end @@ -525,11 +537,11 @@ function M:_render(item, row) text = text:gsub("\n", " ") vim.api.nvim_buf_set_lines(self.win.buf, row - 1, row, false, { text }) for _, extmark in ipairs(extmarks) do - local col = extmark.col - extmark.col = nil - extmark.row = nil - extmark.field = nil + -- save/restore so cached extmark tables survive the API call + local col, erow, field = extmark.col, extmark.row, extmark.field + extmark.col, extmark.row, extmark.field = nil, nil, nil local ok, err = pcall(vim.api.nvim_buf_set_extmark, self.win.buf, ns, row - 1, col, extmark) + extmark.col, extmark.row, extmark.field = col, erow, field if not ok and self.picker.opts.debug.extmarks then Snacks.notify.error("Failed to set extmark.\n" .. err .. "\n```lua\n" .. vim.inspect(extmark) .. "\n```") end @@ -588,6 +600,7 @@ function M:render() end self.visible = {} + self._width = vim.api.nvim_win_get_width(self.win.win) -- render items for i = self.top, math.min(self:count(), self.top + height - 1) do local item = assert(self:get(i), "item not found") From 197d5e0efb7fa802d9faaf39b3e48823498997cb Mon Sep 17 00:00:00 2001 From: Alexandros Alexiou Date: Thu, 18 Jun 2026 23:40:05 +0300 Subject: [PATCH 2/3] refactor(picker): use named booleans for the selection cache key --- lua/snacks/picker/core/list.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/snacks/picker/core/list.lua b/lua/snacks/picker/core/list.lua index 287c8cfdc..4d95ca73c 100644 --- a/lua/snacks/picker/core/list.lua +++ b/lua/snacks/picker/core/list.lua @@ -464,14 +464,14 @@ function M:unpause() end -- PERF: cache each row's formatted output; on scroll only the new row is rebuilt. --- Invalidated by the matcher ticks (search), self._width (resize) and sel (selection). +-- Invalidated by the matcher ticks (search), self._width (resize) and the selection state. ---@param item snacks.picker.Item function M:format(item) local sc = #self.selected > 0 or self.picker.opts.formatters.selected.show_always - local sel = sc and (self:is_selected(item) and 2 or 1) or 0 + local is_sel = sc and self:is_selected(item) local mt, rt = self.matcher.tick, self.matcher_regex.tick local c = item._fmt - if c and c.mt == mt and c.rt == rt and c.width == self._width and c.sel == sel then + if c and c.mt == mt and c.rt == rt and c.width == self._width and c.sc == sc and c.is_sel == is_sel then return c.text, c.extmarks end @@ -526,7 +526,7 @@ function M:format(item) end Snacks.picker.highlight.matches(extmarks, positions) - item._fmt = { mt = mt, rt = rt, width = self._width, sel = sel, text = text, extmarks = extmarks } + item._fmt = { mt = mt, rt = rt, width = self._width, sc = sc, is_sel = is_sel, text = text, extmarks = extmarks } return text, extmarks end From e3933e0e0721435cf216e95efb11ad78b18ac6de Mon Sep 17 00:00:00 2001 From: Alexandros Alexiou Date: Sun, 6 Sep 2026 10:30:23 +0300 Subject: [PATCH 3/3] fix(picker): don't run tasks aborted before their coroutine ever started Async:abort() on a task whose coroutine was never stepped had no effect: the next step() resumed the coroutine with "abort", but for a not-yet started coroutine that value is discarded as function arguments, so the whole body ran anyway. An aborted finder would then emit all its items into the new run's items table, duplicating every row in the picker (e.g. two back-to-back picker:find() calls in the same tick). --- lua/snacks/picker/util/async.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lua/snacks/picker/util/async.lua b/lua/snacks/picker/util/async.lua index 598684f9a..294136f7f 100644 --- a/lua/snacks/picker/util/async.lua +++ b/lua/snacks/picker/util/async.lua @@ -28,6 +28,7 @@ end ---@field _fn fun() ---@field _suspended? boolean ---@field _aborted? boolean +---@field _started? boolean ---@field _start number ---@field _on table local Async = {} @@ -219,8 +220,17 @@ function Async:step() if not self._co then return false end + if self._aborted and not self._started then + -- Aborted before the coroutine ever ran: don't start it. + -- Resuming a never-started coroutine with "abort" would pass the value as + -- function arguments (ignored) and run the full body anyway, e.g. an + -- aborted finder would still emit all its items into the new run's table. + self:_done() + return false + end local status = coroutine.status(self._co) if status == "suspended" then + self._started = true local ok, res = coroutine.resume(self._co, self._aborted and "abort" or nil) if not ok then error(res)