---@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 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 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 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 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", "", "bd", { silent = true, buffer = self.buf }) end vim.keymap.set("n", "q", "bd", { 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", "", 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) -- 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, }) 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} ---@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} 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 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 if (file:sub(1, #f.path) == f.path) ~= 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()" }, } 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") 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, }) 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