diff --git a/README.md b/README.md index 3a8a87d..6f8797f 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,45 @@ place), so all branches that were on the machine stay available. Pass The snacks explorer (``) shows a chip in its title with the current project, branch, and worktree count when inside a git repo. +### Title bar + +`lua/config/title.lua` keeps the terminal / OS window title pointed at the +project you are in, and the Vim window title alongside it: + +``` +Desi Explorer # project name only +Desi Explorer 󰐘 feat-api # project + branch +Desi Explorer wt-explorer 󰐘 feat-api # project + worktree + branch +``` + +The **project name** is resolved in this order: + +1. the `name` field of a `.nvim-project.json` in the directory nvim was opened + in (or the root of its worktree), +2. the name of the worktree layout's home directory, so every worktree of a + repo shares one project name, +3. the name of the directory itself, when there is no git repo at all. + +To name a project explicitly, commit this to its root: + +```json +{ "name": "Desi Explorer" } +``` + +The **worktree** segment only appears when you are in a linked worktree (it is +the path relative to the layout home, with the `.worktrees/` hop dropped). The +**branch** segment is replaced by `detached` on a detached HEAD, and by `bare` +when nvim sits in a bare home with no working tree checked out. Both are omitted +outside a git repo. + +The title refreshes on `DirChanged`, `FocusGained`, `VimResume` and on +`User GitSignsUpdate`, which covers `:cd`, `gw` worktree switches, +neogit, lazygit and `:Git branch` without polling. `:RefreshTitle` forces a +recompute. + +Note: lualine's `title` component is left off, so the window title is available +to anything reading it but is not printed in the statusline. + ### Search / Symbols (`s`) | Key | Action | @@ -321,6 +360,7 @@ project, branch, and worktree count when inside a git repo. │ │ ├── options.lua # Neovim options │ │ ├── keymaps.lua # Custom keymaps │ │ ├── git-worktree.lua # Worktree-aware branch switching + convert +│ │ ├── title.lua # Title bar: project name, worktree, branch │ │ └── autocmds.lua # Autocommands (yank hl, ft tweaks, SQL no-autoformat…) │ ├── plugins/ │ │ ├── mason.lua # Tooling installs (LSP/linters/formatters/DAP) @@ -375,6 +415,8 @@ project, branch, and worktree count when inside a git repo. - **Ctrl+Shift keys not firing:** some terminals don't transmit them; use the plain-Ctrl fallbacks (`` / `` in the explorer). - **Nerd Font icons missing:** install a Nerd Font and set it in your terminal. + The title bar uses nerd font glyphs too, so it shows boxes without one — edit + `M.config.icons` in `lua/config/title.lua` to `""` for plain text. ## Known issues (in current repo) diff --git a/lua/config/lazy.lua b/lua/config/lazy.lua index 8f16d26..359c2be 100644 --- a/lua/config/lazy.lua +++ b/lua/config/lazy.lua @@ -70,3 +70,6 @@ require("lazy").setup({ require("config.options") require("config.keymaps") require("config.autocmds") + +-- Terminal / window title (project, worktree, branch) +require("config.title").setup() diff --git a/lua/config/options.lua b/lua/config/options.lua index ce289d3..30d4bc9 100644 --- a/lua/config/options.lua +++ b/lua/config/options.lua @@ -46,6 +46,12 @@ opt.timeoutlen = 300 -- Faster key sequence completion -- Completion opt.completeopt = "menu,menuone,noselect" +-- Title bar +opt.title = true -- Set the terminal / OS window title +-- Cheap seed so the title is never blank during startup; `config.title` replaces +-- it on VimEnter with the project name, worktree and branch. +opt.titlestring = vim.fn.fnamemodify(vim.fn.getcwd(), ":t") + -- Folding (using treesitter) opt.foldmethod = "expr" opt.foldexpr = "nvim_treesitter#foldexpr()" diff --git a/lua/config/title.lua b/lua/config/title.lua new file mode 100644 index 0000000..d965a70 --- /dev/null +++ b/lua/config/title.lua @@ -0,0 +1,253 @@ +-- Window & terminal title: project name, worktree and git branch. +-- +-- The project name is resolved in this order: +-- +-- 1. the `name` field of `.nvim-project.json`, looked up in the directory +-- nvim was opened in and then in the root of its worktree, so a project +-- can name itself instead of showing `desi_explorer-main`; +-- 2. the name of the worktree layout's home directory, so every worktree of +-- a repo shares one project name; +-- 3. the name of the directory itself, when there is no git repo at all. +-- +-- The worktree and branch segments only appear inside a git repo. In the +-- bare-home layout (see `config.git-worktree`) there is no working tree in the +-- home, so it reports `bare` instead of a branch. +-- +-- The result is pushed to the terminal / OS title bar ('title' + 'titlestring') +-- and to every window's title, and recomputed whenever the directory or the +-- branch may have changed. The git lookups spawn subprocesses, so the first +-- title is computed on VimEnter rather than during startup. + +local M = {} + +-- 'title' and 'titlestring' were window-local until Neovim 0.12 promoted them +-- to global options. Read the scope once so this works on either: on 0.12+ the +-- global assignment above is enough, older builds also need each window set. +local WINDOW_LOCAL = vim.api.nvim_get_option_info2("titlestring", {}).scope == "win" + +M.config = { + -- Per-project override, e.g. { "name": "Desi Explorer" } + file = ".nvim-project.json", + -- Generated via nr2char so the raw PUA code points never hit the file, the + -- same trick `config.git-worktree` uses. + icons = { + worktree = vim.fn.nr2char(0xeb17), -- source-branch glyph + branch = vim.fn.nr2char(0xf0418), -- branch glyph + }, + labels = { + detached = "detached", -- HEAD is a commit, not a ref + bare = "bare", -- the layout home, with no working tree checked out + }, +} + +-- ───────────────────────────────────────────────────────────────────────────── +-- Path helpers +-- ───────────────────────────────────────────────────────────────────────────── + +---Absolute path with any trailing slashes removed. +---@param path string +---@return string +local function norm(path) + local p = vim.fn.fnamemodify(path, ":p"):gsub("/+$", "") + -- "/" reduces to "" once the slashes are stripped, and an empty cwd makes the + -- git plumbing fail with ENOENT + return p ~= "" and p or "/" +end + +---Display name for a directory. +---@param path string normalised path +---@return string +local function dir_name(path) + local name = vim.fs.basename(path) + -- basename is useless for "/" (and empty), so fall back to the path itself + return (name == "" or name == "/" or name == ".") and path or name +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Project name +-- ───────────────────────────────────────────────────────────────────────────── + +-- A malformed config file is reported once per session rather than on every +-- recompute (FocusGained alone would otherwise repeat it on every alt-tab). +local warned = {} + +---Read the `name` from a `.nvim-project.json`, or nil when it is absent or has +---no usable `name`. +---@param path string +---@return string? +local function read_project_file(path) + if vim.fn.filereadable(path) ~= 1 then + return nil + end + local ok, data = pcall(vim.json.decode, table.concat(vim.fn.readfile(path), "\n")) + if not ok or type(data) ~= "table" or type(data.name) ~= "string" or vim.trim(data.name) == "" then + if not warned[path] then + warned[path] = true + vim.notify(('%s has no usable "name" — falling back to the directory name'):format(path), vim.log.levels.WARN) + end + return nil + end + return vim.trim(data.name) +end + +---Display name for `cwd`, preferring the project's own config file. +---@param cwd? string defaults to nvim's cwd +---@param root? string git worktree root of `cwd` (resolved when omitted) +---@param home? string worktree-layout home of `cwd` (resolved when omitted) +---@return string +function M.project_name(cwd, root, home) + cwd = norm(cwd or vim.uv.cwd()) + if root == nil and home == nil then + local wt = require("config.git-worktree") + root, home = wt.git_root(cwd), wt.home(cwd) + end + -- cwd before root: in a plain checkout they are the same directory, and in a + -- linked worktree cwd is the more specific of the two + for _, dir in ipairs({ cwd, root }) do + if dir then + local name = read_project_file(vim.fs.joinpath(norm(dir), M.config.file)) + if name then + return name + end + end + end + return dir_name(home or cwd) +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Title +-- ───────────────────────────────────────────────────────────────────────────── + +---Label for the worktree `cwd` sits in, relative to the layout home. Nil when +---`cwd` is the main checkout or the bare home, since there is then nothing to +---distinguish it from. +---@param cwd string normalised path +---@param home? string worktree-layout home, if any +---@return string? +local function worktree_label(cwd, home) + if not home then + return nil + end + cwd, home = norm(cwd), norm(home) + if cwd == home then + return nil + end + if cwd:sub(1, #home + 1) ~= home .. "/" then + return dir_name(cwd) -- outside the layout; keep the title short + end + -- Linked worktrees live under `/.worktrees/` (bare home) or + -- `/.worktrees//` (plain checkout, where `home` is + -- the main checkout) — drop the `.worktrees/` hop to leave the branch. + local rel = cwd:sub(#home + 2):gsub("^%.worktrees/", "") + return rel ~= "" and rel or dir_name(cwd) +end + +---Title for `cwd`: the project name, plus worktree and branch in a git repo. +---@param cwd? string defaults to nvim's cwd +---@return string +function M.title(cwd) + cwd = norm(cwd or vim.uv.cwd()) + local parts = {} + + -- The git plumbing spawns subprocesses with cwd, which raises ENOENT when the + -- directory is gone — a worktree deleted underneath nvim, or `:cd` into a + -- directory that was removed. Degrade to the directory name instead of + -- letting the error escape an autocmd callback. + if vim.uv.fs_stat(cwd) then + local wt = require("config.git-worktree") + local root, home = wt.git_root(cwd), wt.home(cwd) + parts[1] = M.project_name(cwd, root, home) + + ---Icon + text, collapsing to the bare text when the icon is empty. + local function chip(icon, text) + return icon ~= "" and (icon .. " " .. text) or text + end + + local worktree = worktree_label(cwd, home) + if worktree then + parts[#parts + 1] = chip(M.config.icons.worktree, worktree) + end + + if root then + parts[#parts + 1] = chip(M.config.icons.branch, wt.current_branch(cwd) or M.config.labels.detached) + elseif home and norm(home) == cwd then + -- sitting in the layout home with no working tree checked out + parts[#parts + 1] = chip(M.config.icons.branch, M.config.labels.bare) + end + else + parts[1] = dir_name(cwd) + end + + return table.concat(parts, " ") +end + +---Push the title into the terminal / OS title bar and into every window. +function M.apply() + local title = M.title() + vim.opt.titlestring = title + vim.opt.title = true + if WINDOW_LOCAL then + for _, win in ipairs(vim.api.nvim_list_wins()) do + vim.wo[win].titlestring = title + vim.wo[win].title = true + end + end + pcall(vim.fn.settitle) -- emit the escape sequence now instead of at the next redraw +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Setup +-- ───────────────────────────────────────────────────────────────────────────── + +---Register the refresh triggers and compute the first title. +function M.setup() + local group = vim.api.nvim_create_augroup("config_title", { clear = true }) + + local function refresh() + M.apply() + end + + -- cwd changes (`:cd`, the explorer, worktree switches). FocusGained also + -- catches branch switches made outside nvim, e.g. in lazygit or a side + -- terminal. + vim.api.nvim_create_autocmd({ "DirChanged", "FocusGained", "VimResume" }, { + group = group, + callback = refresh, + }) + + -- gitsigns re-reads HEAD whenever it refreshes, which covers `:Git branch`, + -- neogit and lazygit without polling — and it fires when a tracked + -- `.nvim-project.json` is written. + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "GitSignsUpdate", + callback = refresh, + }) + + -- 'title'/'titlestring' only need per-window work on older Neovim; newer + -- builds inherit the global value, and covering WinNew anyway keeps splits + -- opened before the first refresh from being missed + if WINDOW_LOCAL then + vim.api.nvim_create_autocmd("WinNew", { + group = group, + callback = function(ev) + vim.wo[ev.buf].title = true + vim.wo[ev.buf].titlestring = M.title() + end, + }) + end + + vim.api.nvim_create_user_command("RefreshTitle", refresh, { + desc = "Recompute the project / worktree / branch title", + }) + + -- Keep the git subprocesses off the startup path: options.lua seeds a cheap + -- directory-name title, and this replaces it once nvim is up. + if vim.v.vim_did_enter == 0 then + vim.api.nvim_create_autocmd("VimEnter", { group = group, once = true, callback = refresh }) + else + refresh() + end +end + +return M