mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-07 21:25:11 +00:00

## 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. -->
501 lines
13 KiB
Lua
501 lines
13 KiB
Lua
local Async = require("snacks.picker.util.async")
|
|
local Finder = require("snacks.picker.core.finder")
|
|
|
|
local uv = vim.uv or vim.loop
|
|
Async.BUDGET = 10
|
|
|
|
---@class snacks.Picker
|
|
---@field opts snacks.picker.Config
|
|
---@field finder snacks.picker.Finder
|
|
---@field format snacks.picker.format
|
|
---@field input snacks.picker.input
|
|
---@field layout snacks.layout
|
|
---@field resolved_layout snacks.picker.layout.Config
|
|
---@field list snacks.picker.list
|
|
---@field matcher snacks.picker.Matcher
|
|
---@field main number
|
|
---@field preview snacks.picker.Preview
|
|
---@field shown? boolean
|
|
---@field sort snacks.picker.sort
|
|
---@field updater uv.uv_timer_t
|
|
---@field start_time number
|
|
---@field source_name string
|
|
---@field closed? boolean
|
|
---@field hist_idx number
|
|
---@field hist_cursor number
|
|
---@field visual? snacks.picker.Visual
|
|
local M = {}
|
|
M.__index = M
|
|
|
|
--- Keep track of garbage collection
|
|
---@type table<snacks.Picker,boolean>
|
|
M._pickers = setmetatable({}, { __mode = "k" })
|
|
--- These are active, so don't garbage collect them
|
|
---@type table<snacks.Picker,boolean>
|
|
M._active = {}
|
|
|
|
---@class snacks.picker.Last
|
|
---@field opts snacks.picker.Config
|
|
---@field selected snacks.picker.Item[]
|
|
---@field filter snacks.picker.Filter
|
|
|
|
---@type snacks.picker.Last?
|
|
M.last = nil
|
|
|
|
---@type {pattern: string, search: string, live?: boolean}[]
|
|
M.history = {}
|
|
|
|
---@hide
|
|
---@param opts? snacks.picker.Config
|
|
function M.new(opts)
|
|
local self = setmetatable({}, M)
|
|
self.opts = Snacks.picker.config.get(opts)
|
|
if self.opts.source == "resume" then
|
|
return M.resume()
|
|
end
|
|
self.visual = Snacks.picker.util.visual()
|
|
self.start_time = uv.hrtime()
|
|
Snacks.picker.current = self
|
|
self.main = require("snacks.picker.core.main").get(self.opts.main)
|
|
local actions = require("snacks.picker.core.actions").get(self)
|
|
self.opts.win.input.actions = actions
|
|
self.opts.win.list.actions = actions
|
|
self.opts.win.preview.actions = actions
|
|
self.hist_idx = #M.history + 1
|
|
self.hist_cursor = self.hist_idx
|
|
|
|
local sort = self.opts.sort or require("snacks.picker.sort").default()
|
|
sort = type(sort) == "table" and require("snacks.picker.sort").default(sort) or sort
|
|
---@cast sort snacks.picker.sort
|
|
self.sort = sort
|
|
|
|
self.updater = assert(uv.new_timer())
|
|
self.matcher = require("snacks.picker.core.matcher").new(self.opts.matcher)
|
|
|
|
self.finder = Finder.new(Snacks.picker.config.finder(self.opts.finder) or function()
|
|
return self.opts.items or {}
|
|
end)
|
|
|
|
local format = type(self.opts.format) == "string" and Snacks.picker.format[self.opts.format]
|
|
or self.opts.format
|
|
or Snacks.picker.format.file
|
|
---@cast format snacks.picker.format
|
|
self.format = format
|
|
|
|
M._pickers[self] = true
|
|
M._active[self] = true
|
|
|
|
local layout = Snacks.picker.config.layout(self.opts)
|
|
self.list = require("snacks.picker.core.list").new(self)
|
|
self.input = require("snacks.picker.core.input").new(self)
|
|
self.preview = require("snacks.picker.core.preview").new(self.opts, layout.preview == "main" and self.main or nil)
|
|
|
|
M.last = {
|
|
opts = self.opts,
|
|
selected = {},
|
|
filter = self.input.filter,
|
|
}
|
|
|
|
self.source_name = Snacks.picker.util.title(self.opts.source or "search")
|
|
|
|
-- properly close the picker when the window is closed
|
|
self.input.win:on("WinClosed", function()
|
|
self:close()
|
|
end, { win = true })
|
|
|
|
-- close if we enter a window that is not part of the picker
|
|
self.input.win:on("WinEnter", function()
|
|
local current = vim.api.nvim_get_current_win()
|
|
if not vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current) then
|
|
vim.schedule(function()
|
|
self:close()
|
|
end)
|
|
end
|
|
end)
|
|
|
|
self:init_layout(layout)
|
|
self.input.win:on("VimResized", function()
|
|
vim.schedule(function()
|
|
self:set_layout(Snacks.picker.config.layout(self.opts))
|
|
end)
|
|
end)
|
|
|
|
local show_preview = self.show_preview
|
|
self.show_preview = Snacks.util.throttle(function()
|
|
show_preview(self)
|
|
end, { ms = 60, name = "preview" })
|
|
|
|
self:find()
|
|
return self
|
|
end
|
|
|
|
---@param layout? snacks.picker.layout.Config
|
|
---@private
|
|
function M:init_layout(layout)
|
|
layout = layout or Snacks.picker.config.layout(self.opts)
|
|
self.resolved_layout = vim.deepcopy(layout)
|
|
self.resolved_layout.cycle = nil -- not needed for applying layout
|
|
local opts = layout --[[@as snacks.layout.Config]]
|
|
local preview_main = layout.preview == "main"
|
|
local preview_hidden = layout.preview == false or preview_main
|
|
local backdrop = nil
|
|
if preview_main then
|
|
backdrop = false
|
|
end
|
|
self.layout = Snacks.layout.new(vim.tbl_deep_extend("force", opts, {
|
|
show = false,
|
|
win = {
|
|
wo = {
|
|
winhighlight = Snacks.picker.highlight.winhl("SnacksPicker"),
|
|
},
|
|
},
|
|
wins = {
|
|
input = self.input.win,
|
|
list = self.list.win,
|
|
preview = not preview_main and self.preview.win or nil,
|
|
},
|
|
hidden = { preview_hidden and "preview" or nil },
|
|
on_update = function()
|
|
self:update_titles()
|
|
end,
|
|
layout = {
|
|
backdrop = backdrop,
|
|
},
|
|
}))
|
|
self.preview:update(preview_main and self.main or nil)
|
|
-- apply box highlight groups
|
|
local boxwhl = Snacks.picker.highlight.winhl("SnacksPickerBox")
|
|
for _, win in pairs(self.layout.box_wins) do
|
|
win.opts.wo.winhighlight = boxwhl
|
|
end
|
|
return layout
|
|
end
|
|
|
|
--- Set the picker layout. Can be either the name of a preset layout
|
|
--- or a custom layout configuration.
|
|
---@param layout? string|snacks.picker.layout.Config
|
|
function M:set_layout(layout)
|
|
layout = layout or Snacks.picker.config.layout(self.opts)
|
|
layout = type(layout) == "string" and Snacks.picker.config.layout(layout) or layout
|
|
---@cast layout snacks.picker.layout.Config
|
|
layout.cycle = nil -- not needed for applying layout
|
|
if vim.deep_equal(layout, self.resolved_layout) then
|
|
-- no need to update
|
|
return
|
|
end
|
|
if self.list.reverse ~= layout.reverse then
|
|
Snacks.notify.warn(
|
|
"Heads up! This layout changed the list order,\nso `up` goes down and `down` goes up.",
|
|
{ title = "Snacks Picker", id = "snacks_picker_layout_change" }
|
|
)
|
|
end
|
|
self.layout:close({ wins = false })
|
|
self:init_layout(layout)
|
|
self.layout:show()
|
|
self.list.reverse = layout.reverse
|
|
self.list.dirty = true
|
|
self.list:update()
|
|
self.input:update()
|
|
end
|
|
|
|
-- Get the word under the cursor or the current visual selection
|
|
function M:word()
|
|
return self.visual and self.visual.text or vim.fn.expand("<cword>")
|
|
end
|
|
|
|
--- Update title templates
|
|
---@hide
|
|
function M:update_titles()
|
|
local data = {
|
|
source = self.source_name,
|
|
live = self.opts.live and self.opts.icons.ui.live or "",
|
|
}
|
|
local wins = { self.layout.root }
|
|
vim.list_extend(wins, vim.tbl_values(self.layout.wins))
|
|
vim.list_extend(wins, vim.tbl_values(self.layout.box_wins))
|
|
for _, win in pairs(wins) do
|
|
if win.opts.title then
|
|
local tpl = win.meta.title_tpl or win.opts.title
|
|
win.meta.title_tpl = tpl
|
|
win:set_title(Snacks.picker.util.tpl(tpl, data))
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Resume the last picker
|
|
---@private
|
|
function M.resume()
|
|
local last = M.last
|
|
if not last then
|
|
Snacks.notify.error("No picker to resume")
|
|
return M.new({ source = "pickers" })
|
|
end
|
|
last.opts.pattern = last.filter.pattern
|
|
last.opts.search = last.filter.search
|
|
local ret = M.new(last.opts)
|
|
ret.list:set_selected(last.selected)
|
|
ret.list:update()
|
|
ret.input:update()
|
|
return ret
|
|
end
|
|
|
|
---@hide
|
|
function M:show_preview()
|
|
if self.opts.on_change then
|
|
self.opts.on_change(self, self:current())
|
|
end
|
|
if not self.preview.win:valid() then
|
|
return
|
|
end
|
|
self.preview:show(self)
|
|
end
|
|
|
|
---@hide
|
|
function M:show()
|
|
if self.shown or self.closed then
|
|
return
|
|
end
|
|
self.shown = true
|
|
self.layout:show()
|
|
if self.preview.main then
|
|
self.preview.win:show()
|
|
end
|
|
self.input.win:focus()
|
|
if self.opts.on_show then
|
|
self.opts.on_show(self)
|
|
end
|
|
end
|
|
|
|
--- Returns an iterator over the items in the picker.
|
|
--- Items will be in sorted order.
|
|
---@return fun():snacks.picker.Item?
|
|
function M:iter()
|
|
local i = 0
|
|
local n = self.finder:count()
|
|
return function()
|
|
i = i + 1
|
|
if i <= n then
|
|
return self.list:get(i)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Get all finder items
|
|
function M:items()
|
|
return self.finder.items
|
|
end
|
|
|
|
--- Get the current item at the cursor
|
|
function M:current()
|
|
return self.list:current()
|
|
end
|
|
|
|
--- Get the selected items.
|
|
--- If `fallback=true` and there is no selection, return the current item.
|
|
---@param opts? {fallback?: boolean} default is `false`
|
|
function M:selected(opts)
|
|
opts = opts or {}
|
|
local ret = vim.deepcopy(self.list.selected)
|
|
if #ret == 0 and opts.fallback then
|
|
return { self:current() }
|
|
end
|
|
return ret
|
|
end
|
|
|
|
--- Total number of items in the picker
|
|
function M:count()
|
|
return self.finder:count()
|
|
end
|
|
|
|
--- Check if the picker is empty
|
|
function M:empty()
|
|
return self:count() == 0
|
|
end
|
|
|
|
--- Close the picker
|
|
function M:close()
|
|
if self.closed then
|
|
return
|
|
end
|
|
M.last.selected = self:selected({ fallback = false })
|
|
self.closed = true
|
|
Snacks.picker.current = nil
|
|
local current = vim.api.nvim_get_current_win()
|
|
local is_picker_win = vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current)
|
|
if is_picker_win and vim.api.nvim_win_is_valid(self.main) then
|
|
vim.api.nvim_set_current_win(self.main)
|
|
end
|
|
self.preview.win:close()
|
|
self.layout:close()
|
|
self.updater:stop()
|
|
M._active[self] = nil
|
|
vim.schedule(function()
|
|
self.list:clear()
|
|
self.finder.items = {}
|
|
self.matcher:abort()
|
|
self.finder:abort()
|
|
end)
|
|
end
|
|
|
|
--- Check if the finder or matcher is running
|
|
function M:is_active()
|
|
return self.finder:running() or self.matcher:running()
|
|
end
|
|
|
|
---@private
|
|
function M:progress(ms)
|
|
if self.updater:is_active() then
|
|
return
|
|
end
|
|
self.updater = vim.defer_fn(function()
|
|
self:update()
|
|
if self:is_active() then
|
|
-- slower progress when we filled topk
|
|
local topk, height = self.list.topk:count(), self.list.state.height or 50
|
|
self:progress(topk > height and 30 or 10)
|
|
end
|
|
end, ms or 10)
|
|
end
|
|
|
|
---@hide
|
|
function M:update()
|
|
if self.closed then
|
|
return
|
|
end
|
|
|
|
-- Schedule the update if we are in a fast event
|
|
if vim.in_fast_event() then
|
|
return vim.schedule(function()
|
|
self:update()
|
|
end)
|
|
end
|
|
|
|
local count = self.finder:count()
|
|
local list_count = self.list:count()
|
|
-- Check if we should show the picker
|
|
if not self.shown then
|
|
-- Always show live pickers
|
|
if self.opts.live then
|
|
self:show()
|
|
elseif not self:is_active() then
|
|
if count == 0 then
|
|
-- no results found
|
|
local msg = "No results"
|
|
if self.opts.source then
|
|
msg = ("No results found for `%s`"):format(self.opts.source)
|
|
end
|
|
Snacks.notify.warn(msg, { title = "Snacks Picker" })
|
|
self:close()
|
|
return
|
|
elseif count == 1 and self.opts.auto_confirm then
|
|
-- auto confirm if only one result
|
|
self:action("confirm")
|
|
self:close()
|
|
return
|
|
else
|
|
-- show the picker if we have results
|
|
self.list:unpause()
|
|
self:show()
|
|
end
|
|
elseif list_count > 1 or (list_count == 1 and not self.opts.auto_confirm) then -- show the picker if we have results
|
|
self:show()
|
|
end
|
|
end
|
|
|
|
if self.shown then
|
|
if not self:is_active() then
|
|
self.list:unpause()
|
|
end
|
|
-- update list and input
|
|
if not self.list.paused then
|
|
self.input:update()
|
|
end
|
|
self.list:update()
|
|
end
|
|
end
|
|
|
|
--- Execute the given action(s)
|
|
---@param actions string|string[]
|
|
function M:action(actions)
|
|
return self.input.win:execute(actions)
|
|
end
|
|
|
|
--- Clear the list and run the finder and matcher
|
|
---@param opts? {on_done?: fun()} Callback when done
|
|
function M:find(opts)
|
|
self.list:clear()
|
|
self.finder:run(self)
|
|
self.matcher:run(self)
|
|
if opts and opts.on_done then
|
|
if self.matcher.task:running() then
|
|
self.matcher.task:on("done", vim.schedule_wrap(opts.on_done))
|
|
else
|
|
opts.on_done()
|
|
end
|
|
end
|
|
self:progress()
|
|
end
|
|
|
|
--- Add current filter to history
|
|
---@private
|
|
function M:hist_record()
|
|
M.history[self.hist_idx] = {
|
|
pattern = self.input.filter.pattern,
|
|
search = self.input.filter.search,
|
|
live = self.opts.live,
|
|
}
|
|
end
|
|
|
|
--- Move the history cursor
|
|
---@param forward? boolean
|
|
function M:hist(forward)
|
|
self:hist_record()
|
|
self.hist_cursor = self.hist_cursor + (forward and 1 or -1)
|
|
self.hist_cursor = math.min(math.max(self.hist_cursor, 1), #M.history)
|
|
self.opts.live = M.history[self.hist_cursor].live
|
|
self.input:set(M.history[self.hist_cursor].pattern, M.history[self.hist_cursor].search)
|
|
end
|
|
|
|
--- Run the matcher with the current pattern.
|
|
--- May also trigger a new find if the search string has changed,
|
|
--- like during live searches.
|
|
function M:match()
|
|
local pattern = vim.trim(self.input.filter.pattern)
|
|
local search = vim.trim(self.input.filter.search)
|
|
local needs_match = false
|
|
self:hist_record()
|
|
if self.matcher.pattern ~= pattern then
|
|
self.matcher:init({ pattern = pattern })
|
|
needs_match = true
|
|
end
|
|
|
|
if self.finder:changed(search) then
|
|
-- pause rapid list updates to prevent flickering
|
|
-- of the search results
|
|
self.list:pause(60)
|
|
return self:find()
|
|
end
|
|
|
|
if not needs_match then
|
|
return
|
|
end
|
|
|
|
local prios = {} ---@type snacks.picker.Item[]
|
|
-- add current topk items to be checked first
|
|
vim.list_extend(prios, self.list.topk:get())
|
|
if not self.matcher:empty() then
|
|
-- next add the rest of the matched items
|
|
vim.list_extend(prios, self.list.items, 1, 1000)
|
|
end
|
|
|
|
self.list:clear()
|
|
self.matcher:run(self, { prios = prios })
|
|
self:progress()
|
|
end
|
|
|
|
--- Get the active filter
|
|
function M:filter()
|
|
return self.input.filter:clone()
|
|
end
|
|
|
|
return M
|