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. -->
This commit is contained in:
Folke Lemaitre 2025-01-14 22:53:59 +01:00 committed by GitHub
parent 1b7a57a0b1
commit 559d6c6bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 12013 additions and 126 deletions

View file

@ -18,9 +18,11 @@ A collection of small QoL plugins for Neovim.
| [gitbrowse](https://github.com/folke/snacks.nvim/blob/main/docs/gitbrowse.md) | Open the current file, branch, commit, or repo in a browser (e.g. GitHub, GitLab, Bitbucket) | |
| [indent](https://github.com/folke/snacks.nvim/blob/main/docs/indent.md) | Indent guides and scopes | |
| [input](https://github.com/folke/snacks.nvim/blob/main/docs/input.md) | Better `vim.ui.input` | ‼️ |
| [layout](https://github.com/folke/snacks.nvim/blob/main/docs/layout.md) | Window layouts | |
| [lazygit](https://github.com/folke/snacks.nvim/blob/main/docs/lazygit.md) | Open LazyGit in a float, auto-configure colorscheme and integration with Neovim | |
| [notifier](https://github.com/folke/snacks.nvim/blob/main/docs/notifier.md) | Pretty `vim.notify` | ‼️ |
| [notify](https://github.com/folke/snacks.nvim/blob/main/docs/notify.md) | Utility functions to work with Neovim's `vim.notify` | |
| [picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) | Picker for selecting items | ‼️ |
| [profiler](https://github.com/folke/snacks.nvim/blob/main/docs/profiler.md) | Neovim lua profiler | |
| [quickfile](https://github.com/folke/snacks.nvim/blob/main/docs/quickfile.md) | When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins. | ‼️ |
| [rename](https://github.com/folke/snacks.nvim/blob/main/docs/rename.md) | LSP-integrated file renaming with support for plugins like [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) and [mini.files](https://github.com/echasnovski/mini.files). | |
@ -104,8 +106,10 @@ Please refer to the readme of each plugin for their specific configuration.
---@field gitbrowse? snacks.gitbrowse.Config
---@field indent? snacks.indent.Config
---@field input? snacks.input.Config
---@field layout? snacks.layout.Config
---@field lazygit? snacks.lazygit.Config
---@field notifier? snacks.notifier.Config
---@field picker? snacks.picker.Config
---@field profiler? snacks.profiler.Config
---@field quickfile? snacks.quickfile.Config
---@field scope? snacks.scope.Config

View file

@ -6,10 +6,10 @@ Table of Contents *snacks-init-table-of-contents*
1. Config |snacks-init-config|
2. Types |snacks-init-types|
3. Module |snacks-init-module|
- Snacks.config.example() |snacks-init-module-snacks.config.example()|
- Snacks.config.get() |snacks-init-module-snacks.config.get()|
- Snacks.config.style() |snacks-init-module-snacks.config.style()|
- Snacks.setup() |snacks-init-module-snacks.setup()|
- Snacks.init.config.example()|snacks-init-module-snacks.init.config.example()|
- Snacks.init.config.get() |snacks-init-module-snacks.init.config.get()|
- Snacks.init.config.style() |snacks-init-module-snacks.init.config.style()|
- Snacks.init.setup() |snacks-init-module-snacks.init.setup()|
==============================================================================
1. Config *snacks-init-config*
@ -92,7 +92,7 @@ Table of Contents *snacks-init-table-of-contents*
<
`Snacks.config.example()` *Snacks.config.example()*
`Snacks.init.config.example()` *Snacks.init.config.example()*
Get an example config from the docs/examples directory.
@ -100,11 +100,11 @@ Get an example config from the docs/examples directory.
---@param snack string
---@param name string
---@param opts? table
Snacks.config.example(snack, name, opts)
Snacks.init.config.example(snack, name, opts)
<
`Snacks.config.get()` *Snacks.config.get()*
`Snacks.init.config.get()` *Snacks.init.config.get()*
>lua
---@generic T: table
@ -112,11 +112,11 @@ Get an example config from the docs/examples directory.
---@param defaults T
---@param ... T[]
---@return T
Snacks.config.get(snack, defaults, ...)
Snacks.init.config.get(snack, defaults, ...)
<
`Snacks.config.style()` *Snacks.config.style()*
`Snacks.init.config.style()` *Snacks.init.config.style()*
Register a new window style config.
@ -124,15 +124,15 @@ Register a new window style config.
---@param name string
---@param defaults snacks.win.Config|{}
---@return string
Snacks.config.style(name, defaults)
Snacks.init.config.style(name, defaults)
<
`Snacks.setup()` *Snacks.setup()*
`Snacks.init.setup()` *Snacks.init.setup()*
>lua
---@param opts snacks.Config?
Snacks.setup(opts)
Snacks.init.setup(opts)
<
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>

View file

@ -98,6 +98,7 @@ INPUT *snacks-input-styles-input*
i_esc = { "<esc>", { "cmp_close", "stopinsert" }, mode = "i", expr = true },
i_cr = { "<cr>", { "cmp_accept", "confirm" }, mode = "i", expr = true },
i_tab = { "<tab>", { "cmp_select_next", "cmp" }, mode = "i", expr = true },
i_ctrl_w = { "<c-w>", "<c-s-w>", mode = "i", expr = true },
q = "cancel",
},
}

181
doc/snacks-layout.txt Normal file
View file

@ -0,0 +1,181 @@
*snacks-layout.txt* snacks.nvim
==============================================================================
Table of Contents *snacks-layout-table-of-contents*
1. Setup |snacks-layout-setup|
2. Config |snacks-layout-config|
3. Types |snacks-layout-types|
4. Module |snacks-layout-module|
- Snacks.layout.new() |snacks-layout-module-snacks.layout.new()|
- layout:close() |snacks-layout-module-layout:close()|
- layout:each() |snacks-layout-module-layout:each()|
- layout:is_enabled() |snacks-layout-module-layout:is_enabled()|
- layout:is_hidden() |snacks-layout-module-layout:is_hidden()|
- layout:maximize() |snacks-layout-module-layout:maximize()|
- layout:show() |snacks-layout-module-layout:show()|
- layout:toggle() |snacks-layout-module-layout:toggle()|
- layout:valid() |snacks-layout-module-layout:valid()|
==============================================================================
1. Setup *snacks-layout-setup*
>lua
-- lazy.nvim
{
"folke/snacks.nvim",
---@type snacks.Config
opts = {
layout = {
-- your layout configuration comes here
-- or leave it empty to use the default settings
-- refer to the configuration section below
}
}
}
<
==============================================================================
2. Config *snacks-layout-config*
>lua
---@class snacks.layout.Config
---@field show? boolean show the layout on creation (default: true)
---@field wins table<string, snacks.win> windows to include in the layout
---@field layout snacks.layout.Box layout definition
---@field fullscreen? boolean open in fullscreen
---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled)
---@field on_update? fun(layout: snacks.layout)
{
layout = {
width = 0.6,
height = 0.6,
zindex = 50,
},
}
<
==============================================================================
3. Types *snacks-layout-types*
>lua
---@class snacks.layout.Win: snacks.win.Config,{}
---@field depth? number
---@field win string layout window name
<
>lua
---@class snacks.layout.Box: snacks.layout.Win,{}
---@field box "horizontal" | "vertical"
---@field id? number
---@field [number] snacks.layout.Win | snacks.layout.Box children
<
>lua
---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box
<
==============================================================================
4. Module *snacks-layout-module*
>lua
---@class snacks.layout
---@field opts snacks.layout.Config
---@field root snacks.win
---@field wins table<string, snacks.win|{enabled?:boolean}>
---@field box_wins snacks.win[]
---@field win_opts table<string, snacks.win.Config>
---@field closed? boolean
Snacks.layout = {}
<
`Snacks.layout.new()` *Snacks.layout.new()*
>lua
---@param opts snacks.layout.Config
Snacks.layout.new(opts)
<
LAYOUT:CLOSE() *snacks-layout-module-layout:close()*
Close the layout
>lua
---@param opts? {wins?: boolean}
layout:close(opts)
<
LAYOUT:EACH() *snacks-layout-module-layout:each()*
>lua
---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box)
---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box}
layout:each(cb, opts)
<
LAYOUT:IS_ENABLED() *snacks-layout-module-layout:is_enabled()*
Check if the window has been used in the layout
>lua
---@param w string
layout:is_enabled(w)
<
LAYOUT:IS_HIDDEN() *snacks-layout-module-layout:is_hidden()*
Check if a window is hidden
>lua
---@param win string
layout:is_hidden(win)
<
LAYOUT:MAXIMIZE() *snacks-layout-module-layout:maximize()*
Toggle fullscreen
>lua
layout:maximize()
<
LAYOUT:SHOW() *snacks-layout-module-layout:show()*
Show the layout
>lua
layout:show()
<
LAYOUT:TOGGLE() *snacks-layout-module-layout:toggle()*
Toggle a window
>lua
---@param win string
layout:toggle(win)
<
LAYOUT:VALID() *snacks-layout-module-layout:valid()*
Check if layout is valid (visible)
>lua
layout:valid()
<
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
vim:tw=78:ts=8:noet:ft=help:norl:

View file

@ -5,6 +5,7 @@ Table of Contents *snacks-meta-table-of-contents*
1. Types |snacks-meta-types|
2. Module |snacks-meta-module|
- Snacks.meta.file() |snacks-meta-module-snacks.meta.file()|
- Snacks.meta.get() |snacks-meta-module-snacks.meta.get()|
Meta functions for Snacks
@ -22,6 +23,7 @@ Meta functions for Snacks
---@field health? boolean
---@field types? boolean
---@field config? boolean
---@field merge? { [string|number]: string }
<
>lua
@ -37,6 +39,13 @@ Meta functions for Snacks
2. Module *snacks-meta-module*
`Snacks.meta.file()` *Snacks.meta.file()*
>lua
Snacks.meta.file(name)
<
`Snacks.meta.get()` *Snacks.meta.get()*
Get the metadata for all snacks plugins

1965
doc/snacks-picker.txt Normal file

File diff suppressed because it is too large Load diff

View file

@ -41,13 +41,19 @@ Similar plugins:
>lua
---@class snacks.scroll.Config
---@field animate snacks.animate.Config
---@field animate snacks.animate.Config|{}
---@field animate_repeat snacks.animate.Config|{}|{delay:number}
{
animate = {
duration = { step = 15, total = 250 },
easing = "linear",
},
spamming = 10, -- threshold for spamming detection
-- faster animation when repeating scroll after delay
animate_repeat = {
delay = 100, -- delay in ms before using the repeat animation
duration = { step = 5, total = 50 },
easing = "linear",
},
-- what buffers to animate
filter = function(buf)
return vim.g.snacks_scroll ~= false and vim.b[buf].snacks_scroll ~= false and vim.bo[buf].buftype ~= "terminal"
@ -73,6 +79,8 @@ Similar plugins:
---@field target vim.fn.winsaveview.ret
---@field scrolloff number
---@field virtualedit? string
---@field changedtick number
---@field last number vim.uv.hrtime of last scroll
<

View file

@ -8,6 +8,7 @@ Table of Contents *snacks-styles-table-of-contents*
- blame_line |snacks-styles-styles-blame_line|
- dashboard |snacks-styles-styles-dashboard|
- float |snacks-styles-styles-float|
- help |snacks-styles-styles-help|
- input |snacks-styles-styles-input|
- lazygit |snacks-styles-styles-lazygit|
- minimal |snacks-styles-styles-minimal|
@ -116,6 +117,20 @@ FLOAT *snacks-styles-styles-float*
<
HELP *snacks-styles-styles-help*
>lua
{
position = "float",
backdrop = false,
border = "top",
row = -1,
width = 0,
height = 0.3,
}
<
INPUT *snacks-styles-styles-input*
>lua
@ -149,6 +164,7 @@ INPUT *snacks-styles-styles-input*
i_esc = { "<esc>", { "cmp_close", "stopinsert" }, mode = "i", expr = true },
i_cr = { "<cr>", { "cmp_accept", "confirm" }, mode = "i", expr = true },
i_tab = { "<tab>", { "cmp_select_next", "cmp" }, mode = "i", expr = true },
i_ctrl_w = { "<c-w>", "<c-s-w>", mode = "i", expr = true },
q = "cancel",
},
}
@ -170,6 +186,7 @@ MINIMAL *snacks-styles-styles-minimal*
cursorcolumn = false,
cursorline = false,
cursorlineopt = "both",
colorcolumn = "",
fillchars = "eob: ,lastline:…",
list = false,
listchars = "extends:…,tab: ",

View file

@ -8,6 +8,7 @@ Table of Contents *snacks-win-table-of-contents*
3. Config |snacks-win-config|
4. Styles |snacks-win-styles|
- float |snacks-win-styles-float|
- help |snacks-win-styles-help|
- minimal |snacks-win-styles-minimal|
- split |snacks-win-styles-split|
5. Types |snacks-win-types|
@ -21,6 +22,7 @@ Table of Contents *snacks-win-table-of-contents*
- win:buf_valid() |snacks-win-module-win:buf_valid()|
- win:close() |snacks-win-module-win:close()|
- win:dim() |snacks-win-module-win:dim()|
- win:execute() |snacks-win-module-win:execute()|
- win:focus() |snacks-win-module-win:focus()|
- win:has_border() |snacks-win-module-win:has_border()|
- win:hide() |snacks-win-module-win:hide()|
@ -28,13 +30,17 @@ Table of Contents *snacks-win-table-of-contents*
- win:line() |snacks-win-module-win:line()|
- win:lines() |snacks-win-module-win:lines()|
- win:on() |snacks-win-module-win:on()|
- win:on_resize() |snacks-win-module-win:on_resize()|
- win:parent_size() |snacks-win-module-win:parent_size()|
- win:redraw() |snacks-win-module-win:redraw()|
- win:scratch() |snacks-win-module-win:scratch()|
- win:scroll() |snacks-win-module-win:scroll()|
- win:set_title() |snacks-win-module-win:set_title()|
- win:show() |snacks-win-module-win:show()|
- win:size() |snacks-win-module-win:size()|
- win:text() |snacks-win-module-win:text()|
- win:toggle() |snacks-win-module-win:toggle()|
- win:toggle_help() |snacks-win-module-win:toggle_help()|
- win:update() |snacks-win-module-win:update()|
- win:valid() |snacks-win-module-win:valid()|
- win:win_valid() |snacks-win-module-win:win_valid()|
@ -97,7 +103,7 @@ Easily create and manage floating windows or splits
---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center)
---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true)
---@field position? "float"|"bottom"|"top"|"left"|"right"
---@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field buf? number If set, use this buffer instead of creating a new one
---@field file? string If set, use this file instead of creating a new buffer
---@field enter? boolean Enter the window after opening (default: false)
@ -114,6 +120,7 @@ Easily create and manage floating windows or splits
---@field fixbuf? boolean don't allow other buffers to be opened in this window
---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer
---@field actions? table<string, snacks.win.Action.spec> Actions that can be used in key mappings
---@field resize? boolean Automatically resize the window when the editor is resized
{
show = true,
fixbuf = true,
@ -152,6 +159,20 @@ FLOAT *snacks-win-styles-float*
<
HELP *snacks-win-styles-help*
>lua
{
position = "float",
backdrop = false,
border = "top",
row = -1,
width = 0,
height = 0.3,
}
<
MINIMAL *snacks-win-styles-minimal*
>lua
@ -160,6 +181,7 @@ MINIMAL *snacks-win-styles-minimal*
cursorcolumn = false,
cursorline = false,
cursorlineopt = "both",
colorcolumn = "",
fillchars = "eob: ,lastline:…",
list = false,
listchars = "extends:…,tab: ",
@ -201,7 +223,7 @@ SPLIT *snacks-win-styles-split*
---@class snacks.win.Event: vim.api.keyset.create_autocmd
---@field buf? true
---@field win? true
---@field callback? fun(self: snacks.win)
---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
<
>lua
@ -237,12 +259,15 @@ SPLIT *snacks-win-styles-split*
---@class snacks.win
---@field id number
---@field buf? number
---@field scratch_buf? number
---@field win? number
---@field opts snacks.win.Config
---@field augroup? number
---@field backdrop? snacks.win
---@field keys snacks.win.Keys[]
---@field events (snacks.win.Event|{event:string|string[]})[]
---@field meta table<string, string>
---@field closed? boolean
Snacks.win = {}
<
@ -319,6 +344,14 @@ WIN:DIM() *snacks-win-module-win:dim()*
<
WIN:EXECUTE() *snacks-win-module-win:execute()*
>lua
---@param actions string|string[]
win:execute(actions)
<
WIN:FOCUS() *snacks-win-module-win:focus()*
>lua
@ -367,12 +400,19 @@ WIN:ON() *snacks-win-module-win:on()*
>lua
---@param event string|string[]
---@param cb fun(self: snacks.win)
---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
---@param opts? snacks.win.Event
win:on(event, cb, opts)
<
WIN:ON_RESIZE() *snacks-win-module-win:on_resize()*
>lua
win:on_resize()
<
WIN:PARENT_SIZE() *snacks-win-module-win:parent_size()*
>lua
@ -388,6 +428,13 @@ WIN:REDRAW() *snacks-win-module-win:redraw()*
<
WIN:SCRATCH() *snacks-win-module-win:scratch()*
>lua
win:scratch()
<
WIN:SCROLL() *snacks-win-module-win:scroll()*
>lua
@ -396,6 +443,15 @@ WIN:SCROLL() *snacks-win-module-win:scroll()*
<
WIN:SET_TITLE() *snacks-win-module-win:set_title()*
>lua
---@param title string
---@param pos? "center"|"left"|"right"
win:set_title(title, pos)
<
WIN:SHOW() *snacks-win-module-win:show()*
>lua
@ -427,6 +483,14 @@ WIN:TOGGLE() *snacks-win-module-win:toggle()*
<
WIN:TOGGLE_HELP() *snacks-win-module-win:toggle_help()*
>lua
---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config}
win:toggle_help(opts)
<
WIN:UPDATE() *snacks-win-module-win:update()*
>lua

91
docs/examples/picker.lua Normal file
View file

@ -0,0 +1,91 @@
local M = {}
M.examples = {}
M.examples.general = {
"folke/snacks.nvim",
opts = {
picker = {},
},
-- stylua: ignore
keys = {
{ "<leader>,", function() Snacks.picker.buffers() end, desc = "Buffers" },
{ "<leader>/", function() Snacks.picker.grep() end, desc = "Grep" },
{ "<leader>:", function() Snacks.picker.command_history() end, desc = "Command History" },
{ "<leader><space>", function() Snacks.picker.files() end, desc = "Find Files" },
-- find
{ "<leader>fb", function() Snacks.picker.buffers() end, desc = "Buffers" },
{ "<leader>fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" },
{ "<leader>ff", function() Snacks.picker.files() end, desc = "Find Files" },
{ "<leader>fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" },
{ "<leader>fr", function() Snacks.picker.recent() end, desc = "Recent" },
-- git
{ "<leader>gc", function() Snacks.picker.git_log() end, desc = "Git Log" },
{ "<leader>gs", function() Snacks.picker.git_status() end, desc = "Git Status" },
-- Grep
{ "<leader>sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" },
{ "<leader>sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" },
{ "<leader>sg", function() Snacks.picker.grep() end, desc = "Grep" },
{ "<leader>sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } },
-- search
{ '<leader>s"', function() Snacks.picker.registers() end, desc = "Registers" },
{ "<leader>sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" },
{ "<leader>sc", function() Snacks.picker.command_history() end, desc = "Command History" },
{ "<leader>sC", function() Snacks.picker.commands() end, desc = "Commands" },
{ "<leader>sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" },
{ "<leader>sh", function() Snacks.picker.help() end, desc = "Help Pages" },
{ "<leader>sH", function() Snacks.picker.highlights() end, desc = "Highlights" },
{ "<leader>sj", function() Snacks.picker.jumps() end, desc = "Jumps" },
{ "<leader>sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" },
{ "<leader>sl", function() Snacks.picker.loclist() end, desc = "Location List" },
{ "<leader>sM", function() Snacks.picker.man() end, desc = "Man Pages" },
{ "<leader>sm", function() Snacks.picker.marks() end, desc = "Marks" },
{ "<leader>sR", function() Snacks.picker.resume() end, desc = "Resume" },
{ "<leader>sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" },
{ "<leader>uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" },
{ "<leader>qp", function() Snacks.picker.projects() end, desc = "Projects" },
-- LSP
{ "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" },
{ "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" },
{ "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" },
{ "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" },
{ "<leader>ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" },
},
}
M.examples.trouble = {
"folke/trouble.nvim",
optional = true,
specs = {
"folke/snacks.nvim",
opts = function(_, opts)
return vim.tbl_deep_extend("force", opts or {}, {
picker = {
actions = require("trouble.sources.snacks").actions,
win = {
input = {
keys = {
["<c-t>"] = {
"trouble_open",
mode = { "n", "i" },
},
},
},
},
},
})
end,
},
}
M.examples.todo_comments = {
"folke/todo-comments.nvim",
optional = true,
-- stylua: ignore
keys = {
{ "<leader>st", function() Snacks.picker.todo_comments() end, desc = "Todo" },
{ "<leader>sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" },
},
}
return M

View file

@ -13,8 +13,10 @@
---@field gitbrowse? snacks.gitbrowse.Config
---@field indent? snacks.indent.Config
---@field input? snacks.input.Config
---@field layout? snacks.layout.Config
---@field lazygit? snacks.lazygit.Config
---@field notifier? snacks.notifier.Config
---@field picker? snacks.picker.Config
---@field profiler? snacks.profiler.Config
---@field quickfile? snacks.quickfile.Config
---@field scope? snacks.scope.Config
@ -53,10 +55,12 @@
---@field health snacks.health
---@field indent snacks.indent
---@field input snacks.input
---@field layout snacks.layout
---@field lazygit snacks.lazygit
---@field meta snacks.meta
---@field notifier snacks.notifier
---@field notify snacks.notify
---@field picker snacks.picker
---@field profiler snacks.profiler
---@field quickfile snacks.quickfile
---@field rename snacks.rename
@ -73,7 +77,7 @@
Snacks = {}
```
### `Snacks.config.example()`
### `Snacks.init.config.example()`
Get an example config from the docs/examples directory.
@ -81,10 +85,10 @@ Get an example config from the docs/examples directory.
---@param snack string
---@param name string
---@param opts? table
Snacks.config.example(snack, name, opts)
Snacks.init.config.example(snack, name, opts)
```
### `Snacks.config.get()`
### `Snacks.init.config.get()`
```lua
---@generic T: table
@ -92,10 +96,10 @@ Snacks.config.example(snack, name, opts)
---@param defaults T
---@param ... T[]
---@return T
Snacks.config.get(snack, defaults, ...)
Snacks.init.config.get(snack, defaults, ...)
```
### `Snacks.config.style()`
### `Snacks.init.config.style()`
Register a new window style config.
@ -103,12 +107,12 @@ Register a new window style config.
---@param name string
---@param defaults snacks.win.Config|{}
---@return string
Snacks.config.style(name, defaults)
Snacks.init.config.style(name, defaults)
```
### `Snacks.setup()`
### `Snacks.init.setup()`
```lua
---@param opts snacks.Config?
Snacks.setup(opts)
Snacks.init.setup(opts)
```

146
docs/layout.md Normal file
View file

@ -0,0 +1,146 @@
# 🍿 layout
<!-- docgen -->
## 📦 Setup
```lua
-- lazy.nvim
{
"folke/snacks.nvim",
---@type snacks.Config
opts = {
layout = {
-- your layout configuration comes here
-- or leave it empty to use the default settings
-- refer to the configuration section below
}
}
}
```
## ⚙️ Config
```lua
---@class snacks.layout.Config
---@field show? boolean show the layout on creation (default: true)
---@field wins table<string, snacks.win> windows to include in the layout
---@field layout snacks.layout.Box layout definition
---@field fullscreen? boolean open in fullscreen
---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled)
---@field on_update? fun(layout: snacks.layout)
{
layout = {
width = 0.6,
height = 0.6,
zindex = 50,
},
}
```
## 📚 Types
```lua
---@class snacks.layout.Win: snacks.win.Config,{}
---@field depth? number
---@field win string layout window name
```
```lua
---@class snacks.layout.Box: snacks.layout.Win,{}
---@field box "horizontal" | "vertical"
---@field id? number
---@field [number] snacks.layout.Win | snacks.layout.Box children
```
```lua
---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box
```
## 📦 Module
```lua
---@class snacks.layout
---@field opts snacks.layout.Config
---@field root snacks.win
---@field wins table<string, snacks.win|{enabled?:boolean}>
---@field box_wins snacks.win[]
---@field win_opts table<string, snacks.win.Config>
---@field closed? boolean
Snacks.layout = {}
```
### `Snacks.layout.new()`
```lua
---@param opts snacks.layout.Config
Snacks.layout.new(opts)
```
### `layout:close()`
Close the layout
```lua
---@param opts? {wins?: boolean}
layout:close(opts)
```
### `layout:each()`
```lua
---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box)
---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box}
layout:each(cb, opts)
```
### `layout:is_enabled()`
Check if the window has been used in the layout
```lua
---@param w string
layout:is_enabled(w)
```
### `layout:is_hidden()`
Check if a window is hidden
```lua
---@param win string
layout:is_hidden(win)
```
### `layout:maximize()`
Toggle fullscreen
```lua
layout:maximize()
```
### `layout:show()`
Show the layout
```lua
layout:show()
```
### `layout:toggle()`
Toggle a window
```lua
---@param win string
layout:toggle(win)
```
### `layout:valid()`
Check if layout is valid (visible)
```lua
layout:valid()
```

View file

@ -16,6 +16,7 @@ Meta functions for Snacks
---@field health? boolean
---@field types? boolean
---@field config? boolean
---@field merge? { [string|number]: string }
```
```lua
@ -28,6 +29,12 @@ Meta functions for Snacks
## 📦 Module
### `Snacks.meta.file()`
```lua
Snacks.meta.file(name)
```
### `Snacks.meta.get()`
Get the metadata for all snacks plugins

1718
docs/picker.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -93,6 +93,19 @@ The other options are used with `:lua Snacks.dashboard()`
}
```
### `help`
```lua
{
position = "float",
backdrop = false,
border = "top",
row = -1,
width = 0,
height = 0.3,
}
```
### `input`
```lua
@ -146,6 +159,7 @@ The other options are used with `:lua Snacks.dashboard()`
cursorcolumn = false,
cursorline = false,
cursorlineopt = "both",
colorcolumn = "",
fillchars = "eob: ,lastline:…",
list = false,
listchars = "extends:…,tab: ",

View file

@ -55,7 +55,7 @@ Snacks.win({
---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center)
---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true)
---@field position? "float"|"bottom"|"top"|"left"|"right"
---@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field buf? number If set, use this buffer instead of creating a new one
---@field file? string If set, use this file instead of creating a new buffer
---@field enter? boolean Enter the window after opening (default: false)
@ -72,6 +72,7 @@ Snacks.win({
---@field fixbuf? boolean don't allow other buffers to be opened in this window
---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer
---@field actions? table<string, snacks.win.Action.spec> Actions that can be used in key mappings
---@field resize? boolean Automatically resize the window when the editor is resized
{
show = true,
fixbuf = true,
@ -105,6 +106,19 @@ docs for more information on how to customize these styles
}
```
### `help`
```lua
{
position = "float",
backdrop = false,
border = "top",
row = -1,
width = 0,
height = 0.3,
}
```
### `minimal`
```lua
@ -113,6 +127,7 @@ docs for more information on how to customize these styles
cursorcolumn = false,
cursorline = false,
cursorlineopt = "both",
colorcolumn = "",
fillchars = "eob: ,lastline:…",
list = false,
listchars = "extends:…,tab: ",
@ -151,7 +166,7 @@ docs for more information on how to customize these styles
---@class snacks.win.Event: vim.api.keyset.create_autocmd
---@field buf? true
---@field win? true
---@field callback? fun(self: snacks.win)
---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
```
```lua
@ -185,12 +200,15 @@ docs for more information on how to customize these styles
---@class snacks.win
---@field id number
---@field buf? number
---@field scratch_buf? number
---@field win? number
---@field opts snacks.win.Config
---@field augroup? number
---@field backdrop? snacks.win
---@field keys snacks.win.Keys[]
---@field events (snacks.win.Event|{event:string|string[]})[]
---@field meta table<string, string>
---@field closed? boolean
Snacks.win = {}
```
@ -257,6 +275,13 @@ win:close(opts)
win:dim(parent)
```
### `win:execute()`
```lua
---@param actions string|string[]
win:execute(actions)
```
### `win:focus()`
```lua
@ -299,11 +324,17 @@ win:lines(from, to)
```lua
---@param event string|string[]
---@param cb fun(self: snacks.win)
---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
---@param opts? snacks.win.Event
win:on(event, cb, opts)
```
### `win:on_resize()`
```lua
win:on_resize()
```
### `win:parent_size()`
```lua
@ -317,6 +348,12 @@ win:parent_size()
win:redraw()
```
### `win:scratch()`
```lua
win:scratch()
```
### `win:scroll()`
```lua
@ -324,6 +361,14 @@ win:redraw()
win:scroll(up)
```
### `win:set_title()`
```lua
---@param title string
---@param pos? "center"|"left"|"right"
win:set_title(title, pos)
```
### `win:show()`
```lua
@ -351,6 +396,13 @@ win:text(from, to)
win:toggle()
```
### `win:toggle_help()`
```lua
---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config}
win:toggle_help(opts)
```
### `win:update()`
```lua

View file

@ -749,13 +749,18 @@ end
function M.pick(cmd, opts)
cmd = cmd or "files"
local config = Snacks.config.get("dashboard", defaults, opts)
local picker = Snacks.picker.config.get()
-- stylua: ignore
local try = {
function() return config.preset.pick(cmd, opts) end,
function() return require("fzf-lua")[cmd](opts) end,
function() return require("telescope.builtin")[cmd == "files" and "find_files" or cmd](opts) end,
function() return require("mini.pick").builtin[cmd](opts) end,
function() return Snacks.picker(cmd, opts) end,
}
if picker.enabled then
table.insert(try, 1, table.remove(try, #try))
end
for _, fn in ipairs(try) do
if pcall(fn) then
return

View file

@ -104,7 +104,7 @@ function M.setup(opts)
BufReadPre = { "bigfile" },
BufReadPost = { "quickfile", "indent" },
LspAttach = { "words" },
UIEnter = { "dashboard", "scroll", "input", "scope" },
UIEnter = { "dashboard", "scroll", "input", "scope", "picker" },
}
local function load(event)

403
lua/snacks/layout.lua Normal file
View file

@ -0,0 +1,403 @@
---@class snacks.layout
---@field opts snacks.layout.Config
---@field root snacks.win
---@field wins table<string, snacks.win|{enabled?:boolean}>
---@field box_wins snacks.win[]
---@field win_opts table<string, snacks.win.Config>
---@field closed? boolean
local M = {}
M.__index = M
M.meta = {
desc = "Window layouts",
}
---@class snacks.layout.Win: snacks.win.Config,{}
---@field depth? number
---@field win string layout window name
---@class snacks.layout.Box: snacks.layout.Win,{}
---@field box "horizontal" | "vertical"
---@field id? number
---@field [number] snacks.layout.Win | snacks.layout.Box children
---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box
---@class snacks.layout.Config
---@field show? boolean show the layout on creation (default: true)
---@field wins table<string, snacks.win> windows to include in the layout
---@field layout snacks.layout.Box layout definition
---@field fullscreen? boolean open in fullscreen
---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled)
---@field on_update? fun(layout: snacks.layout)
local defaults = {
layout = {
width = 0.6,
height = 0.6,
zindex = 50,
},
}
---@param opts snacks.layout.Config
function M.new(opts)
local self = setmetatable({}, M)
self.opts = vim.tbl_extend("force", defaults, opts)
self.win_opts = {}
self.wins = self.opts.wins or {}
self.box_wins = {}
-- assign ids to boxes and create box wins if needed
local id = 1
self:each(function(box, parent)
box.depth = (parent and parent.depth + 1) or 0
if box.box then
---@cast box snacks.layout.Box
box.id, id = id, id + 1
local has_border = box.border and box.border ~= "" and box.border ~= "none"
local is_root = box.id == 1
if is_root or has_border then
local backdrop = false ---@type boolean?
if is_root then
backdrop = nil
end
self.box_wins[box.id] = Snacks.win(Snacks.win.resolve(box, {
relative = is_root and "editor" or "win",
focusable = false,
enter = false,
show = false,
resize = false,
noautocmd = true,
backdrop = backdrop,
zindex = (self.opts.layout.zindex or 50) + box.depth,
bo = { filetype = "snacks_layout_box" },
border = box.border,
}))
end
end
end)
self.root = self.box_wins[1]
assert(self.root, "no root box found")
for w, win in pairs(self.wins) do
self.win_opts[w] = vim.deepcopy(win.opts)
end
-- close layout when any win is closed
self.root:on("WinClosed", function(_, ev)
if self.closed then
return true
end
local wid = tonumber(ev.match)
for _, win in pairs(self:get_wins()) do
if win.win == wid then
self:close()
return true
end
end
end)
-- update layout on VimResized
self.root:on("VimResized", function()
self:update()
end)
if self.opts.show ~= false then
vim.schedule(function()
self:show()
end)
end
return self
end
---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box)
---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box}
function M:each(cb, opts)
opts = opts or {}
---@param widget snacks.layout.Widget
---@param parent? snacks.layout.Box
local function _each(widget, parent)
if widget.box then
if opts.boxes ~= false then
cb(widget, parent)
end
---@cast widget snacks.layout.Box
for _, child in ipairs(widget) do
_each(child, widget)
end
elseif opts.wins ~= false then
cb(widget, parent)
end
end
_each(opts.box or self.opts.layout)
end
--- Check if a window is hidden
---@param win string
function M:is_hidden(win)
return self.opts.hidden and vim.tbl_contains(self.opts.hidden, win)
end
--- Toggle a window
---@param win string
function M:toggle(win)
self.opts.hidden = self.opts.hidden or {}
if self:is_hidden(win) then
self.opts.hidden = vim.tbl_filter(function(w)
return w ~= win
end, self.opts.hidden)
else
table.insert(self.opts.hidden, win)
end
self:update()
end
---@package
function M:update()
if self.closed then
return
end
vim.o.lazyredraw = true
for _, win in pairs(self.wins) do
win.enabled = false
end
local layout = vim.deepcopy(self.opts.layout)
if self.opts.fullscreen then
layout.width = 0
layout.height = 0
layout.col = 0
layout.row = 0
end
if not self.root:valid() then
self.root:show()
end
self:update_box(layout, {
col = 0,
row = 0,
width = vim.o.columns,
height = vim.o.lines,
})
for _, win in pairs(self:get_wins()) do
win:show()
end
for w, win in pairs(self.wins) do
if not self:is_enabled(w) and win:win_valid() then
win:close()
end
end
if self.opts.on_update then
self.opts.on_update(self)
end
vim.o.lazyredraw = false
end
---@param box snacks.layout.Box
---@param parent snacks.win.Dim
---@private
function M:update_box(box, parent)
local size_main = box.box == "horizontal" and "width" or "height"
local pos_main = box.box == "horizontal" and "col" or "row"
local is_root = box.id == 1
if not is_root then
box.col = box.col or 0
box.row = box.row or 0
end
local children = {} ---@type snacks.layout.Widget[]
for c, child in ipairs(box) do
if not (child.win and self:is_hidden(child.win)) then
children[#children + 1] = child
end
box[c] = nil
end
for c, child in ipairs(children) do
box[c] = child
end
local dim, border = self:dim_box(box, parent)
local orig_dim = vim.deepcopy(dim)
if is_root then
dim.col = 0
dim.row = 0
else
dim.col = dim.col + border.left
dim.row = dim.row + border.top
end
local free = vim.deepcopy(dim)
local function size(child)
return child[size_main] or 0
end
local dims = {} ---@type table<number, snacks.win.Dim>
local flex = 0
for c, child in ipairs(box) do
flex = flex + (size(child) == 0 and 1 or 0)
if size(child) > 0 then
dims[c] = self:resolve(child, dim)
free[size_main] = free[size_main] - dims[c][size_main]
end
end
local free_main = free[size_main]
for c, child in ipairs(box) do
if size(child) == 0 then
free[size_main] = math.floor(free_main / flex)
flex = flex - 1
free_main = free_main - free[size_main]
dims[c] = self:resolve(child, free)
end
end
-- assert(free[size_main] >= 0, "not enough space for children")
-- fix positions
local offset = 0
for c, child in ipairs(box) do
local wins = self:get_wins(child)
for _, win in ipairs(wins) do
win.opts[pos_main] = win.opts[pos_main] + offset
end
offset = offset + dims[c][size_main]
end
dim.width = dim.width + border.left + border.right
dim.height = dim.height + border.top + border.bottom
local box_win = self.box_wins[box.id]
if box_win then
if not is_root then
box_win.opts.win = self.root.win
end
box_win.opts.col = orig_dim.col
box_win.opts.row = orig_dim.row
box_win.opts.width = orig_dim.width
box_win.opts.height = orig_dim.height
end
return dim
end
---@param widget? snacks.layout.Widget
---@package
function M:get_wins(widget)
local ret = {} ---@type snacks.win[]
self:each(function(w)
if w.box and self.box_wins[w.id] then
table.insert(ret, self.box_wins[w.id])
elseif w.win and self:is_enabled(w.win) then
table.insert(ret, self.wins[w.win])
end
end, { box = widget })
return ret
end
---@param widget snacks.layout.Widget
---@param parent snacks.win.Dim
---@private
function M:resolve(widget, parent)
if widget.box then
---@cast widget snacks.layout.Box
return self:update_box(widget, parent)
else
assert(widget.win, "widget must have win or box")
---@cast widget snacks.layout.Win
return self:update_win(widget, parent)
end
end
---@param widget snacks.layout.Box
---@param parent snacks.win.Dim
---@private
function M:dim_box(widget, parent)
local opts = vim.deepcopy(widget) --[[@as snacks.win.Config]]
-- adjust max width / height
opts.max_width = math.min(parent.width, opts.max_width or parent.width)
opts.max_height = math.min(parent.height, opts.max_height or parent.height)
local fake_win = setmetatable({ opts = opts }, Snacks.win)
local ret = fake_win:dim(parent)
return ret, fake_win:border_size()
end
---@param win snacks.layout.Win
---@param parent snacks.win.Dim
---@private
function M:update_win(win, parent)
local w = self.wins[win.win]
w.enabled = true
assert(w, ("win %s not part of layout"):format(win.win))
-- add win opts from layout
w.opts = vim.tbl_extend(
"force",
vim.deepcopy(self.win_opts[win.win] or {}),
{
width = 0,
height = 0,
enter = false,
},
win,
{
relative = "win",
win = self.root.win,
backdrop = false,
resize = false,
zindex = self.root.opts.zindex + win.depth,
}
)
-- adjust max width / height
w.opts.max_width = math.min(parent.width, w.opts.max_width or parent.width)
w.opts.max_height = math.min(parent.height, w.opts.max_height or parent.height)
-- resolve width / height relative to parent box
local dim = w:dim(parent)
w.opts.width, w.opts.height = dim.width, dim.height
local border = w:border_size()
w.opts.col, w.opts.row = parent.col, parent.row
dim.width = dim.width + border.left + border.right
dim.height = dim.height + border.top + border.bottom
-- dim.col = dim.col + border.left
-- dim.row = dim.row + border.top
return dim
end
--- Toggle fullscreen
function M:maximize()
self.opts.fullscreen = not self.opts.fullscreen
self:update()
end
--- Close the layout
---@param opts? {wins?: boolean}
function M:close(opts)
if self.closed then
return
end
opts = opts or {}
self.closed = true
for w, win in pairs(self.wins) do
if opts.wins == false then
win.opts = self.win_opts[w]
elseif win:valid() then
win:close()
end
end
for _, win in pairs(self.box_wins) do
win:close()
end
end
--- Check if layout is valid (visible)
function M:valid()
return self.root:valid()
end
--- Check if the window has been used in the layout
---@param w string
function M:is_enabled(w)
return not self:is_hidden(w) and self.wins[w].enabled
end
--- Show the layout
function M:show()
if self:valid() then
return
end
self:update()
end
return M

View file

@ -41,6 +41,16 @@ local query = vim.treesitter.query.parse(
(expression_list
value: (table_constructor) @example_config)
) @example
;; props
(assignment_statement
(variable_list
name: (dot_index_expression
field: (identifier) @prop_name)
@_pn (#lua-match? @_pn "^M%."))
(expression_list
value: (_) @prop_value)
) @prop
]]
)
@ -56,14 +66,24 @@ local query = vim.treesitter.query.parse(
---@field captures snacks.docs.Capture[]
---@field comments string[]
---@class snacks.docs.Method
---@field mod string
---@field name string
---@field args string
---@field comment? string
---@field types? string
---@field type "method"|"function"}[]
---@class snacks.docs.Info
---@field config? string
---@field mod? string
---@field methods {name: string, args: string, comment?: string, types?: string, type: "method"|"function"}[]
---@field modname? string
---@field methods snacks.docs.Method[]
---@field types string[]
---@field setup? string
---@field examples table<string, string>
---@field styles {name:string, opts:string, comment?:string}[]
---@field props table<string, string>
---@param lines string[]
function M.parse(lines)
@ -85,6 +105,7 @@ function M.parse(lines)
---@type snacks.docs.Parse
local ret = { captures = {}, comments = {} }
local used_comments = {} ---@type table<number, boolean>
for id, node in query:iter_captures(parser:trees()[1]:root(), source) do
local name = query.captures[id]
if not name:find("_") then
@ -92,7 +113,7 @@ function M.parse(lines)
local fields = {}
for id2, node2 in query:iter_captures(node, source) do
local c = query.captures[id2]
if c:find(".+_") then
if c:find(name .. "_") then
fields[c:gsub("^.*_", "")] = vim.treesitter.get_node_text(node2, source)
end
end
@ -101,7 +122,7 @@ function M.parse(lines)
local comment = "" ---@type string
if comments[node:start()] then
comment = comments[node:start()]
comments[node:start()] = nil
used_comments[node:start()] = true
end
table.insert(ret.captures, {
@ -114,6 +135,9 @@ function M.parse(lines)
})
end
end
for l in pairs(used_comments) do
comments[l] = nil
end
-- remove comments that are followed by code
for l in pairs(comments) do
@ -131,7 +155,9 @@ function M.parse(lines)
end
---@param lines string[]
function M.extract(lines)
---@param opts {prefix: string, name:string}
function M.extract(lines, opts)
local fqn = opts.prefix .. "." .. opts.name
local parse = M.parse(lines)
---@type snacks.docs.Info
local ret = {
@ -141,10 +167,16 @@ function M.extract(lines)
end, parse.comments),
styles = {},
examples = {},
props = {},
}
for _, c in ipairs(parse.captures) do
if c.comment:find("@private") then
if
c.comment:find("@private")
or c.comment:find("@protected")
or c.comment:find("@package")
or c.comment:find("@hide")
then
-- skip private
elseif c.name == "local" then
if vim.tbl_contains({ "defaults", "config" }, c.fields.name) then
@ -152,13 +184,23 @@ function M.extract(lines)
elseif c.fields.name == "M" then
ret.mod = c.comment
end
elseif c.name == "prop" then
local name = c.fields.name:sub(1)
local value = c.fields.value
ret.props[name] = c.comment == "" and value or c.comment .. "\n" .. value
elseif c.name == "fun" then
local name = c.fields.name:sub(2)
local args = (c.fields.params or ""):sub(2, -2)
local type = name:sub(1, 1)
name = name:sub(2)
if not name:find("^_") then
table.insert(ret.methods, { name = name, args = args, comment = c.comment, type = type })
table.insert(ret.methods, {
mod = type == ":" and opts.name or fqn,
name = name,
args = args,
comment = c.comment,
type = type,
})
end
elseif c.name == "style" then
table.insert(ret.styles, { name = c.fields.name, opts = c.fields.config, comment = c.comment })
@ -167,6 +209,27 @@ function M.extract(lines)
end
end
if ret.mod then
local mod_lines = vim.split(ret.mod, "\n")
mod_lines = vim.tbl_filter(function(line)
local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]]
if overload then
table.insert(ret.methods, {
mod = fqn,
name = "",
args = "",
type = "",
comment = "---@type " .. overload,
})
return false
elseif line:find("^%s*$") then
return false
end
return true
end, mod_lines)
ret.mod = table.concat(mod_lines, "\n")
end
return ret
end
@ -186,6 +249,7 @@ end
---@param opts? {extract_comment: boolean} -- default true
function M.md(str, opts)
str = str or ""
str = str:gsub("\r", "")
opts = opts or {}
if opts.extract_comment == nil then
opts.extract_comment = true
@ -223,13 +287,15 @@ function M.examples(name)
return {}
end
local lines = vim.fn.readfile(fname)
local info = M.extract(lines)
local info = M.extract(lines, { prefix = "Snacks.examples", name = name })
return info.examples
end
---@param name string
---@param info snacks.docs.Info
function M.render(name, info)
---@param opts? {setup?:boolean, config?:boolean, styles?:boolean, types?:boolean, prefix?:string, examples?:boolean}
function M.render(name, info, opts)
opts = opts or {}
local lines = {} ---@type string[]
local function add(line)
table.insert(lines, line)
@ -239,8 +305,11 @@ function M.render(name, info)
if name == "init" then
prefix = "Snacks"
end
if info.modname then
prefix = "local M"
end
if name ~= "init" and (info.config or info.setup) then
if name ~= "init" and (info.config or info.setup) and opts.setup ~= false then
add("## 📦 Setup\n")
add(([[
```lua
@ -260,24 +329,26 @@ function M.render(name, info)
]]):format(info.setup or name, name))
end
if info.config then
if info.config and opts.config ~= false then
add("## ⚙️ Config\n")
add(M.md(info.config))
end
local examples = M.examples(name)
local names = vim.tbl_keys(examples)
table.sort(names)
if not vim.tbl_isempty(examples) then
add("## 🚀 Examples\n")
for _, n in ipairs(names) do
local example = examples[n]
add(("### `%s`\n"):format(n))
add(M.md(example))
if opts.examples ~= false then
local examples = M.examples(name)
local names = vim.tbl_keys(examples)
table.sort(names)
if not vim.tbl_isempty(examples) then
add("## 🚀 Examples\n")
for _, n in ipairs(names) do
local example = examples[n]
add(("### `%s`\n"):format(n))
add(M.md(example))
end
end
end
if #info.styles > 0 then
if #info.styles > 0 and opts.styles ~= false then
table.sort(info.styles, function(a, b)
return a.name < b.name
end)
@ -303,57 +374,44 @@ docs for more information on how to customize these styles
end
end
if #info.types > 0 then
if #info.types > 0 and opts.types ~= false then
add("## 📚 Types\n")
for _, t in ipairs(info.types) do
add(M.md(t))
end
end
if info.mod or #info.methods > 0 then
add("## 📦 Module\n")
local mod_lines = info.mod and not info.mod:find("^%s*$") and vim.split(info.mod, "\n") or {}
local hide = #mod_lines == 0 or (#mod_lines == 1 and mod_lines[1]:find("@class"))
if not hide or #info.methods > 0 then
local title = info.modname and ("`%s`"):format(info.modname) or "Module"
add(("## 📦 %s\n"):format(title))
end
if info.mod then
local mod_lines = vim.split(info.mod, "\n")
mod_lines = vim.tbl_filter(function(line)
local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]]
if overload then
table.insert(info.methods, {
name = "",
args = "",
type = "",
comment = "---@type " .. overload,
})
return false
elseif line:find("^%s*$") then
return false
end
return true
end, mod_lines)
local hide = #mod_lines == 1 and mod_lines[1]:find("@class")
if not hide then
table.insert(mod_lines, prefix .. " = {}")
add(M.md(table.concat(mod_lines, "\n")))
end
if info.mod and not hide then
table.insert(mod_lines, prefix .. " = {}")
add(M.md(table.concat(mod_lines, "\n")))
end
table.sort(info.methods, function(a, b)
if a.mod ~= b.mod then
return a.mod < b.mod
end
if a.type == b.type then
return a.name < b.name
end
return a.type < b.type
end)
local last ---@type string?
for _, method in ipairs(info.methods) do
add(("### `%s%s%s()`\n"):format(method.type == ":" and name or prefix, method.type, method.name))
local code = ("%s\n%s%s%s(%s)"):format(
method.comment or "",
method.type == ":" and name or prefix,
method.type,
method.name,
method.args
)
local title = ("### `%s%s%s()`\n"):format(method.mod, method.type, method.name)
if title ~= last then
last = title
add(title)
end
local code = ("%s\n%s%s%s(%s)"):format(method.comment or "", method.mod, method.type, method.name, method.args)
add(M.md(code))
end
@ -383,7 +441,33 @@ function M.write(name, lines)
table.insert(top, "")
vim.list_extend(top, lines)
vim.fn.writefile(top, path)
vim.fn.writefile(vim.split(table.concat(top, "\n"), "\n"), path)
end
---@param ret string[]
function M.picker(ret)
local lines = vim.fn.readfile("lua/snacks/picker/config/sources.lua")
local info = M.extract(lines, { prefix = "Snacks.picker", name = "sources" })
local sources = vim.tbl_keys(info.props)
table.sort(sources)
table.insert(ret, "## 🔍 Sources\n")
for _, source in ipairs(sources) do
local opts = info.props[source]
table.insert(ret, ("### `%s`"):format(source))
table.insert(ret, "")
table.insert(ret, M.md(opts))
end
lines = vim.fn.readfile("lua/snacks/picker/config/layouts.lua")
info = M.extract(lines, { prefix = "Snacks.picker", name = "layouts" })
sources = vim.tbl_keys(info.props)
table.sort(sources)
table.insert(ret, "## 🖼️ Layouts\n")
for _, source in ipairs(sources) do
local opts = info.props[source]
table.insert(ret, ("### `%s`"):format(source))
table.insert(ret, "")
table.insert(ret, M.md(opts))
end
end
function M._build()
@ -401,6 +485,7 @@ function M._build()
examples = {},
styles = {},
setup = "---@type table<string, snacks.win.Config>\n styles",
props = {},
}
for _, plugin in pairs(plugins) do
@ -408,11 +493,56 @@ function M._build()
local name = plugin.name
print("[gen] " .. name .. ".md")
local lines = vim.fn.readfile(plugin.file)
local info = M.extract(lines)
local info = M.extract(lines, { prefix = "Snacks", name = name })
local children = {} ---@type snacks.docs.Info[]
for c, child in pairs(plugin.meta.merge or {}) do
local child_name = type(c) == "number" and child or c --[[@as string]]
local child_file = ("%s/%s/%s"):format(Snacks.meta.root, name, child:gsub("%.", "/"))
for _, f in ipairs({ ".lua", "/init.lua" }) do
if vim.uv.fs_stat(child_file .. f) then
child_file = child_file .. f
break
end
end
assert(vim.uv.fs_stat(child_file), ("file not found: %s"):format(child_file))
local child_lines = vim.fn.readfile(child_file)
local child_info = M.extract(child_lines, { prefix = "Snacks." .. name, name = child_name })
child_info.modname = "snacks." .. name .. "." .. child
if child_info.config then
assert(not info.config, "config already exists")
info.config = child_info.config
end
vim.list_extend(info.types, child_info.types)
table.insert(children, child_info)
end
vim.list_extend(styles.styles, info.styles)
info.config = name ~= "init" and info.config or nil
plugin.meta.config = info.config ~= nil
M.write(name, M.render(name, info))
local rendered = {} ---@type string[]
vim.list_extend(rendered, M.render(name, info))
if name == "picker" then
M.picker(rendered)
end
for _, child in ipairs(children) do
table.insert(rendered, "")
vim.list_extend(
rendered,
M.render(name, child, {
setup = false,
config = false,
styles = false,
types = false,
examples = false,
})
)
end
M.write(name, rendered)
if plugin.meta.types then
table.insert(types.fields, ("---@field %s snacks.%s"):format(plugin.name, plugin.name))
end
@ -454,7 +584,7 @@ end
function M.readme(plugins, types)
local path = "lua/snacks/init.lua"
local lines = vim.fn.readfile(path) --[[ @as string[] ]]
local info = M.extract(lines)
local info = M.extract(lines, { prefix = "Snacks", name = "init" })
local readme = table.concat(vim.fn.readfile("README.md"), "\n")
local example = table.concat(vim.fn.readfile("docs/examples/init.lua"), "\n")

