snacks.nvim/lua/snacks/dashboard.lua
2024-11-18 15:19:00 +01:00

987 lines
32 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,
})
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 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 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
-- 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 },
{ icon = "", key = "q", desc = "Quit", action = ":qa" },
},
-- Used by the `header` section
header = [[
███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗
████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║
██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║
██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║
██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝]],
},
-- item field formatters
formats = {
icon = function(item, sss)
if item.file and item.icon == "file" or item.icon == "directory" then
return M.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
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.6,
width = 0.6,
bo = {
bufhidden = "wipe",
buftype = "nofile",
filetype = "snacks_dashboard",
swapfile = false,
undofile = false,
},
wo = {
cursorcolumn = false,
cursorline = false,
list = false,
number = false,
relativenumber = false,
sidescrolloff = 0,
signcolumn = "no",
spell = false,
statuscolumn = "",
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[]
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:trace("buf/win")
self.buf = self.opts.buf or vim.api.nvim_create_buf(false, true)
self.win = self.opts.win or Snacks.win({ style = "dashboard", buf = self.buf, enter = true }).win --[[@as number]]
self:trace() -- buf/win
self:trace("dashboard")
self:trace("init")
self:init()
self:trace() -- init
self:update()
self:trace() -- dashboard
if self.opts.debug then
Snacks.debug.stats({ min = 0.2 })
end
return self
end
---@param name? string
function D:trace(name)
return self.opts.debug and Snacks.debug.trace(name)
end
function D:init()
if self.opts.debug then -- track initial load of icon provider
local icon = M.icon
M.icon = function(...)
self:trace("icon-provider")
local ret = icon(...)
self:trace()
M.icon = icon
return ret
end
end
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.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", {
buffer = self.buf,
callback = function(ev)
-- only re-render if the same window and size has changed
if tonumber(ev.match) == self.win and not vim.deep_equal(self._size, self:size()) then
self:update()
end
end,
})
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
vim.schedule(function()
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)
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 setmetatable({ format[1] }, { __index = 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
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 = width
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 = width
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 = self.opts.width }
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])
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 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.title then -- always add the title
table.insert(results, { title = item.title, icon = item.icon, pane = item.pane })
end
if item.section then -- add section items
self:trace(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
if item.gap then -- add padding between child items
for i = first_child, #results - 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_child] then
results[first_child].padding = { 0, padding[2] }
end
if padding[1] > 0 and results[#results] then
results[#results].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)
self:trace(event)
vim.api.nvim_exec_autocmds("User", { pattern = "SnacksDashboard" .. event, modeline = false })
self:trace()
end
function D:on(event, cb)
vim.api.nvim_create_autocmd("User", { pattern = "SnacksDashboard" .. event, callback = cb })
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._.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()
self:trace("layout")
local max_panes = 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
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
for p = 1, math.max(unpack(vim.tbl_keys(self.panes))) or 1 do
self.panes[p] = self.panes[p] or {}
end
self:trace()
end
-- Format and render the dashboard
function D:render()
self:trace("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:trace("format")
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))
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
self:trace() -- format
-- 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:trace("lines/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
self:trace() -- lines/extmarks
self:trace() -- render
end
function D:keys()
self:trace("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(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
self:trace()
end
function D:update()
self:trace("update")
self:fire("UpdatePre")
self._size = self:size()
self:trace("items")
self.items = self:resolve(self.opts.sections)
self:trace() -- items
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 }
vim.api.nvim_create_autocmd("CursorMoved", {
group = vim.api.nvim_create_augroup("snacks_dashboard_cursor", { clear = true }),
buffer = self.buf,
callback = function()
local item = self:find(vim.api.nvim_win_get_cursor(self.win), last)
if not item then -- can happen for panes without actionable items
for _, it in ipairs(self.items) do
if it.action then
item = it
break
end
end
end
if item then
last = { item._.row, (self.lines[item._.row]:find("[%w%d%p]", item._.col + 1) or item._.col + 1) - 1 }
end
vim.api.nvim_win_set_cursor(self.win, last)
end,
})
self:fire("UpdatePost")
self:trace() -- update
end
--- Check if the dashboard should be opened
function M.setup()
local buf = 1
-- don't open the dashboard if there are any arguments
if vim.fn.argc() > 0 then
return
end
-- there should be only one non-floating window and it should be the first buffer
local wins = vim.tbl_filter(function(win)
return vim.api.nvim_win_get_config(win).relative == ""
end, vim.api.nvim_list_wins())
if #wins ~= 1 or vim.api.nvim_win_get_buf(wins[1]) ~= buf then
return
end
-- don't open the dashboard if input is piped
if uv.guess_handle(3) == "pipe" then
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
return
end
M.open({ buf = buf, win = wins[1] })
end
-- Get an icon
---@param name string
---@param cat? string
---@return snacks.dashboard.Text
function M.icon(name, cat)
-- stylua: ignore
local try = {
function() return require("mini.icons").get(cat or "file", name) end,
function() return require("nvim-web-devicons").get_icon(name) end,
}
for _, fn in ipairs(try) do
local ok, icon, hl = pcall(fn)
if ok then
return { icon, hl = hl, width = 2 }
end
end
return { "", hl = "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)
-- 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,
}
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
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"] = ":SessionLoad",
["neovim-session-manager"] = ":SessionManager load_current_dir_session",
["possession.nvim"] = ":PossessionLoadCwd",
}
for name, action in pairs(plugins) do
if M.have_plugin(name) then
return setmetatable({ -- add the action and disable the section
action = action,
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}
---@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 vim.fs.normalize(opts.cwd == true and vim.fn.getcwd() or opts.cwd) or ""
local ret = {} ---@type snacks.dashboard.Section
for _, file in ipairs(vim.v.oldfiles) do
file = vim.fs.normalize(file, { _fast = true, expand_env = false })
if file:sub(1, #root) == root and uv.fs_stat(file) then
ret[#ret + 1] = {
file = file,
icon = "file",
action = ":e " .. 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 ipairs(vim.v.oldfiles) do
file = vim.fs.normalize(file, { _fast = true, expand_env = false })
local dir = uv.fs_stat(file) and 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
-- stylua: ignore
if opts.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)
end
vim.fn.chdir(dir)
local session = M.sections.session()
if opts.session and session then
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 width, height = opts.width or self.opts.width, opts.height or 10
local cache_parts = {
table.concat(type(cmd) == "table" and cmd or { cmd }, " "),
uv.cwd(),
opts.random and math.random(1, opts.random) or "",
"txt",
}
local cache_dir = vim.fn.stdpath("cache") .. "/snacks"
local cache_file = cache_dir .. "/" .. table.concat(cache_parts, "."):gsub("[^%w%-_%.]", "_")
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 render(data)
local lines = vim.split(data, "\n")
for l = 1, math.min(height, #lines) do
vim.api.nvim_chan_send(chan, (l > 1 and "\n" or "") .. lines[l])
end
-- HACK: this forces a refresh of the terminal buffer and prevents flickering
vim.bo[buf].scrollback = 1
end
if stat and stat.type == "file" and stat.size > 0 and os.time() - stat.mtime.sec < ttl then
local fin = assert(uv.fs_open(cache_file, "r", 438))
render(uv.fs_read(fin, stat.size, 0) or "")
uv.fs_close(fin)
else
local output = {}
vim.fn.jobstart(cmd, {
height = height,
width = width,
pty = true,
on_stdout = function(_, data)
table.insert(output, table.concat(data, "\n"))
end,
on_exit = function(_, code)
render(table.concat(output, ""))
if code == 0 and ttl > 0 then
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,
})
end
return {
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,
})
local hl = opts.hl and hl_groups[opts.hl] or opts.hl or "SnacksDashboardTerminal"
Snacks.util.wo(win, { winhighlight = "NormalFloat:" .. hl })
local close = vim.schedule_wrap(function()
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
return true
end)
vim.api.nvim_create_autocmd("BufWipeout", { buffer = self.buf, callback = close })
self:on("UpdatePre", close)
self:trace()
end,
text = ("\n"):rep(height - 1),
}
end
end
--- Add the startup section
---@return snacks.dashboard.Section?
function M.sections.startup()
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)
return {
align = "center",
text = {
{ "⚡ Neovim loaded ", hl = "footer" },
{ M.lazy_stats.loaded .. "/" .. M.lazy_stats.count, hl = "special" },
{ " plugins in ", hl = "footer" },
{ ms .. "ms", hl = "special" },
},
}
end
return M