snacks.nvim/lua/snacks/picker/util/async.lua
Folke Lemaitre 559d6c6bf2
feat(snacks): added snacks.picker (#445)
## Description

More info coming tomorrow.

In short:
- very fast. pretty much realtime filtering/sorting in huge repos (like
1.7 million files)
- extensible
- easy to customize the layout (and lots of presets) with
`snacks.layout`
- simple to create custom pickers
- `vim.ui.select`
- lots of builtin pickers
- uses treesitter highlighting wherever it makes sense
- fast lua fuzzy matcher which supports the [fzf
syntax](https://junegunn.github.io/fzf/search-syntax/) and additionally
supports field filters, like `file:lua$ 'function`

There's no snacks picker command, just use lua.

```lua
-- all pickers
Snacks.picker()

-- run files picker
Snacks.picker.files(opts)
Snacks.picker.pick("files", opts)
Snacks.picker.pick({source = "files", ...})
```

<!-- Describe the big picture of your changes to communicate to the
maintainers
  why we should accept this pull request. -->

## Todo
- [x] issue with preview loc not always correct when scrolling fast in
list (probably due to `snacks.scroll`)
- [x] `grep` (`live_grep`) is sometimes too fast in large repos and can
impact ui rendering. Not very noticeable, but something I want to look
at.
- [x] docs
- [x] treesitter highlights are broken. Messed something up somewhere

## Related Issue(s)

<!--
  If this PR fixes any issues, please link to the issue here.
  - Fixes #<issue_number>
-->

## Screenshots

<!-- Add screenshots of the changes if applicable. -->
2025-01-14 22:53:59 +01:00

310 lines
6.1 KiB
Lua

---@class snacks.picker.async
local M = {}
---@type snacks.picker.Async[]
M._active = {}
---@type snacks.picker.Async[]
M._suspended = {}
M._executor = assert((vim.uv or vim.loop).new_check())
M.BUDGET = 10
---@type table<thread, snacks.picker.Async>
M._threads = setmetatable({}, { __mode = "k" })
local uv = (vim.uv or vim.loop)
function M.exiting()
return vim.v.exiting ~= vim.NIL
end
---@alias snacks.picker.AsyncEvent "done" | "error" | "yield" | "ok" | "abort"
---@class snacks.picker.Async
---@field _co? thread
---@field _fn fun()
---@field _suspended? boolean
---@field _aborted? boolean
---@field _start number
---@field _on table<snacks.picker.AsyncEvent, fun(res:any, async:snacks.picker.Async)[]>
local Async = {}
Async.__index = Async
---@param fn async fun()
---
function Async.new(fn)
local self = setmetatable({}, Async)
return self:init(fn)
end
---@param fn async fun()
---@return snacks.picker.Async
function Async:init(fn)
self._fn = fn
self._on = {}
self._start = uv.hrtime()
self._co = coroutine.create(function()
local ok, err = pcall(self._fn)
if not ok then
if self._aborted then
self:_emit("abort")
else
self:_error(err)
end
end
self:_done()
end)
M._threads[self._co] = self
return M.add(self)
end
function Async:_done()
self:_emit("done")
self._fn = nil
self._co = nil
self._on = {}
end
function Async:delta()
return (uv.hrtime() - self._start) / 1e6
end
---@param event snacks.picker.AsyncEvent
---@param cb async fun(res:any, async:snacks.picker.Async)
function Async:on(event, cb)
if event == "done" and not self:running() then
cb(nil, self)
return self
end
self._on[event] = self._on[event] or {}
table.insert(self._on[event], cb)
return self
end
---@private
---@param event snacks.picker.AsyncEvent
---@param res any
function Async:_emit(event, res)
for _, cb in ipairs(self._on[event] or {}) do
cb(res, self)
end
end
function Async:_error(err)
if vim.tbl_isempty(self._on.error or {}) then
Snacks.notify.error("Unhandled async error:\n" .. err)
end
self:_emit("error", err)
end
function Async:running()
return self._co and coroutine.status(self._co) ~= "dead" and not self._aborted
end
---@async
function Async:sleep(ms)
vim.defer_fn(function()
self:resume()
end, ms)
self:suspend()
end
---@async
---@param yield? boolean
function Async:suspend(yield)
self._suspended = true
if coroutine.running() == self._co and yield ~= false then
M.yield()
end
end
function Async:resume()
if not self._suspended then
return
end
self._suspended = false
M._run()
end
---@async
---@param yield? boolean
function Async:wake(yield)
local async = M.running()
assert(async, "Not in an async context")
self:on("done", function()
async:resume()
end)
async:suspend(yield)
end
---@async
function Async:wait()
if coroutine.running() == self._co then
error("Cannot wait on self")
end
local async = M.running()
if async then
self:wake()
else
while self:running() do
vim.wait(10)
end
end
return self
end
function Async:step()
if self._suspended then
return true
end
if not self._co then
return false
end
local status = coroutine.status(self._co)
if status == "suspended" then
local ok, res = coroutine.resume(self._co)
if not ok then
error(res)
elseif res then
self:_emit("yield", res)
end
end
return self:running()
end
function Async:abort()
if not self:running() then
return
end
self._aborted = true
coroutine.resume(self._co, "abort")
end
function M.abort()
for _, async in ipairs(M._active) do
async:abort()
end
end
---@async
function M.yield()
if coroutine.yield() == "abort" then
error("aborted", 2)
end
end
function M.step()
local start = uv.hrtime()
for _ = 1, #M._active do
if M.exiting() or uv.hrtime() - start > M.BUDGET * 1e6 then
break
end
local state = table.remove(M._active, 1) ---@type snacks.picker.Async
if state:step() then
if state._suspended then
table.insert(M._suspended, state)
else
table.insert(M._active, state)
end
end
end
for _ = 1, #M._suspended do
local state = table.remove(M._suspended, 1)
table.insert(state._suspended and M._suspended or M._active, state)
end
-- M.debug()
if #M._active == 0 or M.exiting() then
return M._executor:stop()
end
end
function M.debug()
local lines = {
"- active: " .. #M._active,
"- suspended: " .. #M._suspended,
}
for _, async in ipairs(M._active) do
local info = debug.getinfo(async._fn)
local file = vim.fn.fnamemodify(info.short_src:sub(1), ":~:.")
table.insert(lines, ("%s:%d"):format(file, info.linedefined))
if #lines > 10 then
break
end
end
local msg = table.concat(lines, "\n")
M._notif = vim.notify(msg, nil, { replace = M._notif })
end
---@param async snacks.picker.Async
function M.add(async)
table.insert(M._active, async)
M._run()
return async
end
---@async
function M.suspend()
local async = assert(M.running(), "Not in an async context")
async:suspend()
end
function M._run()
if not M.exiting() and not M._executor:is_active() then
-- M._executor:start(vim.schedule_wrap(M.step))
M._executor:start(M.step)
end
end
function M.running()
local co = coroutine.running()
if co then
return M._threads[co]
end
end
---@async
---@param ms number
function M.sleep(ms)
local async = M.running()
assert(async, "Not in an async context")
async:sleep(ms)
end
---@param ms? number
function M.yielder(ms)
if not coroutine.running() then
return function() end
end
local ns, count, start = (ms or 5) * 1e6, 0, uv.hrtime()
---@async
return function()
count = count + 1
if count % 100 == 0 then
if uv.hrtime() - start > ns then
M.yield()
start = uv.hrtime()
end
end
end
end
local nop ---@type snacks.picker.Async
--- Returns a no-op async function
function M.nop()
if not nop then
nop = Async.new(function() end)
nop:step()
M._active = vim.tbl_filter(function(a)
return a ~= nop
end, M._active)
end
return nop
end
M.Async = Async
M.new = Async.new
return M