View file

@ -15,6 +15,7 @@ M.meta = {
---@field health? boolean
---@field types? boolean
---@field config? boolean
---@field merge? { [string|number]: string }
---@class snacks.meta.Plugin
---@field name string
@ -22,15 +23,20 @@ M.meta = {
---@field meta snacks.meta.Meta
---@field health? fun()
M.root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
function M.file(name)
return vim.fs.normalize(("%s/%s"):format(M.root, name))
end
--- Get the metadata for all snacks plugins
---@return snacks.meta.Plugin[]
function M.get()
local ret = {} ---@type snacks.meta.Plugin[]
local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
for file, t in vim.fs.dir(root, { depth = 1 }) do
for file, t in vim.fs.dir(M.root, { depth = 1 }) do
local name = vim.fn.fnamemodify(file, ":t:r")
file = t == "directory" and ("%s/init.lua"):format(file) or file
file = root .. "/" .. file
file = M.root .. "/" .. file
local mod = name == "init" and setmetatable({ meta = { desc = "Snacks", hide = true } }, { __index = Snacks })
or Snacks[name] --[[@as snacks.meta.Plugin]]
assert(type(mod) == "table", ("`Snacks.%s` not found"):format(name))

View file

@ -12,10 +12,12 @@
---@field health snacks.health
---@field indent snacks.indent
---@field input snacks.input
---@field layout snacks.layout
---@field lazygit snacks.lazygit
---@field meta snacks.meta
---@field notifier snacks.notifier
---@field notify snacks.notify
---@field picker snacks.picker
---@field profiler snacks.profiler
---@field quickfile snacks.quickfile
---@field rename snacks.rename
@ -38,8 +40,10 @@
---@field gitbrowse? snacks.gitbrowse.Config|{}
---@field indent? snacks.indent.Config|{}
---@field input? snacks.input.Config|{}
---@field layout? snacks.layout.Config|{}
---@field lazygit? snacks.lazygit.Config|{}
---@field notifier? snacks.notifier.Config|{}
---@field picker? snacks.picker.Config|{}
---@field profiler? snacks.profiler.Config|{}
---@field quickfile? snacks.quickfile.Config|{}
---@field scope? snacks.scope.Config|{}

View file

@ -0,0 +1,328 @@
---@class snacks.picker.actions
---@field [string] snacks.picker.Action.spec
local M = {}
local SCROLL_WHEEL_DOWN = Snacks.util.keycode("<ScrollWheelDown>")
local SCROLL_WHEEL_UP = Snacks.util.keycode("<ScrollWheelUp>")
function M.edit(picker)
picker:close()
local win = vim.api.nvim_get_current_win()
-- save position in jump list
vim.api.nvim_win_call(win, function()
vim.cmd("normal! m'")
end)
local items = picker:selected({ fallback = true })
for _, item in ipairs(items) do
-- load the buffer
local buf = item.buf ---@type number
if not buf then
local path = assert(Snacks.picker.util.path(item), "Either item.buf or item.file is required")
buf = vim.fn.bufadd(path)
end
if not vim.api.nvim_buf_is_loaded(buf) then
vim.api.nvim_buf_call(buf, function()
vim.cmd("edit")
end)
vim.bo[buf].buflisted = true
end
-- set the buffer
vim.api.nvim_win_set_buf(win, buf)
-- set the cursor
if item.pos then
vim.api.nvim_win_set_cursor(win, { item.pos[1], item.pos[2] })
elseif item.search then
vim.cmd(item.search)
vim.cmd("noh")
end
-- center
vim.cmd("norm! zzzv")
end
end
M.cancel = function() end
M.confirm = M.edit
function M.toggle_maximize(picker)
picker.layout:maximize()
end
function M.toggle_preview(picker)
picker.layout:toggle("preview")
picker:show_preview()
end
---@param items snacks.picker.Item[]
---@param opts? {win?:number}
local function setqflist(items, opts)
local qf = {} ---@type vim.quickfix.entry[]
for _, item in ipairs(items) do
qf[#qf + 1] = {
filename = item.file,
bufnr = item.buf,
lnum = item.pos and item.pos[1] or 1,
col = item.pos and item.pos[2] or 1,
end_lnum = item.end_pos and item.end_pos[1] or nil,
end_col = item.end_pos and item.end_pos[2] or nil,
text = item.text,
pattern = item.search,
valid = true,
}
end
if opts and opts.win then
vim.fn.setloclist(opts.win, qf)
vim.cmd("lopen")
else
vim.fn.setqflist(qf)
vim.cmd("copen")
end
end
--- Send selected or all items to the quickfix list.
function M.qflist(picker)
picker:close()
local sel = picker:selected()
local items = #sel > 0 and sel or picker.finder.items
setqflist(items)
end
--- Send selected or all items to the location list.
function M.loclist(picker)
picker:close()
local sel = picker:selected()
local items = #sel > 0 and sel or picker.finder.items
setqflist(items, { win = picker.main })
end
function M.copy(_, item)
if item then
vim.fn.setreg("+", item.data or item.text)
end
end
function M.history_back(picker)
picker:hist()
end
function M.history_forward(picker)
picker:hist(true)
end
function M.edit_tab(picker)
picker:close()
vim.cmd("tabnew")
return picker:action("edit")
end
function M.edit_split(picker)
picker:close()
vim.cmd("split")
return picker:action("edit")
end
function M.edit_vsplit(picker)
picker:close()
vim.cmd("vsplit")
return picker:action("edit")
end
--- Toggles the selection of the current item,
--- and moves the cursor to the next item.
function M.select_and_next(picker)
picker.list:select()
M.list_down(picker)
end
--- Toggles the selection of the current item,
--- and moves the cursor to the prev item.
function M.select_and_prev(picker)
picker.list:select()
M.list_down(picker)
end
function M.cmd(picker, item)
picker:close()
if item and item.cmd then
vim.schedule(function()
vim.cmd(item.cmd)
end)
end
end
function M.search(picker, item)
picker:close()
if item then
vim.api.nvim_input("/" .. item.text)
end
end
--- Tries to load the session, if it fails, it will open the picker.
function M.load_session(picker)
picker:close()
local item = picker:current()
if not item then
return
end
local dir = item.file
local session_loaded = false
vim.api.nvim_create_autocmd("SessionLoadPost", {
once = true,
callback = function()
session_loaded = true
end,
})
vim.defer_fn(function()
if not session_loaded then
Snacks.picker.files()
end
end, 100)
vim.fn.chdir(dir)
local session = Snacks.dashboard.sections.session()
if session then
vim.cmd(session.action:sub(2))
end
end
function M.help(picker)
local item = picker:current()
if item then
picker:close()
vim.cmd("help " .. item.text)
end
end
function M.preview_scroll_down(picker)
picker.preview.win:scroll()
end
function M.preview_scroll_up(picker)
picker.preview.win:scroll(true)
end
function M.toggle_live(picker)
if not picker.opts.supports_live then
Snacks.notify.warn("Live search is not supported for `" .. picker.source_name .. "`", { title = "Snacks Picker" })
return
end
picker.opts.live = not picker.opts.live
picker.input:set()
picker.input:update()
end
function M.toggle_focus(picker)
if vim.api.nvim_get_current_win() == picker.input.win.win then
picker.list.win:focus()
else
picker.input.win:focus()
end
end
function M.cycle_win(picker)
local wins = { picker.input.win.win, picker.preview.win.win, picker.list.win.win }
wins = vim.tbl_filter(function(w)
return vim.api.nvim_win_is_valid(w)
end, wins)
local win = vim.api.nvim_get_current_win()
local idx = 1
for i, w in ipairs(wins) do
if w == win then
idx = i
break
end
end
win = wins[idx % #wins + 1] or 1 -- cycle
vim.api.nvim_set_current_win(win)
if win == picker.input.win.win then
vim.cmd("startinsert")
end
end
function M.focus_input(picker)
picker.input.win:focus()
vim.cmd("startinsert")
end
function M.focus_list(picker)
picker.list.win:focus()
end
function M.focus_preview(picker)
picker.preview.win:focus()
end
function M.toggle_ignored(picker)
local opts = picker.opts --[[@as snacks.picker.files.Config]]
opts.ignored = not opts.ignored
picker:find()
end
function M.toggle_hidden(picker)
local opts = picker.opts --[[@as snacks.picker.files.Config]]
opts.hidden = not opts.hidden
picker:find()
end
function M.list_top(picker)
picker.list:move(1, true)
end
function M.list_bottom(picker)
picker.list:move(picker.list:count(), true)
end
function M.list_down(picker)
picker.list:move(1)
end
function M.list_up(picker)
picker.list:move(-1)
end
function M.list_scroll_top(picker)
local cursor = picker.list.cursor
picker.list:view(cursor, cursor)
end
function M.list_scroll_bottom(picker)
local cursor = picker.list.cursor
picker.list:view(cursor, picker.list.cursor - picker.list:height() + 1)
end
function M.list_scroll_center(picker)
local cursor = picker.list.cursor
picker.list:view(cursor, picker.list.cursor - math.ceil(picker.list:height() / 2) + 1)
end
function M.list_scroll_down(picker)
picker.list:scroll(picker.list.state.scroll)
end
function M.list_scroll_up(picker)
picker.list:scroll(-picker.list.state.scroll)
end
function M.list_scroll_wheel_down(picker)
local mouse_win = vim.fn.getmousepos().winid
if mouse_win == picker.list.win.win then
picker.list:scroll(picker.list.state.mousescroll)
else
vim.api.nvim_feedkeys(SCROLL_WHEEL_DOWN, "n", true)
end
end
function M.list_scroll_wheel_up(picker)
local mouse_win = vim.fn.getmousepos().winid
if mouse_win == picker.list.win.win then
picker.list:scroll(-picker.list.state.mousescroll)
else
vim.api.nvim_feedkeys(SCROLL_WHEEL_UP, "n", true)
end
end
return M

View file

@ -0,0 +1,261 @@
local M = {}
---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number}
---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean}
---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark
---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[]
---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean?
---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean
---@class snacks.picker.finder.Item: snacks.picker.Item
---@field idx? number
---@field score? number
--- Generic filter used by finders to pre-filter items
---@class snacks.picker.filter.Config
---@field cwd? boolean|string only show files for the given cwd
---@field buf? boolean|number only show items for the current or given buffer
---@field paths? table<string, boolean> only show items that include or exclude the given paths
---@field filter? fun(item:snacks.picker.finder.Item):boolean custom filter function
---@class snacks.picker.Item
---@field [string] any
---@field idx number
---@field score number
---@field match_tick? number
---@field text string
---@field pos? {[1]:number, [2]:number}
---@field end_pos? {[1]:number, [2]:number}
---@field highlights? snacks.picker.Highlight[][]
---@class snacks.picker.sources.Config
---@class snacks.picker.preview.Config
---@field man_pager? string MANPAGER env to use for `man` preview
---@field file snacks.picker.preview.file.Config
---@class snacks.picker.preview.file.Config
---@field max_size? number default 1MB
---@field max_line_length? number defaults to 500
---@field ft? string defaults to auto-detect
---@class snacks.picker.layout.Config
---@field layout snacks.layout.Box
---@field reverse? boolean when true, the list will be reversed (bottom-up)
---@field fullscreen? boolean open in fullscreen
---@field cycle? boolean cycle through the list
---@field preview? boolean|"main" show preview window in the picker or the main window
---@field preset? string|fun(source:string):string
---@class snacks.picker.win.Config
---@field input? snacks.win.Config|{}
---@field list? snacks.win.Config|{}
---@field preview? snacks.win.Config|{}
---@class snacks.picker.Config
---@field source? string source name and config to use
---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher
---@field search? string|fun(picker:snacks.Picker):string search string used by finders
---@field cwd? string current working directory
---@field live? boolean when true, typing will trigger live searches
---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches
---@field ui_select? boolean set `vim.ui.select` to a snacks picker
--- Source definition
---@field items? snacks.picker.finder.Item[] items to show instead of using a finder
---@field format? snacks.picker.format|string format function or preset
---@field finder? snacks.picker.finder|string finder function or preset
---@field preview? snacks.picker.preview|string preview function or preset
---@field matcher? snacks.picker.matcher.Config matcher config
---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config
--- UI
---@field win? snacks.picker.win.Config
---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string)
---@field icons? snacks.picker.icons
---@field prompt? string prompt text / icon
--- Preset options
---@field previewers? snacks.picker.preview.Config
---@field sources? snacks.picker.sources.Config|{}
---@field layouts? table<string, snacks.picker.layout.Config>
--- Actions
---@field actions? table<string, snacks.picker.Action.spec> actions used by keymaps
---@field confirm? snacks.picker.Action.spec shortcut for confirm action
---@field auto_confirm? boolean automatically confirm if there is only one item
---@field main? snacks.picker.main.Config main editor window config
---@field on_change? fun(picker:snacks.Picker, item:snacks.picker.Item) called when the cursor changes
---@field on_show? fun(picker:snacks.Picker) called when the picker is shown
local defaults = {
prompt = "",
sources = {},
layout = {
cycle = true,
preset = function()
return vim.o.columns >= 120 and "default" or "vertical"
end,
},
ui_select = true, -- replace `vim.ui.select` with the snacks picker
previewers = {
file = {
max_size = 1024 * 1024, -- 1MB
max_line_length = 500,
},
},
win = {
list = {
keys = {
["<CR>"] = "confirm",
["gg"] = "list_top",
["G"] = "list_bottom",
["i"] = "focus_input",
["j"] = "list_down",
["k"] = "list_up",
["q"] = "close",
["<Tab>"] = "select_and_next",
["<S-Tab>"] = "select_and_prev",
["<Down>"] = "list_down",
["<Up>"] = "list_up",
["<c-d>"] = "list_scroll_down",
["<c-u>"] = "list_scroll_up",
["zt"] = "list_scroll_top",
["zb"] = "list_scroll_bottom",
["zz"] = "list_scroll_center",
["/"] = "toggle_focus",
["<ScrollWheelDown>"] = "list_scroll_wheel_down",
["<ScrollWheelUp>"] = "list_scroll_wheel_up",
["<c-f>"] = "preview_scroll_down",
["<c-b>"] = "preview_scroll_up",
["<c-v>"] = "edit_vsplit",
["<c-s>"] = "edit_split",
["<c-j>"] = "list_down",
["<c-k>"] = "list_up",
["<c-n>"] = "list_down",
["<c-p>"] = "list_up",
["<a-w>"] = "cycle_win",
["<Esc>"] = "close",
},
},
input = {
keys = {
["<Esc>"] = "close",
["<CR>"] = "confirm",
["G"] = "list_bottom",
["gg"] = "list_top",
["j"] = "list_down",
["k"] = "list_up",
["/"] = "toggle_focus",
["q"] = "close",
["?"] = "toggle_help",
["<a-m>"] = { "toggle_maximize", mode = { "i", "n" } },
["<a-p>"] = { "toggle_preview", mode = { "i", "n" } },
["<a-w>"] = { "cycle_win", mode = { "i", "n" } },
["<C-w>"] = { "<c-s-w>", mode = { "i" }, expr = true, desc = "delete word" },
["<C-Up>"] = { "history_back", mode = { "i", "n" } },
["<C-Down>"] = { "history_forward", mode = { "i", "n" } },
["<Tab>"] = { "select_and_next", mode = { "i", "n" } },
["<S-Tab>"] = { "select_and_prev", mode = { "i", "n" } },
["<Down>"] = { "list_down", mode = { "i", "n" } },
["<Up>"] = { "list_up", mode = { "i", "n" } },
["<c-j>"] = { "list_down", mode = { "i", "n" } },
["<c-k>"] = { "list_up", mode = { "i", "n" } },
["<c-n>"] = { "list_down", mode = { "i", "n" } },
["<c-p>"] = { "list_up", mode = { "i", "n" } },
["<c-b>"] = { "preview_scroll_up", mode = { "i", "n" } },
["<c-d>"] = { "list_scroll_down", mode = { "i", "n" } },
["<c-f>"] = { "preview_scroll_down", mode = { "i", "n" } },
["<c-g>"] = { "toggle_live", mode = { "i", "n" } },
["<c-u>"] = { "list_scroll_up", mode = { "i", "n" } },
["<ScrollWheelDown>"] = { "list_scroll_wheel_down", mode = { "i", "n" } },
["<ScrollWheelUp>"] = { "list_scroll_wheel_up", mode = { "i", "n" } },
["<c-v>"] = { "edit_vsplit", mode = { "i", "n" } },
["<c-s>"] = { "edit_split", mode = { "i", "n" } },
["<c-q>"] = { "qflist", mode = { "i", "n" } },
["<a-i>"] = { "toggle_ignored", mode = { "i", "n" } },
["<a-h>"] = { "toggle_hidden", mode = { "i", "n" } },
},
b = {
minipairs_disable = true,
},
},
preview = {
minimal = false,
wo = {
cursorline = false,
colorcolumn = "",
},
keys = {
["<Esc>"] = "close",
["q"] = "close",
["i"] = "focus_input",
["<ScrollWheelDown>"] = "list_scroll_wheel_down",
["<ScrollWheelUp>"] = "list_scroll_wheel_up",
["<a-w>"] = "cycle_win",
},
},
},
---@class snacks.picker.icons
-- stylua: ignore
icons = {
indent = {
vertical = "",
middle = "├╴",
last = "└╴",
},
ui = {
live = "󰐰 ",
selected = "",
-- selected = " ",
},
git = {
commit = "󰜘 ",
},
diagnostics = {
Error = "",
Warn = "",
Hint = "",
Info = "",
},
kinds = {
Array = "",
Boolean = "󰨙 ",
Class = "",
Color = "",
Control = "",
Collapsed = "",
Constant = "󰏿 ",
Constructor = "",
Copilot = "",
Enum = "",
EnumMember = "",
Event = "",
Field = "",
File = "",
Folder = "",
Function = "󰊕 ",
Interface = "",
Key = "",
Keyword = "",
Method = "󰊕 ",
Module = "",
Namespace = "󰦮 ",
Null = "",
Number = "󰎠 ",
Object = "",
Operator = "",
Package = "",
Property = "",
Reference = "",
Snippet = "󱄽 ",
String = "",
Struct = "󰆼 ",
Text = "",
TypeParameter = "",
Unit = "",
Uknown = "",
Value = "",
Variable = "󰀫 ",
},
},
}
M.defaults = defaults
return M

