--- AstroCore configuration (AstroNvim v6 / Neovim 0.12) --- Core settings, keymappings, and autocmds --- @module plugins.astrocore local ignore_patterns = require "config.ignore_patterns" local file_filter = ignore_patterns.create_picker_filter() -- : close the window; only quit (saving all) when it's the last one local function close_win() local ok, err = pcall(vim.cmd.close) if ok then return end -- E444: last window; E5601: only floating windows would remain if not tostring(err):find "E444" and not tostring(err):find "E5601" then error(err, 0) end vim.cmd "wqa" end -- : save the current buffer only if it has unsaved changes, then close it local function save_close_buf() if vim.bo.buftype == "" then vim.cmd "update" end require("snacks").bufdelete() end -- e: two-stage find-by-extension. Stage 1 scans the repo with fd -- (respects .gitignore) and shows every file extension ranked by count; -- stage 2 opens fff prefilled with a `*. ` glob constraint so the -- rest of the query fuzzy-matches within that extension. local function find_files_by_extension() local snacks = require "snacks" local root = snacks.git.get_root() or vim.uv.cwd() local files = vim.fn.systemlist { "fd", "--type", "f", "--base-directory", root } if vim.v.shell_error ~= 0 then vim.notify("fd failed to list files in " .. root, vim.log.levels.ERROR) return end local counts = {} for _, path in ipairs(files) do -- last dot of the basename; skips dotfiles (.gitignore) and bare names local ext = path:match "[^/]%.([%w_%-]+)$" if ext then counts[ext] = (counts[ext] or 0) + 1 end end local items = {} for ext, count in pairs(counts) do items[#items + 1] = { text = ext, ext = ext, count = count } end if #items == 0 then vim.notify("No file extensions found in " .. root, vim.log.levels.WARN) return end table.sort(items, function(a, b) if a.count ~= b.count then return a.count > b.count end return a.ext < b.ext end) snacks.picker { title = "File Extensions", layout = { preset = "select" }, items = items, format = function(item) return { { ("%-12s"):format(item.ext), "SnacksPickerLabel" }, { ("%d file%s"):format(item.count, item.count == 1 and "" or "s"), "SnacksPickerComment" }, } end, confirm = function(picker, item) picker:close() if not item then return end require("fff").find_files { cwd = root, title = "*." .. item.ext .. " files", query = "*." .. item.ext .. " " } end, } end -- x: every URL in the current buffer, in file order; confirm hands the -- URL to vim.ui.open (xdg-open). Reuses astrocore's url_matcher so the -- picker agrees with the highlighturl feature about what counts as a URL. local function find_buffer_urls() local buf = vim.api.nvim_get_current_buf() local re = vim.regex(require("astrocore").url_matcher) local items = {} for lnum, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do local offset = 0 while offset < #line do -- match_str returns 0-indexed byte offsets relative to the substring local s, e = re:match_str(line:sub(offset + 1)) if not s then break end local url = line:sub(offset + s + 1, offset + e) items[#items + 1] = { text = url, url = url, buf = buf, pos = { lnum, offset + s }, lnum = lnum } offset = offset + e end end if #items == 0 then vim.notify("No URLs found in buffer", vim.log.levels.WARN) return end require("snacks").picker { title = "Buffer URLs", items = items, format = function(item) return { { ("%4d "):format(item.lnum), "SnacksPickerRow" }, { item.url, "SnacksPickerLink" }, } end, confirm = function(picker, item) picker:close() if not item then return end vim.ui.open(item.url) end, } end -- m: files under the git root whose mtime is today (local midnight -- onward). fd's --changed-within accepts a YYYY-MM-DD date as "since". local function find_files_modified_today() local snacks = require "snacks" snacks.picker.files { title = "Modified today", cwd = snacks.git.get_root() or vim.uv.cwd(), cmd = "fd", args = { "--changed-within", os.date "%Y-%m-%d" }, transform = file_filter, } end return { "AstroNvim/astrocore", ---@type AstroCoreOpts opts = { sessions = { -- cwd autosave is handled by the git_branch_sessions autocmd below so -- the session name carries the worktree/branch, see config.session autosave = { last = true, cwd = false, }, ignore = { dirs = {}, filetypes = { "gitcommit", "gitrebase" }, buftypes = {}, }, }, rooter = { detector = { "lsp", { ".git", "_darcs", ".hg", ".bzr", ".svn" }, { "package.json", "lua", "Cargo.toml", "Pipfile", "docs.json", }, }, ignore = { -- golangci_lint_ls roots at the nearest go.mod, not the repo servers = { "golangci_lint_ls" }, dirs = {}, }, autochdir = true, scope = "global", notify = false, }, -- v6: features schema changed -- diagnostics_mode (0-3) -> diagnostics = { virtual_text, virtual_lines } -- large_buf and cmp are new keys features = { large_buf = { size = 1024 * 256, lines = 10000 }, autopairs = true, cmp = true, diagnostics = { virtual_text = false, virtual_lines = false }, highlighturl = true, notifications = false, }, -- vim.diagnostic.config() values applied when diagnostics are toggled on diagnostics = { virtual_text = true, underline = true, }, autocmds = { custom_title = { { event = { "BufEnter", "DirChanged" }, desc = "Update terminal title with org/repo format", callback = function() local filepath = vim.fn.expand "%:p" local filename = vim.fn.expand "%:t" if filename == "" then local cwd = vim.fn.getcwd() local github_match = cwd:match "github%.com/([^/]+/[^/]+)" if github_match then vim.opt.titlestring = github_match else vim.opt.titlestring = "Nvim" end return end local github_match = filepath:match "github%.com/([^/]+/[^/]+)" if github_match then vim.opt.titlestring = string.format("%s | %s", filename, github_match) else vim.opt.titlestring = string.format("%s | Nvim", filename) end end, }, }, git_branch_sessions = { { event = "VimLeavePre", desc = "Save the worktree/branch directory session on close", callback = vim.schedule_wrap(function() if require("astrocore.buffer").is_valid_session() then require("config.session").save { notify = false } end end), }, { event = "VimEnter", desc = "Show neo-tree; restore worktree/branch session, else open the git files picker", nested = true, -- trigger other autocommands as buffers open -- no-op when nvim was started with file arguments, see config.session callback = function() require("config.session").restore() end, }, }, }, options = { opt = { relativenumber = false, number = true, spell = false, wrap = true, clipboard = "unnamedplus", title = true, titlestring = "", laststatus = 0, -- no statusline under the last window }, g = {}, }, mappings = { n = { [""] = { close_win, desc = "Close window (quit if last)" }, [""] = { "suspend", desc = "Suspend nvim (return with 'fg')" }, ["ZZ"] = { "wqa", desc = "Save all buffers and quit Neovim" }, [""] = { "wqa", desc = "Save all buffers and quit Neovim" }, [""] = { save_close_buf, desc = "Save (if modified) and close buffer" }, ["F"] = { "za", desc = "Toggle fold under cursor" }, -- v6: vim.diagnostic.goto_next/goto_prev deprecated in 0.11, use jump() ["J"] = { function() vim.diagnostic.jump { count = 1, float = true } end, desc = "Next diagnostic", }, ["K"] = { function() vim.diagnostic.jump { count = -1, float = true } end, desc = "Previous diagnostic", }, ["L"] = { "BufferLineCycleNext", desc = "Next buffer" }, ["H"] = { "BufferLineCyclePrev", desc = "Previous buffer" }, ["e"] = { function() require("snacks").picker.git_files { transform = file_filter } end, desc = "Find git files", }, -- AstroNvim's dirsession maps key off the bare cwd; use the -- worktree/branch-aware name instead ["SS"] = { function() require("config.session").save() end, desc = "Save this dirsession", }, ["S."] = { function() require("config.session").load() end, desc = "Load current dirsession", }, [""] = { function() require("snacks").picker.smart() end, desc = "Find files", }, ["b"] = { "BufferLinePick", desc = "Pick buffer" }, ["f"] = { function() require("snacks").picker.buffers() end, desc = "Find buffers", }, [""] = { function() require("snacks").bufdelete.other() end, desc = "Close all buffers except the current one", }, ["g"] = { function() local groups = { "DEX", "DID", "DWN", "SVC", "Tests", "Actions", "Claude", "Docs", "Config" } for _, group in ipairs(groups) do vim.cmd("BufferLineGroupToggle " .. group) end end, desc = "Toggle all buffer groups", }, ["l"] = { function() require("snacks").picker.lines() end, desc = "Find in line", }, ["o"] = { function() require("snacks").gitbrowse() end, desc = "Open Repo on Web" }, [""] = { function() if vim.bo.modified and vim.bo.buftype == "" then vim.cmd "write" else require("snacks").picker.buffers() end end, desc = "Save if modified, else find buffers", }, [""] = { "w", desc = "Save buffer" }, [""] = { "tabnext", desc = "Next Tab" }, [""] = { function() require("snacks").picker.gh_issue() end, desc = "GitHub issues (open)", }, ["I"] = { function() require("snacks").picker.gh_issue { state = "all" } end, desc = "GitHub issues (all)", }, [""] = { function() require("snacks").picker.gh_pr() end, desc = "GitHub PRs (open)", }, ["P"] = { function() require("snacks").picker.gh_pr { state = "all" } end, desc = "GitHub PRs (all)", }, [""] = { function() require("snacks").lazygit() end, desc = "Toggle lazygit (float)", }, [""] = { function() require("snacks").terminal.toggle("lazydocker", { win = { style = "float" } }) end, desc = "Toggle lazydocker (float)", }, -- grug-far.nvim: buffer-based find & replace (plugins/grug-far.lua). -- `open` reads the visual selection itself, so the same callback -- serves both modes. [""] = { function() require("grug-far").open() end, desc = "Find & replace (grug-far)" }, -- which-key prefix group labels [""] = { desc = "Git + GitHub" }, [""] = { desc = "Find" }, [""] = { desc = "Buffers" }, [""] = { desc = "Assist" }, -- ghq project switcher ["P"] = { function() local repos = vim.fn.systemlist "ghq list -p" vim.ui.select(repos, { prompt = "Select repository" }, function(dir) if not dir then return end vim.cmd.cd(dir) require("snacks").picker.files() end) end, desc = "Find ghq projects", }, [""] = { "!gh release list", desc = "List Releases" }, ["d"] = { function() require("snacks").picker.git_diff() end, desc = "Search git diffs", }, [""] = { function() require("snacks").picker.git_branches() end, desc = "Search git branches", }, ["vv"] = { "gg0VG$", desc = "Select all contents in buffer" }, ["T"] = { "gg", desc = "Go to top of file" }, ["d"] = { function() require("snacks").picker.diagnostics() end, desc = "Find diagnostics", }, ["l"] = { function() require("snacks").picker.lines() end, desc = "Find in line", }, -- NOTE: and are the same key at the mapping level, so -- this also shadows (jumplist-forward) in normal mode [""] = { function() require("config.templates").pick() end, desc = "Find templates", }, ["w"] = { function() require("fff").live_grep() end, desc = "Live grep", }, ["z"] = { function() require("fff").live_grep { grep = { modes = { "fuzzy", "plain" } } } end, desc = "Live fuzzy grep", }, [""] = { function() require("fff").live_grep_under_cursor() end, desc = "Grep word under cursor", }, ["f"] = { function() require("fff").find_files() end, desc = "Find files (cwd)", }, ["m"] = { find_files_modified_today, desc = "Find files modified today", }, ["x"] = { find_buffer_urls, desc = "Find URLs in buffer (open in browser)", }, ["r"] = { function() require("fff").resume() end, desc = "Resume last find/grep", }, ["."] = { function() require("fff").find_files_in_dir(vim.fn.expand "%:p:h") end, desc = "Find files in buffer directory", }, ["e"] = { find_files_by_extension, desc = "Find files by extension", }, ["o"] = { function() require("snacks").picker.recent() end, desc = "Find recent files", }, ["p"] = { function() require("snacks").picker.projects() end, desc = "Find projects", }, ["gc"] = { function() local ai_dir = vim.fn.getcwd() .. "/.ai" if vim.fn.isdirectory(ai_dir) == 0 then vim.notify("No .ai directory found in current project", vim.log.levels.WARN) return end require("fff").find_files_in_dir(ai_dir) end, desc = "Find AI files", }, ["g"] = { desc = "Find by Group" }, ["gw"] = { function() local workflows_dir = vim.fn.getcwd() .. "/.github/workflows" if vim.fn.isdirectory(workflows_dir) == 0 then vim.notify("No .github/workflows directory found in current project", vim.log.levels.WARN) return end require("fff").find_files_in_dir(workflows_dir) end, desc = "Find GitHub Workflow files", }, ["gd"] = { function() require("fff").find_files { title = "Devbox/Docker files", query = "{devbox.json,process-compose.yaml,Dockerfile,docker-compose.yaml,compose.yaml} ", } end, desc = "Find Devbox/Docker files", }, ["gm"] = { function() require("fff").find_files { title = "Makefiles", query = "*Makefile* " } end, desc = "Find Makefile files", }, ["gp"] = { function() require("fff").find_files { title = "package.json files", query = "*package.json " } end, desc = "Find Package.json files", }, [""] = { function() if vim.b.toggle_number == 99 then vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "t", false) return end local content = vim.fn.getreg "+" vim.api.nvim_paste(content, true, -1) end, desc = "Paste from system clipboard", }, -- LSP-scoped assist binds (a/s/S) are capability-gated in -- plugins/astrolsp.lua; diagnostics exist without a server, so this -- one stays global ["d"] = { function() require("config.picker").with_trouble "diagnostics" end, desc = "Search diagnostics ( sends to Trouble)", }, [""] = { "SupermavenToggle", desc = "Toggle Supermaven" }, [""] = { function() require("config.omp_term").toggle() end, desc = "Toggle Oh My Pi" }, -- u toggles are mapped through Snacks.toggle in -- config.toggles (called from polish.lua) so which-key renders their -- enabled/disabled state ["u"] = { desc = "UI Toggles" }, }, i = { [""] = { "wa", desc = "Save buffer, and return to normal mode" }, [""] = { function() vim.cmd "stopinsert" save_close_buf() end, desc = "Save (if modified), close buffer, and return to normal mode", }, [""] = { function() vim.cmd "stopinsert" vim.cmd "wa" require("snacks").bufdelete() end, desc = "Save, close buffer, and return to normal mode", }, }, v = { [""] = { save_close_buf, desc = "Save (if modified) and close buffer" }, [""] = { function() vim.cmd "w" require("snacks").bufdelete() end, desc = "Save and close buffer", }, [""] = { function() require("fff").live_grep_under_cursor() end, desc = "Grep selection", }, -- charwise (v) selection prefills the search input, linewise (V) -- limits search & replace to the selected range (visualSelectionUsage -- = "auto-detect" in plugins/grug-far.lua) [""] = { function() require("grug-far").open() end, desc = "Find & replace selection (grug-far)", }, ["D"] = { function() vim.cmd 'normal! "vy' local selection = vim.fn.getreg "v" local escaped = vim.fn.escape(selection, [[/\.*$^~[]]) vim.cmd(string.format([[%%s/%s//ge]], escaped)) vim.notify(string.format("Deleted all occurrences of: %s", selection), vim.log.levels.INFO) end, desc = "Delete all occurrences of selected text", }, ["R"] = { function() vim.cmd 'normal! "vy' local selection = vim.fn.getreg "v" local escaped = vim.fn.escape(selection, [[/\.*$^~[]]) require("snacks").input({ prompt = string.format("Replace '%s' with:", selection), default = "", }, function(replacement) if replacement then local escaped_replacement = vim.fn.escape(replacement, [[/\&~]]) vim.cmd(string.format([[%%s/%s/%s/ge]], escaped, escaped_replacement)) vim.notify(string.format("Replaced '%s' with '%s'", selection, replacement), vim.log.levels.INFO) end end) end, desc = "Replace all occurrences of selected text", }, }, t = { [""] = { function() vim.cmd 'normal! "+p' end, desc = "Paste from system clipboard" }, ["f"] = { function() require("snacks").picker.buffers() end, desc = "Find buffers", }, [""] = { function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes([[]], true, false, true), "n", false) close_win() end, desc = "Exit terminal and close window (quit if last)", }, }, }, }, }