From 416e895b0e154a06ad0b3c68acdd90ca8257e983 Mon Sep 17 00:00:00 2001 From: Roel de Cort Date: Tue, 28 Jul 2026 16:56:11 +0200 Subject: [PATCH] feat(nixvim): modernize startup dashboard Signed-off-by: Roel de Cort --- README.md | 2 +- config/auto_cmds.nix | 16 +-- config/default.nix | 2 +- config/plugins/editor/illuminate.nix | 1 + config/plugins/editor/indent-blankline.nix | 1 + config/plugins/git/codediff.nix | 31 +++-- config/plugins/ui/dashboard.nix | 126 +++++++++++++++++++++ config/plugins/ui/lualine.nix | 6 +- config/plugins/ui/startup.nix | 82 -------------- config/plugins/ui/vcs_status.lua | 102 ++++++++++++++++- config/plugins/ui/workspace_dashboard.lua | 75 ++++++++++++ 11 files changed, 332 insertions(+), 112 deletions(-) create mode 100644 config/plugins/ui/dashboard.nix delete mode 100644 config/plugins/ui/startup.nix create mode 100644 config/plugins/ui/workspace_dashboard.lua diff --git a/README.md b/README.md index 6cedff33..ef71420a 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ home-manager.users..home.packages = [ - `bufferline.nix`: Configures the Bufferline plugin for enhanced buffer/tab display. - `lualine.nix`: Configures Lualine with Git, Jujutsu, and configured-forge context. -- `startup.nix`: Configures the startup screen. +- `dashboard.nix`: Configures a project-aware Snacks dashboard for review, task, and navigation workflows. ### LSP diff --git a/config/auto_cmds.nix b/config/auto_cmds.nix index dab6cd30..3c363286 100644 --- a/config/auto_cmds.nix +++ b/config/auto_cmds.nix @@ -1,7 +1,6 @@ { autoGroups = { highlight_yank = { }; - vim_enter = { }; indentscope = { }; restore_cursor = { }; }; @@ -19,25 +18,12 @@ ''; }; } - { - group = "vim_enter"; - event = [ "VimEnter" ]; - pattern = "*"; - callback = { - __raw = '' - function() - vim.cmd('Startup') - end - ''; - }; - } { group = "indentscope"; event = [ "FileType" ]; pattern = [ "help" - "Startup" - "startup" + "snacks_dashboard" "neo-tree" "Trouble" "trouble" diff --git a/config/default.nix b/config/default.nix index e8b61ff9..ee04dcef 100644 --- a/config/default.nix +++ b/config/default.nix @@ -30,7 +30,7 @@ _: { # UI plugins ./plugins/ui/bufferline.nix ./plugins/ui/lualine.nix - ./plugins/ui/startup.nix + ./plugins/ui/dashboard.nix # LSP and formatting ./plugins/lsp/lsp.nix diff --git a/config/plugins/editor/illuminate.nix b/config/plugins/editor/illuminate.nix index 3822c01c..c48d4194 100644 --- a/config/plugins/editor/illuminate.nix +++ b/config/plugins/editor/illuminate.nix @@ -9,6 +9,7 @@ "alpha" "harpoon" "reason" + "snacks_dashboard" ]; }; }; diff --git a/config/plugins/editor/indent-blankline.nix b/config/plugins/editor/indent-blankline.nix index 1282a9a3..dd075967 100644 --- a/config/plugins/editor/indent-blankline.nix +++ b/config/plugins/editor/indent-blankline.nix @@ -2,6 +2,7 @@ plugins = { indent-blankline = { enable = true; + settings.exclude.filetypes = [ "snacks_dashboard" ]; }; }; } diff --git a/config/plugins/git/codediff.nix b/config/plugins/git/codediff.nix index d64edd79..e094d6c3 100644 --- a/config/plugins/git/codediff.nix +++ b/config/plugins/git/codediff.nix @@ -16,8 +16,7 @@ let return result.code == 0 end - local function review_base() - local root = git_root() + local function review_base(root) local result = vim.system( { "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD" }, { cwd = root, text = true } @@ -40,22 +39,40 @@ let end local function with_review_base(callback) - local base = review_base() + local root = git_root() + local base = review_base(root) if not base then vim.notify("Could not determine the default review branch", vim.log.levels.ERROR) return end - callback(base) + callback(base, root) end vim.api.nvim_create_user_command("CodeReviewBranch", function() with_review_base(function(base) - vim.cmd("CodeDiff " .. vim.fn.fnameescape(base .. "...HEAD")) + vim.cmd("CodeDiff " .. vim.fn.fnameescape(base .. "...")) end) - end, { desc = "Review branch changes against its merge base" }) + end, { desc = "Review branch and working-tree changes against their merge base" }) vim.api.nvim_create_user_command("CodeReviewHistory", function() - with_review_base(function(base) + with_review_base(function(base, root) + local result = vim.system( + { "git", "rev-list", "--count", base .. "..HEAD" }, + { cwd = root, text = true } + ):wait() + local count = result.code == 0 and tonumber(vim.trim(result.stdout or "")) or nil + + if count == 0 then + vim.notify( + "No branch commits yet; uncommitted changes are available under Review branch (D)", + vim.log.levels.INFO + ) + return + elseif count == nil then + vim.notify("Could not determine branch history", vim.log.levels.ERROR) + return + end + vim.cmd("CodeDiff history " .. vim.fn.fnameescape(base .. "..HEAD") .. " --reverse") end) end, { desc = "Review branch commits in chronological order" }) diff --git a/config/plugins/ui/dashboard.nix b/config/plugins/ui/dashboard.nix new file mode 100644 index 00000000..3322248b --- /dev/null +++ b/config/plugins/ui/dashboard.nix @@ -0,0 +1,126 @@ +{ lib, ... }: +{ + extraFiles."lua/workspace_dashboard.lua".source = ./workspace_dashboard.lua; + + plugins.snacks = { + enable = true; + settings.dashboard = { + enabled = true; + width = 94; + preset.header = '' + ██████╗░███████╗░█████╗░░█████╗░██████╗░████████╗░░░████████╗███████╗░█████╗░██╗░░██╗ + ██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝░░░╚══██╔══╝██╔════╝██╔══██╗██║░░██║ + ██║░░██║█████╗░░██║░░╚═╝██║░░██║██████╔╝░░░██║░░░░░░░░░██║░░░█████╗░░██║░░╚═╝███████║ + ██║░░██║██╔══╝░░██║░░██╗██║░░██║██╔══██╗░░░██║░░░░░░░░░██║░░░██╔══╝░░██║░░██╗██╔══██║ + ██████╔╝███████╗╚█████╔╝╚█████╔╝██║░░██║░░░██║░░░██╗░░░██║░░░███████╗╚█████╔╝██║░░██║ + ╚═════╝░╚══════╝░╚════╝░░╚════╝░╚═╝░░╚═╝░░░╚═╝░░░╚═╝░░░╚═╝░░░╚══════╝░╚════╝░╚═╝░░╚═╝ + ''; + sections = lib.nixvim.mkRaw '' + (function() + local row = require("workspace_dashboard").row + + local function heading(title) + return { + align = "center", + padding = 1, + text = { { title, hl = "title" } }, + } + end + + return { + { section = "header" }, + function() + return require("workspace_dashboard").summary() + end, + + heading("Review & Run"), + row("󰕚", "d", "Review working tree", ":CodeDiff"), + row("󰜘", "D", "Review branch", ":CodeReviewBranch"), + row("󰄉", "h", "Review branch history", ":CodeReviewHistory"), + row("󰑮", "t", "Run project task", ":OverseerRun"), + + heading("Navigate"), + row("", "f", "Find file", ":Telescope find_files"), + row("", "/", "Find text", ":Telescope live_grep"), + row("", "e", "File explorer", ":Neotree toggle"), + row("󰧑", "b", "SecondBrain", function() + require("telescope.builtin").find_files({ + cwd = vim.fn.expand("~/projects/personal/SecondBrain"), + }) + end), + + heading("Continue"), + function() + return require("workspace_dashboard").recent_files() + end, + + (function() + local quit = row("", "q", "Quit", ":qa") + quit.padding = { 0, 1 } + return quit + end)(), + } + end)() + ''; + }; + }; + + extraConfigLua = lib.mkAfter '' + local dashboard_group = vim.api.nvim_create_augroup("dashboard_chrome", { clear = true }) + local previous_laststatus + + local function hide_dashboard_statusline() + if vim.bo.filetype ~= "snacks_dashboard" then + return + end + + if previous_laststatus == nil then + previous_laststatus = vim.o.laststatus + end + vim.o.laststatus = 0 + vim.wo.statusline = "" + vim.b.miniindentscope_disable = true + end + + local function restore_statusline() + if previous_laststatus == nil then + return + end + + vim.o.laststatus = previous_laststatus + previous_laststatus = nil + end + + vim.api.nvim_create_autocmd({ "FileType", "BufEnter", "WinEnter" }, { + group = dashboard_group, + pattern = "*", + callback = hide_dashboard_statusline, + }) + + -- Snacks temporarily suppresses normal buffer events while creating the + -- dashboard, so its lifecycle event owns the initial chrome update. + vim.api.nvim_create_autocmd("User", { + group = dashboard_group, + pattern = { "SnacksDashboardOpened", "SnacksDashboardUpdatePost" }, + callback = hide_dashboard_statusline, + }) + + vim.api.nvim_create_autocmd("BufLeave", { + group = dashboard_group, + pattern = "*", + callback = function() + if vim.bo.filetype ~= "snacks_dashboard" then + return + end + + restore_statusline() + end, + }) + + vim.api.nvim_create_autocmd("User", { + group = dashboard_group, + pattern = "SnacksDashboardClosed", + callback = restore_statusline, + }) + ''; +} diff --git a/config/plugins/ui/lualine.nix b/config/plugins/ui/lualine.nix index bc54b2a0..2d7033c6 100644 --- a/config/plugins/ui/lualine.nix +++ b/config/plugins/ui/lualine.nix @@ -11,13 +11,13 @@ "fzf" "neo-tree" ]; - disabledFiletypes = { + disabled_filetypes = { statusline = [ - "startup" "alpha" + "snacks_dashboard" ]; }; - theme = "catppuccin"; + theme = "catppuccin-nvim"; }; sections = { lualine_a = [ diff --git a/config/plugins/ui/startup.nix b/config/plugins/ui/startup.nix deleted file mode 100644 index f3464e4a..00000000 --- a/config/plugins/ui/startup.nix +++ /dev/null @@ -1,82 +0,0 @@ -{ - plugins.startup = { - enable = true; - settings = { - colors = { - background = "#ffffff"; - folded_section = "#ffffff"; - }; - - header = { - type = "text"; - oldfiles_directory = false; - align = "center"; - fold_section = false; - title = "Header"; - margin = 5; - content = [ - " ██████╗░███████╗░█████╗░░█████╗░██████╗░████████╗░░░████████╗███████╗░█████╗░██╗░░██╗" - " ██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝░░░╚══██╔══╝██╔════╝██╔══██╗██║░░██║" - " ██║░░██║█████╗░░██║░░╚═╝██║░░██║██████╔╝░░░██║░░░░░░░░░██║░░░█████╗░░██║░░╚═╝███████║" - " ██║░░██║██╔══╝░░██║░░██╗██║░░██║██╔══██╗░░░██║░░░░░░░░░██║░░░██╔══╝░░██║░░██╗██╔══██║" - " ██████╔╝███████╗╚█████╔╝╚█████╔╝██║░░██║░░░██║░░░██╗░░░██║░░░███████╗╚█████╔╝██║░░██║" - " ╚═════╝░╚══════╝░╚════╝░░╚════╝░╚═╝░░╚═╝░░░╚═╝░░░╚═╝░░░╚═╝░░░╚══════╝░╚════╝░╚═╝░░╚═╝" - ]; - highlight = "Statement"; - default_color = ""; - oldfiles_amount = 0; - }; - - body = { - type = "mapping"; - oldfiles_directory = false; - align = "center"; - fold_section = false; - title = "Menu"; - margin = 5; - content = [ - [ - " Find File" - "Telescope find_files" - "ff" - ] - [ - "󰍉 Find Word" - "Telescope live_grep" - "fr" - ] - [ - " Recent Files" - "Telescope oldfiles" - "fg" - ] - [ - " File Browser" - "Telescope file_browser" - "fe" - ] - [ - "󰧑 SecondBrain" - "edit ~/projects/personal/SecondBrain" - "sb" - ] - ]; - highlight = "string"; - default_color = ""; - oldfiles_amount = 0; - }; - - options = { - paddings = [ - 1 - 3 - ]; - }; - - parts = [ - "header" - "body" - ]; - }; - }; -} diff --git a/config/plugins/ui/vcs_status.lua b/config/plugins/ui/vcs_status.lua index d217c85d..595dc895 100644 --- a/config/plugins/ui/vcs_status.lua +++ b/config/plugins/ui/vcs_status.lua @@ -21,8 +21,16 @@ local function buffer_root() return root end -local function refresh_lualine() +local function refresh_ui() vim.schedule(function() + if vim.bo.filetype == "snacks_dashboard" then + local dashboard_ok, dashboard = pcall(require, "snacks.dashboard") + if dashboard_ok then + dashboard.update() + end + return + end + local ok, lualine = pcall(require, "lualine") if ok then lualine.refresh({ place = { "statusline" } }) @@ -34,7 +42,7 @@ local function finish(entry) entry.pending = entry.pending - 1 if entry.pending == 0 then entry.updated_at = now_ms() - refresh_lualine() + refresh_ui() end end @@ -54,6 +62,41 @@ local function parse_jj(entry, result) finish(entry) end +local function parse_git(entry, result) + if result.code ~= 0 then + entry.git = nil + finish(entry) + return + end + + local status = { + branch = "", + changed = 0, + conflicted = 0, + ahead = 0, + behind = 0, + } + + for line in (result.stdout or ""):gmatch("[^\r\n]+") do + local branch = line:match("^# branch%.head (.+)$") + local ahead, behind = line:match("^# branch%.ab %+(%d+) %-(%d+)$") + if branch and branch ~= "(detached)" then + status.branch = branch + elseif ahead and behind then + status.ahead = tonumber(ahead) or 0 + status.behind = tonumber(behind) or 0 + elseif line:match("^[12u?] ") then + status.changed = status.changed + 1 + if line:match("^u ") then + status.conflicted = status.conflicted + 1 + end + end + end + + entry.git = status + finish(entry) +end + local forge_definitions = { { label = "GH", patterns = { "github", "github.com" } }, { label = "GL", patterns = { "gitlab", "gitlab.com" } }, @@ -84,9 +127,10 @@ local function refresh(root, entry) end local is_jj = vim.uv.fs_stat(root .. "/.jj") ~= nil - entry.pending = is_jj and 2 or 1 + entry.pending = 2 if is_jj then + entry.git = nil vim.system({ "jj", "--ignore-working-copy", @@ -115,6 +159,13 @@ local function refresh(root, entry) end) else entry.jj = nil + vim.system( + { "git", "-C", root, "status", "--porcelain=v2", "--branch" }, + { text = true }, + function(result) + parse_git(entry, result) + end + ) vim.system({ "git", "-C", root, "remote", "--verbose" }, { text = true }, function(result) parse_forges(entry, result) end) @@ -132,6 +183,7 @@ local function current_entry() entry = { pending = 0, updated_at = 0, + git = nil, jj = nil, forges = "", } @@ -174,6 +226,50 @@ function M.forges() return entry and entry.forges or "" end +function M.context() + local root = buffer_root() + if not root then + return vim.fs.basename(vim.uv.cwd() or "") + end + + local entry = current_entry() + local parts = { vim.fs.basename(root) } + + if entry and entry.jj then + table.insert(parts, "JJ " .. entry.jj.change_id) + if entry.jj.bookmarks ~= "" then + table.insert(parts, entry.jj.bookmarks) + end + if entry.jj.conflicted then + table.insert(parts, "conflicted") + end + elseif entry and entry.git then + table.insert(parts, entry.git.branch ~= "" and entry.git.branch or "Git") + if entry.git.changed == 0 then + table.insert(parts, "clean") + else + table.insert(parts, entry.git.changed .. " changed") + end + if entry.git.conflicted > 0 then + table.insert(parts, entry.git.conflicted .. " conflicted") + end + if entry.git.ahead > 0 then + table.insert(parts, "↑" .. entry.git.ahead) + end + if entry.git.behind > 0 then + table.insert(parts, "↓" .. entry.git.behind) + end + else + table.insert(parts, "Git") + end + + if entry and entry.forges ~= "" then + table.insert(parts, entry.forges) + end + + return table.concat(parts, " | ") +end + local group = vim.api.nvim_create_augroup("vcs_statusline", { clear = true }) vim.api.nvim_create_autocmd({ "BufEnter", "BufFilePost" }, { diff --git a/config/plugins/ui/workspace_dashboard.lua b/config/plugins/ui/workspace_dashboard.lua new file mode 100644 index 00000000..e756c86d --- /dev/null +++ b/config/plugins/ui/workspace_dashboard.lua @@ -0,0 +1,75 @@ +local M = {} + +local icon_width = 4 +local label_width = 52 + +function M.row(icon, key, label, action, label_highlight) + local icon_text + if type(icon) == "table" then + icon_text = vim.tbl_extend("force", {}, icon, { width = icon_width }) + else + icon_text = { icon, hl = "icon", width = icon_width } + end + + return { + align = "center", + key = key, + action = action, + text = { + icon_text, + { label, hl = label_highlight or "desc", width = label_width }, + { key, hl = "key" }, + }, + } +end + +local function root() + local cwd = vim.uv.cwd() + return (cwd and vim.fs.root(cwd, { ".jj", ".git" })) or cwd +end + +function M.summary() + return { + align = "center", + padding = 1, + text = { { require("vcs_status").context(), hl = "file" } }, + } +end + +function M.recent_files() + local items = {} + local repository_root = root() + local oldfiles_opts = repository_root and { filter = { [repository_root] = true } } or nil + + for file in Snacks.dashboard.oldfiles(oldfiles_opts) do + local number = #items + 1 + local display = (repository_root and vim.fs.relpath(repository_root, file)) + or vim.fn.fnamemodify(file, ":~") + + if #display > 50 then + display = vim.fn.pathshorten(display) + end + if #display > 50 then + display = "…" .. display:sub(-49) + end + + table.insert( + items, + M.row( + Snacks.dashboard.icon(file, "file"), + tostring(number), + display, + ":edit " .. vim.fn.fnameescape(file), + "file" + ) + ) + + if #items == 4 then + break + end + end + + return items +end + +return M