View file

@ -0,0 +1,81 @@
---@class snacks.picker.config.highlights
local M = {}
Snacks.util.set_hl({
Match = "Special",
Search = "Search",
Prompt = "Special",
InputSearch = "@keyword",
Special = "Special",
Label = "SnacksPickerSpecial",
Totals = "NonText",
File = "",
Dir = "NonText",
Dimmed = "Conceal",
Row = "String",
Col = "LineNr",
Comment = "Comment",
Delim = "Delimiter",
Spinner = "Special",
Selected = "Number",
Idx = "Number",
Bold = "Bold",
Indent = "LineNr",
Italic = "Italic",
Code = "@markup.raw.markdown_inline",
AuPattern = "String",
AuEvent = "Constant",
AuGroup = "Type",
DiagnosticCode = "Special",
DiagnosticSource = "Comment",
Register = "Number",
KeymapMode = "Number",
KeymapLhs = "Special",
BufNr = "Number",
BufFlags = "NonText",
KeymapRhs = "NonText",
GitCommit = "@variable.builtin",
GitBreaking = "Error",
GitDate = "Special",
GitIssue = "Number",
GitType = "Title", -- conventional commit type
GitScope = "Italic", -- conventional commit scope
GitStatus = "NonText",
GitStatusAdded = "Added",
GitStatusModified = "Changed",
GitStatusDeleted = "Removed",
GitStatusRenamed = "SnacksPickerGitStatus",
GitStatusCopied = "SnacksPickerGitStatus",
GitStatusUntracked = "SnacksPickerGitStatus",
ManSection = "Number",
ManPage = "Special",
-- LSP Symbol Kinds
IconArray = "@punctuation.bracket",
IconBoolean = "@boolean",
IconClass = "@type",
IconConstant = "@constant",
IconConstructor = "@constructor",
IconEnum = "@lsp.type.enum",
IconEnumMember = "@lsp.type.enumMember",
IconEvent = "Special",
IconField = "@variable.member",
IconFile = "Normal",
IconFunction = "@function",
IconInterface = "@lsp.type.interface",
IconKey = "@lsp.type.keyword",
IconMethod = "@function.method",
IconModule = "@module",
IconNamespace = "@module",
IconNull = "@constant.builtin",
IconNumber = "@number",
IconObject = "@constant",
IconOperator = "@operator",
IconPackage = "@module",
IconProperty = "@property",
IconString = "@string",
IconStruct = "@lsp.type.struct",
IconTypeParameter = "@lsp.type.typeParameter",
IconVariable = "@variable",
}, { prefix = "SnacksPicker", default = true })
return M

View file

@ -0,0 +1,119 @@
---@class snacks.picker.config
local M = {}
---@param opts? snacks.picker.Config
function M.get(opts)
M.setup()
opts = opts or {}
local sources = require("snacks.picker.config.sources")
local defaults = require("snacks.picker.config.defaults").defaults
defaults.sources = sources
local user = Snacks.config.picker or {}
local global = Snacks.config.get("picker", defaults, opts) -- defaults + global user config
---@type snacks.picker.Config[]
local todo = {
defaults,
user,
opts.source and global.sources[opts.source] or {},
opts,
}
for _, t in ipairs(todo) do
if t.confirm then
t.actions = t.actions or {}
t.actions.confirm = t.confirm
end
end
local ret = vim.tbl_deep_extend("force", unpack(todo))
ret.layouts = ret.layouts or {}
local layouts = require("snacks.picker.config.layouts")
for k, v in pairs(layouts or {}) do
ret.layouts[k] = ret.layouts[k] or v
end
return ret
end
--- Resolve the layout configuration
---@param opts snacks.picker.Config|string
function M.layout(opts)
if type(opts) == "string" then
opts = M.get({ layout = { preset = opts } })
end
local layouts = require("snacks.picker.config.layouts")
local layout = M.resolve(opts.layout or {}, opts.source)
layout = type(layout) == "string" and { preset = layout } or layout
---@cast layout snacks.picker.layout.Config
if layout.layout then
return layout
end
local preset = M.resolve(layout.preset or "custom", opts.source)
local ret = vim.deepcopy(opts.layouts and opts.layouts[preset] or layouts[preset] or {})
ret = vim.tbl_deep_extend("force", ret, layout or {})
ret.preset = nil
return ret
end
---@generic T
---@generic A
---@param v (fun(...:A):T)|unknown
---@param ... A
---@return T
function M.resolve(v, ...)
return type(v) == "function" and v(...) or v
end
--- Get the finder
---@param finder string|snacks.picker.finder
---@return snacks.picker.finder
function M.finder(finder)
if not finder or type(finder) == "function" then
return finder
end
local mod, fn = finder:match("^(.-)_(.+)$")
if not (mod and fn) then
mod, fn = finder, finder
end
return require("snacks.picker.source." .. mod)[fn]
end
local did_setup = false
function M.setup()
if did_setup then
return
end
did_setup = true
require("snacks.picker.config.highlights")
for source in pairs(Snacks.picker.config.get().sources) do
M.wrap(source)
end
--- Automatically wrap new sources added after setup
setmetatable(require("snacks.picker.config.sources"), {
__newindex = function(t, k, v)
rawset(t, k, v)
M.wrap(k)
end,
})
end
---@param source string
---@param opts? {check?: boolean}
function M.wrap(source, opts)
if opts and opts.check then
local config = M.get()
if not config.sources[source] then
return
end
end
---@type fun(opts: snacks.picker.Config): snacks.Picker
local ret = function(_opts)
return Snacks.picker.pick(source, _opts)
end
---@diagnostic disable-next-line: no-unknown
Snacks.picker[source] = ret
return ret
end
return M

View file

