Updated how titles are created in nvim window #13
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user