From 58790c858cca16a5495da5c96771fbebb06bcee1 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:34:30 +0100 Subject: [PATCH 01/28] feat(picker.files): allow forcing the files finder to use a certain cmd --- lua/snacks/picker/source/files.lua | 57 ++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/lua/snacks/picker/source/files.lua b/lua/snacks/picker/source/files.lua index 5773cdf40..f42b2ff1a 100644 --- a/lua/snacks/picker/source/files.lua +++ b/lua/snacks/picker/source/files.lua @@ -6,28 +6,52 @@ local M = {} local uv = vim.uv or vim.loop +---@type {cmd:string[], args:string[], enabled?:boolean}[] local commands = { - rg = { "--files", "--no-messages", "--color", "never", "-g", "!.git" }, - fd = { "--type", "f", "--type", "l", "--color", "never", "-E", ".git" }, - find = { ".", "-type", "f", "-not", "-path", "*/.git/*" }, + { + cmd = { "fd", "fdfind" }, + args = { "--type", "f", "--type", "l", "--color", "never", "-E", ".git" }, + }, + { + cmd = { "rg" }, + args = { "--files", "--no-messages", "--color", "never", "-g", "!.git" }, + }, + { + cmd = { "find" }, + args = { ".", "-type", "f", "-not", "-path", "*/.git/*" }, + enabled = vim.fn.has("win-32") == 0, + }, } +---@param opts? snacks.picker.files.Config +---@return string? cmd, string[]? args +function M.get_cmd(opts) + opts = opts or {} + local checked = {} ---@type string[] + for _, command in ipairs(commands) do + if command.enabled ~= false and (not opts.cmd or vim.tbl_contains(command.cmd, opts.cmd)) then + for _, c in ipairs(command.cmd) do + table.insert(checked, c) + if vim.fn.executable(c) == 1 then + return c, vim.deepcopy(command.args) + end + end + end + end + checked = #checked == 0 and opts.cmd and { opts.cmd } or checked + checked = vim.tbl_map(function(c) + return "`" .. c .. "`" + end, checked) + Snacks.notify.error("No supported finder found:\n- " .. table.concat(checked, "\n-")) +end + ---@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") + local cmd, args = M.get_cmd(opts) + if not cmd or not args then + return 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" -- exclude @@ -111,6 +135,9 @@ function M.files(opts, ctx) and vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil local cmd, args = get_cmd(opts, ctx.filter) + if not cmd then + return function() end + end return require("snacks.picker.source.proc").proc({ opts, { From e3ed74528bac7e5edb505a5e9bd53d71e45101fa Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:34:53 +0100 Subject: [PATCH 02/28] fix(picker.lsp): remove symbol detail from search text. too noisy --- lua/snacks/picker/source/lsp.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/snacks/picker/source/lsp.lua b/lua/snacks/picker/source/lsp.lua index 2a62a920c..8b854d9b1 100644 --- a/lua/snacks/picker/source/lsp.lua +++ b/lua/snacks/picker/source/lsp.lua @@ -281,7 +281,7 @@ function M.results_to_items(client, results, opts) local loc = result.location or { range = result.selectionRange or result.range, uri = uri } loc.uri = loc.uri or uri M.add_loc(item, loc, client) - local text = table.concat({ M.symbol_kind(result.kind), result.name, result.detail or "" }, " ") + local text = table.concat({ M.symbol_kind(result.kind), result.name }, " ") if opts.text_with_file and item.file then text = text .. " " .. item.file end From 1fdff3380b0d207fc028fa7b901c863f3fbedd04 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:35:45 +0100 Subject: [PATCH 03/28] feat(picker): opening a picker with the same source as an active picker, will close it instead (toggle) --- lua/snacks/picker/init.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/snacks/picker/init.lua b/lua/snacks/picker/init.lua index 613efb318..85730df46 100644 --- a/lua/snacks/picker/init.lua +++ b/lua/snacks/picker/init.lua @@ -17,6 +17,9 @@ local M = setmetatable({}, { end, ---@param M snacks.picker __index = function(M, k) + if k == "current" then + return nil + end if type(k) ~= "string" then return end @@ -69,6 +72,10 @@ function M.pick(source, opts) opts.source = "pickers" return M.pick(opts) end + if opts.source and M.current and M.current.opts.source == opts.source then + M.current:close() + return + end return require("snacks.picker.core.picker").new(opts) end From 96691a3981eb89d8e3cdbdedd022d42c1b2a1833 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:36:31 +0100 Subject: [PATCH 04/28] feat(rename): optional `file`, `on_rename` for `Snacks.rename.rename_file()` --- lua/snacks/rename.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/snacks/rename.lua b/lua/snacks/rename.lua index efd826097..94c54c98d 100644 --- a/lua/snacks/rename.lua +++ b/lua/snacks/rename.lua @@ -14,8 +14,13 @@ end -- Prompt for the new filename, -- do the rename, and trigger LSP handlers -function M.rename_file() +---@param opts? {file?: string, on_rename?: fun(file:string)} +function M.rename_file(opts) + opts = opts or {} local buf = vim.api.nvim_get_current_buf() + if opts.file then + buf = vim.fn.bufadd(opts.file) + end local old = assert(realpath(vim.api.nvim_buf_get_name(buf))) local root = assert(realpath(uv.cwd() or ".")) @@ -37,9 +42,14 @@ function M.rename_file() vim.fn.mkdir(vim.fs.dirname(new), "p") M.on_rename_file(old, new, function() vim.fn.rename(old, new) - vim.cmd.edit(new) + if not opts.on_rename then + vim.cmd.edit(new) + end vim.api.nvim_buf_delete(buf, { force = true }) vim.fn.delete(old) + if opts.on_rename then + opts.on_rename(new) + end end) end) end From fda2bf707d766dfd2263074950d6ba4ecbf69565 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:37:40 +0100 Subject: [PATCH 05/28] refactor(picker.input): do startinsert when entering the input window --- lua/snacks/picker/actions.lua | 4 ---- lua/snacks/picker/core/input.lua | 12 ++++++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua index a12ff4a18..7b2b38c97 100644 --- a/lua/snacks/picker/actions.lua +++ b/lua/snacks/picker/actions.lua @@ -421,14 +421,10 @@ function M.cycle_win(picker) 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) diff --git a/lua/snacks/picker/core/input.lua b/lua/snacks/picker/core/input.lua index 3dbbe3870..fa50dddbe 100644 --- a/lua/snacks/picker/core/input.lua +++ b/lua/snacks/picker/core/input.lua @@ -19,11 +19,11 @@ function M.new(picker) self.win = Snacks.win(Snacks.win.resolve(picker.opts.win.input, { show = false, - enter = true, + enter = false, height = 1, text = picker.opts.live and self.filter.search or self.filter.pattern, ft = "regex", - on_win = function(win) + on_buf = function(win) -- HACK: set all other picker input prompt buffers to nofile. -- Otherwise when the prompt buffer is closed, -- Neovim always stops insert mode. @@ -33,8 +33,7 @@ function M.new(picker) end end vim.fn.prompt_setprompt(win.buf, "") - win:focus() - vim.cmd("startinsert!") + vim.bo[win.buf].modified = false end, bo = { filetype = "snacks_picker_input", @@ -47,6 +46,10 @@ function M.new(picker) }, })) + self.win:on("BufEnter", function() + vim.cmd("startinsert!") + end, { buf = true }) + local ref = Snacks.util.ref(self) self.win:on( { "TextChangedI", "TextChanged" }, @@ -55,6 +58,7 @@ function M.new(picker) if not input or not input.win:valid() then return end + vim.bo[input.win.buf].modified = false -- only one line -- Can happen when someone pastes a multiline string if vim.api.nvim_buf_line_count(input.win.buf) > 1 then From e8464f2a2298a1f3fccef1812d8be026b837fca9 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:39:03 +0100 Subject: [PATCH 06/28] feat(picker): `opts.focus = "input"|"list"|false` to configure what to focus (if anything) when showing the picker --- lua/snacks/picker/config/defaults.lua | 2 ++ lua/snacks/picker/core/picker.lua | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua index a193d5e08..c88c1c969 100644 --- a/lua/snacks/picker/config/defaults.lua +++ b/lua/snacks/picker/config/defaults.lua @@ -83,6 +83,7 @@ local M = {} ---@field prompt? string prompt text / icon ---@field title? string defaults to a capitalized source name ---@field auto_close? boolean automatically close the picker when focusing another window (defaults to true) +---@field focus? "input"|"list"|false where to focus when the picker is opened (defaults to "input") --- Preset options ---@field previewers? snacks.picker.previewers.Config|{} ---@field formatters? snacks.picker.formatters.Config|{} @@ -101,6 +102,7 @@ local M = {} local defaults = { prompt = " ", sources = {}, + focus = "input", layout = { cycle = true, --- Use the default layout or vertical if the window is too narrow diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index 8d6720868..11ee35a63 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -436,7 +436,11 @@ function M:show() if self.preview.main then self.preview.win:show() end - self.input.win:focus() + if self.opts.focus == "input" then + self.input.win:focus() + elseif self.opts.focus == "list" then + self.list.win:focus() + end if self.opts.on_show then self.opts.on_show(self) end From b44c3748aeb857803c59bbf496eb2149aca9d8db Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:39:34 +0100 Subject: [PATCH 07/28] fix(picker.config): normalize `opts.cwd` --- lua/snacks/picker/config/init.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/snacks/picker/config/init.lua b/lua/snacks/picker/config/init.lua index 7788e6cce..b67e012b0 100644 --- a/lua/snacks/picker/config/init.lua +++ b/lua/snacks/picker/config/init.lua @@ -77,8 +77,10 @@ function M.get(opts) -- Merge the configs opts = Snacks.config.merge(unpack(todo)) - if opts.cwd == true then + if opts.cwd == true or opts.cwd == "" then opts.cwd = nil + elseif opts.cwd then + opts.cwd = vim.fs.normalize(vim.fn.fnamemodify(opts.cwd, ":p")) end M.multi(opts) return opts From 4b45f3f3f7af36b61d7496f6bed25463c4c1a89c Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:40:10 +0100 Subject: [PATCH 08/28] feat(picker.config): added `opts.config` which can be a function that can change the resolved options --- lua/snacks/picker/config/defaults.lua | 1 + lua/snacks/picker/config/init.lua | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua index c88c1c969..c043e8bcb 100644 --- a/lua/snacks/picker/config/defaults.lua +++ b/lua/snacks/picker/config/defaults.lua @@ -98,6 +98,7 @@ local M = {} ---@field on_show? fun(picker:snacks.Picker) called when the picker is shown ---@field jump? snacks.picker.jump.Config|{} --- Other +---@field config? fun(opts:snacks.picker.Config):snacks.picker.Config? custom config function ---@field debug? snacks.picker.debug|{} local defaults = { prompt = " ", diff --git a/lua/snacks/picker/config/init.lua b/lua/snacks/picker/config/init.lua index b67e012b0..fe9fd09e3 100644 --- a/lua/snacks/picker/config/init.lua +++ b/lua/snacks/picker/config/init.lua @@ -82,6 +82,11 @@ function M.get(opts) elseif opts.cwd then opts.cwd = vim.fs.normalize(vim.fn.fnamemodify(opts.cwd, ":p")) end + for _, t in ipairs(todo) do + if t.config then + opts = t.config(opts) or opts + end + end M.multi(opts) return opts end From 472ca24a1ce4e6883fc7e0748f0e9e61a376fc34 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:41:15 +0100 Subject: [PATCH 09/28] fix(picker): don't destroy active pickers (only an issue when multiple pickers were open) --- lua/snacks/picker/core/picker.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index 11ee35a63..5279a326f 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -70,14 +70,16 @@ function M.new(opts) end, }) - local picker_count = vim.tbl_count(M._pickers) + local picker_count = vim.tbl_count(M._pickers) - vim.tbl_count(M._active) if picker_count > 0 then -- clear items from previous pickers for garbage collection for picker, _ in pairs(M._pickers) do - picker.finder.items = {} - picker.list.items = {} - picker.list:clear() - picker.list.picker = nil + if not M._active[picker] then + picker.finder.items = {} + picker.list.items = {} + picker.list:clear() + picker.list.picker = nil + end end end From eb95a218ba7c2600e762a2549fc71e26db0d0162 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:41:39 +0100 Subject: [PATCH 10/28] fix(picker.layout): fix list cursorline when layout updates --- lua/snacks/picker/core/picker.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index 5279a326f..b0338b195 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -254,6 +254,7 @@ function M:init_layout(layout) self:update_titles() self:show_preview() self.input:update() + self.list:update_cursorline() end, layout = { backdrop = backdrop, From 929e86eceab58b32a106ba5c8add0fc9d09ece4b Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:42:59 +0100 Subject: [PATCH 11/28] fix(layout): better update check for split layouts --- lua/snacks/layout.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua index 1aede7abe..627d28b04 100644 --- a/lua/snacks/layout.lua +++ b/lua/snacks/layout.lua @@ -5,6 +5,7 @@ ---@field box_wins snacks.win[] ---@field win_opts table ---@field closed? boolean +---@field screenpos number[]? local M = {} M.__index = M @@ -111,6 +112,17 @@ function M.new(opts) end end) + self.root:on("WinResized", function(_, ev) + if self.closed then + return true + end + local sp = vim.fn.screenpos(self.root.win, 1, 1) + if not vim.deep_equal(sp, self.screenpos) then + self.screenpos = sp + self:update() + end + end) + -- update layout on VimResized self.root:on("VimResized", function() self:update() @@ -183,6 +195,7 @@ function M:update() end if not self.root:valid() then self.root:show() + self.screenpos = vim.fn.screenpos(self.root.win, 1, 1) end -- Calculate offsets for vertical splits From 1bdc28acae4cfa02775f9aa7ff438aafa17c102f Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:43:39 +0100 Subject: [PATCH 12/28] fix(layout): make sure split layouts are still visible when a float layout with backdrop opens --- lua/snacks/layout.lua | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua index 627d28b04..01e1c1a66 100644 --- a/lua/snacks/layout.lua +++ b/lua/snacks/layout.lua @@ -52,10 +52,12 @@ function M.new(opts) if self.opts.layout.position and self.opts.layout.position ~= "float" then local inner = self.opts.layout self.opts.layout = { + zindex = inner.zindex or 30, box = "vertical", position = inner.position, width = inner.width, height = inner.height, + backdrop = inner.backdrop, inner, } inner.width, inner.height, inner.col, inner.row, inner.position = 0, 0, 0, 0, nil @@ -71,9 +73,18 @@ function M.new(opts) 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 + local backdrop = box.backdrop + if backdrop == nil then + backdrop = 60 + end + if is_root and backdrop then + backdrop = type(backdrop) == "number" and { blend = backdrop } or backdrop + backdrop = backdrop == true and {} or backdrop + ---@cast backdrop snacks.win.Backdrop + backdrop.win = backdrop.win or {} + backdrop.win.zindex = 20 + else + backdrop = false end self.box_wins[box.id] = Snacks.win(Snacks.win.resolve(box, { relative = is_root and (box.relative or "editor") or "win", From 083ce94048c9138aac51fdf881bba59e37e35909 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:44:52 +0100 Subject: [PATCH 13/28] feat(picker.list): added support for setting a cursor/topline target for the next render. Target clears when reached, or when finders finishes. --- lua/snacks/picker/core/list.lua | 35 +++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/lua/snacks/picker/core/list.lua b/lua/snacks/picker/core/list.lua index 65634d5c5..9e5eb52fa 100644 --- a/lua/snacks/picker/core/list.lua +++ b/lua/snacks/picker/core/list.lua @@ -14,6 +14,7 @@ ---@field selected snacks.picker.Item[] ---@field selected_map table ---@field matcher snacks.picker.Matcher matcher for formatting list items +---@field target? {cursor: number, top?: number} local M = {} M.__index = M @@ -92,12 +93,26 @@ function M.new(picker) end ---@param cursor number ----@param topline? number -function M:view(cursor, topline) - if topline then - self:scroll(topline, true, false) +---@param top? number +---@param render? boolean +function M:view(cursor, top, render) + if top then + self:scroll(top, true, false) + end + self:move(cursor, true, render) + if self.cursor < cursor then + self.target = { cursor = cursor, top = top } + else + self.target = nil end - self:move(cursor, true) +end + +--- Sets the target cursor/top for the next render. +--- Useful to keep the cursor/top, right before triggering a `find`. +---@param cursor? number +---@param top? number +function M:set_target(cursor, top) + self.target = { cursor = cursor or self.cursor, top = top or self.top } end ---@param idx number @@ -125,6 +140,7 @@ function M:on_show() self.state.mousescroll = tonumber(vim.o.mousescroll:match("ver:(%d+)")) or 1 Snacks.util.wo(self.win.win, { scrolloff = 0 }) self.dirty = true + self:update_cursorline() end function M:count() @@ -443,7 +459,14 @@ function M:update_cursorline() end function M:render() - self:move(0, false, false) + if self.target then + self:view(self.target.cursor, self.target.top, false) + if not self.picker:is_active() then + self.target = nil + end + else + self:move(0, false, false) + end local redraw = false if self.dirty then From 9757a794974e81cbd156fa24e33aaa161d7af693 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:45:27 +0100 Subject: [PATCH 14/28] feat(picker): `picker:iter()` now also returns `idx` --- lua/snacks/picker/core/picker.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index b0338b195..c9e81fb76 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -461,14 +461,14 @@ end --- Returns an iterator over the filtered items in the picker. --- Items will be in sorted order. ----@return fun():snacks.picker.Item? +---@return fun():(snacks.picker.Item?, number?) function M:iter() local i = 0 local n = self.list:count() return function() i = i + 1 if i <= n then - return self:resolve(self.list:get(i)) + return self:resolve(self.list:get(i)), i end end end From 7f21483e777aa1a8a120dda018ee203dffc1baa4 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:46:18 +0100 Subject: [PATCH 15/28] fix(picker): better handling of win Enter/Leave mostly for split layouts --- lua/snacks/picker/core/picker.lua | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index c9e81fb76..fc3c5cf93 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -204,10 +204,7 @@ end function M:is_focused() local current = vim.api.nvim_get_current_win() - return vim.tbl_contains( - { self.input.win.win, self.list.win.win, self.preview.win.win, self.layout.root.win }, - current - ) + return vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current) end --- Execute the callback in normal mode. @@ -261,8 +258,30 @@ function M:init_layout(layout) }, })) + local left_picker = true -- left a picker window + self.layout.root:on("WinLeave", function() + left_picker = self:is_focused() + end) + + local last_win = self.input.filter.current_win + local last_pwin ---@type number? + self.layout.root:on("WinEnter", function() + local win = vim.api.nvim_get_current_win() + if self:is_focused() then + last_pwin = win + elseif win ~= self.layout.root.win then + last_win = win + end + end) + self.layout.root:on("WinEnter", function() - self:action("focus_input") + if left_picker and last_win and vim.api.nvim_win_is_valid(last_win) then + vim.api.nvim_set_current_win(last_win) + elseif last_pwin and vim.api.nvim_win_is_valid(last_pwin) then + vim.api.nvim_set_current_win(last_pwin) + else + self.input.win:focus() + end end, { buf = true }) self.preview:update(preview_main and self.main or nil) From 8630ebfe712e54dd15b1c927383ee22b7a9f84fc Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 10:56:08 +0100 Subject: [PATCH 16/28] feat(picker.format): better path formatting for directories --- lua/snacks/picker/config/highlights.lua | 5 ++-- lua/snacks/picker/format.lua | 31 +++++++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lua/snacks/picker/config/highlights.lua b/lua/snacks/picker/config/highlights.lua index fdc5a9f94..7f2aee584 100644 --- a/lua/snacks/picker/config/highlights.lua +++ b/lua/snacks/picker/config/highlights.lua @@ -9,12 +9,13 @@ Snacks.util.set_hl({ Special = "Special", Label = "SnacksPickerSpecial", Totals = "NonText", - File = "", + File = "", -- basename of a file path + Directory = "Directory", -- basename of a directory path + Dir = "NonText", -- dirname of a path Flag = "DiagnosticVirtualTextInfo", FlagHidden = "SnacksPickerFlag", FlagIgnored = "SnacksPickerFlag", FlagFollow = "SnacksPickerFlag", - Dir = "NonText", Dimmed = "Conceal", Row = "String", Col = "LineNr", diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index dd5b40936..12b35eab1 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -43,26 +43,33 @@ function M.filename(item, picker) if picker.opts.icons.files.enabled ~= false then local icon, hl = Snacks.util.icon(name, cat) + if item.dir and item.open then + icon = " " + end local padded_icon = icon:sub(-1) == " " and icon or icon .. " " ret[#ret + 1] = { padded_icon, hl, virtual = true } end - local dir, file = path:match("^(.*)/(.+)$") + local base_hl = item.dir and "SnacksPickerDirectory" or "SnacksPickerFile" + local dir_hl = "SnacksPickerDir" + if picker.opts.formatters.file.filename_only then - dir = nil path = vim.fn.fnamemodify(path, ":t") - end - if file and dir then - if picker.opts.formatters.file.filename_first then - ret[#ret + 1] = { file, "SnacksPickerFile", field = "file" } - ret[#ret + 1] = { " " } - ret[#ret + 1] = { dir, "SnacksPickerDir", field = "file" } + ret[#ret + 1] = { path, base_hl, field = "file" } + else + local dir, base = path:match("^(.*)/(.+)$") + if base and dir then + if picker.opts.formatters.file.filename_first then + ret[#ret + 1] = { base, base_hl, field = "file" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { dir, dir_hl, field = "file" } + else + ret[#ret + 1] = { dir .. "/", dir_hl, field = "file" } + ret[#ret + 1] = { base, base_hl, field = "file" } + end else - ret[#ret + 1] = { dir .. "/", "SnacksPickerDir", field = "file" } - ret[#ret + 1] = { file, "SnacksPickerFile", field = "file" } + ret[#ret + 1] = { path, base_hl, field = "file" } end - else - ret[#ret + 1] = { path, "SnacksPickerFile", field = "file" } end if item.pos and item.pos[1] > 0 then ret[#ret + 1] = { ":", "SnacksPickerDelim" } From 5c57e08b630c5305e407ac9b0a2b49f679b8d7c1 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 11:05:39 +0100 Subject: [PATCH 17/28] refactor(picker.format): remove redundant `depth` and addes support for indent guides to file formatter --- lua/snacks/picker/format.lua | 6 +++++- lua/snacks/picker/source/lsp.lua | 3 +-- lua/snacks/picker/source/vim.lua | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index 12b35eab1..c57a86bc4 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -96,6 +96,10 @@ function M.file(item, picker) vim.list_extend(ret, M.severity(item, picker)) end + if item.parent then + vim.list_extend(ret, M.indent(item, picker)) + end + vim.list_extend(ret, M.filename(item, picker)) if item.comment then @@ -191,7 +195,7 @@ function M.indent(item, picker) local indents = picker.opts.icons.indent local indent = {} ---@type string[] local node = item - while node and node.depth > 0 do + while node and node.parent do local is_last, icon = node.last, "" if node ~= item then icon = is_last and " " or indents.vertical diff --git a/lua/snacks/picker/source/lsp.lua b/lua/snacks/picker/source/lsp.lua index 8b854d9b1..04ca7e2a3 100644 --- a/lua/snacks/picker/source/lsp.lua +++ b/lua/snacks/picker/source/lsp.lua @@ -272,7 +272,6 @@ function M.results_to_items(client, results, opts) local item = { kind = M.symbol_kind(result.kind), parent = parent, - depth = (parent.depth or 0) + 1, detail = result.detail, name = result.name, text = "", @@ -299,7 +298,7 @@ function M.results_to_items(client, results, opts) result.children = nil end - local root = { depth = 0, text = "" } ---@type snacks.picker.finder.Item + local root = { text = "" } ---@type snacks.picker.finder.Item ---@type snacks.picker.finder.Item for _, result in ipairs(results) do add(result, root) diff --git a/lua/snacks/picker/source/vim.lua b/lua/snacks/picker/source/vim.lua index 1bb8f4f52..b278702e5 100644 --- a/lua/snacks/picker/source/vim.lua +++ b/lua/snacks/picker/source/vim.lua @@ -396,7 +396,6 @@ function M.undo(opts, ctx) current = entry.seq == tree.seq_cur, last = e == #entries, parent = parent, - depth = parent and parent.depth + 1 or 1, action = function() vim.api.nvim_buf_call(buf, function() vim.cmd("undo " .. entry.seq) From e46dead3024ebf73653100ebc5591b991c6f009e Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 11:43:29 +0100 Subject: [PATCH 18/28] feat(layout): make fullscreen work for split layouts --- lua/snacks/layout.lua | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua index 01e1c1a66..a356e5adf 100644 --- a/lua/snacks/layout.lua +++ b/lua/snacks/layout.lua @@ -5,6 +5,7 @@ ---@field box_wins snacks.win[] ---@field win_opts table ---@field closed? boolean +---@field split? boolean ---@field screenpos number[]? local M = {} M.__index = M @@ -50,6 +51,7 @@ function M.new(opts) -- wrap the split layout in a vertical box -- this is needed since a simple split window can't have borders/titles if self.opts.layout.position and self.opts.layout.position ~= "float" then + self.split = true local inner = self.opts.layout self.opts.layout = { zindex = inner.zindex or 30, @@ -210,20 +212,23 @@ function M:update() end -- Calculate offsets for vertical splits - local voffset = 0 + local top, bottom = 0, 0 local pos = self.opts.layout.position - if pos and (pos == "left" or pos == "right") then - voffset = (vim.o.cmdheight + (vim.o.laststatus == 3 and 1 or 0)) or 0 - voffset = voffset - + (((vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)) and 1 or 0) or 0) + if pos and (pos == "left" or pos == "right") or self.opts.fullscreen then + bottom = (vim.o.cmdheight + (vim.o.laststatus == 3 and 1 or 0)) or 0 + top = (vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)) and 1 or 0 end self:update_box(layout, { col = 0, - row = 0, + row = self.opts.fullscreen and self.split and top or 0, -- only needed for fullscreen splits width = vim.o.columns, - height = vim.o.lines - voffset, + height = vim.o.lines - top - bottom, }) + -- fix fullscreen float layouts + if self.opts.fullscreen and not self.split then + self.root.opts.row = self.root.opts.row + top + end for _, win in pairs(self:get_wins()) do if win:valid() then -- update windows with eventignore=all @@ -405,6 +410,11 @@ function M:update_win(win, parent) zindex = self.root.opts.zindex + win.depth, } ) + -- fix fullscreen for splits + if self.opts.fullscreen and self.split then + w.opts.relative = "editor" + w.opts.win = nil + end -- 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) From 0c0cdeb671971d75d2e535c2f1603a8992c51bf6 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 12:23:01 +0100 Subject: [PATCH 19/28] fix(layout): better handling of resizing of split layouts --- lua/snacks/layout.lua | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua index a356e5adf..09e34f258 100644 --- a/lua/snacks/layout.lua +++ b/lua/snacks/layout.lua @@ -132,7 +132,9 @@ function M.new(opts) local sp = vim.fn.screenpos(self.root.win, 1, 1) if not vim.deep_equal(sp, self.screenpos) then self.screenpos = sp - self:update() + return self:update() + elseif vim.tbl_contains(vim.v.event.windows, self.root.win) then + return self:update() end end) @@ -376,6 +378,15 @@ end ---@param parent snacks.win.Dim ---@private function M:dim_box(widget, parent) + -- honor the actual window size for split layouts + if not self.opts.fullscreen and widget.id == 1 and self.split and self.root:valid() then + return { + height = vim.api.nvim_win_get_height(self.root.win), + width = vim.api.nvim_win_get_width(self.root.win), + col = 0, + row = 0, + }, { left = 0, right = 0, top = 0, bottom = 0 } + end 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) From 2c4fad846886a12f5a8c8bf33ff8c6661ef9172a Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 12:44:18 +0100 Subject: [PATCH 20/28] fix(picker.actions): detect and report circular action references --- lua/snacks/picker/core/actions.lua | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lua/snacks/picker/core/actions.lua b/lua/snacks/picker/core/actions.lua index 3a93839e2..406e82904 100644 --- a/lua/snacks/picker/core/actions.lua +++ b/lua/snacks/picker/core/actions.lua @@ -57,8 +57,10 @@ end ---@param action snacks.picker.Action.spec ---@param picker snacks.Picker ---@param name? string +---@param stack? string[] ---@return snacks.picker.Action -function M.resolve(action, picker, name) +function M.resolve(action, picker, name, stack) + stack = stack or {} if not action then assert(name, "Missing action without name") local fn, desc = picker.input.win[name], name @@ -72,15 +74,25 @@ function M.resolve(action, picker, name) desc = desc, } elseif type(action) == "string" then + if vim.tbl_contains(stack, action) then + if action == "confirm" or name == "confirm" then + action = "jump" + else + Snacks.notify.error("Circular action reference for `" .. action .. "`:\n- " .. table.concat(stack, "\n- ")) + return {} + end + end + stack[#stack + 1] = action return M.resolve( (picker.opts.actions or {})[action] or require("snacks.picker.actions")[action], picker, - action:gsub("_ ", " ") + action, + stack ) elseif type(action) == "table" and islist(action) then ---@type snacks.picker.Action[] local actions = vim.tbl_map(function(a) - return M.resolve(a, picker) + return M.resolve(a, picker, nil, stack) end, action) return { action = function(p, i, aa) @@ -98,7 +110,7 @@ function M.resolve(action, picker, name) elseif type(action) == "table" then if type(action.action) ~= "function" then action = vim.deepcopy(action) - action.action = M.resolve(action.action, picker).action + action.action = M.resolve(action.action, picker, nil, stack).action end ---@cast action snacks.picker.Action return action From d37fdbf88bf3dd79368ace93ba3250e3761ec809 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 12:46:42 +0100 Subject: [PATCH 21/28] feat(picker.actions): separate edit_split etc in separate split and edit actions. Fixes #791 --- lua/snacks/picker/actions.lua | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua index 7b2b38c97..5461d9ead 100644 --- a/lua/snacks/picker/actions.lua +++ b/lua/snacks/picker/actions.lua @@ -104,9 +104,21 @@ end M.cancel = "close" M.edit = M.jump M.confirm = M.jump -M.edit_split = { action = "jump", cmd = "split" } -M.edit_vsplit = { action = "jump", cmd = "vsplit" } -M.edit_tab = { action = "jump", cmd = "tabnew" } +M.edit_split = { "split", "confirm" } +M.edit_vsplit = { "vsplit", "confirm" } +M.edit_tab = { "tab", "confirm" } + +function M.split() + vim.cmd("split") +end + +function M.vsplit() + vim.cmd("vsplit") +end + +function M.tab() + vim.cmd("tabnew") +end function M.toggle_maximize(picker) picker.layout:maximize() @@ -361,6 +373,7 @@ function M.load_session(picker) end function M.help(picker) + dd("help") local item = picker:current() if item then picker:close() @@ -453,6 +466,7 @@ end function M.toggle_hidden(picker) local opts = picker.opts --[[@as snacks.picker.files.Config]] opts.hidden = not opts.hidden + picker.list:set_target() picker:find() end From 6feb6adc798b5f61cf2671442f1a29b065eec937 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 14:05:26 +0100 Subject: [PATCH 22/28] refactor(picker): indent/hierarchy => tree --- lua/snacks/picker/config/defaults.lua | 2 +- lua/snacks/picker/config/highlights.lua | 2 +- lua/snacks/picker/config/sources.lua | 6 +++--- lua/snacks/picker/format.lua | 18 +++++++++--------- lua/snacks/picker/source/lsp.lua | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua index c043e8bcb..bab2bcfce 100644 --- a/lua/snacks/picker/config/defaults.lua +++ b/lua/snacks/picker/config/defaults.lua @@ -283,7 +283,7 @@ local defaults = { keymaps = { nowait = "󰓅 " }, - indent = { + tree = { vertical = "│ ", middle = "├╴", last = "└╴", diff --git a/lua/snacks/picker/config/highlights.lua b/lua/snacks/picker/config/highlights.lua index 7f2aee584..c1be1628b 100644 --- a/lua/snacks/picker/config/highlights.lua +++ b/lua/snacks/picker/config/highlights.lua @@ -29,7 +29,7 @@ Snacks.util.set_hl({ Unselected = "NonText", Idx = "Number", Bold = "Bold", - Indent = "LineNr", + Tree = "LineNr", Italic = "Italic", Code = "@markup.raw.markdown_inline", AuPattern = "String", diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index 5a6ec41ca..e9dd3b54b 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -417,13 +417,13 @@ M.lsp_references = { -- LSP document symbols ---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config ----@field hierarchy? boolean show symbol hierarchy +---@field tree? boolean show symbol tree ---@field filter table? symbol kind filter ---@field workspace? boolean show workspace symbols M.lsp_symbols = { finder = "lsp_symbols", format = "lsp_symbol", - hierarchy = true, + tree = true, filter = { default = { "Class", @@ -465,7 +465,7 @@ M.lsp_symbols = { ---@type snacks.picker.lsp.symbols.Config M.lsp_workspace_symbols = vim.tbl_extend("force", {}, M.lsp_symbols, { workspace = true, - hierarchy = false, + tree = false, supports_live = true, live = true, -- live by default }) diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index c57a86bc4..c7eaa85b9 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -97,7 +97,7 @@ function M.file(item, picker) end if item.parent then - vim.list_extend(ret, M.indent(item, picker)) + vim.list_extend(ret, M.tree(item, picker)) end vim.list_extend(ret, M.filename(item, picker)) @@ -190,22 +190,22 @@ function M.git_stash(item, picker) return ret end -function M.indent(item, picker) +function M.tree(item, picker) local ret = {} ---@type snacks.picker.Highlight[] - local indents = picker.opts.icons.indent + local icons = picker.opts.icons.tree local indent = {} ---@type string[] local node = item while node and node.parent do local is_last, icon = node.last, "" if node ~= item then - icon = is_last and " " or indents.vertical + icon = is_last and " " or icons.vertical else - icon = is_last and indents.last or indents.middle + icon = is_last and icons.last or icons.middle end table.insert(indent, 1, icon) node = node.parent end - ret[#ret + 1] = { table.concat(indent), "SnacksPickerIndent" } + ret[#ret + 1] = { table.concat(indent), "SnacksPickerTree" } return ret end @@ -218,7 +218,7 @@ function M.undo(item, picker) else ret[#ret + 1] = { a("", 2) } end - vim.list_extend(ret, M.indent(item, picker)) + vim.list_extend(ret, M.tree(item, picker)) ret[#ret + 1] = { a(tostring(entry.seq), 4), "SnacksPickerIdx" } ret[#ret + 1] = { " " } @@ -241,8 +241,8 @@ end function M.lsp_symbol(item, picker) local opts = picker.opts --[[@as snacks.picker.lsp.symbols.Config]] local ret = {} ---@type snacks.picker.Highlight[] - if item.hierarchy and not opts.workspace then - vim.list_extend(ret, M.indent(item, picker)) + if item.tree and not opts.workspace then + vim.list_extend(ret, M.tree(item, picker)) end local kind = item.kind or "Unknown" ---@type string local kind_hl = "SnacksPickerIcon" .. kind diff --git a/lua/snacks/picker/source/lsp.lua b/lua/snacks/picker/source/lsp.lua index 04ca7e2a3..dfc326b4d 100644 --- a/lua/snacks/picker/source/lsp.lua +++ b/lua/snacks/picker/source/lsp.lua @@ -369,7 +369,7 @@ function M.symbols(opts, ctx) end, }) for _, item in ipairs(items) do - item.hierarchy = opts.hierarchy + item.tree = opts.tree ---@diagnostic disable-next-line: await-in-sync cb(item) end From b0b4fd82b28f87c36c8de64066aa07db5dfe9449 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 14:13:49 +0100 Subject: [PATCH 23/28] feat(picker.format): directory formatting --- lua/snacks/picker/format.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index c7eaa85b9..d854bfaf9 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -57,6 +57,9 @@ function M.filename(item, picker) path = vim.fn.fnamemodify(path, ":t") ret[#ret + 1] = { path, base_hl, field = "file" } else + if item.dir then + path = path .. "/" + end local dir, base = path:match("^(.*)/(.+)$") if base and dir then if picker.opts.formatters.file.filename_first then From db9a5cf2f3cbf365a89552cc18bdd86203363b0d Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 14:51:10 +0100 Subject: [PATCH 24/28] feat(picker): reworked toggles (flags). they're now configurable. Closes #770 --- lua/snacks/picker/actions.lua | 19 -------------- lua/snacks/picker/config/defaults.lua | 14 ++++++++++ lua/snacks/picker/config/highlights.lua | 5 +--- lua/snacks/picker/config/init.lua | 15 +++++++++++ lua/snacks/picker/config/sources.lua | 2 ++ lua/snacks/picker/core/picker.lua | 34 +++++++++++++++---------- lua/snacks/picker/source/buffers.lua | 1 + 7 files changed, 53 insertions(+), 37 deletions(-) diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua index 5461d9ead..0b18b36e8 100644 --- a/lua/snacks/picker/actions.lua +++ b/lua/snacks/picker/actions.lua @@ -448,12 +448,6 @@ 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.item_action(picker, item, action) if item.action then picker:norm(function() @@ -463,19 +457,6 @@ function M.item_action(picker, item, action) end end -function M.toggle_hidden(picker) - local opts = picker.opts --[[@as snacks.picker.files.Config]] - opts.hidden = not opts.hidden - picker.list:set_target() - picker:find() -end - -function M.toggle_follow(picker) - local opts = picker.opts --[[@as snacks.picker.files.Config]] - opts.follow = not opts.follow - picker:find() -end - function M.list_top(picker) picker.list:move(1, true) end diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua index bab2bcfce..987bacbe3 100644 --- a/lua/snacks/picker/config/defaults.lua +++ b/lua/snacks/picker/config/defaults.lua @@ -8,6 +8,7 @@ local M = {} ---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean ---@alias snacks.picker.transform fun(item:snacks.picker.finder.Item, ctx:snacks.picker.finder.ctx):(boolean|snacks.picker.finder.Item|nil) ---@alias snacks.picker.Pos {[1]:number, [2]:number} +---@alias snacks.picker.toggle {icon?:string, enabled?:boolean, value?:boolean} --- Generic filter used by finders to pre-filter items ---@class snacks.picker.filter.Config @@ -166,6 +167,14 @@ local defaults = { tagstack = false, -- save the current position in the tagstack reuse_win = false, -- reuse an existing window if the buffer is already open }, + ---@type table + toggles = { + follow = "f", + hidden = "h", + ignored = "i", + modified = "m", + regex = { icon = "R", value = false }, + }, win = { -- input window input = { @@ -244,6 +253,8 @@ local defaults = { [""] = "list_scroll_wheel_down", [""] = "list_scroll_wheel_up", [""] = "select_all", + [""] = { "toggle_maximize" }, + [""] = { "toggle_preview" }, [""] = "preview_scroll_down", [""] = "preview_scroll_up", [""] = "preview_scroll_right", @@ -256,6 +267,9 @@ local defaults = { [""] = "list_up", [""] = "cycle_win", [""] = "close", + [""] = "toggle_ignored", + [""] = "toggle_hidden", + [""] = "toggle_follow", }, wo = { conceallevel = 2, diff --git a/lua/snacks/picker/config/highlights.lua b/lua/snacks/picker/config/highlights.lua index c1be1628b..6ef0c3672 100644 --- a/lua/snacks/picker/config/highlights.lua +++ b/lua/snacks/picker/config/highlights.lua @@ -12,10 +12,7 @@ Snacks.util.set_hl({ File = "", -- basename of a file path Directory = "Directory", -- basename of a directory path Dir = "NonText", -- dirname of a path - Flag = "DiagnosticVirtualTextInfo", - FlagHidden = "SnacksPickerFlag", - FlagIgnored = "SnacksPickerFlag", - FlagFollow = "SnacksPickerFlag", + Toggle = "DiagnosticVirtualTextInfo", Dimmed = "Conceal", Row = "String", Col = "LineNr", diff --git a/lua/snacks/picker/config/init.lua b/lua/snacks/picker/config/init.lua index fe9fd09e3..cf4e5adbe 100644 --- a/lua/snacks/picker/config/init.lua +++ b/lua/snacks/picker/config/init.lua @@ -87,6 +87,21 @@ function M.get(opts) opts = t.config(opts) or opts end end + + -- add hl groups and actions for toggles + opts.actions = opts.actions or {} + for name in pairs(opts.toggles) do + local hl = table.concat(vim.tbl_map(function(a) + return a:sub(1, 1):upper() .. a:sub(2) + end, vim.split(name, "_"))) + Snacks.util.set_hl({ [hl] = "SnacksPickerToggle" }, { default = true, prefix = "SnacksPickerToggle" }) + opts.actions["toggle_" .. name] = function(picker) + picker.opts[name] = not picker.opts[name] + picker.list:set_target() + picker:find() + end + end + M.multi(opts) return opts end diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index e9dd3b54b..2a642c3ef 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -16,6 +16,7 @@ M.autocmds = { ---@field unloaded? boolean show loaded buffers ---@field current? boolean show current buffer ---@field nofile? boolean show `buftype=nofile` buffers +---@field modified? boolean show only modified buffers ---@field sort_lastused? boolean sort by last used ---@field filter? snacks.picker.filter.Config M.buffers = { @@ -232,6 +233,7 @@ M.git_diff = { ---@field rtp? boolean search in runtimepath M.grep = { finder = "grep", + regex = true, format = "file", live = true, -- live grep by default supports_live = true, diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index fc3c5cf93..1a0f64aa7 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -355,19 +355,25 @@ function M:update_titles() live = self.opts.live and self.opts.icons.ui.live or "", preview = vim.trim(self.preview.title or ""), } - local opts = self.opts --[[@as snacks.picker.files.Config]] - local flags = {} ---@type snacks.picker.Text[] - if opts.follow then - flags[#flags + 1] = { " " .. self.opts.icons.ui.follow .. " ", "SnacksPickerFlagFollow" } - flags[#flags + 1] = { " ", "FloatTitle" } - end - if opts.hidden then - flags[#flags + 1] = { " " .. self.opts.icons.ui.hidden .. " ", "SnacksPickerFlagHidden" } - flags[#flags + 1] = { " ", "FloatTitle" } - end - if opts.ignored then - flags[#flags + 1] = { " " .. self.opts.icons.ui.ignored .. " ", "SnacksPickerFlagIgnored" } - flags[#flags + 1] = { " ", "FloatTitle" } + local toggles = {} ---@type snacks.picker.Text[] + for name, toggle in pairs(self.opts.toggles) do + if toggle then + toggle = type(toggle) == "string" and { icon = toggle } or toggle + toggle = toggle == true and { icon = name:sub(1, 1) } or toggle + toggle = toggle == false and { enabled = false } or toggle + local want = toggle.value + if toggle.value == nil then + want = true + end + ---@cast toggle snacks.picker.toggle + if toggle.enabled ~= false and self.opts[name] == want then + local hl = table.concat(vim.tbl_map(function(a) + return a:sub(1, 1):upper() .. a:sub(2) + end, vim.split(name, "_"))) + toggles[#toggles + 1] = { " " .. toggle.icon .. " ", "SnacksPickerToggle" .. hl } + toggles[#toggles + 1] = { " ", "FloatTitle" } + end + end end local wins = { self.layout.root } vim.list_extend(wins, vim.tbl_values(self.layout.wins)) @@ -380,7 +386,7 @@ function M:update_titles() local title = Snacks.picker.util.tpl(tpl, data) if title:find("{flags}", 1, true) then title = title:gsub("{flags}", "") - vim.list_extend(ret, flags) + vim.list_extend(ret, toggles) end title = vim.trim(title):gsub("%s+", " ") if title ~= "" then diff --git a/lua/snacks/picker/source/buffers.lua b/lua/snacks/picker/source/buffers.lua index 802008970..03ac7ada1 100644 --- a/lua/snacks/picker/source/buffers.lua +++ b/lua/snacks/picker/source/buffers.lua @@ -21,6 +21,7 @@ function M.buffers(opts, ctx) 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") + and (not opts.modified or vim.bo[buf].modified) if keep then local name = vim.api.nvim_buf_get_name(buf) if name == "" then From 7aa892e1b2a936410199c94dccea146d8ffbd195 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 15:14:20 +0100 Subject: [PATCH 25/28] feat(picker): new file explorer `Snacks.picker.explorer()` --- lua/snacks/picker/config/sources.lua | 34 +- lua/snacks/picker/source/explorer.lua | 439 ++++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 lua/snacks/picker/source/explorer.lua diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index 2a642c3ef..45d6bb0db 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -37,6 +37,38 @@ M.buffers = { }, } +---@class snacks.picker.explorer.Config: snacks.picker.files.Config +---@field follow_file? boolean follow the file from the current buffer +---@field tree? boolean show the file tree (default: true) +M.explorer = { + finder = "explorer", + sort = { fields = { "sort" } }, + tree = true, + follow_file = true, + focus = "list", + auto_close = false, + layout = { preset = "sidebar", preview = false }, + formatters = { file = { filename_only = true } }, + matcher = { sort_empty = true }, + config = function(opts) + return require("snacks.picker.source.explorer").setup(opts) + end, + win = { + list = { + keys = { + [""] = "explorer_up", + ["a"] = "explorer_add", + ["d"] = "explorer_del", + ["r"] = "explorer_rename", + ["c"] = "explorer_copy", + ["y"] = "explorer_yank", + [""] = "explorer_cd", + ["."] = "explorer_focus", + }, + }, + }, +} + M.cliphist = { finder = "system_cliphist", format = "text", @@ -115,7 +147,7 @@ M.diagnostics_buffer = { } ---@class snacks.picker.files.Config: snacks.picker.proc.Config ----@field cmd? string +---@field cmd? "fd"| "rg"| "find" command to use. Leave empty to auto-detect ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search diff --git a/lua/snacks/picker/source/explorer.lua b/lua/snacks/picker/source/explorer.lua new file mode 100644 index 000000000..c297bd2b5 --- /dev/null +++ b/lua/snacks/picker/source/explorer.lua @@ -0,0 +1,439 @@ +local M = {} + +---@type table +M._state = setmetatable({}, { __mode = "k" }) +local uv = vim.uv or vim.loop + +---@class snacks.picker.explorer.Item: snacks.picker.finder.Item +---@field file string +---@field dir? boolean +---@field parent? snacks.picker.explorer.Item +---@field open? boolean + +---@class snacks.picker.explorer.State +---@field cwd string +---@field expanded table +---@field all? boolean +---@field picker snacks.Picker.ref +---@field opts snacks.picker.explorer.Config +---@field on_find? fun()? +local State = {} +State.__index = State +---@param picker snacks.Picker +function State.new(picker) + local self = setmetatable({}, State) + self.opts = picker.opts --[[@as snacks.picker.explorer.Config]] + self.picker = picker:ref() + local filter = picker:filter() + self.cwd = filter.cwd + self.expanded = { [self.cwd] = true } + local buf = vim.api.nvim_win_get_buf(picker.main) + local buf_file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf)) + if uv.fs_stat(buf_file) then + self:expand(buf_file) + end + picker.list.win:on({ "WinEnter", "BufEnter" }, function() + self:follow() + end) + -- schedule initial follow + if self.opts.follow_file then + self.on_find = function() + self.on_find = nil + self:show(buf_file) + end + end + return self +end + +function State:follow() + if not self.opts.follow_file then + return + end + local picker = self.picker() + if not picker or picker:is_focused() then + return + end + local buf = vim.api.nvim_get_current_buf() + local file = vim.api.nvim_buf_get_name(buf) + self:show(file) +end + +---@param path string +function State:show(path) + local picker = self.picker() + if not picker then + return + end + path = vim.fs.normalize(path) + if not uv.fs_stat(path) then + return + end + local function show() + for item, idx in picker:iter() do + if item.file == path then + picker.list:view(idx) + break + end + end + end + if not self:is_visible(path) then + self:expand(path) + self:update({ on_done = show }) + else + show() + end +end + +---@param path string +function State:is_visible(path) + local dir = vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path) + if not self:in_cwd(dir) then + return false + end + if not self.expanded[dir] then + return false + end + for p, v in pairs(self.expanded) do + if not v and p:find(dir .. "/", 1, true) == 1 then + return false + end + end + return true +end + +---@param dir string +function State:is_open(dir) + return self.expanded[dir] +end + +function State:in_cwd(path) + return path == self.cwd or path:find(self.cwd .. "/", 1, true) == 1 +end + +---@param path string +function State:expand(path) + if not self:in_cwd(path) then + return + end + if vim.fn.isdirectory(path) == 1 then + self.expanded[path] = true + else + self.expanded[vim.fs.dirname(path)] = true + end + for p in vim.fs.parents(path) do + if not self:in_cwd(p) then + break + end + self.expanded[p] = true + end +end + +---@param item snacks.picker.Item +function State:toggle(item) + local dir = Snacks.picker.util.path(item) + if not dir then + return + end + self.expanded[dir] = not self.expanded[dir] + if self.expanded[dir] then + self:expand(dir) + end + self:update() +end + +function State:expand_dirs() + local expand = {} ---@type table + local exclude = {} ---@type table + for k, v in pairs(self.expanded) do + if self:in_cwd(k) then + (v and expand or exclude)[k] = true + end + end + -- remove excluded directories + for p in pairs(expand) do + for e in pairs(exclude) do + if p:find(e .. "/", 1, true) == 1 then + expand[p] = nil + break + end + end + end + local ret = vim.tbl_keys(expand) ---@type string[] + -- add parents + for p in pairs(expand) do + for pp in vim.fs.parents(p) do + if expand[pp] or not self:in_cwd(pp) then + break + end + expand[pp] = true + ret[#ret + 1] = pp + end + end + return ret +end + +---@param opts snacks.picker.explorer.Config +function State:setup(opts) + opts = Snacks.picker.util.shallow_copy(opts) + opts.cmd = "fd" + opts.cwd = self.cwd + opts.args = { "--type", "d", "--path-separator", "/" } + if not self.all then + opts.dirs = self:expand_dirs() + vim.list_extend(opts.args, { "--max-depth", "1" }) + end + return opts +end + +---@param opts? {target?: boolean, on_done?: fun()} +function State:update(opts) + opts = opts or {} + local picker = self.picker() + if not picker then + return + end + if opts.target ~= false then + picker.list:set_target() + end + picker:find({ on_done = opts.on_done }) +end + +function State:dir() + local picker = self.picker() + if not picker then + return self.cwd + end + local item = picker:current() + if item and item.dir then + return item.file + elseif item then + return vim.fn.fnamemodify(item.file, ":h") + else + return self.cwd + end +end + +function State:set_cwd(cwd) + self.cwd = cwd + self.expanded[cwd] = true + for k in pairs(self.expanded) do + if not self:in_cwd(k) then + self.expanded[k] = nil + end + end + self:update({ target = false }) +end + +function State:up() + self:set_cwd(vim.fs.dirname(self.cwd)) +end + +---@param opts snacks.picker.explorer.Config +function M.setup(opts) + return Snacks.config.merge(opts, { + actions = M.actions, + formatters = { + file = { + filename_only = opts.tree, + }, + }, + }) +end + +---@type table +M.actions = { + explorer_up = function(picker) + dd("explorer_up") + M.get_state(picker):up() + end, + explorer_add = function(picker) + local state = M.get_state(picker) + Snacks.input({ + prompt = 'Add a new file or directory (directories end with a "/")', + }, function(value) + if not value or value:find("^%s$") then + return + end + local dir = state:dir() + local path = vim.fs.normalize(dir .. "/" .. value) + local is_dir = value:sub(-1) == "/" + dir = is_dir and path or vim.fs.dirname(path) + vim.fn.mkdir(dir, "p") + state:expand(dir) + if not is_dir then + if uv.fs_stat(path) then + Snacks.notify.warn("File already exists:\n- `" .. path .. "`") + return + end + io.open(path, "w"):close() + end + state:update() + end) + end, + explorer_rename = function(picker, item) + if not item then + return + end + local state = M.get_state(picker) + Snacks.rename.rename_file({ + file = item.file, + on_rename = function(new) + state:expand(new) + state:update() + end, + }) + end, + explorer_copy = function(picker, item) + if not item then + return + end + if item.dir then + Snacks.notify.warn("Cannot copy directories") + return + end + local state = M.get_state(picker) + Snacks.input({ + prompt = "Copy to", + }, function(value) + if not value or value:find("^%s$") then + return + end + local dir = state:dir() + local path = vim.fs.normalize(dir .. "/" .. value) + vim.fn.mkdir(vim.fs.dirname(path), "p") + state:expand(dir) + if uv.fs_stat(path) then + Snacks.notify.warn("File already exists:\n- `" .. path .. "`") + return + end + uv.fs_copyfile(item.file, path, function(err) + if err then + Snacks.notify.error("Failed to copy `" .. item.file .. "` to `" .. path .. "`:\n- " .. err) + end + state:update() + end) + end) + end, + explorer_del = function(picker) + local state = M.get_state(picker) + ---@type string[] + local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected({ fallback = true })) + if #paths == 0 then + return + end + local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files" + Snacks.picker.select({ "Yes", "No" }, { prompt = "Delete " .. what .. "?" }, function(_, idx) + if idx == 1 then + for _, path in ipairs(paths) do + local ok, err = pcall(vim.fn.delete, path, "rf") + if not ok then + Snacks.notify.error("Failed to delete `" .. path .. "`:\n- " .. err) + end + end + state:update() + end + end) + end, + explorer_focus = function(picker) + local state = M.get_state(picker) + state:set_cwd(state:dir()) + end, + explorer_yank = function(picker, item) + if not item then + return + end + vim.fn.setreg("+", item.file) + Snacks.notify.info("Yanked `" .. item.file .. "`") + end, + explorer_cd = function(picker, item) + local state = M.get_state(picker) + vim.fn.chdir(state:dir()) + state:set_cwd(vim.fn.getcwd()) + end, + confirm = function(picker) + local state = M.get_state(picker) + local item = picker:current() + if not item then + return + elseif item.dir then + state:toggle(item) + else + picker:action("jump") + end + end, +} + +---@param picker snacks.Picker +function M.get_state(picker) + if not M._state[picker] then + M._state[picker] = State.new(picker) + end + return M._state[picker] +end + +---@param opts snacks.picker.explorer.Config +---@type snacks.picker.finder +function M.explorer(opts, ctx) + local state = M.get_state(ctx.picker) + opts = state:setup(opts) + + local files = require("snacks.picker.source.files").files(opts, ctx) + local dirs = {} ---@type table + local last = {} ---@type table + + ---@type snacks.picker.explorer.Item + local root = { + file = state.cwd, + dir = true, + open = true, + text = "", + hello = "world", + sort = "", + } + + return function(cb) + if state.on_find then + ctx.picker.matcher.task:on("done", vim.schedule_wrap(state.on_find)) + end + cb(root) + files(function(item) + ---@cast item snacks.picker.explorer.Item + + -- Directories + if item.file:sub(-1) == "/" then + item.dir = true + item.file = item.file:sub(1, -2) + item.open = state:is_open(item.file) + dirs[item.file] = item + end + + local dirname, basename = item.file:match("(.*)/(.*)") + dirname, basename = dirname or "", basename or item.file + local parent = dirs[dirname] ~= item and dirs[dirname] or root + + -- hierarchical sorting + if item.dir then + item.sort = parent.sort .. "/0" .. basename + else + item.sort = parent.sort .. "/1" .. basename + end + + if opts.tree then + -- tree + item.parent = parent + if not last[parent] or last[parent].sort < item.sort then + if last[parent] then + last[parent].last = false + end + item.last = true + last[parent] = item + end + end + + -- add to picker + cb(item) + end) + end +end + +return M From 414ef096381bb1200d268d9b0736d33fe08b4929 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 15:16:18 +0100 Subject: [PATCH 26/28] ci: gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 771c8351e..c570bb578 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,9 @@ /debug /doc/tags foo.* +foo*.* node_modules tt.* +/tmp +repomix* +.aider* From 553ea5a85a843f4fca3ccf26eafb581cb403585e Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 16:14:14 +0100 Subject: [PATCH 27/28] fix(picker): show new notifications on top --- lua/snacks/picker/source/snacks.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/snacks/picker/source/snacks.lua b/lua/snacks/picker/source/snacks.lua index 278428f60..135293e98 100644 --- a/lua/snacks/picker/source/snacks.lua +++ b/lua/snacks/picker/source/snacks.lua @@ -2,7 +2,7 @@ local M = {} ---@param opts snacks.picker.notifications.Config function M.notifier(opts) - local notifs = Snacks.notifier.get_history({ filter = opts.filter }) + local notifs = Snacks.notifier.get_history({ filter = opts.filter, reverse = true }) local items = {} ---@type snacks.picker.finder.Item[] for _, notif in ipairs(notifs) do From fb2c5463ece6208d5adc2b88c825094b5336f5b9 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Thu, 30 Jan 2025 16:52:39 +0100 Subject: [PATCH 28/28] perf(picker.score): no need to track `in_gap` status. redundant since we can depend on `gap` instead --- lua/snacks/picker/core/score.lua | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/lua/snacks/picker/core/score.lua b/lua/snacks/picker/core/score.lua index fde4298a5..b230f76c1 100644 --- a/lua/snacks/picker/core/score.lua +++ b/lua/snacks/picker/core/score.lua @@ -5,7 +5,6 @@ ---@field consecutive number ---@field prev? number ---@field prev_class number ----@field in_gap boolean ---@field is_file boolean ---@field first_bonus number ---@field str string @@ -105,7 +104,6 @@ function M.new(opts) self.is_file = true self.consecutive = 0 self.prev_class = CHAR_WHITE - self.in_gap = false self.str = "" self.first_bonus = 0 return self @@ -143,7 +141,6 @@ function M:init(str, first) then self.score = self.score + BONUS_NO_PATH_SEP end - self.in_gap = false self:update(first) end @@ -157,14 +154,7 @@ function M:update(pos) if gap > 0 then self.prev_class = CHAR_CLASS[self.str:byte(pos - 1)] or CHAR_NONWORD bonus = BONUS_MATRIX[self.prev_class][class] or 0 - if self.in_gap then - -- Already in a gap => extension penalty - self.score = self.score + gap * SCORE_GAP_EXTENSION - else - -- New gap => start penalty - self.score = self.score + SCORE_GAP_START + (gap - 1) * SCORE_GAP_EXTENSION - self.in_gap = true - end + self.score = self.score + SCORE_GAP_START + (gap - 1) * SCORE_GAP_EXTENSION self.consecutive = 0 self.first_bonus = 0 else @@ -182,7 +172,6 @@ function M:update(pos) bonus = math.max(bonus, self.first_bonus, BONUS_CONSECUTIVE) end self.consecutive = self.consecutive + 1 - self.in_gap = false end if not self.prev then