|
|
|
@@ -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
|