@ -0,0 +1,137 @@
---@class snacks.picker.layouts
---@field [string] snacks.picker.layout.Config
local M = {}
M.default = {
layout = {
box = "horizontal",
width = 0.8,
min_width = 120,
height = 0.8,
{
box = "vertical",
border = "rounded",
title = "{source} {live}",
title_pos = "center",
{ win = "input", height = 1, border = "bottom" },
{ win = "list", border = "none" },
},
{ win = "preview", border = "rounded", width = 0.5 },
},
}
M.telescope = {
reverse = true,
layout = {
box = "horizontal",
backdrop = false,
width = 0.8,
height = 0.9,
border = "none",
{
box = "vertical",
{ win = "list", title = " Results ", title_pos = "center", border = "rounded" },
{ win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" },
},
{
win = "preview",
width = 0.45,
border = "rounded",
title = " Preview ",
title_pos = "center",
},
},
}
M.ivy = {
layout = {
box = "vertical",
backdrop = false,
row = -1,
width = 0,
height = 0.4,
border = "top",
title = " {source} {live}",
title_pos = "left",
{ win = "input", height = 1, border = "bottom" },
{
box = "horizontal",
{ win = "list", border = "none" },
{ win = "preview", width = 0.6, border = "left" },
},
},
}
M.dropdown = {
layout = {
backdrop = false,
row = 1,
width = 0.4,
min_width = 80,
height = 0.8,
border = "none",
box = "vertical",
{ win = "preview", height = 0.4, border = "rounded" },
{
box = "vertical",
border = "rounded",
title = "{source} {live}",
title_pos = "center",
{ win = "input", height = 1, border = "bottom" },
{ win = "list", border = "none" },
},
},
}
M.vertical = {
layout = {
backdrop = false,
width = 0.5,
min_width = 80,
height = 0.8,
min_height = 30,
box = "vertical",
border = "rounded",
title = "{source} {live}",
title_pos = "center",
{ win = "input", height = 1, border = "bottom" },
{ win = "list", border = "none" },
{ win = "preview", height = 0.4, border = "top" },
},
}
M.select = {
preview = false,
layout = {
backdrop = false,
width = 0.5,
min_width = 80,
height = 0.4,
min_height = 10,
box = "vertical",
border = "rounded",
title = " Select ",
title_pos = "center",
{ win = "input", height = 1, border = "bottom" },
{ win = "list", border = "none" },
{ win = "preview", height = 0.4, border = "top" },
},
}
M.vscode = {
preview = false,
layout = {
backdrop = false,
row = 1,
width = 0.4,
min_width = 80,
height = 0.4,
border = "none",
box = "vertical",
{ win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" },
{ win = "list", border = "hpad" },
{ win = "preview", border = "rounded" },
},
}
return M

View file

@ -0,0 +1,509 @@
---@class snacks.picker.Config
---@field supports_live? boolean
---@class snacks.picker.sources.Config
---@field [string] snacks.picker.Config|{}
local M = {}
M.autocmds = {
finder = "vim_autocmds",
format = "autocmd",
preview = "preview",
}
---@class snacks.picker.buffers.Config: snacks.picker.Config
---@field hidden? boolean show hidden buffers (unlisted)
---@field unloaded? boolean show loaded buffers
---@field current? boolean show current buffer
---@field nofile? boolean show `buftype=nofile` buffers
---@field sort_lastused? boolean sort by last used
---@field filter? snacks.picker.filter.Config
M.buffers = {
finder = "buffers",
format = "buffer",
hidden = false,
unloaded = true,
current = true,
sort_lastused = true,
}
M.cliphist = {
finder = "system_cliphist",
format = "text",
preview = "preview",
confirm = { "copy", "close" },
}
-- Neovim colorschemes with live preview
M.colorschemes = {
finder = "vim_colorschemes",
format = "text",
preview = "colorscheme",
preset = "vertical",
confirm = function(picker, item)
picker:close()
if item then
picker.preview.state.colorscheme = nil
vim.schedule(function()
vim.cmd("colorscheme " .. item.text)
end)
end
end,
}
-- Neovim command history
---@type snacks.picker.history.Config
M.command_history = {
finder = "vim_history",
name = "cmd",
format = "text",
preview = "none",
layout = {
preset = "vscode",
},
confirm = "cmd",
}
-- Neovim commands
M.commands = {
finder = "vim_commands",
format = "text",
preview = "preview",
confirm = "cmd",
}
---@class snacks.picker.diagnostics.Config: snacks.picker.Config
---@field filter? snacks.picker.filter.Config
---@field severity? vim.diagnostic.SeverityFilter
M.diagnostics = {
finder = "diagnostics",
format = "diagnostic",
sort = {
fields = {
"is_current",
"is_cwd",
"severity",
"file",
"lnum",
},
},
-- only show diagnostics from the cwd by default
filter = { cwd = true },
}
---@type snacks.picker.diagnostics.Config
M.diagnostics_buffer = {
finder = "diagnostics",
format = "diagnostic",
sort = {
fields = { "severity", "file", "lnum" },
},
filter = { buf = true },
}
---@class snacks.picker.files.Config: snacks.picker.proc.Config
---@field cmd? string
---@field hidden? boolean show hidden files
---@field ignored? boolean show ignored files
---@field dirs? string[] directories to search
---@field follow? boolean follow symlinks
M.files = {
finder = "files",
format = "file",
hidden = false,
ignored = false,
follow = false,
supports_live = true,
}
-- Find git files
---@class snacks.picker.git.files.Config: snacks.picker.Config
---@field untracked? boolean show untracked files
---@field submodules? boolean show submodule files
M.git_files = {
finder = "git_files",
format = "file",
untracked = false,
submodules = false,
}
-- Git log
---@class snacks.picker.git.log.Config: snacks.picker.Config
---@field follow? boolean track file history across renames
---@field current_file? boolean show current file log
---@field current_line? boolean show current line log
M.git_log = {
finder = "git_log",
format = "git_log",
preview = "git_show",
confirm = "close",
}
---@type snacks.picker.git.log.Config
M.git_log_file = {
finder = "git_log",
format = "git_log",
preview = "git_show",
current_file = true,
follow = true,
confirm = "close",
}
---@type snacks.picker.git.log.Config
M.git_log_line = {
finder = "git_log",
format = "git_log",
preview = "git_show",
current_line = true,
follow = true,
confirm = "close",
}
M.git_status = {
finder = "git_status",
format = "git_status",
preview = "git_status",
}
---@class snacks.picker.grep.Config: snacks.picker.proc.Config
---@field cmd? string
---@field hidden? boolean show hidden files
---@field ignored? boolean show ignored files
---@field dirs? string[] directories to search
---@field follow? boolean follow symlinks
---@field glob? string|string[] glob file pattern(s)
---@field buffers? boolean search in open buffers
---@field need_search? boolean require a search pattern
M.grep = {
finder = "grep",
format = "file",
live = true, -- live grep by default
supports_live = true,
}
---@type snacks.picker.grep.Config
M.grep_buffers = {
finder = "grep",
format = "file",
live = true,
buffers = true,
need_search = false,
supports_live = true,
}
---@type snacks.picker.grep.Config
M.grep_word = {
finder = "grep",
format = "file",
search = function(picker)
return picker:word()
end,
live = false,
supports_live = true,
}
-- Neovim help tags
---@class snacks.picker.help.Config: snacks.picker.Config
---@field lang? string[] defaults to `vim.opt.helplang`
M.help = {
finder = "help",
format = "text",
previewers = {
file = { ft = "help" },
},
win = {
preview = {
minimal = true,
},
},
confirm = "help",
}
M.highlights = {
finder = "vim_highlights",
format = "hl",
preview = "preview",
}
M.jumps = {
finder = "vim_jumps",
format = "file",
}
---@class snacks.picker.keymaps.Config: snacks.picker.Config
---@field global? boolean show global keymaps
---@field local? boolean show buffer keymaps
---@field modes? string[]
M.keymaps = {
finder = "vim_keymaps",
format = "keymap",
preview = "preview",
global = true,
["local"] = true,
modes = { "n", "v", "x", "s", "o", "i", "c", "t" },
confirm = function(picker, item)
picker:close()
if item then
vim.api.nvim_input(item.item.lhs)
end
end,
}
-- Search lines in the current buffer
---@class snacks.picker.lines.Config: snacks.picker.Config
---@field buf? number
M.lines = {
finder = "lines",
format = "lines",
layout = {
preview = "main",
preset = "ivy",
},
-- allow any window to be used as the main window
main = { current = true },
---@param picker snacks.Picker
on_show = function(picker)
local cursor = vim.api.nvim_win_get_cursor(picker.main)
local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview)
picker.list:view(cursor[1], info.topline)
picker:show_preview()
end,
}
-- Loclist
---@type snacks.picker.qf.Config
M.loclist = {
finder = "qf",
format = "file",
qf_win = 0,
}
---@class snacks.picker.lsp.Config: snacks.picker.Config
---@field include_current? boolean default false
---@field unique_lines? boolean include only locations with unique lines
---@field filter? snacks.picker.filter.Config
-- LSP declarations
---@type snacks.picker.lsp.Config
M.lsp_declarations = {
finder = "lsp_declarations",
format = "file",
include_current = false,
auto_confirm = true,
}
-- LSP definitions
---@type snacks.picker.lsp.Config
M.lsp_definitions = {
finder = "lsp_definitions",
format = "file",
include_current = false,
auto_confirm = true,
}
-- LSP implementations
---@type snacks.picker.lsp.Config
M.lsp_implementations = {
finder = "lsp_implementations",
format = "file",
include_current = false,
auto_confirm = true,
}
-- LSP references
---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config
---@field include_declaration? boolean default true
M.lsp_references = {
finder = "lsp_references",
format = "file",
include_declaration = true,
include_current = false,
auto_confirm = true,
}
-- LSP document symbols
---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config
---@field hierarchy? boolean show symbol hierarchy
---@field filter table<string, string[]|boolean>? symbol kind filter
M.lsp_symbols = {
finder = "lsp_symbols",
format = "lsp_symbol",
hierarchy = true,
filter = {
default = {
"Class",
"Constructor",
"Enum",
"Field",
"Function",
"Interface",
"Method",
"Module",
"Namespace",
"Package",
"Property",
"Struct",
"Trait",
},
-- set to `true` to include all symbols
markdown = true,
help = true,
-- you can specify a different filter for each filetype
lua = {
"Class",
"Constructor",
"Enum",
"Field",
"Function",
"Interface",
"Method",
"Module",
"Namespace",
-- "Package", -- remove package since luals uses it for control flow structures
"Property",
"Struct",
"Trait",
},
},
}
-- LSP type definitions
---@type snacks.picker.lsp.Config
M.lsp_type_definitions = {
finder = "lsp_type_definitions",
format = "file",
include_current = false,
auto_confirm = true,
}
M.man = {
finder = "system_man",
format = "man",
preview = "man",
confirm = function(picker, item)
picker:close()
if item then
vim.schedule(function()
vim.cmd("Man " .. item.ref)
end)
end
end,
}
---@class snacks.picker.marks.Config: snacks.picker.Config
---@field global? boolean show global marks
---@field local? boolean show buffer marks
M.marks = {
finder = "vim_marks",
format = "file",
global = true,
["local"] = true,
}
-- List all available sources
M.pickers = {
finder = "meta_pickers",
format = "text",
confirm = function(picker, item)
picker:close()
if item then
Snacks.picker(item.text)
end
end,
}
M.picker_actions = {
finder = "meta_actions",
format = "text",
}
M.picker_format = {
finder = "meta_format",
format = "text",
}
M.picker_layouts = {
finder = "meta_layouts",
format = "text",
on_change = function(picker, item)
vim.schedule(function()
picker:set_layout(item.text)
end)
end,
}
M.picker_preview = {
finder = "meta_preview",
format = "text",
}
-- Open recent projects
---@class snacks.picker.projects.Config: snacks.picker.Config
---@field filter? snacks.picker.filter.Config
M.projects = {
finder = "recent_projects",
format = "file",
confirm = "load_session",
win = {
preview = {
minimal = true,
},
},
}
-- Quickfix list
---@type snacks.picker.qf.Config
M.qflist = {
finder = "qf",
format = "file",
}
-- Find recent files
---@class snacks.picker.recent.Config: snacks.picker.Config
---@field filter? snacks.picker.filter.Config
M.recent = {
finder = "recent_files",
format = "file",
filter = {
paths = {
[vim.fn.stdpath("data")] = false,
[vim.fn.stdpath("cache")] = false,
[vim.fn.stdpath("state")] = false,
},
},
}
-- Neovim registers
M.registers = {
finder = "vim_registers",
format = "register",
preview = "preview",
confirm = { "copy", "close" },
}
-- Special picker that resumes the last picker
M.resume = {}
-- Neovim search history
---@type snacks.picker.history.Config
M.search_history = {
finder = "vim_history",
name = "search",
format = "text",
preview = "none",
layout = {
preset = "vscode",
},
confirm = "search",
}
-- Open a project from zoxide
M.zoxide = {
finder = "files_zoxide",
format = "file",
confirm = "load_session",
win = {
preview = {
minimal = true,
},
},
}
return M

View file

@ -0,0 +1,51 @@
local M = {}
---@private
function M.health()
local config = Snacks.picker.config.get()
if Snacks.config.get("picker", {}).enabled and config.ui_select then
if vim.ui.select == Snacks.picker.select then
Snacks.health.ok("`vim.ui.select` is set to `Snacks.picker.select`")
else
Snacks.health.error("`vim.ui.select` is not set to `Snacks.picker.select`")
end
else
Snacks.health.warn("`vim.ui.select` for `Snacks.picker` is not enabled")
end
for _, lang in ipairs({ "regex" }) do
local has_lang = pcall(vim.treesitter.language.add, lang)
if has_lang then
Snacks.health.ok("Treesitter language `" .. lang .. "` is available")
else
Snacks.health.error("Treesitter language `" .. lang .. "` is not available")
end
end
local is_win = jit.os:find("Windows")
local function have(tool)
if vim.fn.executable(tool) == 1 then
local version = vim.fn.system(tool .. " --version") or ""
version = vim.trim(vim.split(version, "\n")[1])
Snacks.health.ok("'" .. tool .. "' `" .. version .. "`")
return true
end
end
local required = { { "git" }, { "rg" }, { "fd", "fdfind", not is_win and "find" or nil } }
for _, tools in ipairs(required) do
local found = false
for _, tool in ipairs(tools) do
if have(tool) then
found = true
end
end
if not found then
Snacks.health.error("None of the tools found: " .. table.concat(
vim.tbl_map(function()
return "'" .. tostring(_) .. "'"
end, tools),
", "
))
end
end
end
return M

View file

@ -0,0 +1,89 @@
local M = {}
---@alias snacks.picker.Action.fn fun(self: snacks.Picker, item?:snacks.picker.Item):(boolean|string?)
---@alias snacks.picker.Action.spec.one string|snacks.picker.Action|snacks.picker.Action.fn
---@alias snacks.picker.Action.spec snacks.picker.Action.spec.one|snacks.picker.Action.spec.one[]
---@class snacks.picker.Action
---@field action snacks.picker.Action.fn
---@field desc? string
---@param picker snacks.Picker
function M.get(picker)
local ref = Snacks.util.ref(picker)
---@type table<string, snacks.win.Action>
local ret = {}
setmetatable(ret, {
---@param t table<string, snacks.win.Action>
---@param k string
__index = function(t, k)
if type(k) ~= "string" then
return
end
local p = ref()
if not p then
return
end
t[k] = M.resolve(k, p, k) or false
return rawget(t, k)
end,
})
return ret
end
---@param action snacks.picker.Action.spec
---@param picker snacks.Picker
---@param name? string
---@return snacks.picker.Action?
function M.resolve(action, picker, name)
if not action then
assert(name, "Missing action without name")
local fn, desc = picker.input.win[name], name
return {
action = function()
if not fn then
return name
end
fn(picker.input.win)
end,
desc = desc,
}
elseif type(action) == "string" then
return M.resolve(
(picker.opts.actions or {})[action] or require("snacks.picker.actions")[action],
picker,
action:gsub("_ ", " ")
)
elseif type(action) == "table" and vim.islist(action) then
---@type snacks.picker.Action[]
local actions = vim.tbl_map(function(a)
return M.resolve(a, picker)
end, action)
return {
action = function(_, item)
for _, a in ipairs(actions) do
a.action(picker, item)
end
end,
desc = table.concat(
vim.tbl_map(function(a)
return a.desc
end, actions),
", "
),
}
end
action = type(action) == "function" and {
action = action,
desc = name or nil,
} or action
---@cast action snacks.picker.Action
return {
action = function()
return action.action(picker, picker:current())
end,
desc = action.desc,
}
end
return M

View file

@ -0,0 +1,101 @@
---@class snacks.picker.Filter
---@field pattern string Pattern used to filter items by the matcher
---@field search string Initial search string used by finders
---@field buf? number
---@field file? string
---@field cwd string
---@field all boolean
---@field paths {path:string, want:boolean}[]
---@field opts snacks.picker.filter.Config
local M = {}
M.__index = M
local uv = vim.uv or vim.loop
---@param picker snacks.Picker
function M.new(picker)
local opts = picker.opts ---@type snacks.picker.Config|{filter?:snacks.picker.filter.Config}
local self = setmetatable({}, M)
self.opts = opts.filter or {}
local function gets(v)
return type(v) == "function" and v(picker) or v or "" --[[@as string]]
end
self.pattern = gets(opts.pattern)
self.search = gets(opts.search)
local filter = opts.filter
self.all = not filter or not (filter.cwd or filter.buf or filter.paths or filter.filter)
self.paths = {}
local cwd = filter and filter.cwd
self.cwd = type(cwd) == "string" and cwd or opts.cwd or uv.cwd() or "."
self.cwd = vim.fs.normalize(self.cwd --[[@as string]], { _fast = true })
if not self.all and filter then
self.buf = filter.buf == true and 0 or filter.buf --[[@as number?]]
self.buf = self.buf == 0 and vim.api.nvim_get_current_buf() or self.buf
self.file = self.buf and vim.fs.normalize(vim.api.nvim_buf_get_name(self.buf), { _fast = true }) or nil
for path, want in pairs(filter.paths or {}) do
table.insert(filter, { path = vim.fs.normalize(path), want = want })
end
end
return self
end
---@param opts? {trim?:boolean}
---@return snacks.picker.Filter
function M:clone(opts)
local ret = setmetatable({}, { __index = self })
if opts and opts.trim then
ret.pattern = vim.trim(self.pattern)
ret.search = vim.trim(self.search)
else
ret.pattern = self.pattern
ret.search = self.search
end
return ret
end
---@param item snacks.picker.finder.Item):boolean
function M:match(item)
if self.all then
return true
end
if self.opts.filter and not self.opts.filter(item) then
return false
end
if self.buf and (item.buf ~= self.buf) and (item.file ~= self.file) then
return false
end
if not (self.opts.cwd or self.opts.paths) then
return true
end
local path = Snacks.picker.util.path(item)
if not path then
return false
end
if self.opts.cwd and path:sub(1, #self.cwd) ~= self.cwd then
return false
end
if self.opts.paths then
for _, p in ipairs(self.paths) do
if (path:sub(1, #p.path) == p.path) ~= p.want then
return false
end
end
end
return true
end
---@param items snacks.picker.finder.Item[]
function M:filter(items)
if self.all then
return items
end
local ret = {} ---@type snacks.picker.finder.Item[]
for _, item in ipairs(items) do
if self:match(item) then
table.insert(ret, item)
end
end
return ret
end
return M

View file

@ -0,0 +1,88 @@
local Async = require("snacks.picker.util.async")
---@class snacks.picker.Finder
---@field _find snacks.picker.finder
---@field task snacks.picker.Async
---@field items snacks.picker.finder.Item[]
---@field filter? snacks.picker.Filter
local M = {}
M.__index = M
---@alias snacks.picker.finder fun(opts:snacks.picker.Config, filter:snacks.picker.Filter): (snacks.picker.finder.Item[] | fun(cb:async fun(item:snacks.picker.finder.Item)))
local YIELD_FIND = 1 -- ms
---@param find snacks.picker.finder
function M.new(find)
local self = setmetatable({}, M)
self._find = find
self.task = Async.nop()
self.items = {}
return self
end
function M:running()
return self.task:running()
end
function M:abort()
self.task:abort()
end
function M:count()
return #self.items
end
---@param search string
function M:changed(search)
search = vim.trim(search)
return not self.filter or self.filter.search ~= search
end
---@param picker snacks.Picker
function M:run(picker)
local score = require("snacks.picker.core.matcher").DEFAULT_SCORE
self.task:abort()
self.items = {}
local yield ---@type fun()
self.filter = picker.input.filter:clone({ trim = true })
local finder = self._find(picker.opts, self.filter)
local limit = picker.opts.limit or math.huge
-- PERF: if finder is a table, we can skip the async part
if type(finder) == "table" then
local items = finder --[[@as snacks.picker.finder.Item[] ]]
for i, item in ipairs(items) do
item.idx, item.score = i, score
self.items[i] = item
end
return
end
collectgarbage("stop") -- moar speed
---@cast finder fun(cb:async fun(item:snacks.picker.finder.Item))
---@diagnostic disable-next-line: await-in-sync
self.task = Async.new(function()
---@async
finder(function(item)
if #self.items >= limit then
self.task:abort()
if coroutine.running() then
Async.yield()
end
return
end
item.idx, item.score = #self.items + 1, score
self.items[item.idx] = item
picker.matcher.task:resume()
yield = yield or Async.yielder(YIELD_FIND)
yield()
end)
end):on("done", function()
collectgarbage("restart")
picker.matcher.task:resume()
picker:update()
end)
end
return M

View file

@ -0,0 +1,147 @@
---@class snacks.picker.input
---@field win snacks.win
---@field totals string
---@field picker snacks.Picker
---@field _statuscolumn string
---@field filter snacks.picker.Filter
local M = {}
M.__index = M
local uv = vim.uv or vim.loop
local ns = vim.api.nvim_create_namespace("snacks.picker.input")
---@param picker snacks.Picker
function M.new(picker)
local self = setmetatable({}, M)
self.totals = ""
self.picker = picker
self.filter = require("snacks.picker.core.filter").new(picker)
self._statuscolumn = self:statuscolumn()
self.win = Snacks.win(Snacks.win.resolve(picker.opts.win.input, {
show = false,
enter = true,
height = 1,
text = picker.opts.live and self.filter.search or self.filter.pattern,
ft = "regex",
on_win = function()
vim.fn.prompt_setprompt(self.win.buf, "")
self.win:focus()
vim.cmd.startinsert()
vim.api.nvim_win_set_cursor(self.win.win, { 1, #self:get() + 1 })
vim.fn.prompt_setcallback(self.win.buf, function()
self.win:execute("confirm")
end)
vim.fn.prompt_setinterrupt(self.win.buf, function()
self.win:close()
end)
end,
bo = {
filetype = "snacks_picker_input",
buftype = "prompt",
},
wo = {
statuscolumn = self._statuscolumn,
cursorline = false,
winhighlight = Snacks.picker.highlight.winhl("SnacksPickerInput"),
},
}))
self.win:on(
{ "TextChangedI", "TextChanged" },
Snacks.util.throttle(function()
if not self.win:valid() then
return
end
vim.bo[self.win.buf].modified = false
local pattern = self:get()
if self.picker.opts.live then
self.filter.search = pattern
else
self.filter.pattern = pattern
end
picker:match()
end, { ms = picker.opts.live and 100 or 30 }),
{ buf = true }
)
return self
end
function M:statuscolumn()
local parts = {} ---@type string[]
local function add(str, hl)
if str then
parts[#parts + 1] = ("%%#%s#%s%%*"):format(hl, str:gsub("%%", "%%"))
end
end
local pattern = self.picker.opts.live and self.filter.pattern or self.filter.search
if pattern ~= "" then
if #pattern > 20 then
pattern = Snacks.picker.util.truncate(pattern, 20)
end
add(pattern, "SnacksPickerInputSearch")
end
add(self.picker.opts.prompt or "", "SnacksPickerPrompt")
return table.concat(parts, " ")
end
function M:update()
if not self.win:valid() then
return
end
local sc = self:statuscolumn()
if self._statuscolumn ~= sc then
self._statuscolumn = sc
vim.wo[self.win.win].statuscolumn = sc
end
local line = {} ---@type snacks.picker.Highlight[]
if self.picker:is_active() then
line[#line + 1] = { M.spinner(), "SnacksPickerSpinner" }
line[#line + 1] = { " " }
end
local selected = #self.picker.list.selected
if selected > 0 then
line[#line + 1] = { ("(%d)"):format(selected), "SnacksPickerTotals" }
line[#line + 1] = { " " }
end
line[#line + 1] = { ("%d/%d"):format(self.picker.list:count(), #self.picker.finder.items), "SnacksPickerTotals" }
line[#line + 1] = { " " }
local totals = table.concat(vim.tbl_map(function(v)
return v[1]
end, line))
if self.totals == totals then
return
end
self.totals = totals
vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, {
id = 999,
virt_text = line,
virt_text_pos = "right_align",
})
end
function M:get()
return self.win:line()
end
---@param pattern? string
---@param search? string
function M:set(pattern, search)
self.filter.pattern = pattern or self.filter.pattern
self.filter.search = search or self.filter.search
vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, {
self.picker.opts.live and self.filter.search or self.filter.pattern,
})
vim.api.nvim_win_set_cursor(self.win.win, { 1, #self:get() + 1 })
self.totals = ""
self._statuscolumn = ""
self:update()
self.picker:update_titles()
end
function M.spinner()
local spinner = { "", "", "", "", "", "", "", "", "", "" }
return spinner[math.floor(uv.hrtime() / (1e6 * 80)) % #spinner + 1]
end
return M

View file

@ -0,0 +1,465 @@
---@class snacks.picker.list
---@field picker snacks.Picker
---@field items snacks.picker.Item[]
---@field top number
---@field cursor number
---@field win snacks.win
---@field dirty boolean
---@field state snacks.picker.list.State
---@field paused boolean
---@field topk snacks.picker.MinHeap
---@field _current? snacks.picker.Item
---@field did_preview? boolean
---@field reverse? boolean
---@field selected snacks.picker.Item[]
---@field selected_map table<string, snacks.picker.Item>
local M = {}
M.__index = M
---@class snacks.picker.list.State
---@field height number
---@field scrolloff number
---@field scroll number
---@field mousescroll number
local ns = vim.api.nvim_create_namespace("snacks.picker.list")
local function minmax(value, min, max)
return math.max(min, math.min(value, max))
end
---@param picker snacks.Picker
function M.new(picker)
local self = setmetatable({}, M)
self.reverse = picker.opts.layout.reverse
self.picker = picker
self.selected = {}
self.selected_map = {}
local win_opts = Snacks.win.resolve(picker.opts.win.list, {
show = false,
enter = false,
on_win = function()
self:on_show()
end,
minimal = true,
bo = { modifiable = false, filetype = "snacks_picker_list" },
wo = {
conceallevel = 3,
concealcursor = "nvc",
cursorline = false,
winhighlight = Snacks.picker.highlight.winhl("SnacksPickerList", { CursorLine = "Visual" }),
},
})
self.win = Snacks.win(win_opts)
self.top, self.cursor = 1, 1
self.items = {}
self.state = { height = 0, scrolloff = 0, scroll = 0, mousescroll = 1 }
self.dirty = true
self.topk = require("snacks.picker.util.minheap").new({
capacity = 1000,
cmp = self.picker.sort,
})
self.win:on("CursorMoved", function()
if not self.win:valid() then
return
end
local cursor = vim.api.nvim_win_get_cursor(self.win.win)
if cursor[1] ~= self:idx2row(self.cursor) then
local idx = self:row2idx(cursor[1])
self:_move(idx, true, true)
end
end, { buf = true })
self.win:on("VimResized", function()
self.state.height = vim.api.nvim_win_get_height(self.win.win)
self.dirty = true
self:update()
end)
self.win:on("WinResized", function()
if vim.tbl_contains(vim.v.event.windows, self.win.win) then
self.state.height = vim.api.nvim_win_get_height(self.win.win)
self.dirty = true
self:update()
end
end)
self.win:on({ "WinEnter", "WinLeave" }, function()
self:update_cursorline()
end)
return self
end
---@param cursor number
---@param topline? number
function M:view(cursor, topline)
if topline then
self:scroll(topline, true, false)
end
self:move(cursor, true)
end
---@param idx number
function M:idx2row(idx)
local ret = idx - self.top + 1
if not self.reverse then
return ret
end
return self.state.height - ret + 1
end
---@param row number
function M:row2idx(row)
local ret = row + self.top - 1
if not self.reverse then
return ret
end
return self.state.height - ret + 1
end
function M:on_show()
self.state.scrolloff = vim.wo[self.win.win].scrolloff
self.state.scroll = vim.wo[self.win.win].scroll
self.state.height = vim.api.nvim_win_get_height(self.win.win)
self.state.mousescroll = tonumber(vim.o.mousescroll:match("ver:(%d+)")) or 1
vim.wo[self.win.win].scrolloff = 0
self.dirty = true
end
function M:count()
return #self.items
end
function M:close()
self.win:close()
self.items = {}
self.topk:clear()
end
function M:scrolloff()
local scrolloff = math.min(self.state.scrolloff, math.floor(self:height() / 2))
local offset = math.min(self.cursor, self:count() - self.cursor)
return offset > scrolloff and scrolloff or 0
end
---@param to number
---@param absolute? boolean
---@param render? boolean
function M:_scroll(to, absolute, render)
local old_top = self.top
self.top = absolute and to or self.top + to
self.top = minmax(self.top, 1, self:count() - self:height() + 1)
self.cursor = absolute and to or self.cursor + to
local scrolloff = self:scrolloff()
self.cursor = minmax(self.cursor, self.top + scrolloff, self.top + self:height() - 1 - scrolloff)
self.dirty = self.dirty or self.top ~= old_top
if render ~= false then
self:render()
end
end
---@param to number
---@param absolute? boolean
---@param render? boolean
function M:scroll(to, absolute, render)
if self.reverse then
to = absolute and (self:count() - to + 1) or -1 * to
end
self:_scroll(to, absolute, render)
end
---@param to number
---@param absolute? boolean
---@param render? boolean
function M:_move(to, absolute, render)
local old_top = self.top
local height = self:height()
if height <= 1 then
self.cursor, self.top = 1, 1
else
self.cursor = absolute and to or self.cursor + to
if self.picker.opts.layout.cycle then
self.cursor = (self.cursor - 1) % self:count() + 1
end
self.cursor = minmax(self.cursor, 1, self:count())
local scrolloff = self:scrolloff()
self.top = minmax(self.top, self.cursor - self:height() + scrolloff + 1, self.cursor - scrolloff)
end
self.dirty = self.dirty or self.top ~= old_top
if render ~= false then
self:render()
end
end
---@param to number
---@param absolute? boolean
---@param render? boolean
function M:move(to, absolute, render)
if self.reverse then
to = absolute and (self:count() - to + 1) or -1 * to
end
self:_move(to, absolute, render)
end
function M:clear()
self.topk:clear()
self.top, self.cursor = 1, 1
self.items = {}
self.dirty = true
if next(self.items) == nil then
return
end
self:update()
end
function M:pause(ms)
self.paused = true
vim.defer_fn(function()
self:unpause()
end, ms)
end
function M:add(item)
local idx = #self.items + 1
self.items[idx] = item
local added, prev = self.topk:add(item)
if added then
self.dirty = true
if prev then
-- replace with previous item, since new item is now in topk
self.items[idx] = prev
end
elseif not self.dirty then
self.dirty = idx >= self.top and idx <= self.top + (self.state.height or 50)
end
end
---@return snacks.picker.Item?
function M:current()
return self:get(self.cursor)
end
--- Returns the item at the given sorted index.
--- Item will be taken from topk if available, otherwise from items.
--- In case the matcher is running, the item will be taken from the finder.
---@param idx number
---@return snacks.picker.Item?
function M:get(idx)
return self.topk:get(idx) or self.items[idx] or self.picker.finder.items[idx]
end
function M:height()
return math.min(self.state.height, self:count())
end
function M:update()
if vim.in_fast_event() then
return vim.schedule(function()
self:update()
end)
end
if self.paused and #self.items < self.state.height then
return
end
if not self.win:valid() then
return
end
self:render()
end
-- Toggle selection of current item
function M:select()
local item = self:current()
if not item then
return
end
local key = self:select_key(item)
if self.selected_map[key] then
self.selected_map[key] = nil
self.selected = vim.tbl_filter(function(v)
return self:select_key(v) ~= key
end, self.selected)
else
self.selected_map[key] = item
table.insert(self.selected, item)
end
self.picker.input:update()
self.dirty = true
self:render()
end
---@param item snacks.picker.Item
---@return string
function M:select_key(item)
item._select_key = item._select_key
or Snacks.picker.util.text(item, { "text", "file", "key", "id", "pos", "end_pos" })
return item._select_key
end
---@param items snacks.picker.Item[]
function M:set_selected(items)
self.selected = items
self.selected_map = {}
for _, item in ipairs(items) do
self.selected_map[self:select_key(item)] = item
end
self.picker.input:update()
self.dirty = true
self:update()
end
function M:unpause()
if not self.paused then
return
end
self.paused = false
self:update()
end
---@param item snacks.picker.Item
function M:format(item)
local line = self.picker.format(item, self.picker)
local parts = {} ---@type string[]
local ret = {} ---@type snacks.picker.Extmark[]
local selected = self.selected_map[self:select_key(item)] ~= nil
local selw = vim.api.nvim_strwidth(self.picker.opts.icons.ui.selected)
parts[#parts + 1] = string.rep(" ", selw)
if selected then
ret[#ret + 1] = {
virt_text = { {
self.picker.opts.icons.ui.selected or parts[1],
"SnacksPickerSelected",
} },
virt_text_pos = "overlay",
line_hl_group = "SnacksPickerSelectedLine",
col = 0,
hl_mode = "combine",
}
end
while #line > 0 and type(line[#line][1]) == "string" and line[#line][1]:find("^%s*$") do
table.remove(line)
end
local col = selw
for _, text in ipairs(line) do
if type(text[1]) == "string" then
---@cast text snacks.picker.Text
if text.virtual then
table.insert(ret, {
col = col,
virt_text = { { text[1], text[2] } },
virt_text_pos = "overlay",
hl_mode = "combine",
})
parts[#parts + 1] = string.rep(" ", vim.api.nvim_strwidth(text[1]))
else
table.insert(ret, {
col = col,
end_col = col + #text[1],
hl_group = text[2],
})
parts[#parts + 1] = text[1]
end
col = col + #parts[#parts]
else
text = vim.deepcopy(text)
---@cast text snacks.picker.Extmark
-- fix extmark col and end_col
text.col = text.col + selw
if text.end_col then
text.end_col = text.end_col + selw
end
table.insert(ret, text)
end
end
local str = table.concat(parts, ""):gsub("\n", " ")
local _, positions = self.picker.matcher:match({ text = str:gsub("%s*$", ""), idx = 1, score = 0 }, {
positions = true,
force = true,
})
for _, pos in ipairs(positions or {}) do
table.insert(ret, {
col = pos - 1,
end_col = pos,
hl_group = "SnacksPickerMatch",
})
end
return str, ret
end
---@param item snacks.picker.Item
---@param row number
function M:_render(item, row)
local text, extmarks = self:format(item)
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
vim.api.nvim_buf_set_extmark(self.win.buf, ns, row - 1, col, extmark)
end
end
function M:update_cursorline()
if self.win.win and vim.api.nvim_win_is_valid(self.win.win) then
vim.wo[self.win.win].cursorline = self:count() > 0
end
end
function M:render()
self:move(0, false, false)
local redraw = false
if self.dirty then
local height = self:height()
self.dirty = false
vim.api.nvim_win_call(self.win.win, function()
vim.fn.winrestview({ topline = 1, leftcol = 0 })
end)
vim.api.nvim_buf_clear_namespace(self.win.buf, ns, 0, -1)
vim.bo[self.win.buf].modifiable = true
local lines = vim.split(string.rep("\n", self.state.height), "\n")
vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines)
for i = self.top, math.min(self:count(), self.top + height - 1) do
local item = assert(self:get(i), "item not found")
local row = self:idx2row(i)
self:_render(item, row)
end
vim.bo[self.win.buf].modifiable = false
redraw = true
end
-- Fix cursor and cursorline
self:update_cursorline()
local cursor = vim.api.nvim_win_get_cursor(self.win.win)
if cursor[1] ~= self:idx2row(self.cursor) then
vim.api.nvim_win_set_cursor(self.win.win, { self:idx2row(self.cursor), 0 })
end
-- force redraw if list changed
if redraw then
self.win:redraw()
end
-- check if current item changed
local current = self:current()
if self._current ~= current then
self._current = current
if not self.did_preview then
-- show first preview instantly
self.did_preview = true
self.picker:show_preview()
else
vim.schedule(function()
self.picker:show_preview()
end)
end
end
end
return M

View file

@ -0,0 +1,50 @@
---@class snacks.picker.main
local M = {}
---@class snacks.picker.main.Config
---@field float? boolean main window can be a floating window (defaults to false)
---@field file? boolean main window should be a file (defaults to true)
---@field current? boolean main window should be the current window (defaults to false)
---@param opts? snacks.picker.main.Config
function M.get(opts)
opts = vim.tbl_extend("force", {
float = false,
file = true,
current = false,
}, opts or {})
local current = vim.api.nvim_get_current_win()
if opts.current then
return current
end
local prev = vim.fn.winnr("#")
local wins = { current, prev }
local all = vim.api.nvim_list_wins()
-- sort all by lastused of the win buffer
table.sort(all, function(a, b)
local ba = vim.api.nvim_win_get_buf(a)
local bb = vim.api.nvim_win_get_buf(b)
return vim.fn.getbufinfo(ba)[1].lastused > vim.fn.getbufinfo(bb)[1].lastused
end)
vim.list_extend(wins, all)
wins = vim.tbl_filter(function(win)
-- exclude invalid windows
if win == 0 or not vim.api.nvim_win_is_valid(win) then
return false
end
-- exclude non-file buffers
if opts.file and vim.bo[vim.api.nvim_win_get_buf(win)].buftype ~= "" then
return false
end
local win_config = vim.api.nvim_win_get_config(win)
local is_float = win_config.relative ~= ""
-- exclude floating windows and non-focusable windows
if is_float and (not opts.float or not win_config.focusable) then
return false
end
return true
end, wins)
return wins[1] or current
end
return M

View file

@ -0,0 +1,465 @@
local Async = require("snacks.picker.util.async")
---@class snacks.picker.Matcher
---@field opts snacks.picker.matcher.Config
---@field mods snacks.picker.matcher.Mods[][]
---@field one? snacks.picker.matcher.Mods
---@field pattern string
---@field min_score number
---@field tick number
---@field task snacks.picker.Async
---@field live? boolean
local M = {}
M.__index = M
M.DEFAULT_SCORE = 1000
M.INVERSE_SCORE = 1000
local YIELD_MATCH = 5 -- ms
local clear = require("table.clear")
---@class snacks.picker.matcher.Config
---@field fuzzy? boolean
---@field smartcase? boolean
---@field ignorecase? boolean
---@class snacks.picker.matcher.Mods
---@field pattern string
---@field entropy number higher entropy is less likely to match
---@field field? string
---@field ignorecase? boolean
---@field fuzzy? boolean
---@field word? boolean
---@field exact_suffix? boolean
---@field exact_prefix? boolean
---@field inverse? boolean
-- PERF: reuse tables to avoid allocations and GC
local fuzzy_positions = {} ---@type number[]
local fuzzy_best_positions = {} ---@type number[]
local fuzzy_last_positions = {} ---@type number[]
local fuzzy_fast_positions = {} ---@type number[]
---@param t number[]
function M.clear(t)
clear(t) -- luajit table.clear is faster
return t
end
---@param opts? snacks.picker.matcher.Config
function M.new(opts)
local self = setmetatable({}, M)
self.opts = vim.tbl_deep_extend("force", {
fuzzy = true,
smartcase = true,
ignorecase = true,
}, opts or {})
self.pattern = ""
self.min_score = 0
self.task = Async.nop()
self.mods = {}
self.live = false
self.tick = 0
return self
end
function M:empty()
return not next(self.mods)
end
function M:running()
return self.task:running()
end
function M:abort()
self.task:abort()
end
---@param picker snacks.Picker
---@param opts? {prios?: snacks.picker.Item[]}
function M:run(picker, opts)
opts = opts or {}
self.task:abort()
-- PERF: fast path for empty pattern
if self:empty() and not picker.finder.task:running() then
picker.list.items = picker.finder.items
picker:update()
return
end
---@async
self.task = Async.new(function()
local yield = Async.yielder(YIELD_MATCH)
local idx = 0
---@async
---@param item snacks.picker.Item
local function check(item)
if self:update(item) and item.score > 0 then
picker.list:add(item)
end
yield()
end
-- process high priority items first
for _, item in ipairs(opts.prios or {}) do
check(item)
end
repeat
-- then the rest
while idx < #picker.finder.items do
idx = idx + 1
check(picker.finder.items[idx])
end
-- suspend till we have more items
if picker.finder.task:running() then
Async.suspend()
end
until idx >= #picker.finder.items and not picker.finder.task:running()
picker:update()
end)
end
---@param opts? {pattern?: string, live?: boolean}
function M:init(opts)
opts = opts or {}
self.tick = self.tick + 1
local pattern = vim.trim(opts.pattern or self.pattern)
self.mods = {}
self.min_score = 0
self.pattern = pattern
self:abort()
self.one = nil
self.live = opts.live
if pattern == "" then
return
end
local is_or = false
for _, p in ipairs(vim.split(pattern, " +")) do
if p == "|" then
is_or = true
else
local mods = self:_prepare(p)
if mods.pattern ~= "" then
if is_or and #self.mods > 0 then
table.insert(self.mods[#self.mods], mods)
else
table.insert(self.mods, { mods })
end
end
is_or = false
end
end
for _, ors in ipairs(self.mods) do
-- sort by entropy, lower entropy is more likely to match
table.sort(ors, function(a, b)
return a.entropy < b.entropy
end)
end
-- sort by entropy, higher entropy is less likely to match
table.sort(self.mods, function(a, b)
return a[1].entropy > b[1].entropy
end)
if #self.mods == 1 and #self.mods[1] == 1 then
self.one = self.mods[1][1]
end
end
---@param pattern string
---@return snacks.picker.matcher.Mods
function M:_prepare(pattern)
---@type snacks.picker.matcher.Mods
local mods = { pattern = pattern, entropy = 0 }
local field, p = pattern:match("^([%w_]+):(.*)$")
if field then
mods.field = field
mods.pattern = p
end
mods.ignorecase = self.opts.ignorecase
local is_lower = mods.pattern:lower() == mods.pattern
if self.opts.smartcase then
mods.ignorecase = is_lower
end
mods.fuzzy = self.opts.fuzzy
if not mods.fuzzy then
mods.entropy = mods.entropy + 10
end
if mods.pattern:sub(1, 1) == "!" then
mods.fuzzy, mods.inverse = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy - 1
end
if mods.pattern:sub(1, 1) == "'" then
mods.fuzzy = false
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 10
if mods.pattern:sub(-1, -1) == "'" then
mods.word = true
mods.pattern = mods.pattern:sub(1, -2)
mods.entropy = mods.entropy + 10
end
elseif mods.pattern:sub(1, 1) == "^" then
mods.fuzzy, mods.exact_prefix = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 20
end
if mods.pattern:sub(-1, -1) == "$" then
mods.fuzzy = false
mods.exact_suffix = true
mods.pattern = mods.pattern:sub(1, -2)
mods.entropy = mods.entropy + 20
end
local rare_chars = #mods.pattern:gsub("[%w%s]", "")
mods.entropy = mods.entropy + math.min(#mods.pattern, 20) + rare_chars * 2
if not mods.ignorecase and not is_lower then
mods.entropy = mods.entropy * 2
end
return mods
end
---@param item snacks.picker.Item
---@return boolean updated
function M:update(item)
if item.match_tick == self.tick then
return false
end
local score = self:match(item)
item.match_tick, item.score = self.tick, score
return true
end
---@param item snacks.picker.Item
---@param opts? {positions: boolean, force?: boolean}
---@return number score, number[]? positions
function M:match(item, opts)
opts = opts or {}
if self:empty() or (self.live and not opts.force) then
return M.DEFAULT_SCORE -- empty pattern matches everything
end
local score = 0
local positions = opts.positions and {} or nil ---@type number[]?
if self.one then
score = self:_match(item, self.one, positions) or 0
return score, positions
end
for _, ors in ipairs(self.mods) do
local s = 0 ---@type number?
local p = opts.positions and {} or nil ---@type number[]?
if #ors == 1 then
s = self:_match(item, ors[1], p)
else
for _, mods in ipairs(ors) do
s = self:_match(item, mods, p)
if s then
break
end
end
end
if s then
score, positions = M:merge(score, positions, s, p)
else
return 0
end
end
return score, positions
end
---@param score_a? number
---@param positions_a? number[]
---@param score_b? number
---@param positions_b? number[]
function M:merge(score_a, positions_a, score_b, positions_b)
local positions = positions_a or positions_b
if positions_a and positions_b then
table.move(positions_b, 1, #positions_b, #positions + 1, positions)
end
return score_a + score_b, positions
end
---@param str string
---@param c number
function M.is_alpha(str, c)
local b = str:byte(c, c)
return (b >= 65 and b <= 90) or (b >= 97 and b <= 122)
end
---@param item snacks.picker.Item
---@param mods snacks.picker.matcher.Mods
---@param positions? number[]
---@return number? score
function M:_match(item, mods, positions)
local str = item.text
if mods.field then
if item[mods.field] == nil then
if mods.inverse then
return M.INVERSE_SCORE
end
return
end
str = tostring(item[mods.field])
end
str = mods.ignorecase and str:lower() or str
if mods.fuzzy then
return self:fuzzy(str, mods.pattern, positions)
end
local from, to ---@type number?, number?
if mods.exact_prefix then
if str:sub(1, #mods.pattern) == mods.pattern then
from, to = 1, #mods.pattern
end
elseif mods.exact_suffix then
if str:sub(-#mods.pattern) == mods.pattern then
from, to = #str - #mods.pattern + 1, #str
end
else
from, to = str:find(mods.pattern, 1, true)
-- word match
while mods.word and from and to do
local bound_left = from == 1 or not M.is_alpha(str, from - 1)
local bound_right = to == #str or not M.is_alpha(str, to + 1)
if bound_left and bound_right then
break
end
from, to = str:find(mods.pattern, to + 1, true)
end
end
if mods.inverse then
if not from then
return M.INVERSE_SCORE
end
return
end
if from and to then
if positions then
M.positions(from, to, positions)
end
return self.score(from, to, #str)
end
end
---@param from number
---@param to number
---@param positions number[]
function M.positions(from, to, positions)
for i = from, to do
table.insert(positions, i)
end
end
---@param from number
---@param to number
---@param len number
function M.score(from, to, len)
return 1000 / (to - from + 1) -- calculate compactness score (distance between first and last match)
+ (100 / from) -- add bonus for early match
+ (100 / (len + 1)) -- add bonus for string length
end
---@param str string
---@param pattern string
---@param positions? number[]
---@return number? score
function M:fuzzy_fast(str, pattern, positions)
local n, m, p, c = #str, #pattern, 1, 1
positions = positions or M.clear(fuzzy_fast_positions)
while c <= n and p <= m do
local pos = str:find(pattern:sub(p, p), c, true)
if not pos then
break
end
positions[p] = pos
p = p + 1
c = pos + 1
end
return p > m and M.score(positions[1], positions[m], n) or nil
end
--- Does a forward scan followed by a backward scan for each end position,
--- to find the best match.
---@param str string
---@param pattern string
---@param best_positions? number[]
---@return number? score
function M:fuzzy(str, pattern, best_positions)
local n, m, p, c = #str, #pattern, 1, 1
-- Find last char positions first for early exit
best_positions = best_positions or M.clear(fuzzy_best_positions)
local best_score = -1
-- initial forward scan
while c <= n and p <= m do
local pos = str:find(pattern:sub(p, p), c, true)
if not pos then
break
end
best_positions[p] = pos
p = p + 1
c = pos + 1
end
-- no full match
if p <= m then
return
end
-- calculate score for the initial match
best_score = M.score(best_positions[1], best_positions[m], n)
-- early exit for exact match
if best_positions[m] - best_positions[1] + 1 == m then
return best_score
end
-- find all last positions
local last_positions = M.clear(fuzzy_last_positions)
last_positions[1] = best_positions[m]
local last_p = pattern:sub(m, m)
while c <= n do
local pos = str:find(last_p, c, true)
if not pos then
break
end
table.insert(last_positions, pos)
c = pos + 1
end
local rev = str:reverse()
-- backward scan from last positions to refine the match
local positions = M.clear(fuzzy_positions)
local best = best_positions
for _, last in ipairs(last_positions) do
p = m - 1 -- Start from the second last character of the pattern
positions[m] = last
c = n - last + 1
local score = 0
while c > 0 and p > 0 do
local pos = rev:find(pattern:sub(p, p), c, true)
local from = n - pos + 1
score = M.score(from, last, n)
if score <= best_score then
break
end
positions[p] = from
p = p - 1
c = pos + 1
end
if score > best_score then
best_score = score
positions, best = best, positions
end
end
if best ~= best_positions then
table.move(best, 1, m, 1, best_positions)
end
return best_score
end
return M

View file

@ -0,0 +1,501 @@
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

View file

@ -0,0 +1,230 @@
---@class snacks.picker.Preview
---@field item? snacks.picker.Item
---@field win snacks.win
---@field preview snacks.picker.preview
---@field state table<string, any>
---@field main? number
---@field win_opts {main: snacks.win.Config, layout: snacks.win.Config, win: snacks.win.Config}
---@field winhl string
local M = {}
M.__index = M
---@class snacks.picker.preview.ctx
---@field picker snacks.Picker
---@field item snacks.picker.Item
---@field prev? snacks.picker.Item
---@field preview snacks.picker.Preview
---@field buf number
---@field win number
local ns = vim.api.nvim_create_namespace("snacks.picker.preview")
local ns_loc = vim.api.nvim_create_namespace("snacks.picker.preview.loc")
---@param opts snacks.picker.Config
---@param main? number
function M.new(opts, main)
local self = setmetatable({}, M)
self.winhl = Snacks.picker.highlight.winhl("SnacksPickerPreview")
local win_opts = Snacks.win.resolve(
{
title_pos = "center",
},
opts.win.preview,
{
show = false,
enter = false,
width = 0,
height = 0,
fixbuf = false,
bo = { filetype = "snacks_picker_preview" },
on_win = function()
self.item = nil
self:reset()
end,
wo = {
winhighlight = self.winhl,
},
}
)
self.win_opts = {
main = {
relative = "win",
backdrop = false,
},
layout = {
backdrop = win_opts.backdrop == true,
relative = "win",
},
}
self.win = Snacks.win(win_opts)
self:update(main)
self.state = {}
self.win:on("WinClosed", function()
self:clear(self.win.buf)
end, { win = true })
local preview = opts.preview or Snacks.picker.preview.file
preview = type(preview) == "string" and Snacks.picker.preview[preview] or preview
---@cast preview snacks.picker.preview
self.preview = preview
return self
end
---@param main? number
function M:update(main)
self.main = main
self.win_opts.main.win = main
self.win.opts = vim.tbl_deep_extend("force", self.win.opts, main and self.win_opts.main or self.win_opts.layout)
self.win.opts.wo.winhighlight = main and vim.wo[main].winhighlight or self.winhl
if main then
self.win:update()
end
end
---@param picker snacks.Picker
function M:show(picker)
local item, prev = picker:current(), self.item
self.item = item
if item then
local buf = self.win.buf
local ok, err = pcall(
self.preview,
setmetatable({
preview = self,
item = item,
prev = prev,
picker = picker,
}, {
__index = function(_, k)
if k == "buf" then
return self.win.buf
elseif k == "win" then
return self.win.win
end
end,
})
)
if not ok then
self:notify(err, "error")
end
if self.win.buf ~= buf then
self:clear(buf)
end
else
self:reset()
end
end
---@param title string
function M:set_title(title)
self.win:set_title(title)
end
---@param buf? number
function M:clear(buf)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, ns_loc, 0, -1)
end
function M:reset()
if vim.api.nvim_buf_is_valid(self.win.scratch_buf) then
vim.api.nvim_win_set_buf(self.win.win, self.win.scratch_buf)
else
self.win:scratch()
end
self:set_title("")
vim.treesitter.stop(self.win.buf)
vim.bo[self.win.buf].modifiable = true
vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, {})
self:clear(self.win.buf)
vim.bo[self.win.buf].filetype = "snacks_picker_preview"
vim.bo[self.win.buf].syntax = ""
vim.wo[self.win.win].cursorline = false
end
-- create a new scratch buffer
function M:scratch()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].bufhidden = "wipe"
local ei = vim.o.eventignore
vim.o.eventignore = "all"
vim.bo[buf].filetype = "snacks_picker_preview"
vim.o.eventignore = ei
vim.api.nvim_win_set_buf(self.win.win, buf)
return buf
end
--- highlight the buffer
---@param opts? {file?:string, buf?:number, ft?:string, lang?:string}
function M:highlight(opts)
opts = opts or {}
local ft = opts.ft
if not ft and (opts.file or opts.buf) then
ft = vim.filetype.match({
buf = opts.buf or self.win.buf,
filename = opts.file,
})
end
local lang = opts.lang or ft and vim.treesitter.language.get_lang(ft)
if not (lang and pcall(vim.treesitter.start, self.win.buf, lang)) then
if ft then
vim.bo[self.win.buf].syntax = ft
end
end
end
-- show the item location
function M:loc()
vim.api.nvim_buf_clear_namespace(self.win.buf, ns_loc, 0, -1)
if not self.item then
return
end
local line_count = vim.api.nvim_buf_line_count(self.win.buf)
if self.item.pos and self.item.pos[1] > 0 and self.item.pos[1] <= line_count then
vim.api.nvim_win_set_cursor(self.win.win, { self.item.pos[1], 0 })
vim.api.nvim_win_call(self.win.win, function()
vim.cmd("norm! zz")
vim.wo[self.win.win].cursorline = true
end)
if self.item.end_pos then
vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, self.item.pos[2] - 1, {
end_row = self.item.end_pos[1] - 1,
end_col = self.item.end_pos[2] - 1,
hl_group = "SnacksPickerSearch",
})
end
elseif self.item.search then
vim.api.nvim_win_call(self.win.win, function()
vim.cmd("keepjumps norm! gg")
if pcall(vim.cmd, self.item.search) then
vim.cmd("norm! zz")
vim.wo[self.win.win].cursorline = true
end
end)
end
end
---@param msg string
---@param level? "info" | "warn" | "error"
---@param opts? {item?:boolean}
function M:notify(msg, level, opts)
level = level or "info"
local lines = vim.split(level .. ": " .. msg, "\n", { plain = true })
local msg_len = #lines
if not (opts and opts.item == false) then
lines[#lines + 1] = ""
vim.list_extend(lines, vim.split(vim.inspect(self.item), "\n", { plain = true }))
end
vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines)
vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, {
hl_group = "Diagnostic" .. level:sub(1, 1):upper() .. level:sub(2),
end_row = msg_len,
})
self:highlight({ lang = "lua" })
end
return M

View file

@ -0,0 +1,381 @@
---@class snacks.picker.formatters
---@field [string] snacks.picker.format
local M = {}
function M.severity(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
local severity = item.severity
severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity
if not severity or type(severity) == "number" then
return ret
end
---@cast severity string
local lower = severity:lower()
local cap = severity:sub(1, 1):upper() .. lower:sub(2)
ret[#ret + 1] = { picker.opts.icons.diagnostics[cap], "Diagnostic" .. cap, virtual = true }
ret[#ret + 1] = { " ", virtual = true }
return ret
end
---@param item snacks.picker.Item
function M.filename(item)
---@type snacks.picker.Highlight[]
local ret = {}
if not item.file then
return ret
end
local path = vim.fs.normalize(item.file)
path = vim.fn.fnamemodify(path, ":~:.")
local name, cat = path, "file"
if item.buf and vim.api.nvim_buf_is_loaded(item.buf) then
name = vim.bo[item.buf].filetype
cat = "filetype"
elseif item.dir then
cat = "directory"
end
local icon, hl = Snacks.util.icon(name, cat)
ret[#ret + 1] = { icon .. " ", hl, virtual = true }
local dir, file = path:match("^(.*)/(.+)$")
if dir then
table.insert(ret, { dir .. "/", "SnacksPickerDir" })
table.insert(ret, { file, "SnacksPickerFile" })
else
table.insert(ret, { path, "SnacksPickerFile" })
end
if item.pos then
table.insert(ret, { ":", "SnacksPickerDelim" })
table.insert(ret, { tostring(item.pos[1]), "SnacksPickerRow" })
if item.pos[2] > 0 then
table.insert(ret, { ":", "SnacksPickerDelim" })
table.insert(ret, { tostring(item.pos[2]), "SnacksPickerCol" })
end
end
ret[#ret + 1] = { " " }
return ret
end
function M.file(item, picker)
---@type snacks.picker.Highlight[]
local ret = {}
if item.severity then
vim.list_extend(ret, M.severity(item, picker))
end
if item.label then
table.insert(ret, 1, { item.label, "SnacksPickerLabel" })
table.insert(ret, 2, { " ", virtual = true })
end
vim.list_extend(ret, M.filename(item))
if item.comment then
table.insert(ret, { item.comment, "SnacksPickerComment" })
table.insert(ret, { " " })
end
if item.line then
Snacks.picker.highlight.format(item, item.line, ret)
table.insert(ret, { " " })
end
return ret
end
function M.git_log(item, picker)
local a = Snacks.picker.util.align
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { picker.opts.icons.git.commit, "SnacksPickerGitCommit" }
ret[#ret + 1] = { item.commit, "SnacksPickerGitCommit" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(item.date, 16), "SnacksPickerGitDate" }
local msg = item.msg ---@type string
local type, scope, breaking, body = msg:match("^(%S+)(%(.-%))(!?):%s*(.*)$")
if not type then
type, breaking, body = msg:match("^(%S+)(!?):%s*(.*)$")
end
local msg_hl = "SnacksPickerGitMsg"
if type and body then
local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type)
msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg"
ret[#ret + 1] =
{ type, breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType" }
if scope and scope ~= "" then
ret[#ret + 1] = { scope, "SnacksPickerGitScope" }
end
if breaking ~= "" then
ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" }
end
ret[#ret + 1] = { ":", "SnacksPickerDelim" }
ret[#ret + 1] = { " " }
msg = body
end
ret[#ret + 1] = { msg, msg_hl }
Snacks.picker.highlight.markdown(ret)
Snacks.picker.highlight.highlight(ret, {
["#%d+"] = "SnacksPickerGitIssue",
})
return ret
end
function M.lsp_symbol(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
if item.hierarchy then
local indents = picker.opts.icons.indent
local indent = {} ---@type string[]
local node = item
while node and node.depth > 0 do
local is_last, icon = node.last, ""
if node ~= item then
icon = is_last and " " or indents.vertical
else
icon = is_last and indents.last or indents.middle
end
table.insert(indent, 1, icon)
node = node.parent
end
ret[#ret + 1] = { table.concat(indent), "SnacksPickerIndent" }
end
local kind = item.kind or "Unknown" ---@type string
local kind_hl = "SnacksPickerIcon" .. kind
ret[#ret + 1] = { picker.opts.icons.kinds[kind], kind_hl }
ret[#ret + 1] = { " " }
-- ret[#ret + 1] = { kind:lower() .. string.rep(" ", 10 - #kind), kind_hl }
-- ret[#ret + 1] = { " " }
local name = vim.trim(item.name:gsub("\r?\n", " "))
name = name == "" and item.detail or name
Snacks.picker.highlight.format(item, name, ret)
-- ret[#ret + 1] = { name }
return ret
end
---@param kind? string
---@param count number
---@return snacks.picker.format
function M.ui_select(kind, count)
return function(item)
local ret = {} ---@type snacks.picker.Highlight[]
local idx = tostring(item.idx)
idx = (" "):rep(#tostring(count) - #idx) .. idx
ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" }
ret[#ret + 1] = { " " }
if kind == "codeaction" then
---@type lsp.Command|lsp.CodeAction, lsp.HandlerContext
local action, ctx = item.item.action, item.item.ctx
local client = vim.lsp.get_client_by_id(ctx.client_id)
ret[#ret + 1] = { action.title }
if client then
ret[#ret + 1] = { " " }
ret[#ret + 1] = { ("[%s]"):format(client.name), "SnacksPickerSpecial" }
end
else
ret[#ret + 1] = { item.formatted }
end
return ret
end
end
function M.lines(item)
local ret = {} ---@type snacks.picker.Highlight[]
local line_count = vim.api.nvim_buf_line_count(item.buf)
local idx = Snacks.picker.util.align(tostring(item.idx), #tostring(line_count), { align = "right" })
ret[#ret + 1] = { idx, "LineNr", virtual = true }
ret[#ret + 1] = { " ", virtual = true }
ret[#ret + 1] = { item.text }
local offset = #idx + 2
for _, extmark in ipairs(item.highlights or {}) do
extmark = vim.deepcopy(extmark)
if type(extmark[1]) ~= "string" then
---@cast extmark snacks.picker.Extmark
extmark.col = extmark.col + offset
if extmark.end_col then
extmark.end_col = extmark.end_col + offset
end
end
ret[#ret + 1] = extmark
end
return ret
end
function M.text(item)
return {
{ item.text },
}
end
function M.diagnostic(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
local diag = item.item ---@type vim.Diagnostic
if item.severity then
vim.list_extend(ret, M.severity(item, picker))
end
ret[#ret + 1] = { diag.message }
Snacks.picker.highlight.markdown(ret)
ret[#ret + 1] = { " " }
if diag.source then
ret[#ret + 1] = { diag.source, "SnacksPickerDiagnosticSource" }
ret[#ret + 1] = { " " }
end
if diag.code then
ret[#ret + 1] = { ("(%s)"):format(diag.code), "SnacksPickerDiagnosticCode" }
ret[#ret + 1] = { " " }
end
vim.list_extend(ret, M.filename(item, picker))
return ret
end
function M.autocmd(item)
local ret = {} ---@type snacks.picker.Highlight[]
---@type vim.api.keyset.get_autocmds.ret
local au = item.item
local a = Snacks.picker.util.align
ret[#ret + 1] = { a(au.event, 15), "SnacksPickerAuEvent" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(au.pattern, 10), "SnacksPickerAuPattern" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(tostring(au.group_name or ""), 15), "SnacksPickerAuGroup" }
ret[#ret + 1] = { " " }
if au.command ~= "" then
Snacks.picker.highlight.format(item, au.command, ret, { lang = "vim" })
else
ret[#ret + 1] = { "callback", "Function" }
end
return ret
end
function M.hl(item)
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { item.hl_group, item.hl_group }
return ret
end
function M.man(item)
local a = Snacks.picker.util.align
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { a(item.page, 20), "SnacksPickerManPage" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { ("(%s)"):format(item.section), "SnacksPickerManSection" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { item.desc, "SnacksPickerManDesc" }
return ret
end
-- Pretty keymaps using which-key icons when available
function M.keymap(item)
local ret = {} ---@type snacks.picker.Highlight[]
---@type vim.api.keyset.get_keymap
local k = item.item
local a = Snacks.picker.util.align
if package.loaded["which-key"] then
local Icons = require("which-key.icons")
local icon, hl = Icons.get({ keymap = k, desc = k.desc })
if icon then
ret[#ret + 1] = { a(icon, 3), hl }
else
ret[#ret + 1] = { " " }
end
end
local lhs = vim.fn.keytrans(Snacks.util.keycode(k.lhs))
ret[#ret + 1] = { k.mode, "SnacksPickerKeymapMode" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(lhs, 15), "SnacksPickerKeymapLhs" }
ret[#ret + 1] = { " " }
local rhs_len = 0
if k.rhs and k.rhs ~= "" then
local rhs = k.rhs or ""
rhs_len = #rhs
local cmd = rhs:lower():find("<cmd>")
if cmd then
ret[#ret + 1] = { rhs:sub(1, cmd + 4), "NonText" }
rhs = rhs:sub(cmd + 5)
local cr = rhs:lower():find("<cr>$")
if cr then
rhs = rhs:sub(1, cr - 1)
end
Snacks.picker.highlight.format(item, rhs, ret, { lang = "vim" })
if cr then
ret[#ret + 1] = { "<CR>", "NonText" }
end
elseif rhs:lower():find("^<plug>") then
ret[#ret + 1] = { "<Plug>", "NonText" }
local plug = rhs:sub(7):gsub("^%(", ""):gsub("%)$", "")
ret[#ret + 1] = { "(", "SnacksPickerDelim" }
Snacks.picker.highlight.format(item, plug, ret, { lang = "vim" })
ret[#ret + 1] = { ")", "SnacksPickerDelim" }
elseif rhs:find("v:lua%.") then
ret[#ret + 1] = { "v:lua", "NonText" }
ret[#ret + 1] = { ".", "SnacksPickerDelim" }
Snacks.picker.highlight.format(item, rhs:sub(7), ret, { lang = "lua" })
else
ret[#ret + 1] = { k.rhs, "SnacksPickerKeymapRhs" }
end
else
ret[#ret + 1] = { "callback", "Function" }
rhs_len = 8
end
if rhs_len < 15 then
ret[#ret + 1] = { (" "):rep(15 - rhs_len) }
end
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(k.desc or "", 20) }
if item.file then
ret[#ret + 1] = { " " }
vim.list_extend(ret, M.filename(item))
end
return ret
end
function M.git_status(item)
local ret = {} ---@type snacks.picker.Highlight[]
local a = Snacks.picker.util.align
local s = vim.trim(item.status):sub(1, 1)
local hls = {
["A"] = "SnacksPickerGitStatusAdded",
["M"] = "SnacksPickerGitStatusModified",
["D"] = "SnacksPickerGitStatusDeleted",
["R"] = "SnacksPickerGitStatusRenamed",
["C"] = "SnacksPickerGitStatusCopied",
["?"] = "SnacksPickerGitStatusUntracked",
}
local hl = hls[s] or "SnacksPickerGitStatus"
ret[#ret + 1] = { a(item.status, 2, { align = "right" }), hl }
ret[#ret + 1] = { " " }
vim.list_extend(ret, M.filename(item))
return ret
end
function M.register(item)
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { " " }
ret[#ret + 1] = { "[", "SnacksPickerDelim" }
ret[#ret + 1] = { item.reg, "SnacksPickerRegister" }
ret[#ret + 1] = { "]", "SnacksPickerDelim" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { item.value }
return ret
end
function M.buffer(item)
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { Snacks.picker.util.align(tostring(item.buf), 3), "SnacksPickerBufNr" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { Snacks.picker.util.align(item.flags, 2, { align = "right" }), "SnacksPickerBufFlags" }
ret[#ret + 1] = { " " }
vim.list_extend(ret, M.filename(item))
return ret
end
return M

View file

@ -0,0 +1,91 @@
---@class snacks.picker
---@field actions snacks.picker.actions
---@field config snacks.picker.config
---@field format snacks.picker.formatters
---@field preview snacks.picker.previewers
---@field sort snacks.picker.sorters
---@field util snacks.picker.util
---@field current? snacks.Picker
---@field highlight snacks.picker.highlight
---@field resume fun(opts?: snacks.picker.Config):snacks.Picker
---@field sources snacks.picker.sources.Config
---@overload fun(opts: snacks.picker.Config): snacks.Picker
---@overload fun(source: string, opts: snacks.picker.Config): snacks.Picker
local M = setmetatable({}, {
__call = function(M, ...)
return M.pick(...)
end,
---@param M snacks.picker
__index = function(M, k)
if type(k) ~= "string" then
return
end
local mods = {
"actions",
"config",
"format",
"preview",
"util",
"sort",
highlight = "util.highlight",
sources = "config.sources",
}
for m, mod in pairs(mods) do
mod = mod == k and k or m == k and mod or nil
if mod then
---@diagnostic disable-next-line: no-unknown
M[k] = require("snacks.picker." .. mod)
return rawget(M, k)
end
end
return M.config.wrap(k, { check = true })
end,
})
---@type snacks.meta.Meta
M.meta = {
desc = "Picker for selecting items",
needs_setup = true,
merge = { config = "config.defaults", picker = "core.picker", "actions" },
}
-- create actual picker functions for autocomplete
vim.schedule(M.config.setup)
--- Create a new picker
---@param source? string
---@param opts? snacks.picker.Config
---@overload fun(opts: snacks.picker.Config): snacks.Picker
function M.pick(source, opts)
if not opts and type(source) == "table" then
opts, source = source, nil
end
opts = opts or {}
opts.source = source or opts.source
-- Show pickers if no source, items or finder is provided
if not (opts.source or opts.items or opts.finder) then
opts.source = "pickers"
return M.pick(opts)
end
return require("snacks.picker.core.picker").new(opts)
end
--- Implementation for `vim.ui.select`
---@type snacks.picker.ui_select
function M.select(...)
return require("snacks.picker.select").select(...)
end
---@private
function M.setup()
if M.config.get().ui_select then
vim.ui.select = M.select
end
end
---@private
function M.health()
require("snacks.picker.core._health").health()
end
return M

View file

@ -0,0 +1,250 @@
---@class snacks.picker.previewers
local M = {}
local uv = vim.uv or vim.loop
local ns = vim.api.nvim_create_namespace("snacks.picker.preview")
---@param ctx snacks.picker.preview.ctx
function M.directory(ctx)
ctx.preview:reset()
local ls = {} ---@type {file:string, type:"file"|"directory"}[]
for file, t in vim.fs.dir(ctx.item.file) do
ls[#ls + 1] = { file = file, type = t }
end
vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, vim.split(string.rep("\n", #ls), "\n"))
vim.bo[ctx.buf].modifiable = false
table.sort(ls, function(a, b)
if a.type ~= b.type then
return a.type == "directory"
end
return a.file < b.file
end)
for i, item in ipairs(ls) do
local cat = item.type == "directory" and "directory" or "file"
local hl = item.type == "directory" and "Directory" or nil
local path = item.file
local icon, icon_hl = Snacks.util.icon(path, cat)
local line = { { icon .. " ", icon_hl }, { path, hl } }
vim.api.nvim_buf_set_extmark(ctx.buf, ns, i - 1, 0, {
virt_text = line,
})
end
end
---@param ctx snacks.picker.preview.ctx
function M.none(ctx)
ctx.preview:reset()
ctx.preview:notify("no preview available", "warn")
end
---@param ctx snacks.picker.preview.ctx
function M.preview(ctx)
if ctx.item.preview == "file" then
return M.file(ctx)
end
assert(type(ctx.item.preview) == "table", "item.preview must be a table")
ctx.preview:reset()
local lines = vim.split(ctx.item.preview.text, "\n")
vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, lines)
ctx.preview:highlight({ ft = ctx.item.preview.ft })
for _, extmark in ipairs(ctx.item.preview.extmarks or {}) do
local e = vim.deepcopy(extmark)
e.col, e.row = nil, nil
vim.api.nvim_buf_set_extmark(ctx.buf, ns, (extmark.row or 1) - 1, extmark.col, e)
end
ctx.preview:loc()
end
---@param ctx snacks.picker.preview.ctx
function M.file(ctx)
if ctx.item.buf and vim.api.nvim_buf_is_loaded(ctx.item.buf) then
local name = vim.api.nvim_buf_get_name(ctx.item.buf)
name = uv.fs_stat(name) and vim.fn.fnamemodify(name, ":t") or name
ctx.preview:set_title(name)
vim.api.nvim_win_set_buf(ctx.win, ctx.item.buf)
else
local path = Snacks.picker.util.path(ctx.item)
if not path then
ctx.preview:notify("Item has no `file`", "error")
return
end
-- re-use existing preview when path is the same
if path ~= Snacks.picker.util.path(ctx.prev) then
ctx.preview:reset()
local name = vim.fn.fnamemodify(path, ":t")
ctx.preview:set_title(ctx.item.title or name)
local stat = uv.fs_stat(path)
if not stat then
ctx.preview:notify("file not found: " .. path, "error")
return false
end
if stat.type == "directory" then
return M.directory(ctx)
end
local max_size = ctx.picker.opts.previewers.file.max_size or (1024 * 1024)
if stat.size > max_size then
ctx.preview:notify("large file > 1MB", "warn")
return false
end
if stat.size == 0 then
ctx.preview:notify("empty file", "warn")
return false
end
local file = assert(io.open(path, "r"))
local lines = {}
for line in file:lines() do
---@cast line string
if #line > ctx.picker.opts.previewers.file.max_line_length then
line = line:sub(1, ctx.picker.opts.previewers.file.max_line_length) .. "..."
end
-- Check for binary data in the current line
if line:find("[%z\1-\8\13\14\16-\31]") then
ctx.preview:notify("binary file", "warn")
return
end
table.insert(lines, line)
end
file:close()
vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, lines)
vim.bo[ctx.buf].modifiable = false
ctx.preview:highlight({ file = path, ft = ctx.picker.opts.previewers.file.ft, buf = ctx.buf })
end
end
ctx.preview:loc()
end
---@param cmd string[]
---@param ctx snacks.picker.preview.ctx
---@param opts? {env?:table<string, string>, pty?:boolean, ft?:string}
function M.cmd(cmd, ctx, opts)
opts = opts or {}
local buf = ctx.preview:scratch()
local killed = false
local chan = vim.api.nvim_open_term(buf, {})
local output = {} ---@type string[]
local jid = vim.fn.jobstart(cmd, {
height = vim.api.nvim_win_get_height(ctx.win),
width = vim.api.nvim_win_get_width(ctx.win),
pty = opts.pty ~= false and not opts.ft,
cwd = ctx.item.cwd,
env = vim.tbl_extend("force", {
PAGER = "cat",
DELTA_PAGER = "cat",
}, opts.env or {}),
on_stdout = function(_, data)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
data = table.concat(data, "\n")
local ok = pcall(vim.api.nvim_chan_send, chan, data)
if ok then
vim.api.nvim_buf_call(buf, function()
vim.cmd("norm! gg")
end)
end
table.insert(output, data)
end,
on_exit = function(_, code)
if not killed and code ~= 0 then
Snacks.notify.error(
("Terminal **cmd** `%s` failed with code `%d`:\n- `vim.o.shell = %q`\n\nOutput:\n%s"):format(
cmd,
code,
vim.o.shell,
vim.trim(table.concat(output, ""))
)
)
end
end,
})
if opts.ft then
ctx.preview:highlight({ ft = opts.ft })
end
vim.api.nvim_create_autocmd("BufWipeout", {
buffer = buf,
callback = function()
killed = true
vim.fn.jobstop(jid)
vim.fn.chanclose(chan)
end,
})
if jid <= 0 then
Snacks.notify.error(("Failed to start terminal **cmd** `%s`"):format(cmd))
end
end
---@param ctx snacks.picker.preview.ctx
function M.git_show(ctx)
local cmd = {
"git",
"-c",
"delta." .. vim.o.background .. "=true",
"show",
ctx.item.commit,
}
M.cmd(cmd, ctx)
end
---@param ctx snacks.picker.preview.ctx
function M.git_diff(ctx)
local cmd = {
"git",
"-c",
"delta." .. vim.o.background .. "=true",
"diff",
"--",
ctx.item.file,
}
M.cmd(cmd, ctx)
end
---@param ctx snacks.picker.preview.ctx
function M.git_status(ctx)
local s = vim.trim(ctx.item.status):sub(1, 1)
if s == "?" then
M.file(ctx)
else
M.git_diff(ctx)
end
end
---@param ctx snacks.picker.preview.ctx
function M.colorscheme(ctx)
if not ctx.preview.state.colorscheme then
ctx.preview.state.colorscheme = vim.g.colors_name or "default"
ctx.preview.state.background = vim.o.background
ctx.preview.win:on("WinClosed", function()
if not ctx.preview.state.colorscheme then
return
end
vim.schedule(function()
vim.cmd("colorscheme " .. ctx.preview.state.colorscheme)
vim.o.background = ctx.preview.state.background
end)
end, { win = true })
end
vim.schedule(function()
vim.cmd("colorscheme " .. ctx.item.text)
end)
Snacks.picker.preview.file(ctx)
end
---@param ctx snacks.picker.preview.ctx
function M.man(ctx)
M.cmd({ "man", ctx.item.section, ctx.item.page }, ctx, {
ft = "man",
env = {
MANPAGER = ctx.picker.opts.previewers.man_pager or vim.fn.executable("col") == 1 and "col -bx" or "cat",
MANWIDTH = tostring(ctx.preview.win:dim().width),
MANPATH = vim.env.MANPATH,
},
})
end
return M

View file

@ -0,0 +1,50 @@
local M = {}
---@alias snacks.picker.ui_select fun(items: any[], opts?: {prompt?: string, format_item?: (fun(item: any): string), kind?: string}, on_choice: fun(item?: any, idx?: number))
---@generic T
---@param items T[] Arbitrary items
---@param opts? {prompt?: string, format_item?: (fun(item: T): string), kind?: string}
---@param on_choice fun(item?: T, idx?: number)
function M.select(items, opts, on_choice)
assert(type(on_choice) == "function", "on_choice must be a function")
opts = opts or {}
---@type snacks.picker.finder.Item[]
local finder_items = {}
for idx, item in ipairs(items) do
local text = (opts.format_item or tostring)(item)
table.insert(finder_items, {
formatted = text,
text = idx .. " " .. text,
item = item,
idx = idx,
})
end
local title = opts.prompt or "Select"
title = title:gsub("^%s*", ""):gsub("[%s:]*$", "")
local layout = Snacks.picker.config.layout("select")
layout.preview = false
layout.layout.height = math.floor(math.min(vim.o.lines * 0.8 - 10, #items + 2) + 0.5) + 10
layout.layout.title = " " .. title .. " "
layout.layout.title_pos = "center"
---@type snacks.picker.finder.Item[]
return Snacks.picker.pick({
source = "select",
items = finder_items,
format = Snacks.picker.format.ui_select(opts.kind, #items),
actions = {
confirm = function(picker, item)
picker:close()
vim.schedule(function()
on_choice(item and item.item, item and item.idx)
end)
end,
},
layout = layout,
})
end
return M

View file

@ -0,0 +1,45 @@
---@class snacks.picker.sorters
local M = {}
---@alias snacks.picker.sort.Field { name: string, desc: boolean }
---@class snacks.picker.sort.Config
---@field fields? (snacks.picker.sort.Field|string)[]
---@param opts? snacks.picker.sort.Config
function M.default(opts)
local fields = {} ---@type snacks.picker.sort.Field[]
for _, f in ipairs(opts and opts.fields or { { name = "score", desc = true }, "idx" }) do
if type(f) == "string" then
table.insert(fields, { name = f, desc = false })
else
table.insert(fields, f)
end
end
---@param a snacks.picker.Item
---@param b snacks.picker.Item
return function(a, b)
for _, field in ipairs(fields) do
local av, bv = a[field.name], b[field.name]
if (av ~= nil) and (bv ~= nil) and (av ~= bv) then
if field.desc then
return av > bv
else
return av < bv
end
end
end
return false
end
end
function M.idx()
---@param a snacks.picker.Item
---@param b snacks.picker.Item
return function(a, b)
return a.idx < b.idx
end
end
return M

View file

@ -0,0 +1,45 @@
local M = {}
---@class snacks.picker
---@field buffers fun(opts?: snacks.picker.buffers.Config): snacks.Picker
---@param opts snacks.picker.buffers.Config
---@type snacks.picker.finder
function M.buffers(opts, filter)
local items = {} ---@type snacks.picker.finder.Item[]
local current_buf = vim.api.nvim_get_current_buf()
local alternate_buf = vim.fn.bufnr("#")
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
local keep = (opts.hidden or vim.bo[buf].buflisted)
and (opts.unloaded or vim.api.nvim_buf_is_loaded(buf))
and (opts.current or buf ~= current_buf)
and (opts.nofile or vim.bo[buf].buftype ~= "nofile")
if keep then
local name = vim.api.nvim_buf_get_name(buf)
name = name == "" and "[No Name]" or name
local info = vim.fn.getbufinfo(buf)[1]
local flags = {
buf == current_buf and "%" or (buf == alternate_buf and "#" or ""),
info.hidden == 1 and "h" or "a",
vim.bo[buf].readonly and "=" or "",
info.changed == 1 and "+" or "",
}
table.insert(items, {
flags = table.concat(flags),
buf = buf,
text = name,
file = name,
info = info,
pos = { info.lnum, 0 },
})
end
end
if opts.sort_lastused then
table.sort(items, function(a, b)
return a.info.lastused > b.info.lastused
end)
end
return filter:filter(items)
end
return M

View file

@ -0,0 +1,38 @@
local M = {}
local uv = vim.uv or vim.loop
---@class snacks.picker
---@field diagnostics fun(opts?: snacks.picker.diagnostics.Config): snacks.Picker
---@param opts snacks.picker.diagnostics.Config
---@type snacks.picker.finder
function M.diagnostics(opts, filter)
local items = {} ---@type snacks.picker.finder.Item[]
local current_buf = vim.api.nvim_get_current_buf()
local cwd = vim.fs.normalize(uv.cwd() or ".")
for _, diag in ipairs(vim.diagnostic.get(filter.buf, { severity = opts.severity })) do
local buf = diag.bufnr
if buf and vim.api.nvim_buf_is_valid(buf) then
local file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf), { _fast = true })
local severity = diag.severity
severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity
---@cast severity string?
items[#items + 1] = {
text = table.concat({ severity or "", tostring(diag.code or ""), file, diag.source or "", diag.message }, " "),
file = file,
buf = diag.bufnr,
is_current = buf == current_buf and 0 or 1,
is_cwd = file:sub(1, #cwd) == cwd and 0 or 1,
lnum = diag.lnum,
severity = diag.severity,
pos = { diag.lnum + 1, diag.col + 1 },
end_pos = diag.end_lnum and { diag.end_lnum + 1, diag.end_col + 1 } or nil,
item = diag,
comment = diag.message,
}
end
end
return filter:filter(items)
end
return M

View file

@ -0,0 +1,114 @@
local M = {}
---@class snacks.picker
---@field files fun(opts?: snacks.picker.files.Config): snacks.Picker
---@field zoxide fun(opts?: snacks.picker.Config): snacks.Picker
local uv = vim.uv or vim.loop
local commands = {
rg = { "--files", "--no-messages", "--color", "never", "-g", "!.git" },
fd = { "--type", "f", "--color", "never", "-E", ".git" },
find = { ".", "-type", "f", "-not", "-path", "*/.git/*" },
}
---@param opts snacks.picker.files.Config
---@param filter snacks.picker.Filter
local function get_cmd(opts, filter)
local cmd, args ---@type string, string[]
if vim.fn.executable("fd") == 1 then
cmd, args = "fd", commands.fd
elseif vim.fn.executable("fdfind") == 1 then
cmd, args = "fdfind", commands.fd
elseif vim.fn.executable("rg") == 1 then
cmd, args = "rg", commands.rg
elseif vim.fn.executable("find") == 1 and vim.fn.has("win-32") == 0 then
cmd, args = "find", commands.find
else
error("No supported finder found")
end
args = vim.deepcopy(args)
local is_fd, is_fd_rg, is_find, is_rg = cmd == "fd" or cmd == "fdfind", cmd ~= "find", cmd == "find", cmd == "rg"
-- hidden
if opts.hidden and is_fd_rg then
table.insert(args, "--hidden")
elseif not opts.hidden and is_find then
vim.list_extend(args, { "-not", "-path", "*/.*" })
end
-- ignored
if opts.ignored and is_fd_rg then
args[#args + 1] = "--no-ignore"
end
-- follow
if opts.follow then
args[#args + 1] = "-L"
end
-- file glob
---@type string?
local pattern = filter.search
pattern = pattern ~= "" and pattern or nil
if pattern then
if is_fd then
table.insert(args, pattern)
elseif is_rg then
table.insert(args, "--glob")
table.insert(args, pattern)
elseif is_find then
table.insert(args, "-name")
table.insert(args, pattern)
end
end
-- dirs
if opts.dirs and #opts.dirs > 0 then
local dirs = vim.tbl_map(vim.fs.normalize, opts.dirs) ---@type string[]
if is_fd and not pattern then
args[#args + 1] = "."
end
if is_find then
table.remove(args, 1)
for _, d in pairs(dirs) do
table.insert(args, 1, d)
end
else
vim.list_extend(args, dirs)
end
end
return cmd, args
end
---@param opts snacks.picker.files.Config
---@type snacks.picker.finder
function M.files(opts, filter)
local cwd = not (opts.dirs and #opts.dirs > 0) and vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil
local cmd, args = get_cmd(opts, filter)
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = cmd,
args = args,
---@param item snacks.picker.finder.Item
transform = function(item)
item.cwd = cwd
item.file = item.text
end,
}, opts or {}))
end
---@param opts snacks.picker.Config
---@type snacks.picker.finder
function M.zoxide(opts)
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "zoxide",
args = { "query", "--list" },
---@param item snacks.picker.finder.Item
transform = function(item)
item.file = item.text
end,
}, opts or {}))
end
return M

View file

@ -0,0 +1,102 @@
local M = {}
local uv = vim.uv or vim.loop
---@class snacks.picker
---@field git_files fun(opts?: snacks.picker.git.files.Config): snacks.Picker
---@field git_log fun(opts?: snacks.picker.git.log.Config): snacks.Picker
---@field git_log_file fun(opts?: snacks.picker.git.log.Config): snacks.Picker
---@field git_log_line fun(opts?: snacks.picker.git.log.Config): snacks.Picker
---@field git_status fun(opts?: snacks.picker.Config): snacks.Picker
---@param opts snacks.picker.git.files.Config
---@type snacks.picker.finder
function M.files(opts)
local args = { "-c", "core.quotepath=false", "ls-files", "--exclude-standard", "--cached" }
if opts.untracked then
table.insert(args, "--others")
elseif opts.submodules then
table.insert(args, "--recurse-submodules")
end
local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "git",
args = args,
---@param item snacks.picker.finder.Item
transform = function(item)
item.cwd = cwd
item.file = item.text
end,
}, opts or {}))
end
---@param opts snacks.picker.git.log.Config
---@type snacks.picker.finder
function M.log(opts)
local args = {
"log",
"--pretty=format:%h %s (%ch)",
"--abbrev-commit",
"--decorate",
"--date=short",
"--color=never",
"--no-show-signature",
"--no-patch",
}
if opts.follow and not opts.current_line then
args[#args + 1] = "--follow"
end
if opts.current_line then
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
args[#args + 1] = "-L"
args[#args + 1] = line .. ",+1:" .. vim.api.nvim_buf_get_name(0)
elseif opts.current_file then
args[#args + 1] = "--"
args[#args + 1] = vim.api.nvim_buf_get_name(0)
end
local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "git",
args = args,
---@param item snacks.picker.finder.Item
transform = function(item)
local commit, msg, date = item.text:match("^(%S+) (.*) %((.*)%)$")
if not commit then
error(item.text)
end
item.cwd = cwd
item.commit = commit
item.msg = msg
item.date = date
item.file = item.text
end,
}, opts or {}))
end
---@param opts snacks.picker.Config
---@type snacks.picker.finder
function M.status(opts)
local args = {
"status",
"--porcelain=v1",
}
local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "git",
args = args,
---@param item snacks.picker.finder.Item
transform = function(item)
local status, file = item.text:sub(1, 2), item.text:sub(4)
item.cwd = cwd
item.status = status
item.file = file
end,
}, opts or {}))
end
return M

View file

@ -0,0 +1,109 @@
local M = {}
local uv = vim.uv or vim.loop
---@class snacks.picker
---@field grep fun(opts?: snacks.picker.grep.Config): snacks.Picker
---@field grep_word fun(opts?: snacks.picker.grep.Config): snacks.Picker
---@field grep_buffers fun(opts?: snacks.picker.grep.Config): snacks.Picker
---@param opts snacks.picker.grep.Config
---@param filter snacks.picker.Filter
local function get_cmd(opts, filter)
local cmd = "rg"
local args = {
"--color=never",
"--no-heading",
"--with-filename",
"--line-number",
"--column",
"--smart-case",
"--max-columns=500",
"--max-columns-preview",
"-g",
"!.git",
}
args = vim.deepcopy(args)
-- hidden
if opts.hidden then
table.insert(args, "--hidden")
end
-- ignored
if opts.ignored then
args[#args + 1] = "--no-ignore"
end
-- follow
if opts.follow then
args[#args + 1] = "-L"
end
local glob = type(opts.glob) == "table" and opts.glob or { opts.glob }
---@cast glob string[]
for _, g in ipairs(glob) do
args[#args + 1] = "-g"
args[#args + 1] = g
end
args[#args + 1] = "--"
-- search pattern
table.insert(args, filter.search)
local paths = {} ---@type string[]
if opts.buffers then
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(buf)
if name ~= "" and vim.bo[buf].buflisted and uv.fs_stat(name) then
paths[#paths + 1] = name
end
end
elseif opts.dirs and #opts.dirs > 0 then
paths = opts.dirs or {}
end
-- dirs
if #paths > 0 then
paths = vim.tbl_map(vim.fs.normalize, paths) ---@type string[]
vim.list_extend(args, paths)
end
return cmd, args
end
---@param opts snacks.picker.grep.Config
---@type snacks.picker.finder
function M.grep(opts, filter)
if opts.need_search ~= false and filter.search == "" then
return function() end
end
local absolute = (opts.dirs and #opts.dirs > 0) or opts.buffers
local cwd = not absolute and vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil
local cmd, args = get_cmd(opts, filter)
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
notify = false,
cmd = cmd,
args = args,
---@param item snacks.picker.finder.Item
transform = function(item)
item.cwd = cwd
local file, line, col, text = item.text:match("^(.+):(%d+):(%d+):(.*)$")
if not file then
if not item.text:match("WARNING") then
error("invalid grep output: " .. item.text)
end
return false
else
item.line = text
item.file = file
item.pos = { tonumber(line), tonumber(col) }
end
end,
}, opts or {}))
end
return M

View file

@ -0,0 +1,64 @@
local M = {}
---@class snacks.picker
---@field help fun(opts?: snacks.picker.help.Config): snacks.Picker
---@param opts snacks.picker.help.Config
---@type snacks.picker.finder
function M.help(opts)
local langs = opts.lang or vim.opt.helplang:get() ---@type string[]
local rtp = vim.o.runtimepath
if package.loaded.lazy then
rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",")
end
local files = vim.fn.globpath(rtp, "doc/*", true, true) ---@type string[]
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
if not vim.tbl_contains(langs, "en") then
table.insert(langs, "en")
end
local tag_files = {} ---@type table<string, string[]>
local help_files = {} ---@type table<string, string>
for _, file in ipairs(files) do
local name = vim.fn.fnamemodify(file, ":t")
local lang = "en"
if name == "tags" or name:sub(1, 5) == "tags-" then
lang = name:match("^tags%-(..)$") or lang
if vim.tbl_contains(langs, lang) then
tag_files[lang] = tag_files[lang] or {}
table.insert(tag_files[lang], file)
end
else
help_files[name] = file
end
end
local done = {} ---@type table<string, boolean>
for _, lang in ipairs(langs) do
for _, file in ipairs(tag_files[lang] or {}) do
for line in io.lines(file) do
local fields = vim.split(line, string.char(9), { plain = true })
if not line:match("^!_TAG_") and #fields == 3 and not done[fields[1]] then
done[fields[1]] = true
---@type snacks.picker.finder.Item
local item = {
text = fields[1],
tag = fields[1],
file = help_files[fields[2]],
search = "/\\V" .. fields[3]:sub(2),
}
if item.file then
cb(item)
end
end
end
end
end
end
end
return M

View file

@ -0,0 +1,27 @@
local M = {}
---@class snacks.picker
---@field lines fun(opts?: snacks.picker.lines.Config): snacks.Picker
---@param opts snacks.picker.lines.Config
---@type snacks.picker.finder
function M.lines(opts)
local buf = opts.buf or 0
buf = buf == 0 and vim.api.nvim_get_current_buf() or buf
local extmarks = require("snacks.picker.util.highlight").get_highlights({ buf = buf })
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local items = {} ---@type snacks.picker.finder.Item[]
for l, line in ipairs(lines) do
---@type snacks.picker.finder.Item
local item = {
buf = buf,
text = line,
pos = { l, 0 },
highlights = extmarks[l],
}
items[#items + 1] = item
end
return items
end
return M

View file

@ -0,0 +1,355 @@
local Async = require("snacks.picker.util.async")
local M = {}
---@class snacks.picker
---@field lsp_definitions? fun(opts?: snacks.picker.lsp.Config):snacks.Picker
---@field lsp_implementations? fun(opts?: snacks.picker.lsp.Config):snacks.Picker
---@field lsp_declarations? fun(opts?: snacks.picker.lsp.Config):snacks.Picker
---@field lsp_type_definitions? fun(opts?: snacks.picker.lsp.Config):snacks.Picker
---@field lsp_references? fun(opts?: snacks.picker.lsp.references.Config):snacks.Picker
---@field lsp_symbols? fun(opts?: snacks.picker.lsp.symbols.Config):snacks.Picker
---@alias lsp.Symbol lsp.SymbolInformation|lsp.DocumentSymbol
---@alias lsp.Loc lsp.Location|lsp.LocationLink
local kinds = nil ---@type table<lsp.SymbolKind, string>
--- Gets the original symbol kind name from its number.
--- Some plugins override the symbol kind names, so this function is needed to get the original name.
---@param kind lsp.SymbolKind
---@return string
function M.symbol_kind(kind)
if not kinds then
kinds = {}
for k, v in pairs(vim.lsp.protocol.SymbolKind) do
if type(v) == "number" then
kinds[v] = k
end
end
end
return kinds[kind]
end
--- Neovim 0.11 uses a lua class for clients, while older versions use a table.
--- Wraps older style clients to be compatible with the new style.
---@param client vim.lsp.Client
---@return vim.lsp.Client
local function wrap(client)
local meta = getmetatable(client)
if meta and meta.request then
return client
end
---@diagnostic disable-next-line: undefined-field
if client.wrapped then
return client
end
local methods = { "request", "supports_method", "cancel_request" }
-- old style
return setmetatable({ wrapped = true }, {
__index = function(_, k)
if k == "supports_method" then
-- supports_method doesn't support the bufnr argument
return function(_, method)
return client[k](method)
end
end
if vim.tbl_contains(methods, k) then
return function(_, ...)
return client[k](...)
end
end
return client[k]
end,
})
end
---@param buf number
---@param method string
---@return vim.lsp.Client[]
function M.get_clients(buf, method)
---@param client vim.lsp.Client
local clients = vim.tbl_map(function(client)
return wrap(client)
---@diagnostic disable-next-line: deprecated
end, (vim.lsp.get_clients or vim.lsp.get_active_clients)({ bufnr = buf }))
---@param client vim.lsp.Client
return vim.tbl_filter(function(client)
return client:supports_method(method, buf)
---@diagnostic disable-next-line: deprecated
end, clients)
end
---@param buf number
---@param method string
---@param params fun(client:vim.lsp.Client):table
---@param cb fun(client:vim.lsp.Client, result:table, params:table)
---@async
function M.request(buf, method, params, cb)
local async = Async.running()
local cancel = {} ---@type fun()[]
async:on("abort", function()
for _, c in ipairs(cancel) do
c()
end
end)
vim.schedule(function()
local clients = M.get_clients(buf, method)
local remaining = #clients
for _, client in ipairs(clients) do
local p = params(client)
local status, request_id = client:request(method, p, function(_, result)
if result then
cb(client, result, p)
end
remaining = remaining - 1
if remaining == 0 then
async:resume()
end
end)
if status and request_id then
table.insert(cancel, function()
client:cancel_request(request_id)
end)
end
end
end)
async:suspend()
end
-- Support for older versions of neovim
---@param locs vim.quickfix.entry[]
function M.fix_locs(locs)
for _, loc in ipairs(locs) do
local range = loc.user_data and loc.user_data.range or nil ---@type lsp.Range?
if range then
if not loc.end_lnum then
if range.start.line == range["end"].line then
loc.end_lnum = loc.lnum
loc.end_col = loc.col + range["end"].character - range.start.character
end
end
end
end
end
---@param method string
---@param opts snacks.picker.lsp.Config|{context?:lsp.ReferenceContext}
---@param filter snacks.picker.Filter
function M.get_locations(method, opts, filter)
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_get_current_buf()
local fname = vim.api.nvim_buf_get_name(buf)
fname = vim.fs.normalize(fname)
local cursor = vim.api.nvim_win_get_cursor(win)
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
M.request(buf, method, function(client)
local params = vim.lsp.util.make_position_params(win, client.offset_encoding)
---@diagnostic disable-next-line: inject-field
params.context = opts.context
return params
end, function(client, result)
local items = vim.lsp.util.locations_to_items(result or {}, client.offset_encoding)
M.fix_locs(items)
if not opts.include_current then
---@param item vim.quickfix.entry
items = vim.tbl_filter(function(item)
if item.filename ~= fname then
return true
end
if not item.lnum then
return true
end
if item.lnum == cursor[1] then
return false
end
if not item.end_lnum then
return true
end
return not (item.lnum <= cursor[1] and item.end_lnum >= cursor[1])
end, items)
end
local done = {} ---@type table<string, boolean>
for _, loc in ipairs(items) do
---@type snacks.picker.finder.Item
local item = {
text = loc.filename .. " " .. loc.text,
buf = loc.bufnr,
file = loc.filename,
pos = { loc.lnum, loc.col },
end_pos = loc.end_lnum and loc.end_col and { loc.end_lnum, loc.end_col } or nil,
comment = loc.text,
}
local loc_key = loc.filename .. ":" .. loc.lnum
if filter:match(item) and not (done[loc_key] and opts.unique_lines) then
---@diagnostic disable-next-line: await-in-sync
cb(item)
done[loc_key] = true
end
end
end)
end
end
---@alias lsp.ResultItem lsp.Symbol|lsp.CallHierarchyItem|{text?:string}
---@param client vim.lsp.Client
---@param results lsp.ResultItem[]
---@param opts? {default_uri?:string, filter?:fun(result:lsp.ResultItem):boolean}
function M.results_to_items(client, results, opts)
opts = opts or {}
local items = {} ---@type snacks.picker.finder.Item[]
local locs = {} ---@type lsp.Loc[]
local processed = {} ---@type table<lsp.ResultItem, {uri:string, loc:lsp.Loc, range?:lsp.Loc}>
---@param result lsp.ResultItem
local function process(result)
local uri = result.location and result.location.uri or result.uri or opts.default_uri
local loc = result.location or { range = result.selectionRange or result.range, uri = uri }
loc.uri = loc.uri or uri
if not loc.uri then
assert(loc.uri, "missing uri in result:\n" .. vim.inspect(result))
end
processed[result] = { uri = uri, loc = loc }
if not opts.filter or opts.filter(result) then
locs[#locs + 1] = loc
end
for _, child in ipairs(result.children or {}) do
process(child)
end
end
for _, result in ipairs(results) do
process(result)
end
local loc_items = vim.lsp.util.locations_to_items(locs, client.offset_encoding)
M.fix_locs(loc_items)
local ranges = {} ---@type table<lsp.Loc, vim.quickfix.entry>
for _, i in ipairs(loc_items) do
local loc = i.user_data ---@type lsp.Loc
ranges[loc] = i
end
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
---@param result lsp.ResultItem
---@param parent snacks.picker.finder.Item
local function add(result, parent)
local loc = processed[result].loc
local sym = ranges[loc]
---@type snacks.picker.finder.Item?
local item
if sym then
item = {
kind = M.symbol_kind(result.kind),
parent = parent,
depth = (parent.depth or 0) + 1,
detail = result.detail,
name = result.name,
text = table.concat({ M.symbol_kind(result.kind), result.name, result.detail }, " "),
file = sym.filename,
buf = sym.bufnr,
pos = { sym.lnum, sym.col },
end_pos = sym.end_lnum and sym.end_col and { sym.end_lnum, sym.end_col },
}
items[#items + 1] = item
last[parent] = item
parent = item
end
for _, child in ipairs(result.children or {}) do
add(child, parent)
end
result.children = nil
end
local root = { depth = 0, text = "" } ---@type snacks.picker.finder.Item
---@type snacks.picker.finder.Item
for _, result in ipairs(results) do
add(result, root)
end
for _, item in pairs(last) do
item.last = true
end
return items
end
---@param opts snacks.picker.lsp.symbols.Config
function M.symbols(opts)
local buf = vim.api.nvim_get_current_buf()
local ft = vim.bo[buf].filetype
local filter = opts.filter[ft]
if filter == nil then
filter = opts.filter.default
end
---@param kind string?
local function want(kind)
kind = kind or "Unknown"
return type(filter) == "boolean" or vim.tbl_contains(filter, kind)
end
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
M.request(buf, "textDocument/documentSymbol", function()
return { textDocument = vim.lsp.util.make_text_document_params(buf) }
end, function(client, result, params)
local items = M.results_to_items(client, result, {
default_uri = params.textDocument.uri,
filter = function(item)
return want(M.symbol_kind(item.kind))
end,
})
for _, item in ipairs(items) do
item.hierarchy = opts.hierarchy
---@diagnostic disable-next-line: await-in-sync
cb(item)
end
end)
end
end
---@param opts snacks.picker.lsp.references.Config
---@type snacks.picker.finder
function M.references(opts, filter)
opts = opts or {}
return M.get_locations(
"textDocument/references",
vim.tbl_deep_extend("force", opts, {
context = { includeDeclaration = opts.include_declaration },
}),
filter
)
end
---@param opts snacks.picker.lsp.Config
---@type snacks.picker.finder
function M.definitions(opts, filter)
return M.get_locations("textDocument/definition", opts, filter)
end
---@param opts snacks.picker.lsp.Config
---@type snacks.picker.finder
function M.type_definitions(opts, filter)
return M.get_locations("textDocument/typeDefinition", opts, filter)
end
---@param opts snacks.picker.lsp.Config
---@type snacks.picker.finder
function M.implementations(opts, filter)
return M.get_locations("textDocument/implementation", opts, filter)
end
---@param opts snacks.picker.lsp.Config
---@type snacks.picker.finder
function M.declarations(opts, filter)
return M.get_locations("textDocument/declaration", opts, filter)
end
return M

View file

@ -0,0 +1,46 @@
local M = {}
---@class snacks.picker
---@field pickers fun(opts?: snacks.picker.Config): snacks.Picker
---@param file string
---@param t table<string,unknown>
function M.table(file, t)
file = Snacks.meta.file(file)
local values = vim.tbl_keys(t)
table.sort(values)
---@param value string
return vim.tbl_map(function(value)
return {
file = file,
text = value,
search = ("/^M\\.%s = \\|function M\\.%s("):format(value, value),
}
end, values)
end
---@param opts snacks.picker.Config
---@type snacks.picker.finder
function M.pickers(opts)
return M.table("picker/config/sources.lua", opts.sources or {})
end
---@param opts snacks.picker.Config
---@type snacks.picker.finder
function M.layouts(opts)
return M.table("picker/config/layouts.lua", opts.layouts or {})
end
function M.actions()
return M.table("picker/actions.lua", require("snacks.picker.actions"))
end
function M.preview()
return M.table("picker/preview.lua", require("snacks.picker.preview"))
end
function M.format()
return M.table("picker/format.lua", require("snacks.picker.format"))
end
return M

View file

@ -0,0 +1,118 @@
local Async = require("snacks.picker.util.async")
local M = {}
local uv = vim.uv or vim.loop
M.USE_QUEUE = true
---@class snacks.picker.proc.Config: snacks.picker.Config
---@field cmd string
---@field args? string[]
---@field env? table<string, string>
---@field cwd? string
---@field notify? boolean Notify on failure
---@field transform? fun(item: snacks.picker.finder.Item): boolean?
---@param opts snacks.picker.proc.Config
---@return fun(cb:async fun(item:snacks.picker.finder.Item))
function M.proc(opts)
assert(opts.cmd, "`opts.cmd` is required")
---@async
return function(cb)
if opts.transform then
local _cb = cb
cb = function(item)
if opts.transform(item) ~= false then
_cb(item)
end
end
end
local aborted = false
local stdout = assert(uv.new_pipe())
opts = vim.tbl_deep_extend("force", {}, opts or {}, {
stdio = { nil, stdout, nil },
cwd = opts.cwd and vim.fs.normalize(opts.cwd) or nil,
}) --[[@as snacks.picker.proc.Config]]
local self = Async.running()
local handle ---@type uv.uv_process_t
handle = uv.spawn(opts.cmd, opts, function(code, _signal)
if not aborted and code ~= 0 and opts.notify ~= false then
local full = { opts.cmd or "" }
vim.list_extend(full, opts.args or {})
return Snacks.notify.error(("Command failed:\n- cmd: `%s`"):format(table.concat(full, " ")))
end
stdout:close()
handle:close()
self:resume()
end)
if not handle then
return Snacks.notify.error("Failed to spawn " .. opts.cmd)
end
local prev ---@type string?
self:on("abort", function()
aborted = true
if not handle:is_closing() then
handle:kill("sigterm")
vim.defer_fn(function()
if not handle:is_closing() then
handle:kill("sigkill")
end
end, 200)
end
end)
---@param data? string
local function process(data)
if aborted then
return
end
if not data then
return prev and cb({ text = prev })
end
local from = 1
while from <= #data do
local nl = data:find("\n", from, true)
if nl then
local cr = data:byte(nl - 2, nl - 2) == 13 -- \r
local line = data:sub(from, nl - (cr and 2 or 1))
if prev then
line, prev = prev .. line, nil
end
cb({ text = line })
from = nl + 1
elseif prev then
prev = prev .. data:sub(from)
break
else
prev = data:sub(from)
break
end
end
end
local queue = require("snacks.picker.util.queue").new()
stdout:read_start(function(err, data)
assert(not err, err)
if M.USE_QUEUE then
queue:push(data)
self:resume()
else
process(data)
end
end)
while not (handle:is_closing() and queue:empty()) do
if queue:empty() then
self:suspend()
else
process(queue:pop())
end
end
end
end
return M

View file

@ -0,0 +1,74 @@
local M = {}
---Represents an item in a Neovim quickfix/loclist.
---@class qf.item
---@field bufnr? number The buffer number where the item originates.
---@field filename? string
---@field lnum number The start line number for the item.
---@field end_lnum? number The end line number for the item.
---@field pattern string A pattern related to the item. It can be a search pattern or any relevant string.
---@field col? number The column number where the item starts.
---@field end_col? number The column number where the item ends.
---@field module? string Module information (if any) associated with the item.
---@field nr? number A unique number or ID for the item.
---@field text? string A description or message related to the item.
---@field type? string The type of the item. E.g., "W" might stand for "Warning".
---@field valid number A flag indicating if the item is valid (1) or not (0).
---@field user_data? any Any user data associated with the item.
---@field vcol? number Visual column number. Indicates if the column number is a visual column number (when set to 1) or a byte index (when set to 0).
---@class snacks.picker
---@field loclist fun(opts?: snacks.picker.Config): snacks.Picker
---@field qflist fun(opts?: snacks.picker.Config): snacks.Picker
---@class snacks.picker.qf.Config
---@field qf_win? number
---@field filter? snacks.picker.filter.Config
local severities = {
E = vim.diagnostic.severity.ERROR,
W = vim.diagnostic.severity.WARN,
I = vim.diagnostic.severity.INFO,
H = vim.diagnostic.severity.HINT,
N = vim.diagnostic.severity.HINT,
}
---@param opts snacks.picker.qf.Config
---@type snacks.picker.finder
function M.qf(opts, filter)
local win = opts.qf_win
win = win == 0 and vim.api.nvim_get_current_win() or win
local list = win and vim.fn.getloclist(win, { all = true }) or vim.fn.getqflist({ all = true })
---@cast list { items?: qf.item[] }?
local ret = {} ---@type snacks.picker.finder.Item[]
for _, item in pairs(list and list.items or {}) do
local row = item.lnum == 0 and 1 or item.lnum
local col = (item.col == 0 and 1 or item.col) - 1
local end_row = item.end_lnum == 0 and row or item.end_lnum
local end_col = item.end_col == 0 and col or (item.end_col - 1)
if item.valid == 1 then
local file = item.filename or item.bufnr and vim.api.nvim_buf_get_name(item.bufnr) or nil
local text = item.text or ""
ret[#ret + 1] = {
pos = { row, col },
end_pos = item.end_lnum ~= 0 and { end_row, end_col } or nil,
text = file .. " " .. text,
line = item.text,
file = file,
severity = severities[item.type] or 0,
buf = item.bufnr,
item = item,
}
elseif #ret > 0 and ret[#ret].item.text and item.text then
ret[#ret].item.text = ret[#ret].item.text .. "\n" .. item.text
ret[#ret].item.line = ret[#ret].item.line .. "\n" .. item.text
end
end
return filter:filter(ret)
end
return M

View file

@ -0,0 +1,61 @@
local M = {}
local uv = vim.uv or vim.loop
---@class snacks.picker
---@field recent fun(opts?: snacks.picker.recent.Config): snacks.Picker
---@field projects fun(opts?: snacks.picker.projects.Config): snacks.Picker
---@param filter snacks.picker.Filter
local function oldfiles(filter)
local done = {} ---@type table<string, boolean>
local i = 1
return function()
while vim.v.oldfiles[i] do
local file = vim.fs.normalize(vim.v.oldfiles[i], { _fast = true, expand_env = false })
local want = not done[file] and filter:match({ file = file, text = "" })
done[file] = true
i = i + 1
if want and uv.fs_stat(file) then
return file
end
end
end
end
--- Get the most recent files, optionally filtered by the
--- current working directory or a custom directory.
---@param opts snacks.picker.recent.Config
---@type snacks.picker.finder
function M.files(opts, filter)
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
for file in oldfiles(filter) do
cb({ file = file, text = file })
end
end
end
--- Get the most recent projects based on git roots of recent files.
--- The default action will change the directory to the project root,
--- try to restore the session and open the picker if the session is not restored.
--- You can customize the behavior by providing a custom action.
---@param opts snacks.picker.recent.Config
---@type snacks.picker.finder
function M.projects(opts, filter)
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
local dirs = {} ---@type table<string, boolean>
for file in oldfiles(filter) do
local dir = Snacks.git.get_root(file)
if dir and not dirs[dir] then
dirs[dir] = true
cb({ file = dir, text = file, dir = dir })
end
end
end
end
return M

View file

@ -0,0 +1,64 @@
local M = {}
---@class snacks.picker
---@field cliphist fun(opts?: snacks.picker.proc.Config): snacks.Picker
---@field man fun(opts?: snacks.picker.proc.Config): snacks.Picker
---@param opts snacks.picker.proc.Config
---@type snacks.picker.finder
function M.cliphist(opts)
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "cliphist",
args = { "list" },
---@param item snacks.picker.finder.Item
transform = function(item)
local id, content = item.text:match("^(%d+)%s+(.+)$")
if id and content and not content:find("^%[%[%s+binary data") then
item.text = content
setmetatable(item, {
__index = function(_, k)
if k == "data" then
local data = vim.fn.system({ "cliphist", "decode", id })
rawset(item, "data", data)
if vim.v.shell_error ~= 0 then
error(data)
end
return data
elseif k == "preview" then
return {
text = item.data,
ft = "text",
}
end
end,
})
else
return false
end
end,
}, opts or {}))
end
---@param opts snacks.picker.proc.Config
---@type snacks.picker.finder
function M.man(opts)
return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", {
cmd = "man",
args = { "-k", "." },
---@param item snacks.picker.finder.Item
transform = function(item)
local page, section, desc = item.text:match("^(%S+)%s*%((%S-)%)%s+-%s+(.+)$")
if page and section and desc then
item.section = section
item.desc = desc
item.page = page
item.section = section
item.ref = ("%s(%s)"):format(item.page, item.section or 1)
else
return false
end
end,
}, opts or {}))
end
return M

View file

@ -0,0 +1,290 @@
local M = {}
---@class snacks.picker
---@field commands fun(opts?: snacks.picker.Config): snacks.Picker
---@field marks fun(opts?: snacks.picker.marks.Config): snacks.Picker
---@field jumps fun(opts?: snacks.picker.Config): snacks.Picker
---@field autocmds fun(opts?: snacks.picker.Config): snacks.Picker
---@field highlights fun(opts?: snacks.picker.Config): snacks.Picker
---@field colorschemes fun(opts?: snacks.picker.Config): snacks.Picker
---@field keymaps fun(opts?: snacks.picker.Config): snacks.Picker
---@field registers fun(opts?: snacks.picker.Config): snacks.Picker
---@field command_history fun(opts?: snacks.picker.history.Config): snacks.Picker
---@field search_history fun(opts?: snacks.picker.history.Config): snacks.Picker
---@class snacks.picker.history.Config: snacks.picker.Config
---@field name string
function M.commands()
local commands = vim.api.nvim_get_commands({})
for k, v in pairs(vim.api.nvim_buf_get_commands(0, {})) do
if type(k) == "string" then -- fixes vim.empty_dict() bug
commands[k] = v
end
end
---@async
---@param cb async fun(item: snacks.picker.finder.Item)
return function(cb)
---@type string[]
local names = vim.tbl_keys(commands)
table.sort(names)
for _, name in pairs(names) do
local def = commands[name]
cb({
text = name,
command = def,
cmd = name,
preview = {
text = vim.inspect(def),
ft = "lua",
},
})
end
end
end
---@param opts snacks.picker.history.Config
function M.history(opts)
local count = vim.fn.histnr(opts.name)
local items = {}
for i = count, 1, -1 do
local line = vim.fn.histget(opts.name, i)
if not line:find("^%s*$") then
table.insert(items, {
text = line,
cmd = line,
preview = {
text = line,
ft = "text",
},
})
end
end
return items
end
---@param opts snacks.picker.marks.Config
function M.marks(opts)
local marks = {} ---@type vim.fn.getmarklist.ret.item[]
if opts.global then
vim.list_extend(marks, vim.fn.getmarklist())
end
if opts["local"] then
vim.list_extend(marks, vim.fn.getmarklist(vim.api.nvim_get_current_buf()))
end
---@type snacks.picker.finder.Item[]
local items = {}
local bufname = vim.api.nvim_buf_get_name(0)
for _, mark in ipairs(marks) do
local file = mark.file or bufname
local buf = mark.pos[1] and mark.pos[1] > 0 and mark.pos[1] or nil
local line ---@type string?
if buf and mark.pos[2] > 0 and vim.api.nvim_buf_is_valid(mark.pos[2]) then
line = vim.api.nvim_buf_get_lines(buf, mark.pos[2] - 1, mark.pos[2], false)[1]
end
local label = mark.mark:sub(2, 2)
items[#items + 1] = {
text = table.concat({ label, file, line }, " "),
label = label,
line = line,
buf = buf,
file = file,
pos = mark.pos[2] > 0 and { mark.pos[2], mark.pos[3] },
}
end
table.sort(items, function(a, b)
return a.label < b.label
end)
return items
end
function M.jumps()
local jumps = vim.fn.getjumplist()[1]
local items = {} ---@type snacks.picker.finder.Item[]
for _, jump in ipairs(jumps) do
local buf = jump.bufnr and vim.api.nvim_buf_is_valid(jump.bufnr) and jump.bufnr or 0
local file = jump.filename or buf and vim.api.nvim_buf_get_name(buf) or nil
if buf or file then
local line ---@type string?
if buf then
line = vim.api.nvim_buf_get_lines(buf, jump.lnum - 1, jump.lnum, false)[1]
end
local label = tostring(#jumps - #items)
table.insert(items, 1, {
label = Snacks.picker.util.align(label, #tostring(#jumps), { align = "right" }),
buf = buf,
line = line,
text = table.concat({ file, line }, " "),
file = file,
pos = jump.lnum and jump.lnum > 0 and { jump.lnum, jump.col } or nil,
})
end
end
return items
end
function M.autocmds()
local autocmds = vim.api.nvim_get_autocmds({})
local items = {} ---@type snacks.picker.finder.Item[]
for _, au in ipairs(autocmds) do
local item = au --[[@as snacks.picker.finder.Item]]
item.text = Snacks.picker.util.text(item, { "event", "group_name", "pattern", "command" })
item.preview = {
text = vim.inspect(au),
ft = "lua",
}
item.item = au
if au.callback then
local info = debug.getinfo(au.callback, "S")
if info.what == "Lua" then
item.file = info.source:sub(2)
item.pos = { info.linedefined, 0 }
item.preview = "file"
end
end
items[#items + 1] = item
end
return items
end
function M.highlights()
local hls = vim.api.nvim_get_hl(0, {}) --[[@as table<string,vim.api.keyset.get_hl_info> ]]
local items = {} ---@type snacks.picker.finder.Item[]
for group, hl in pairs(hls) do
local defs = {} ---@type {group:string, hl:vim.api.keyset.get_hl_info}[]
defs[#defs + 1] = { group = group, hl = hl }
local link = hl.link
local done = { [group] = true } ---@type table<string, boolean>
while link and not done[link] do
done[link] = true
local hl_link = hls[link]
if hl_link then
defs[#defs + 1] = { group = link, hl = hl_link }
link = hl_link.link
else
break
end
end
local code = {} ---@type string[]
local extmarks = {} ---@type snacks.picker.Extmark[]
local row = 1
for _, def in ipairs(defs) do
for _, prop in ipairs({ "fg", "bg", "sp" }) do
local v = def.hl[prop]
if type(v) == "number" then
def.hl[prop] = ("#%06X"):format(v)
end
end
code[#code + 1] = ("%s = %s"):format(def.group, vim.inspect(def.hl))
extmarks[#extmarks + 1] = { row = row, col = 0, hl_group = def.group, end_col = #def.group }
row = row + #vim.split(code[#code], "\n") + 1
end
items[#items + 1] = {
text = vim.inspect(defs):gsub("\n", " "),
hl_group = group,
preview = {
text = table.concat(code, "\n\n"),
ft = "lua",
extmarks = extmarks,
},
}
end
table.sort(items, function(a, b)
return a.hl_group < b.hl_group
end)
return items
end
function M.colorschemes()
local items = {} ---@type snacks.picker.finder.Item[]
local rtp = vim.o.runtimepath
if package.loaded.lazy then
rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",")
end
local files = vim.fn.globpath(rtp, "colors/*", true, true) ---@type string[]
for _, file in ipairs(files) do
local name = vim.fn.fnamemodify(file, ":t:r")
local ext = vim.fn.fnamemodify(file, ":e")
if ext == "vim" or ext == "lua" then
items[#items + 1] = {
text = name,
file = file,
}
end
end
return items
end
---@param opts snacks.picker.keymaps.Config
function M.keymaps(opts)
local items = {} ---@type snacks.picker.finder.Item[]
local maps = {} ---@type vim.api.keyset.get_keymap[]
for _, mode in ipairs(opts.modes) do
if opts.global then
vim.list_extend(maps, vim.api.nvim_get_keymap(mode))
end
if opts["local"] then
vim.list_extend(maps, vim.api.nvim_buf_get_keymap(0, mode))
end
end
local done = {} ---@type table<string, boolean>
for _, km in ipairs(maps) do
local key = Snacks.picker.util.text(km, { "mode", "lhs", "buffer" })
if not done[key] then
done[key] = true
local item = {
mode = km.mode,
item = km,
preview = {
text = vim.inspect(km),
ft = "lua",
},
}
if km.callback then
local info = debug.getinfo(km.callback, "S")
if info.what == "Lua" then
item.file = info.source:sub(2)
item.pos = { info.linedefined, 0 }
item.preview = "file"
end
end
item.text = Snacks.picker.util.text(km, { "mode", "lhs", "rhs", "desc" }) .. (item.file or "")
items[#items + 1] = item
end
end
return items
end
function M.registers()
local registers = '*+"-:.%/#=_abcdefghijklmnopqrstuvwxyz0123456789'
local items = {} ---@type snacks.picker.finder.Item[]
local is_osc52 = vim.g.clipboard and vim.g.clipboard.name == "OSC 52"
local has_clipboard = vim.g.loaded_clipboard_provider == 2
for i = 1, #registers, 1 do
local reg = registers:sub(i, i)
local value = ""
if is_osc52 and reg:match("[%+%*]") then
value = "OSC 52 detected, register not checked to maintain compatibility"
elseif has_clipboard or not reg:match("[%+%*]") then
local ok, reg_value = pcall(vim.fn.getreg, reg, 1)
value = (ok and reg_value or "") --[[@as string]]
end
if value ~= "" then
table.insert(items, {
text = ("%s: %s"):format(reg, value:gsub("\n", "\\n"):gsub("\r", "\\r")),
reg = reg,
label = reg,
data = value,
value = value,
preview = {
text = value,
ft = "text",
},
})
end
end
return items
end
return M

View file

@ -0,0 +1,310 @@
---@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

View file

@ -0,0 +1,178 @@
---@class snacks.picker.highlight
local M = {}
---@param opts? {buf?:number, code?:string, ft?:string, lang?:string, file?:string}
function M.get_highlights(opts)
opts = opts or {}
local source = assert(opts.buf or opts.code, "buf or code is required")
assert(not (opts.buf and opts.code), "only one of buf or code is allowed")
local ret = {} ---@type table<number, snacks.picker.Extmark[]>
local ft = opts.ft
or (opts.buf and vim.bo[opts.buf].filetype)
or (opts.file and vim.filetype.match({ filename = opts.file, buf = 0 }))
or vim.bo.filetype
local lang = opts.lang or vim.treesitter.language.get_lang(ft)
local parser ---@type vim.treesitter.LanguageTree?
if lang then
local ok = false
if opts.buf then
ok, parser = pcall(vim.treesitter.get_parser, opts.buf, lang)
else
ok, parser = pcall(vim.treesitter.get_string_parser, source, lang)
end
parser = ok and parser or nil
end
if parser then
parser:parse(true)
parser:for_each_tree(function(tstree, tree)
if not tstree then
return
end
local query = vim.treesitter.query.get(tree:lang(), "highlights")
-- Some injected languages may not have highlight queries.
if not query then
return
end
for capture, node, metadata in query:iter_captures(tstree:root(), source) do
---@type string
local name = query.captures[capture]
if name ~= "spell" then
local range = { node:range() } ---@type number[]
for row = range[1] + 1, range[3] + 1 do
ret[row] = ret[row] or {}
table.insert(ret[row], {
col = range[2],
end_col = range[4],
priority = (tonumber(metadata.priority or metadata[capture] and metadata[capture].priority) or 100),
conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal,
hl_group = "@" .. name .. "." .. lang,
})
end
end
end
end)
end
--- Add buffer extmarks
if opts.buf then
local extmarks = vim.api.nvim_buf_get_extmarks(opts.buf, -1, 0, -1, { details = true })
for _, extmark in pairs(extmarks) do
local row = extmark[2] + 1
ret[row] = ret[row] or {}
local e = extmark[4]
if e then
e.sign_name = nil
e.sign_text = nil
e.ns_id = nil
e.end_row = nil
e.col = extmark[3]
if e.virt_text_pos and not vim.tbl_contains({ "eol", "overlay", "right_align", "inline" }, e.virt_text_pos) then
e.virt_text = nil
e.virt_text_pos = nil
end
table.insert(ret[row], e)
end
end
end
return ret
end
---@param line snacks.picker.Highlight[]
function M.offset(line)
local offset = 0
for _, t in ipairs(line) do
if type(t[1]) == "string" then
if t.virtual then
offset = offset + vim.api.nvim_strwidth(t[1])
else
offset = offset + #t[1]
end
end
end
return offset
end
---@param line snacks.picker.Highlight[]
---@param item snacks.picker.Item
---@param text string
---@param opts? {hl_group?:string, lang?:string}
function M.format(item, text, line, opts)
opts = opts or {}
local offset = M.offset(line)
local highlights = M.get_highlights({ code = text, ft = item.ft, lang = opts.lang or item.lang, file = item.file })[1]
or {}
for _, extmark in ipairs(highlights) do
extmark.col = extmark.col + offset
extmark.end_col = extmark.end_col + offset
line[#line + 1] = extmark
end
line[#line + 1] = { text, opts.hl_group }
end
---@param line snacks.picker.Highlight[]
---@param patterns table<string,string>
function M.highlight(line, patterns)
local offset = M.offset(line)
local text ---@type string?
for i = #line, 1, -1 do
if type(line[i][1]) == "string" then
text = line[i][1]
break
end
end
if not text then
return
end
offset = offset - #text
for pattern, hl in pairs(patterns) do
local from, to, match = text:find(pattern)
while from do
if match then
from, to = text:find(match, from, true)
end
table.insert(line, {
col = offset + from - 1,
end_col = offset + to,
hl_group = hl,
})
from, to = text:find(pattern, to + 1)
end
end
end
---@param line snacks.picker.Highlight[]
function M.markdown(line)
M.highlight(line, {
["`.-`"] = "SnacksPickerCode",
["%*.-%*"] = "SnacksPickerItalic",
["%*%*.-%*%*"] = "SnacksPickerBold",
})
end
---@param prefix string
---@param links? table<string, string>
function M.winhl(prefix, links)
links = links or {}
local winhl = {
NormalFloat = "",
FloatBorder = "Border",
FloatTitle = "Title",
FloatFooter = "Footer",
CursorLine = "CursorLine",
}
local ret = {} ---@type string[]
local groups = {} ---@type table<string, string>
for k, v in pairs(winhl) do
groups[v] = links[k] or (prefix == "SnacksPicker" and k or ("SnacksPicker" .. v))
ret[#ret + 1] = ("%s:%s%s"):format(k, prefix, v)
end
Snacks.util.set_hl(groups, { prefix = prefix, default = true })
return table.concat(ret, ",")
end
return M

View file

@ -0,0 +1,106 @@
---@class snacks.picker.util
local M = {}
---@param item snacks.picker.Item
function M.path(item)
if not (item and item.file) then
return
end
return vim.fs.normalize(item.cwd and item.cwd .. "/" .. item.file or item.file, { _fast = true, expand_env = false })
end
---@param item table<string, any>
---@param keys string[]
function M.text(item, keys)
local buffer = require("string.buffer").new()
for _, key in ipairs(keys) do
if item[key] then
if #buffer > 0 then
buffer:put(" ")
end
if key == "pos" or key == "end_pos" then
buffer:putf("%d:%d", item[key][1], item[key][2])
else
buffer:put(tostring(item[key]))
end
end
end
return buffer:get()
end
---@param text string
---@param width number
---@param opts? {align?: "left" | "right" | "center", truncate?: boolean}
function M.align(text, width, opts)
opts = opts or {}
opts.align = opts.align or "left"
local tw = vim.api.nvim_strwidth(text)
if tw > width then
return opts.truncate and (vim.fn.strcharpart(text, 0, width - 1) .. "") or text
end
local left = math.floor((width - tw) / 2)
local right = width - tw - left
if opts.align == "left" then
left, right = 0, width - tw
elseif opts.align == "right" then
left, right = width - tw, 0
end
return (" "):rep(left) .. text .. (" "):rep(right)
end
---@param text string
---@param width number
function M.truncate(text, width)
if vim.api.nvim_strwidth(text) > width then
return vim.fn.strcharpart(text, 0, width - 1) .. ""
end
return text
end
-- Stops visual mode and returns the selected text
function M.visual()
local modes = { "v", "V", Snacks.util.keycode("<C-v>") }
local mode = vim.fn.mode():sub(1, 1) ---@type string
if not vim.tbl_contains(modes, mode) then
return
end
-- stop visual mode
vim.cmd("normal! " .. mode)
local pos = vim.api.nvim_buf_get_mark(0, "<")
local end_pos = vim.api.nvim_buf_get_mark(0, ">")
-- for some reason, sometimes the column is off by one
-- see: https://github.com/folke/snacks.nvim/issues/190
local col_to = math.min(end_pos[2] + 1, #vim.api.nvim_buf_get_lines(0, end_pos[1] - 1, end_pos[1], false)[1])
local lines = vim.api.nvim_buf_get_text(0, pos[1] - 1, pos[2], end_pos[1] - 1, col_to, {})
local text = table.concat(lines, "\n")
---@class snacks.picker.Visual
local ret = {
pos = pos,
end_pos = end_pos,
text = text,
}
return ret
end
---@param str string
---@param data table<string, string>
function M.tpl(str, data)
return (str:gsub("(%b{})", function(w)
return data[w:sub(2, -2)] or w
end))
end
---@param str string
function M.title(str)
return table.concat(
vim.tbl_map(function(s)
return s:sub(1, 1):upper() .. s:sub(2)
end, vim.split(str, "_")),
" "
)
end
return M

View file

@ -0,0 +1,147 @@
---@class snacks.picker.MinHeap
---@field data any[] -- the heap array
---@field cmp fun(a:any, b:any):boolean -- determines "priority"; if cmp(a,b) == true, a is considered 'larger' for top-k
---@field capacity number
---@field sorted? snacks.picker.Item[]
local M = {}
M.__index = M
---@class snacks.picker.minheap.Config
---@field cmp? fun(a, b):boolean
---@field capacity number
---@param opts? snacks.picker.minheap.Config
function M.new(opts)
opts = opts or {}
local self = setmetatable({}, M)
-- Default comparator means: a > b => a is 'better' (we want the top by value)
-- So if we want the top K largest items, the heap is min-heap based on that comparator
self.cmp = opts.cmp or function(a, b)
return a > b
end
self.capacity = assert(opts.capacity, "capacity is required")
assert(self.capacity > 0, "capacity must be greater than 0")
self.data = {}
return self
end
function M:clear()
self.data = {}
end
-- Private: swap two indices
function M:_swap(i, j)
self.data[i], self.data[j] = self.data[j], self.data[i]
end
-- Private: heapify up (bubble up)
function M:_heapify_up(idx)
while idx > 1 do
local parent = math.floor(idx / 2)
-- If child is 'less' than parent under the min-heap logic, swap
-- Because self.cmp(child, parent) == true => child is 'bigger' => for min-heap we want bigger below
-- So we invert self.cmp because we want to keep the smallest at top:
if self.cmp(self.data[parent], self.data[idx]) then
self:_swap(parent, idx)
idx = parent
else
break
end
end
end
-- Private: heapify down
function M:_heapify_down(idx)
local size = #self.data
while true do
local left = 2 * idx
local right = left + 1
local smallest = idx
if left <= size and self.cmp(self.data[smallest], self.data[left]) then
smallest = left
end
if right <= size and self.cmp(self.data[smallest], self.data[right]) then
smallest = right
end
if smallest ~= idx then
self:_swap(idx, smallest)
idx = smallest
else
break
end
end
end
--- Insert value into the min-heap of capacity K.
--- If the heap is not full, just insert.
--- If it's full and the value is 'larger' than the min (root), replace the root & heapify.
---@generic T
---@param value T
---@return boolean added, T? evicted
function M:add(value)
local size = #self.data
if size < self.capacity then
-- Just insert at the end, heapify up
table.insert(self.data, value)
self:_heapify_up(#self.data)
self.sorted = nil
return true
else
-- If new value is larger than the root (which is the smallest in the min-heap),
-- then pop root & insert new value
if self.cmp(value, self.data[1]) then
local evicted = self.data[1]
self.data[1] = value
self:_heapify_down(1)
self.sorted = nil
return true, evicted
end
end
return false
end
function M:count()
return #self.data
end
---@return any|nil
function M:min()
return self.data[1]
end
---@return any|nil
function M:max()
-- might need to scan if you want the max element in a min-heap
local size = #self.data
if size == 0 then
return nil
end
local maximum = self.data[1]
for i = 2, size do
if self.cmp(self.data[i], maximum) then
maximum = self.data[i]
end
end
return maximum
end
---@param idx number
---@return snacks.picker.Item?
---@overload fun(self: snacks.picker.MinHeap): snacks.picker.Item[]
function M:get(idx)
if not self.sorted then
self.sorted = {}
for i = 1, #self.data do
table.insert(self.sorted, self.data[i])
end
table.sort(self.sorted, self.cmp)
end
if idx then
return self.sorted[idx]
end
return self.sorted
end
return M

View file

@ -0,0 +1,39 @@
--- Efficient queue implementation.
--- Prevents need to shift elements when popping.
---@class snacks.picker.queue
---@field queue any[]
---@field first number
---@field last number
local M = {}
M.__index = M
function M.new()
local self = setmetatable({}, M)
self.first, self.last, self.queue = 0, -1, {}
return self
end
function M:push(value)
self.last = self.last + 1
self.queue[self.last] = value
end
function M:size()
return self.last - self.first + 1
end
function M:empty()
return self:size() == 0
end
function M:pop()
if self:empty() then
return
end
local value = self.queue[self.first]
self.queue[self.first] = nil
self.first = self.first + 1
return value
end
return M

View file

@ -121,6 +121,16 @@ function M.enable()
end),
})
-- update state when leaving insert mode or changing text in normal mode
vim.api.nvim_create_autocmd({ "InsertLeave", "TextChanged" }, {
group = group,
callback = vim.schedule_wrap(function(ev)
for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do
get_state(win)
end
end),
})
-- update current state on cursor move
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
group = group,

View file

@ -71,6 +71,9 @@ function M.icon(name, cat)
return require("mini.icons").get(cat or "file", name)
end,
function()
if cat == "directory" then
return "", "Directory"
end
local Icons = require("nvim-web-devicons")
if cat == "filetype" then
return Icons.get_icon_by_filetype(name, { default = false })

View file

@ -1,12 +1,15 @@
---@class snacks.win
---@field id number
---@field buf? number
---@field scratch_buf? number
---@field win? number
---@field opts snacks.win.Config
---@field augroup? number
---@field backdrop? snacks.win
---@field keys snacks.win.Keys[]
---@field events (snacks.win.Event|{event:string|string[]})[]
---@field meta table<string, string>
---@field closed? boolean
---@overload fun(opts? :snacks.win.Config|{}): snacks.win
local M = setmetatable({}, {
__call = function(t, ...)
@ -27,7 +30,7 @@ M.meta = {
---@class snacks.win.Event: vim.api.keyset.create_autocmd
---@field buf? true
---@field win? true
---@field callback? fun(self: snacks.win)
---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
---@class snacks.win.Backdrop
---@field bg? string
@ -61,7 +64,7 @@ M.meta = {
---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center)
---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true)
---@field position? "float"|"bottom"|"top"|"left"|"right"
---@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
---@field buf? number If set, use this buffer instead of creating a new one
---@field file? string If set, use this file instead of creating a new buffer
---@field enter? boolean Enter the window after opening (default: false)
@ -78,6 +81,7 @@ M.meta = {
---@field fixbuf? boolean don't allow other buffers to be opened in this window
---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer
---@field actions? table<string, snacks.win.Action.spec> Actions that can be used in key mappings
---@field resize? boolean Automatically resize the window when the editor is resized
local defaults = {
show = true,
fixbuf = true,
@ -101,6 +105,15 @@ Snacks.config.style("float", {
zindex = 50,
})
Snacks.config.style("help", {
position = "float",
backdrop = false,
border = "top",
row = -1,
width = 0,
height = 0.3,
})
Snacks.config.style("split", {
position = "bottom",
height = 0.4,
@ -112,6 +125,7 @@ Snacks.config.style("minimal", {
cursorcolumn = false,
cursorline = false,
cursorlineopt = "both",
colorcolumn = "",
fillchars = "eob: ,lastline:…",
list = false,
listchars = "extends:…,tab: ",
@ -168,10 +182,12 @@ local win_opts = {
---@type table<string, string[]>
local borders = {
left = { "", "", "", "", "", "", "", "" },
right = { "", "", "", "", "", "", "", "" },
top = { "", "", "", "", "", "", "", "" },
bottom = { "", "", "", "", "", "", "", "" },
left = { "", "", "", "", "", "", "", "" },
right = { "", "", "", "", "", "", "", "" },
top = { "", "", "", "", "", "", "", "" },
bottom = { "", "", "", "", "", "", "", "" },
hpad = { "", "", "", " ", "", "", "", " " },
vpad = { "", " ", "", "", "", " ", "", "" },
}
Snacks.util.set_hl({
@ -180,9 +196,13 @@ Snacks.util.set_hl({
NormalNC = "NormalFloat",
WinBar = "Title",
WinBarNC = "SnacksWinBar",
WinKey = "Keyword",
WinKeySep = "NonText",
WinKeyDesc = "Function",
}, { prefix = "Snacks", default = true })
local id = 0
local event_stack = {} ---@type string[]
--@private
---@param ...? snacks.win.Config|string|{}
@ -220,6 +240,7 @@ function M.new(opts)
local self = setmetatable({}, M)
id = id + 1
self.id = id
self.meta = {}
opts = M.resolve(Snacks.config.get("win", defaults), opts)
if opts.minimal then
opts = M.resolve("minimal", opts)
@ -244,6 +265,8 @@ function M.new(opts)
spec = { key, spec, desc = spec }
elseif type(spec) == "function" then
spec = { key, spec }
elseif type(spec) == "table" and spec[1] and not spec[2] then
spec[1], spec[2] = key, spec[1]
end
table.insert(self.keys, spec)
end
@ -252,7 +275,7 @@ function M.new(opts)
self:on("WinClosed", self.on_close, { win = true })
-- update window size when resizing
self:on("VimResized", self.update)
self:on("VimResized", self.on_resize)
---@cast opts snacks.win.Config
self.opts = opts
@ -262,6 +285,17 @@ function M.new(opts)
return self
end
function M:on_resize()
if self.opts.resize ~= false then
self:update()
end
end
---@param actions string|string[]
function M:execute(actions)
return self:action(actions)()
end
---@param actions string|string[]
---@return (fun(): boolean|string?) action, string? desc
function M:action(actions)
@ -295,8 +329,82 @@ function M:action(actions)
table.concat(desc, ", ")
end
---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config}
function M:toggle_help(opts)
opts = opts or {}
local col_width, key_width = opts.col_width or 30, opts.key_width or 10
for _, win in ipairs(vim.api.nvim_list_wins()) do
local buf = vim.api.nvim_win_get_buf(win)
if vim.bo[buf].filetype == "snacks_win_help" then
vim.api.nvim_win_close(win, true)
return
end
end
local ns = vim.api.nvim_create_namespace("snacks.win.help")
local win = M.new(M.resolve({ style = "help" }, opts.win or {}, {
show = false,
focusable = false,
zindex = self.opts.zindex + 1,
bo = { filetype = "snacks_win_help" },
}))
self:on("WinClosed", function()
win:close()
end, { win = true })
local dim = win:dim()
local cols = math.floor((dim.width - 1) / col_width)
local rows = math.ceil(#self.keys / cols)
win.opts.height = rows
local keys = {} ---@type vim.api.keyset.get_keymap[]
vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "n"))
vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "i"))
table.sort(keys, function(a, b)
return (a.desc or a.lhs or "") < (b.desc or b.lhs or "")
end)
local help = {} ---@type {[1]:string, [2]:string}[][]
local row, col = 0, 1
---@param str string
---@param len number
---@param align? "left"|"right"
local function trunc(str, len, align)
local w = vim.api.nvim_strwidth(str)
if w > len then
return vim.fn.strcharpart(str, 0, len - 1) .. ""
end
return align == "right" and (string.rep(" ", len - w) .. str) or (str .. string.rep(" ", len - w))
end
local done = {} ---@type table<string, boolean>
for _, keymap in ipairs(keys) do
local key = vim.fn.keytrans(Snacks.util.keycode(keymap.lhs or ""))
if not done[key] and not (keymap.desc and keymap.desc:find("which%-key")) then
done[key] = true
row = row + 1
if row > rows then
row, col = 1, col + 1
end
help[row] = help[row] or {}
vim.list_extend(help[row], {
{ trunc(key, key_width, "right"), "SnacksWinKey" },
{ " " },
{ "", "SnacksWinKeySep" },
{ " " },
{ trunc(keymap.desc or "", col_width - key_width - 3), "SnacksWinKeyDesc" },
})
end
end
win:show()
for l, line in ipairs(help) do
vim.api.nvim_buf_set_lines(win.buf, l - 1, l, false, { "" })
vim.api.nvim_buf_set_extmark(win.buf, ns, l - 1, 0, {
virt_text = line,
virt_text_pos = "overlay",
})
end
end
---@param event string|string[]
---@param cb fun(self: snacks.win)
---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean?
---@param opts? snacks.win.Event
function M:on(event, cb, opts)
opts = opts or {}
@ -318,8 +426,11 @@ function M:_on(event, opts)
end
end
event_opts.group = event_opts.group or self.augroup
event_opts.callback = function()
opts.callback(self)
event_opts.callback = function(ev)
table.insert(event_stack, ev.event)
local ok, err = pcall(opts.callback, self, ev)
table.remove(event_stack)
return not ok and error(err) or err
end
if event_opts.pattern or event_opts.buffer then
-- don't alter the pattern or buffer
@ -347,7 +458,7 @@ end
---@param up? boolean
function M:scroll(up)
vim.api.nvim_buf_call(self.buf, function()
vim.api.nvim_win_call(self.win, function()
vim.cmd(("normal! %s"):format(up and SCROLL_UP or SCROLL_DOWN))
end)
end
@ -355,18 +466,11 @@ end
---@param opts? { buf: boolean }
function M:close(opts)
opts = opts or {}
local wipe = opts.buf ~= false and not self.opts.buf and not self.opts.file
local wipe = opts.buf ~= false and self.buf == self.scratch_buf
local win = self.win
local buf = wipe and self.buf
-- never close modified buffers
if buf and vim.bo[buf].modified and vim.bo[buf].buftype == "" then
if not pcall(vim.api.nvim_buf_delete, buf, { force = false }) then
return
end
end
self.win = nil
if buf then
self.buf = nil
@ -383,14 +487,23 @@ function M:close(opts)
self.augroup = nil
end
end
local try_close
local retries = 0
local try_close ---@type fun()
try_close = function()
local ok, err = pcall(close)
if not ok and err and err:find("E565") then
if not ok and err and err:find("E565") and retries < 10 then
retries = retries + 1
vim.defer_fn(try_close, 50)
elseif not ok then
Snacks.notify.error("Failed to close window: " .. err)
end
end
vim.schedule(try_close)
-- HACK: WinClosed is not recursive, so we need to schedule it
-- if we're in a WinClosed event
if vim.tbl_contains(event_stack, "WinClosed") or not pcall(close) then
vim.schedule(try_close)
end
self:on_close()
end
function M:hide()
@ -407,11 +520,44 @@ function M:toggle()
return self
end
---@param title string
---@param pos? "center"|"left"|"right"
function M:set_title(title, pos)
if not self:has_border() then
return
end
title = vim.trim(title)
if title ~= "" then
-- HACK: add extra space when last char is non word
-- like for icons etc
if not title:sub(-1):match("%w") then
title = title .. " "
end
title = " " .. title .. " "
end
pos = pos or self.opts.title_pos or "center"
if self.opts.title == title and self.opts.title_pos == pos then
return
end
self.opts.title = title
self.opts.title_pos = pos
if not self:valid() then
return
end
vim.api.nvim_win_set_config(self.win, {
title = self.opts.title,
title_pos = self.opts.title_pos,
})
end
---@private
function M:open_buf()
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
-- keep existing buffer
self.buf = self.buf
elseif self.scratch_buf and vim.api.nvim_buf_is_valid(self.scratch_buf) then
-- keep existing scratch buffer
self.buf = self.scratch_buf
elseif self.opts.file then
self.buf = vim.fn.bufadd(self.opts.file)
if not vim.api.nvim_buf_is_loaded(self.buf) then
@ -423,18 +569,30 @@ function M:open_buf()
elseif self.opts.buf then
self.buf = self.opts.buf
else
self.buf = vim.api.nvim_create_buf(false, true)
local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text
text = type(text) == "string" and { text } or text
if text then
---@cast text string[]
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text)
end
self:scratch()
end
return self.buf
end
function M:scratch()
if self.buf == self.scratch_buf and self:buf_valid() then
return
end
self.buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.buf].swapfile = false
self.scratch_buf = self.buf
local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text
text = type(text) == "string" and { text } or text
if text then
---@cast text string[]
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text)
end
if vim.bo[self.buf].filetype == "" and not self.opts.bo.filetype then
self.opts.bo.filetype = "snacks_win"
end
return self.buf
if self:win_valid() then
vim.api.nvim_win_set_buf(self.win, self.buf)
end
end
---@private
@ -442,7 +600,9 @@ function M:open_win()
local relative = self.opts.relative or "editor"
local position = self.opts.position or "float"
local enter = self.opts.enter == nil or self.opts.enter or false
enter = not self.opts.focusable and enter or false
if self.opts.focusable == false then
enter = false
end
local opts = self:win_opts()
if position == "float" then
self.win = vim.api.nvim_open_win(self.buf, enter, opts)
@ -545,6 +705,7 @@ function M:show()
end
self:open_win()
self.closed = false
-- window local variables
for k, v in pairs(self.opts.w or {}) do
vim.w[self.win][k] = v
@ -631,6 +792,7 @@ function M:show()
return spec[2](self)
end
end
spec.desc = spec.desc or opts.desc
---@cast spec snacks.win.Keys
vim.keymap.set(spec.mode or "n", spec[1], rhs, opts)
end
@ -647,6 +809,10 @@ function M:on_close()
self.backdrop:close()
self.backdrop = nil
end
if self.closed then
return
end
self.closed = true
if self.opts.on_close then
self.opts.on_close(self)
end
@ -674,6 +840,10 @@ end
---@private
function M:drop()
if self.backdrop then
self.backdrop:close()
self.backdrop = nil
end
local backdrop = self.opts.backdrop
if not backdrop then
return
@ -805,14 +975,36 @@ end
--- Calculate the size of the border
function M:border_size()
local border = self.opts.border and self.opts.border ~= "" and self.opts.border ~= "none" and self.opts.border
local full = border and not vim.tbl_contains({ "top", "right", "bottom", "left" }, border)
-- The array specifies the eight
-- chars building up the border in a clockwise fashion
-- starting with the top-left corner.
-- { "╔", "═" ,"╗", "║", "╝", "═", "╚", "║" }
local border = self:has_border() and self.opts.border or { "" }
border = type(border) == "string" and borders[border] or border
border = type(border) == "string" and { "x" } or border
assert(type(border) == "table", "Invalid border type")
---@cast border string[]
while #border < 8 do
vim.list_extend(border, border)
end
-- remove border hl groups
border = vim.tbl_map(function(b)
return type(b) == "table" and b[1] or b
end, border)
local function size(from, to)
for i = from, to do
if border[i] ~= "" then
return 1
end
end
return 0
end
---@type { top: number, right: number, bottom: number, left: number }
return {
top = (full or border == "top") and 1 or 0,
right = (full or border == "right") and 1 or 0,
bottom = (full or border == "bottom") and 1 or 0,
left = (full or border == "left") and 1 or 0,
top = size(1, 3),
right = size(3, 5),
bottom = size(5, 7),
left = math.max(size(7, 8), size(1, 1)),
}
end

View file

@ -0,0 +1,96 @@
---@module 'luassert'
local M = {}
M.files = {
"lua/snacks/animate/",
"lua/snacks/animate/easing.lua",
"lua/snacks/animate/init.lua",
"lua/snacks/bigfile.lua",
"lua/snacks/bufdelete.lua",
"lua/snacks/dashboard.lua",
"lua/snacks/debug.lua",
"lua/snacks/dim.lua",
"lua/snacks/git.lua",
"lua/snacks/gitbrowse.lua",
"lua/snacks/health.lua",
"lua/snacks/indent.lua",
"lua/snacks/init.lua",
"lua/snacks/input.lua",
"lua/snacks/lazygit.lua",
"lua/snacks/meta/",
"lua/snacks/meta/docs.lua",
"lua/snacks/meta/init.lua",
"lua/snacks/meta/types.lua",
"lua/snacks/notifier.lua",
"lua/snacks/notify.lua",
"lua/snacks/picker/",
"lua/snacks/picker/async.lua",
"lua/snacks/picker/init.lua",
"lua/snacks/picker/list.lua",
"lua/snacks/picker/matcher.lua",
"lua/snacks/picker/preview.lua",
"lua/snacks/picker/queue.lua",
"lua/snacks/picker/sorter.lua",
"lua/snacks/picker/topk.lua",
"lua/snacks/profiler/",
"lua/snacks/profiler/core.lua",
"lua/snacks/profiler/init.lua",
"lua/snacks/profiler/loc.lua",
"lua/snacks/profiler/picker.lua",
"lua/snacks/profiler/tracer.lua",
"lua/snacks/profiler/ui.lua",
"lua/snacks/quickfile.lua",
"lua/snacks/rename.lua",
"lua/snacks/scope.lua",
"lua/snacks/scratch.lua",
"lua/snacks/scroll.lua",
"lua/snacks/statuscolumn.lua",
"lua/snacks/terminal.lua",
"lua/snacks/toggle.lua",
"lua/snacks/util.lua",
"lua/snacks/win.lua",
"lua/snacks/words.lua",
"lua/snacks/zen.lua",
}
local function fuzzy(pattern)
local chars = vim.split(pattern, "")
local pat = table.concat(chars, ".*")
return vim.tbl_filter(function(v)
return v:find(pat)
end, M.files)
end
describe("fuzzy matching", function()
local matcher = require("snacks.picker.core.matcher").new()
local tests = {
{ "mod.md", "md", { 5, 6 } },
}
for t, test in ipairs(tests) do
it("should find optimal match for " .. t, function()
matcher:init({ pattern = test[2] })
local score, positions = matcher:match({ text = test[1], idx = 1, score = 0 }, { positions = true })
assert(score and score > 0, "no match found")
assert.are.same(test[3], positions)
end)
end
local patterns = { "snacks", "lua", "sgbs", "mark", "dcs", "xxx", "lsw" }
local algos = { "fuzzy", "fuzzy_fast" }
for _, pattern in ipairs(patterns) do
local expect = fuzzy(pattern)
for _, algo in ipairs(algos) do
it(("should find fuzzy matches for %q with %s"):format(pattern, algo), function()
local matches = {} ---@type string[]
for _, file in ipairs(M.files) do
if matcher[algo](matcher, file, pattern) then
table.insert(matches, file)
end
end
assert.are.same(expect, matches)
end)
end
end
end)

View file

@ -0,0 +1,31 @@
---@module 'luassert'
local MinHeap = require("snacks.picker.util.minheap")
describe("MinHeap", function()
local values = {} ---@type number[]
for i = 1, 2000 do
values[i] = i
end
---@param tbl number[]
local function shuffle(tbl)
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
return tbl
end
for _ = 1, 100 do
it("should push and pop values correctly", function()
local topk = MinHeap.new({ capacity = 10 })
for _, v in ipairs(shuffle(values)) do
topk:add(v)
end
table.sort(values, topk.cmp)
local topn = vim.list_slice(values, 1, 10)
assert.same(topn, topk:get())
end)
end
end)