snacks.nvim/lua/snacks/dashboard.lua
Masayuki Izumi 057d4ab80e
fix(dashboard): fix path filtering for recent_files with cwd option (#2201)
## Description

Fix incorrect path filtering when using `recent_files` section with
`cwd`
option in dashboard.

Previously, when setting cwd to `/foo/bar/baz`, files from directories
with
the same prefix like `/foo/bar/bazbaz` were incorrectly included in the
recent
files list. This was due to simple string prefix matching without
considering
 directory boundaries.

The fix ensures proper directory boundary checking by verifying that the
path
either exactly matches the filter path or is followed by a "/"
character.

## Screenshots

```lua
return {
  "snacks.nvim",
  ---@type snacks.Config
  opts = {
    dashboard = {
      enabled = true,
      sections = {
        {
          title = "Recent Files " .. vim.uv.cwd(),
          section = "recent_files",
          cwd = true,
        },
      },
    },
  },
}
```

### before
<img width="659" height="149" alt="CleanShot 2025-09-24 at 00 00 13"
src="https://github.com/user-attachments/assets/943ad53f-11c5-49d2-b680-f032ad5fee94"
/>

### after
<img width="639" height="111" alt="CleanShot 2025-09-24 at 00 01 01"
src="https://github.com/user-attachments/assets/0dbde991-5164-4afa-9981-ae6707a8fcc0"
/>
2025-10-19 11:11:38 +02:00

1231 lines
40 KiB
Lua

---@class snacks.dashboard
---@overload fun(opts?: snacks.dashboard.Opts): snacks.dashboard.Class
local M = setmetatable({}, {
__call = function(M, opts)
return M.open(opts)
end,
})
M.meta = {
desc = " Beautiful declarative dashboards",
needs_setup = true,
}
local uv = vim.uv or vim.loop
math.randomseed(os.time())
---@class snacks.dashboard.Item
---@field indent? number
---@field align? "left" | "center" | "right"
---@field gap? number the number of empty lines between child items
---@field padding? number | {[1]:number, [2]:number} bottom or {bottom, top} padding
--- The action to run when the section is selected or the key is pressed.
--- * if it's a string starting with `:`, it will be run as a command
--- * if it's a string, it will be executed as a keymap
--- * if it's a function, it will be called
---@field action? snacks.dashboard.Action
---@field enabled? boolean|fun(opts:snacks.dashboard.Opts):boolean if false, the section will be disabled
---@field section? string the name of a section to include. See `Snacks.dashboard.sections`
---@field [string] any section options
---@field key? string shortcut key
---@field hidden? boolean when `true`, the item will not be shown, but the key will still be assigned
---@field autokey? boolean automatically assign a numerical key
---@field label? string
---@field desc? string
---@field file? string
---@field footer? string
---@field header? string
---@field icon? string
---@field title? string
---@field text? string|snacks.dashboard.Text[]
---@alias snacks.dashboard.Format.ctx {width?:number}
---@alias snacks.dashboard.Action string|fun(self:snacks.dashboard.Class)
---@alias snacks.dashboard.Gen fun(self:snacks.dashboard.Class):snacks.dashboard.Section?
---@alias snacks.dashboard.Section snacks.dashboard.Item|snacks.dashboard.Gen|snacks.dashboard.Section[]
---@class snacks.dashboard.Text
---@field [1] string the text
---@field hl? string the highlight group
---@field width? number the width used for alignment
---@field align? "left" | "center" | "right"
---@private
---@class snacks.dashboard.Item
---@field package _? snacks.dashboard.Item._ the position of the item in the dashboard
---@private
---@class snacks.dashboard.Item._
---@field pane number 1-indexed
---@field row number 1-indexed
---@field col number 0-indexed
---@private
---@class snacks.dashboard.Line
---@field [number] snacks.dashboard.Text
---@field width number
---@private
---@class snacks.dashboard.Block
---@field [number] snacks.dashboard.Line
---@field width number
---@class snacks.dashboard.Config
---@field enabled? boolean
---@field sections snacks.dashboard.Section
---@field formats table<string, snacks.dashboard.Text|fun(item:snacks.dashboard.Item, ctx:snacks.dashboard.Format.ctx):snacks.dashboard.Text>
local defaults = {
width = 60,
row = nil, -- dashboard position. nil for center
col = nil, -- dashboard position. nil for center
pane_gap = 4, -- empty columns between vertical panes
autokeys = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", -- autokey sequence
-- These settings are used by some built-in sections
preset = {
-- Defaults to a picker that supports `fzf-lua`, `telescope.nvim` and `mini.pick`
---@type fun(cmd:string, opts:table)|nil
pick = nil,
-- Used by the `keys` section to show keymaps.
-- Set your custom keymaps here.
-- When using a function, the `items` argument are the default keymaps.
-- stylua: ignore
---@type snacks.dashboard.Item[]
keys = {
{ icon = "", key = "f", desc = "Find File", action = ":lua Snacks.dashboard.pick('files')" },
{ icon = "", key = "n", desc = "New File", action = ":ene | startinsert" },
{ icon = "", key = "g", desc = "Find Text", action = ":lua Snacks.dashboard.pick('live_grep')" },
{ icon = "", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" },
{ icon = "", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" },
{ icon = "", key = "s", desc = "Restore Session", section = "session" },
{ icon = "󰒲 ", key = "L", desc = "Lazy", action = ":Lazy", enabled = package.loaded.lazy ~= nil },
{ icon = "", key = "q", desc = "Quit", action = ":qa" },
},
-- Used by the `header` section
header = [[
███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗
████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║
██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║
██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║
██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝]],
},
-- item field formatters
formats = {
icon = function(item)
if item.file and item.icon == "file" or item.icon == "directory" then
return Snacks.dashboard.icon(item.file, item.icon)
end
return { item.icon, width = 2, hl = "icon" }
end,
footer = { "%s", align = "center" },
header = { "%s", align = "center" },
file = function(item, ctx)
local fname = vim.fn.fnamemodify(item.file, ":~")
fname = ctx.width and #fname > ctx.width and vim.fn.pathshorten(fname) or fname
if #fname > ctx.width then
local dir = vim.fn.fnamemodify(fname, ":h")
local file = vim.fn.fnamemodify(fname, ":t")
if dir and file then
file = file:sub(-(ctx.width - #dir - 2))
fname = dir .. "/…" .. file
end
end
local dir, file = fname:match("^(.*)/(.+)$")
return dir and { { dir .. "/", hl = "dir" }, { file, hl = "file" } } or { { fname, hl = "file" } }
end,
},
sections = {
{ section = "header" },
{ section = "keys", gap = 1, padding = 1 },
{ section = "startup" },
},
debug = false,
}
-- The default style for the dashboard.
-- When opening the dashboard during startup, only the `bo` and `wo` options are used.
-- The other options are used with `:lua Snacks.dashboard()`
Snacks.config.style("dashboard", {
zindex = 10,
height = 0,
width = 0,
bo = {
bufhidden = "wipe",
buftype = "nofile",
buflisted = false,
filetype = "snacks_dashboard",
swapfile = false,
undofile = false,
},
wo = {
colorcolumn = "",
cursorcolumn = false,
cursorline = false,
foldmethod = "manual",
list = false,
number = false,
relativenumber = false,
sidescrolloff = 0,
signcolumn = "no",
spell = false,
statuscolumn = "",
statusline = "",
winbar = "",
winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal",
wrap = false,
},
})
M.ns = vim.api.nvim_create_namespace("snacks_dashboard")
local links = {
Desc = "Special",
File = "Special",
Dir = "NonText",
Footer = "Title",
Header = "Title",
Icon = "Special",
Key = "Number",
Normal = "Normal",
Terminal = "SnacksDashboardNormal",
Special = "Special",
Title = "Title",
}
local hl_groups = {} ---@type table<string, string>
for group in pairs(links) do
hl_groups[group:lower()] = "SnacksDashboard" .. group
end
Snacks.util.set_hl(links, { prefix = "SnacksDashboard", default = true })
---@class snacks.dashboard.Opts: snacks.dashboard.Config
---@field buf? number the buffer to use. If not provided, a new buffer will be created
---@field win? number the window to use. If not provided, a new floating window will be created
---@class snacks.dashboard.Class
---@field opts snacks.dashboard.Opts
---@field buf number
---@field win number
---@field _size? {width:number, height:number}
---@field items snacks.dashboard.Item[]
---@field row? number
---@field col? number
---@field panes? snacks.dashboard.Item[][]
---@field lines? string[]
---@field augroup integer
local D = {}
---@param opts? snacks.dashboard.Opts
---@return snacks.dashboard.Class
function M.open(opts)
local self = setmetatable({}, { __index = D })
self.opts = Snacks.config.get("dashboard", defaults, opts) --[[@as snacks.dashboard.Opts]]
self.buf = self.opts.buf or vim.api.nvim_create_buf(false, true)
self.buf = self.buf == 0 and vim.api.nvim_get_current_buf() or self.buf
self.win = self.opts.win or Snacks.win({ style = "dashboard", buf = self.buf, enter = true }).win --[[@as number]]
self.win = self.win == 0 and vim.api.nvim_get_current_win() or self.win
self.augroup = vim.api.nvim_create_augroup("snacks_dashboard", { clear = true })
self:init()
self:update()
self.fire("Opened")
return self
end
---@param name? string
function D:trace(name)
return self.opts.debug and Snacks.debug.trace(name and ("dashboard:" .. name) or nil)
end
function D:init()
vim.api.nvim_win_set_buf(self.win, self.buf)
vim.o.ei = "all"
Snacks.util.wo(self.win, Snacks.config.styles.dashboard.wo)
Snacks.util.bo(self.buf, Snacks.config.styles.dashboard.bo)
vim.b[self.buf].snacks_main = true
vim.o.ei = ""
if self:is_float() then
vim.keymap.set("n", "<esc>", "<cmd>bd<cr>", { silent = true, buffer = self.buf })
end
vim.keymap.set("n", "q", "<cmd>bd<cr>", { silent = true, buffer = self.buf })
vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, {
group = self.augroup,
callback = function()
-- only re-render if the size has changed
if not vim.deep_equal(self._size, self:size()) then
self:update()
end
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = self.buf,
callback = function()
self.fire("Closed")
vim.api.nvim_del_augroup_by_id(self.augroup)
end,
})
vim.api.nvim_create_autocmd("WinEnter", {
group = self.augroup,
callback = function(ev)
if ev.buf == self.buf and not vim.api.nvim_win_is_valid(self.win) then
self.win = vim.fn.bufwinid(self.buf)
self:update()
end
end,
})
self.on("Update", function()
self:update()
end, self.augroup)
end
---@return {width:number, height:number}
function D:size()
return {
width = vim.api.nvim_win_get_width(self.win),
height = vim.api.nvim_win_get_height(self.win) + (vim.o.laststatus >= 2 and 1 or 0),
}
end
function D:is_float()
return vim.api.nvim_win_get_config(self.win).relative ~= ""
end
---@param action snacks.dashboard.Action
function D:action(action)
-- close the window before running the action if it's floating
if self:is_float() then
vim.api.nvim_win_close(self.win, true)
self.win = nil
end
if type(action) == "string" then
if action:find("^:") then
return vim.cmd(action:sub(2))
else
local keys = vim.api.nvim_replace_termcodes(action, true, true, true)
return vim.api.nvim_feedkeys(keys, "tm", true)
end
end
action(self)
end
---@param item snacks.dashboard.Item
---@param field string
---@param width? number
---@return snacks.dashboard.Text|snacks.dashboard.Text[]
function D:format_field(item, field, width)
if type(item[field]) == "table" then
return item[field]
end
local format = self.opts.formats[field]
if format == nil then
return { item[field], hl = field }
elseif type(format) == "function" then
return format(item, { width = width })
else
local text = format and vim.deepcopy(format) or { "%s" }
text.hl = text.hl or field
text[1] = text[1] == "%s" and item[field] or text[1]:format(item[field])
return text
end
end
---@param item snacks.dashboard.Text|snacks.dashboard.Line
---@param width? number
---@param align? "left"|"center"|"right"
function D:align(item, width, align)
local len = 0
if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text
width, align, len = width or item.width, align or item.align, vim.api.nvim_strwidth(item[1])
else ---@cast item snacks.dashboard.Line
if #item == 1 then -- only one text, so align that instead
self:align(item[1], width, align)
item.width = item[1].width
return
end
len = item.width
end
if not width or width <= 0 or width == len then
item.width = math.max(width or 0, len)
return
end
align = align or "left"
local before = align == "center" and math.floor((width - len) / 2) or align == "right" and width - len or 0
local after = align == "center" and width - len - before or align == "left" and width - len or 0
if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text
item[1] = (" "):rep(before) .. item[1] .. (" "):rep(after)
item.width = math.max(width, len)
else ---@cast item snacks.dashboard.Line
if before > 0 then
table.insert(item, 1, { (" "):rep(before) })
end
if after > 0 then
table.insert(item, { (" "):rep(after) })
end
item.width = math.max(width, len)
end
end
---@param texts snacks.dashboard.Text[]|snacks.dashboard.Text|string
function D:texts(texts)
texts = type(texts) == "string" and { { texts } } or texts
texts = type(texts[1]) == "string" and { texts } or texts
return texts --[[ @as snacks.dashboard.Text[] ]]
end
--- Create a block from a list of texts (possibly with newlines)
---@param texts snacks.dashboard.Text[]
function D:block(texts)
local ret = { { width = 0 }, width = 0 } ---@type snacks.dashboard.Block
for _, text in ipairs(texts) do
-- PERF: only split lines when needed
local lines = text[1]:find("\n", 1, true) and vim.split(text[1], "\n", { plain = true }) or { text[1] }
for l, line in ipairs(lines) do
if l > 1 then
ret[#ret + 1] = { width = 0 }
end
local child = setmetatable({ line }, { __index = text })
self:align(child)
ret[#ret].width = ret[#ret].width + vim.api.nvim_strwidth(child[1])
ret.width = math.max(ret.width, ret[#ret].width)
table.insert(ret[#ret], child)
end
end
return ret
end
---@param item snacks.dashboard.Item
function D:format(item)
local width = item.indent or 0
---@param fields string[]
---@param opts {align?:"left"|"center"|"right", padding?:number, flex?:boolean, multi?:boolean}
local function find(fields, opts)
local flex = opts.flex and math.max(0, self.opts.width - width) or nil
local texts = {} ---@type snacks.dashboard.Text[]
for _, k in ipairs(fields) do
if item[k] then
vim.list_extend(texts, self:texts(self:format_field(item, k, flex)))
if not opts.multi then
break
end
end
end
if #texts == 0 then
return { width = 0 }
end
local block = self:block(texts)
block.width = block.width + (opts.padding or 0)
width = width + block.width
return block
end
local block = item.text and self:block(self:texts(item.text))
local left = block and { width = 0 } or find({ "icon" }, { align = "left", padding = 1 })
local right = block and { width = 0 } or find({ "label", "key" }, { align = "right", padding = 1 })
local center = block or find({ "header", "footer", "title", "desc", "file" }, { flex = true, multi = true })
local padding = self:padding(item)
local ret = { width = self.opts.width } ---@type snacks.dashboard.Block
for l = 1, math.max(#left, #center, #right, 1) + padding[1] do
ret[l] = { width = 0 }
left[l] = left[l] or { width = 0 }
right[l] = right[l] or { width = 0 }
center[l] = center[l] or { width = 0 }
self:align(left[l], left.width, "left")
if item.indent then
self:align(left[l], left[l].width + item.indent, "right")
end
self:align(right[l], right.width, "right")
self:align(center[l], self.opts.width - left[l].width - right[l].width, item.align)
vim.list_extend(ret[l], left[l])
vim.list_extend(ret[l], center[l])
vim.list_extend(ret[l], right[l])
ret[l].width = left[l].width + center[l].width + right[l].width
end
for _ = 1, padding[2] do
table.insert(ret, 1, { width = self.opts.width })
end
return ret
end
---@param item snacks.dashboard.Item
function D:enabled(item)
local e = item.enabled
if type(e) == "function" then
return e(self.opts)
end
return e == nil or e
end
---@param item snacks.dashboard.Section?
---@param results? snacks.dashboard.Item[]
---@param parent? snacks.dashboard.Item
function D:resolve(item, results, parent)
results = results or {}
if not item then
return results
end
if type(item) == "table" and vim.tbl_isempty(item) then
return results
end
if type(item) == "table" and parent then -- inherit parent properties
for _, prop in ipairs({ "indent", "align", "pane" }) do
item[prop] = item[prop] or parent[prop]
end
end
if type(item) == "function" then
return self:resolve(item(self), results, parent)
elseif type(item) == "table" and self:enabled(item) then
if not item.section and not item[1] then
table.insert(results, item)
return results
end
local first_child = #results + 1
if item.section then -- add section items
self:trace("resolve." .. item.section)
local items = M.sections[item.section](item) ---@type snacks.dashboard.Section?
self:resolve(items, results, item)
self:trace()
end
if item[1] then -- add child items
for _, child in ipairs(item) do
self:resolve(child, results, item)
end
end
-- add the title if there are child items
if #results >= first_child and item.title then
table.insert(results, first_child, {
title = item.title,
icon = item.icon,
pane = item.pane,
action = item.action,
key = item.key,
label = item.label,
})
item.action = nil
item.label = nil
item.key = nil
first_child = first_child + 1
end
-- correct first/last taking hidden items into account
local first, last = first_child, #results
for c = first_child, #results do
first = first or not results[c].hidden and c or nil
last = not results[c].hidden and c or last
end
if item.gap then -- add padding between child items
for i = first, last - 1 do
results[i].padding = item.gap
end
end
if item.padding then -- add padding to the first and last child items
local padding = self:padding(item)
if padding[2] > 0 and results[first] then
results[first].padding = { 0, padding[2] }
end
if padding[1] > 0 and results[last] then
results[last].padding = { padding[1], 0 }
end
end
elseif type(item) ~= "table" then
Snacks.notify.error("Invalid item:\n```lua\n" .. vim.inspect(item) .. "\n```", { title = "Dashboard" })
end
return results
end
---@return {[1]: number, [2]: number}
function D:padding(item)
return item.padding and (type(item.padding) == "table" and item.padding or { item.padding, 0 }) or { 0, 0 }
end
function D.fire(event)
vim.api.nvim_exec_autocmds("User", { pattern = "SnacksDashboard" .. event, modeline = false })
end
---@param event string|string[]
---@param cb fun()
---@param group? string|integer
function D.on(event, cb, group)
vim.api.nvim_create_autocmd("User", { pattern = "SnacksDashboard" .. event, callback = cb, group = group })
end
---@param pos {[1]:number, [2]:number}
---@param from? {[1]:number, [2]:number}
function D:find(pos, from)
from = from or pos
local line = self.lines[pos[1]]
local char = vim.fn.charidx(line, pos[2]) -- map col to charachter index
local pane = math.floor((char - self.col) / (self.opts.width + self.opts.pane_gap)) + 1
pane = math.max(1, math.min(pane, #self.panes))
if pos[1] == from[1] then
if pos[2] == from[2] - 1 then
pane = pane - 1
elseif pos[2] == from[2] + 1 then
pane = pane + 1
end
end
pane = math.max(1, math.min(pane, #self.panes))
local ret ---@type snacks.dashboard.Item?
for _, item in ipairs(self.items) do
if item._ and item._.pane == pane and item.action then
if ret and pos[1] < from[1] and item._.row > pos[1] then
break
end
ret = item
if pos[1] >= from[1] and item._.row >= pos[1] then
break
end
end
end
return ret
end
-- Layout in panes
function D:layout()
local max_panes =
math.max(1, math.floor((self._size.width + self.opts.pane_gap) / (self.opts.width + self.opts.pane_gap)))
self.panes = {} ---@type snacks.dashboard.Item[][]
for _, item in ipairs(self.items) do
if not item.hidden then
local pane = item.pane or 1
pane = math.fmod(pane - 1, max_panes) + 1 -- distribute panes evenly
self.panes[pane] = self.panes[pane] or {}
table.insert(self.panes[pane], item)
end
end
for p = 1, math.max(unpack(vim.tbl_keys(self.panes))) or 1 do
self.panes[p] = self.panes[p] or {}
end
end
-- Format and render the dashboard
function D:render()
-- horizontal position
self.col = self.opts.col
or math.floor(self._size.width - (self.opts.width * #self.panes + self.opts.pane_gap * (#self.panes - 1))) / 2
self.lines = {} ---@type string[]
local extmarks = {} ---@type {row:number, col:number, opts:vim.api.keyset.set_extmark}[]
for p, pane in ipairs(self.panes) do
local indent = (" "):rep(p == 1 and self.col or self.opts.pane_gap)
local row = 0
for _, item in ipairs(pane or {}) do
for l, line in ipairs(self:format(item)) do
row = row + 1
if p > 1 and not self.lines[row] then -- add lines for empty panes
self.lines[row] = (" "):rep(self.col + (self.opts.width + self.opts.pane_gap) * (p - 1))
elseif p == 1 and line.width > self.opts.width then
self.lines[row] = (" "):rep(self.col - math.floor((line.width - self.opts.width) / 2))
else
self.lines[row] = (self.lines[row] or "") .. indent
end
if l == 1 then
item._ = { pane = p, row = row, col = #self.lines[row] - 1 }
end
---@cast line snacks.dashboard.Line
for _, text in ipairs(line) do
self.lines[row] = self.lines[row] .. text[1]
if text.hl then
table.insert(extmarks, {
row = row - 1,
col = #self.lines[row] - #text[1],
opts = { hl_group = hl_groups[text.hl] or text.hl, end_col = #self.lines[row] },
})
end
end
end
end
end
-- vertical position
self.row = self.opts.row or math.max(math.floor((self._size.height - #self.lines) / 2), 0)
for _ = 1, self.row do
table.insert(self.lines, 1, "")
end
-- fix item positions
for _, item in ipairs(self.items) do
if item._ then
item._.row = item._.row + self.row
if item.render then
item.render(self, { item._.row, item._.col })
end
end
end
self:render_buf(extmarks)
end
---@param extmarks {row:number, col:number, opts:vim.api.keyset.set_extmark}[]
function D:render_buf(extmarks)
-- set lines
vim.bo[self.buf].modifiable = true
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, self.lines)
vim.bo[self.buf].modifiable = false
-- extmarks
vim.api.nvim_buf_clear_namespace(self.buf, M.ns, 0, -1)
for _, extmark in ipairs(extmarks) do
vim.api.nvim_buf_set_extmark(self.buf, M.ns, extmark.row + self.row, extmark.col, extmark.opts)
end
end
function D:keys()
local autokeys = self.opts.autokeys:gsub("[hjklq]", "")
for _, item in ipairs(self.items) do
if item.key and not item.autokey then
autokeys = autokeys:gsub(vim.pesc(item.key), "", 1)
end
end
for _, item in ipairs(self.items) do
if item.autokey then
item.key, autokeys = autokeys:sub(1, 1), autokeys:sub(2)
end
if item.key then
vim.keymap.set("n", item.key, function()
self:action(item.action)
end, { buffer = self.buf, nowait = not item.autokey, desc = "Dashboard action" })
end
end
end
function D:update()
if not (self.buf and vim.api.nvim_buf_is_valid(self.buf)) then
return
end
self.fire("UpdatePre")
self._size = self:size()
self.items = self:resolve(self.opts.sections)
self:layout()
self:keys()
self:render()
-- actions on enter
vim.keymap.set("n", "<cr>", function()
local item = self:find(vim.api.nvim_win_get_cursor(self.win))
return item and item.action and self:action(item.action)
end, { buffer = self.buf, nowait = true, desc = "Dashboard action" })
-- cursor movement
local last = { 1, 0 }
local function update_cursor()
local item = self:find(vim.api.nvim_win_get_cursor(self.win), last)
-- can happen for panes without actionable items
item = item or vim.tbl_filter(function(it)
return it.action and it._
end, self.items)[1]
if item then
local col = self.lines[item._.row]:find("[%w%d%p]", item._.col + 1)
col = col or (item._.col + 1 + (item.indent and (item.indent + 1) or 0))
last = { item._.row, (col or item._.col + 1) - 1 }
end
vim.api.nvim_win_set_cursor(self.win, last)
end
vim.api.nvim_create_autocmd("CursorMoved", {
group = vim.api.nvim_create_augroup("snacks_dashboard_cursor", { clear = true }),
buffer = self.buf,
callback = update_cursor,
})
update_cursor()
self.fire("UpdatePost")
end
-- Get an icon
---@param name string
---@param cat? string
---@return snacks.dashboard.Text
function M.icon(name, cat)
local icon, hl = Snacks.util.icon(name, cat)
return { icon or "", hl = hl or "icon", width = 2 }
end
-- Used by the default preset to pick something
---@param cmd? string
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, 2, table.remove(try, #try))
end
for _, fn in ipairs(try) do
if pcall(fn) then
return
end
end
Snacks.notify.error("No picker found for " .. cmd)
end
-- Checks if the plugin is installed.
-- Only works with [lazy.nvim](https://github.com/folke/lazy.nvim)
---@param name string
function M.have_plugin(name)
return package.loaded.lazy and require("lazy.core.config").spec.plugins[name] ~= nil
end
---@param opts? {filter?: table<string, boolean>}
---@return fun():string?
function M.oldfiles(opts)
opts = vim.tbl_deep_extend("force", {
filter = {
[vim.fn.stdpath("data")] = false,
[vim.fn.stdpath("cache")] = false,
[vim.fn.stdpath("state")] = false,
},
}, opts or {})
---@cast opts {filter:table<string, boolean>}
local filter = {} ---@type {path:string, want:boolean}[]
for path, want in pairs(opts.filter or {}) do
table.insert(filter, { path = svim.fs.normalize(path), want = want })
end
local done = {} ---@type table<string, boolean>
local i = 1
local oldfiles = vim.v.oldfiles
return function()
while oldfiles[i] do
local file = svim.fs.normalize(oldfiles[i], { _fast = true, expand_env = false })
local want = not done[file]
if want then
done[file] = true
for _, f in ipairs(filter) do
local matches = file:sub(1, #f.path) == f.path and
(file:len() == #f.path or file:sub(#f.path + 1, #f.path + 1) == "/")
if matches ~= f.want then
want = false
break
end
end
end
i = i + 1
if want and uv.fs_stat(file) then
return file
end
end
end
end
M.sections = {}
-- Adds a section to restore the session if any of the supported plugins are installed.
---@param item? snacks.dashboard.Item
---@return snacks.dashboard.Item?
function M.sections.session(item)
local plugins = {
{ "persistence.nvim", ":lua require('persistence').load()" },
{ "persisted.nvim", ":lua require('persisted').load()" },
{ "neovim-session-manager", ":SessionManager load_current_dir_session" },
{ "possession.nvim", ":PossessionLoadCwd" },
{ "mini.sessions", ":lua require('mini.sessions').read()" },
{ "mini.nvim", ":lua require('mini.sessions').read()" },
{ "auto-session", ":SessionRestore" },
}
for _, plugin in pairs(plugins) do
if M.have_plugin(plugin[1]) then
return setmetatable({ -- add the action and disable the section
action = plugin[2],
section = false,
}, { __index = item })
end
end
end
--- Get the most recent files, optionally filtered by the
--- current working directory or a custom directory.
---@param opts? {limit?:number, cwd?:string|boolean, filter?:fun(file:string):boolean?}
---@return snacks.dashboard.Gen
function M.sections.recent_files(opts)
return function()
opts = opts or {}
local limit = opts.limit or 5
local root = opts.cwd and svim.fs.normalize(opts.cwd == true and vim.fn.getcwd() or opts.cwd) or ""
local ret = {} ---@type snacks.dashboard.Section
for file in M.oldfiles({ filter = { [root] = true } }) do
if not opts.filter or opts.filter(file) then
ret[#ret + 1] = {
file = file,
icon = "file",
action = ":e " .. vim.fn.fnameescape(file),
autokey = true,
}
if #ret >= limit then
break
end
end
end
return ret
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.
--- Use `opts.dirs` to provide a list of directories to use instead of the git roots.
---@param opts? {limit?:number, dirs?:(string[]|fun():string[]), pick?:boolean, session?:boolean, action?:fun(dir)}
function M.sections.projects(opts)
opts = vim.tbl_extend("force", { pick = true, session = true }, opts or {})
local limit = opts.limit or 5
local dirs = opts.dirs or {}
dirs = type(dirs) == "function" and dirs() or dirs --[[ @as string[] ]]
dirs = vim.list_slice(dirs, 1, limit)
if not opts.dirs then
for file in M.oldfiles() do
local dir = Snacks.git.get_root(file)
if dir and not vim.tbl_contains(dirs, dir) then
table.insert(dirs, dir)
if #dirs >= limit then
break
end
end
end
end
local ret = {} ---@type snacks.dashboard.Item[]
for _, dir in ipairs(dirs) do
ret[#ret + 1] = {
file = dir,
icon = "directory",
action = function(self)
if opts.action then
return opts.action(dir)
end
vim.fn.chdir(dir)
local session = M.sections.session()
-- stylua: ignore
if opts.session and session then
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 and opts.pick then M.pick() end end, 100)
self:action(session.action)
elseif opts.pick then
M.pick()
end
end,
autokey = true,
}
end
return ret
end
---@return snacks.dashboard.Gen
function M.sections.header()
return function(self)
return { header = self.opts.preset.header, padding = 2 }
end
end
---@return snacks.dashboard.Gen
function M.sections.keys()
return function(self)
return vim.deepcopy(self.opts.preset.keys)
end
end
---@param opts {cmd:string|string[], ttl?:number, height?:number, width?:number, random?:number}|snacks.dashboard.Item
---@return snacks.dashboard.Gen
function M.sections.terminal(opts)
return function(self)
local cmd = opts.cmd or 'echo "No `cmd` provided"'
local ttl = opts.ttl or 3600
local height = opts.height or 10
local width = opts.width
if not width then
width = self.opts.width - (opts.indent or 0)
end
local cache_parts = {
table.concat(type(cmd) == "table" and cmd or { cmd }, " "),
uv.cwd(),
opts.random and math.random(1, opts.random) or "",
}
local hashed_cache_key = vim.fn.sha256(table.concat(cache_parts, "."))
local cache_dir = vim.fn.stdpath("cache") .. "/snacks"
local cache_file = cache_dir .. "/" .. hashed_cache_key .. ".txt"
local stat = uv.fs_stat(cache_file)
local buf = vim.api.nvim_create_buf(false, true)
local chan = vim.api.nvim_open_term(buf, {})
local function send(data, refresh)
vim.api.nvim_chan_send(chan, data)
if refresh then
-- HACK: this forces a refresh of the terminal buffer and prevents flickering
vim.bo[buf].scrollback = 9999
vim.bo[buf].scrollback = 9998
end
end
local jid, stopped ---@type number?, boolean?
local has_cache = stat and stat.type == "file" and stat.size > 0
local is_expired = has_cache and stat and os.time() - stat.mtime.sec >= ttl
if has_cache and stat then
local fin = assert(uv.fs_open(cache_file, "r", 438))
send(uv.fs_read(fin, stat.size, 0) or "", true)
uv.fs_close(fin)
end
if not has_cache or is_expired then
local output, recording = {}, assert(uv.new_timer())
-- record output for max 5 seconds. otherwise assume its streaming
recording:start(5000, 0, function()
output = {}
end)
local first = true
jid = vim.fn.jobstart(cmd, {
height = height,
width = width,
pty = true,
on_stdout = function(_, data)
data = table.concat(data, "\n")
local termenv = {
["\27%]11;%?\27\\"] = function() -- OSC 11
local rgb = (vim.o.background == "light") and "ffff/ffff/ffff" or "0000/0000/0000"
return "\x1b]11;rgb:" .. rgb .. "\x1b\\"
end,
["\27%[6n"] = function() -- CSI 6 n
return "\x1b[1;" .. tostring(width) .. "R"
end,
}
for seq, repl in pairs(termenv) do
if data:find(seq) then
pcall(vim.fn.chansend, jid, repl())
data = data:gsub(seq, "")
end
end
if data == "" then
return
end
if recording:is_active() then
table.insert(output, data)
end
if first and has_cache then -- clear the screen if cache was expired
first = false
data = "\27[2J\27[H" .. data -- clear screen
end
pcall(send, data)
end,
on_exit = function(_, code)
if not recording:is_active() or stopped then
return
end
if 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, ""))
)
)
elseif ttl > 0 then -- save the output
vim.fn.mkdir(cache_dir, "p")
local fout = assert(uv.fs_open(cache_file, "w", 438))
uv.fs_write(fout, table.concat(output, ""))
uv.fs_close(fout)
end
end,
})
if jid <= 0 then
Snacks.notify.error(("Failed to start terminal **cmd** `%s`"):format(cmd))
end
end
return {
action = not opts.title and opts.action or nil,
key = not opts.title and opts.key or nil,
label = not opts.title and opts.label or nil,
render = function(_, pos)
self:trace("terminal.render")
local win = vim.api.nvim_open_win(buf, false, {
bufpos = { pos[1] - 1, pos[2] + 1 },
col = opts.indent or 0,
focusable = false,
height = height,
noautocmd = true,
relative = "win",
row = 0,
zindex = Snacks.config.styles.dashboard.zindex + 1,
style = "minimal",
width = width,
win = self.win,
border = "none",
})
local hl = opts.hl and hl_groups[opts.hl] or opts.hl or "SnacksDashboardTerminal"
Snacks.util.wo(win, { winhighlight = "TermCursorNC:" .. hl .. ",NormalFloat:" .. hl })
Snacks.util.bo(buf, { filetype = Snacks.config.styles.dashboard.bo.filetype })
local close = vim.schedule_wrap(function()
stopped = true
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
pcall(vim.fn.jobstop, jid)
return true
end)
self.on("UpdatePre", close, self.augroup)
self.on("Closed", close, self.augroup)
self:trace()
end,
text = ("\n"):rep(height - 1),
}
end
end
--- Add the startup section
---@param opts? {icon?:string}
---@return snacks.dashboard.Section?
function M.sections.startup(opts)
opts = opts or {}
M.lazy_stats = M.lazy_stats and M.lazy_stats.startuptime > 0 and M.lazy_stats or require("lazy.stats").stats()
local ms = (math.floor(M.lazy_stats.startuptime * 100 + 0.5) / 100)
local icon = opts.icon or ""
return {
align = "center",
text = {
{ icon .. "Neovim loaded ", hl = "footer" },
{ M.lazy_stats.loaded .. "/" .. M.lazy_stats.count, hl = "special" },
{ " plugins in ", hl = "footer" },
{ ms .. "ms", hl = "special" },
},
}
end
M.status = {
did_setup = false,
opened = false,
reason = nil, ---@type string?
}
--- Check if the dashboard should be opened
function M.setup()
local explorer = Snacks.config.get("explorer", defaults).enabled == true
M.status.did_setup = true
local buf = 1
local skip = false
if explorer and vim.fn.argc(-1) == 1 then
local arg = vim.fn.argv(0) --[[@as string]]
if arg ~= "" and vim.fn.isdirectory(arg) == 1 then
skip = true
end
end
-- don't open the dashboard if there are any arguments
if not skip and vim.fn.argc(-1) > 0 then
M.status.reason = "argc(-1) > 0"
return
end
-- don't open dashboard if Neovim was invoked for example `nvim +'Octo issue edit 1'`
if not skip and vim.api.nvim_buf_get_name(0) ~= "" then
M.status.reason = "buffer has a name"
return
end
-- there should be only one non-floating window and it should be the first buffer
local wins = vim.tbl_filter(function(win)
local b = vim.api.nvim_win_get_buf(win)
return vim.api.nvim_win_get_config(win).relative == "" and not vim.bo[b].filetype:find("snacks")
end, vim.api.nvim_list_wins())
if #wins ~= 1 then
M.status.reason = "more than one non-floating window"
return
elseif vim.api.nvim_win_get_buf(wins[1]) ~= buf then
M.status.reason = "window does not contain the first buffer"
return
end
if vim.bo[buf].modified then
M.status.reason = "buffer is modified"
return
end
local uis = vim.api.nvim_list_uis()
-- check for headless
if #uis == 0 then
M.status.reason = "headless"
return
end
-- don't open the dashboard if in TUI and input is piped
if uis[1].stdout_tty and not uis[1].stdin_tty then
M.status.reason = "stdin is not a tty"
return
end
-- don't open the dashboard if there is any text in the buffer
if vim.api.nvim_buf_line_count(buf) > 1 or #(vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") > 0 then
M.status.reason = "buffer is not empty"
return
end
M.status.opened = true
if Snacks.config.dashboard.debug then
Snacks.debug.tracemod("dashboard", M)
Snacks.debug.tracemod("dashboard", D, ":")
end
local options = { showtabline = vim.o.showtabline, laststatus = vim.o.laststatus }
vim.o.showtabline, vim.o.laststatus = 0, 0
local dashboard = M.open({ buf = buf, win = wins[1] })
D.on("Closed", function()
for k, v in pairs(options) do
if vim.o[k] == 0 and v ~= 0 then
vim.o[k] = v
end
end
end, dashboard.augroup)
if Snacks.config.dashboard.debug then
Snacks.debug.stats({ min = 0.2 })
end
end
-- Update the dashboard
function M.update()
D.fire("Update")
end
function M.health()
if Snacks.config.dashboard.enabled then
if M.status.did_setup then
Snacks.health.ok("setup ran")
if M.status.opened then
Snacks.health.ok("dashboard opened")
else
Snacks.health.warn("dashboard did not open: `" .. M.status.reason .. "`")
end
else
Snacks.health.error("setup did not run")
end
local modnames = { "alpha", "dashboard", "mini.starter" }
for _, modname in ipairs(modnames) do
if package.loaded[modname] then
Snacks.health.error("`" .. modname .. "` conflicts with `Snacks.dashboard`")
end
end
end
end
M.Dashboard = D
return M