Updated how titles are created in nvim window #13

Merged
sam_oneal merged 1 commits from title into main 2026-10-03 06:22:53 +00:00
4 changed files with 304 additions and 0 deletions
+42
View File
@@ -245,6 +245,45 @@ place), so all branches that were on the machine stay available. Pass
The snacks explorer (`<C-e>`) shows a chip in its title with the current The snacks explorer (`<C-e>`) shows a chip in its title with the current
project, branch, and worktree count when inside a git repo. 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`, `<leader>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 (`<leader>s`) ### Search / Symbols (`<leader>s`)
| Key | Action | | Key | Action |
@@ -321,6 +360,7 @@ project, branch, and worktree count when inside a git repo.
│ │ ├── options.lua # Neovim options │ │ ├── options.lua # Neovim options
│ │ ├── keymaps.lua # Custom keymaps │ │ ├── keymaps.lua # Custom keymaps
│ │ ├── git-worktree.lua # Worktree-aware branch switching + convert │ │ ├── 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…) │ │ └── autocmds.lua # Autocommands (yank hl, ft tweaks, SQL no-autoformat…)
│ ├── plugins/ │ ├── plugins/
│ │ ├── mason.lua # Tooling installs (LSP/linters/formatters/DAP) │ │ ├── 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 - **Ctrl+Shift keys not firing:** some terminals don't transmit them; use the
plain-Ctrl fallbacks (`<C-v>` / `<C-x>` in the explorer). plain-Ctrl fallbacks (`<C-v>` / `<C-x>` in the explorer).
- **Nerd Font icons missing:** install a Nerd Font and set it in your terminal. - **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) ## Known issues (in current repo)
+3
View File
@@ -70,3 +70,6 @@ require("lazy").setup({
require("config.options") require("config.options")
require("config.keymaps") require("config.keymaps")
require("config.autocmds") require("config.autocmds")
-- Terminal / window title (project, worktree, branch)
require("config.title").setup()
+6
View File
@@ -46,6 +46,12 @@ opt.timeoutlen = 300 -- Faster key sequence completion
-- Completion -- Completion
opt.completeopt = "menu,menuone,noselect" 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) -- Folding (using treesitter)
opt.foldmethod = "expr" opt.foldmethod = "expr"
opt.foldexpr = "nvim_treesitter#foldexpr()" opt.foldexpr = "nvim_treesitter#foldexpr()"
+253
View File
@@ -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 `<home>/.worktrees/<branch>` (bare home) or
-- `<parent>/.worktrees/<project>/<branch>` (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