mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-07 13:15:08 +00:00
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:
parent
1b7a57a0b1
commit
559d6c6bf2
67 changed files with 12013 additions and 126 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
181
doc/snacks-layout.txt
Normal 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:
|
|
@ -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
1965
doc/snacks-picker.txt
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
<
|
||||
|
||||
|
||||
|
|
|
@ -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: ",
|
||||
|
|
|
@ -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
91
docs/examples/picker.lua
Normal 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
|
20
docs/init.md
20
docs/init.md
|
@ -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
146
docs/layout.md
Normal 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()
|
||||
```
|
|
@ -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
1718
docs/picker.md
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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: ",
|
||||
|
|
58
docs/win.md
58
docs/win.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
403
lua/snacks/layout.lua
Normal 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
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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|{}
|
||||
|
|
328
lua/snacks/picker/actions.lua
Normal file
328
lua/snacks/picker/actions.lua
Normal 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
|
261
lua/snacks/picker/config/defaults.lua
Normal file
261
lua/snacks/picker/config/defaults.lua
Normal 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
|
81
lua/snacks/picker/config/highlights.lua
Normal file
81
lua/snacks/picker/config/highlights.lua
Normal 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
|
119
lua/snacks/picker/config/init.lua
Normal file
119
lua/snacks/picker/config/init.lua
Normal 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
|
137
lua/snacks/picker/config/layouts.lua
Normal file
137
lua/snacks/picker/config/layouts.lua
Normal 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
|
509
lua/snacks/picker/config/sources.lua
Normal file
509
lua/snacks/picker/config/sources.lua
Normal 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
|
51
lua/snacks/picker/core/_health.lua
Normal file
51
lua/snacks/picker/core/_health.lua
Normal 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
|
89
lua/snacks/picker/core/actions.lua
Normal file
89
lua/snacks/picker/core/actions.lua
Normal 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
|
101
lua/snacks/picker/core/filter.lua
Normal file
101
lua/snacks/picker/core/filter.lua
Normal 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
|
88
lua/snacks/picker/core/finder.lua
Normal file
88
lua/snacks/picker/core/finder.lua
Normal 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
|
147
lua/snacks/picker/core/input.lua
Normal file
147
lua/snacks/picker/core/input.lua
Normal 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
|
465
lua/snacks/picker/core/list.lua
Normal file
465
lua/snacks/picker/core/list.lua
Normal 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
|
50
lua/snacks/picker/core/main.lua
Normal file
50
lua/snacks/picker/core/main.lua
Normal 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
|
465
lua/snacks/picker/core/matcher.lua
Normal file
465
lua/snacks/picker/core/matcher.lua
Normal 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
|
501
lua/snacks/picker/core/picker.lua
Normal file
501
lua/snacks/picker/core/picker.lua
Normal 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
|
230
lua/snacks/picker/core/preview.lua
Normal file
230
lua/snacks/picker/core/preview.lua
Normal 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
|
381
lua/snacks/picker/format.lua
Normal file
381
lua/snacks/picker/format.lua
Normal 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
|
91
lua/snacks/picker/init.lua
Normal file
91
lua/snacks/picker/init.lua
Normal 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
|
250
lua/snacks/picker/preview.lua
Normal file
250
lua/snacks/picker/preview.lua
Normal 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
|
50
lua/snacks/picker/select.lua
Normal file
50
lua/snacks/picker/select.lua
Normal 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
|
45
lua/snacks/picker/sort.lua
Normal file
45
lua/snacks/picker/sort.lua
Normal 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
|
45
lua/snacks/picker/source/buffers.lua
Normal file
45
lua/snacks/picker/source/buffers.lua
Normal 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
|
38
lua/snacks/picker/source/diagnostics.lua
Normal file
38
lua/snacks/picker/source/diagnostics.lua
Normal 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
|
114
lua/snacks/picker/source/files.lua
Normal file
114
lua/snacks/picker/source/files.lua
Normal 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
|
102
lua/snacks/picker/source/git.lua
Normal file
102
lua/snacks/picker/source/git.lua
Normal 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
|
109
lua/snacks/picker/source/grep.lua
Normal file
109
lua/snacks/picker/source/grep.lua
Normal 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
|
64
lua/snacks/picker/source/help.lua
Normal file
64
lua/snacks/picker/source/help.lua
Normal 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
|
27
lua/snacks/picker/source/lines.lua
Normal file
27
lua/snacks/picker/source/lines.lua
Normal 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
|
355
lua/snacks/picker/source/lsp.lua
Normal file
355
lua/snacks/picker/source/lsp.lua
Normal 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
|
46
lua/snacks/picker/source/meta.lua
Normal file
46
lua/snacks/picker/source/meta.lua
Normal 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
|
118
lua/snacks/picker/source/proc.lua
Normal file
118
lua/snacks/picker/source/proc.lua
Normal 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
|
74
lua/snacks/picker/source/qf.lua
Normal file
74
lua/snacks/picker/source/qf.lua
Normal 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
|
61
lua/snacks/picker/source/recent.lua
Normal file
61
lua/snacks/picker/source/recent.lua
Normal 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
|
64
lua/snacks/picker/source/system.lua
Normal file
64
lua/snacks/picker/source/system.lua
Normal 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
|
290
lua/snacks/picker/source/vim.lua
Normal file
290
lua/snacks/picker/source/vim.lua
Normal 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
|
310
lua/snacks/picker/util/async.lua
Normal file
310
lua/snacks/picker/util/async.lua
Normal 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
|
178
lua/snacks/picker/util/highlight.lua
Normal file
178
lua/snacks/picker/util/highlight.lua
Normal 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
|
106
lua/snacks/picker/util/init.lua
Normal file
106
lua/snacks/picker/util/init.lua
Normal 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
|
147
lua/snacks/picker/util/minheap.lua
Normal file
147
lua/snacks/picker/util/minheap.lua
Normal 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
|
39
lua/snacks/picker/util/queue.lua
Normal file
39
lua/snacks/picker/util/queue.lua
Normal 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
|
|
@ -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,
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
96
tests/picker/matcher_spec.lua
Normal file
96
tests/picker/matcher_spec.lua
Normal 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)
|
31
tests/picker/minheap_spec.lua
Normal file
31
tests/picker/minheap_spec.lua
Normal 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)
|
Loading…
Add table
Add a link
Reference in a new issue