mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-02 11:32:42 +00:00
feat(config): add kitty tab bar with git info and split-launch shortcut
This commit is contained in:
1 parent
a6ff6eafbf
commit
941d455cf5
2 files changed
+224
-3
No files matched your search
@@ -49,7 +49,11 @@ map ctrl+shift+k next_tab
|
|||||||
# Shortcuts
|
# Shortcuts
|
||||||
map ctrl+shift+g launch_ov lazygit
|
map ctrl+shift+g launch_ov lazygit
|
||||||
map ctrl+shift+f launch_tb yazi
|
map ctrl+shift+f launch_tb yazi
|
||||||
map ctrl+shift+e launch_tb nvim
|
# nvim in the current dir, left of the active pane, 50/50. kitty can only add a
|
||||||
|
# split *after* the active window, so `layout_action rotate 180` swaps the two
|
||||||
|
# halves of the pair the launch just created — nvim ends up on the left and keeps
|
||||||
|
# focus. Mirrors ctrl+shift+e in the herdr host (position = "left" there).
|
||||||
|
map ctrl+shift+e combine : launch_vs50 nvim : layout_action rotate 180
|
||||||
# herdr host: a *separate* kitty process, own config (launch cannot take --config)
|
# herdr host: a *separate* kitty process, own config (launch cannot take --config)
|
||||||
# tmux host: same trick — kitty stays dumb, tmux owns panes/windows/sessions
|
# tmux host: same trick — kitty stays dumb, tmux owns panes/windows/sessions
|
||||||
map ctrl+shift+p launch_hs atuin-pick
|
map ctrl+shift+p launch_hs atuin-pick
|
||||||
@@ -89,8 +93,10 @@ tab_powerline_style angled
|
|||||||
tab_bar_align left
|
tab_bar_align left
|
||||||
tab_bar_edge top
|
tab_bar_edge top
|
||||||
|
|
||||||
tab_title_template " {index}"
|
# Titles come from ~/.config/kitty/tab_bar.py (`{custom}`): tab index, cwd, git
|
||||||
active_tab_title_template " {title}"
|
# branch and per-tab change counts. Active tabs use the same text; the powerline
|
||||||
|
# colors already say which tab is focused.
|
||||||
|
tab_title_template "{custom}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# ── tab_bar.py · kitty tab titles: directory + git state ──
|
||||||
|
#
|
||||||
|
# kitty loads this file from the config dir and exposes `draw_title` as the
|
||||||
|
# `{custom}` field of tab_title_template (see kitty/tab_bar.py:
|
||||||
|
# load_custom_draw_title). kitty keeps drawing the powerline chrome itself; only
|
||||||
|
# the text inside a tab comes from here.
|
||||||
|
#
|
||||||
|
# A title reads: 2 chezmoi main ⇡1 +2 ~1 ?3
|
||||||
|
# ^ ^ ^ ^ ^ ^
|
||||||
|
# | dir branch staged / unstaged / untracked files
|
||||||
|
# tab index
|
||||||
|
#
|
||||||
|
# git is *never* run on the render path. `draw_title` only ever reads a cache
|
||||||
|
# and notes which working dirs are on screen; a 1s repeating kitty timer (main
|
||||||
|
# thread, same as every other kitty callback) starts `git status` processes,
|
||||||
|
# reaps the finished ones, and marks the tab bar dirty when a value changed.
|
||||||
|
# Output goes to a temp file, not a pipe, so a repo with thousands of dirty
|
||||||
|
# files can never fill a 64K pipe buffer and wedge the child.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from kitty.boss import get_boss
|
||||||
|
from kitty.fast_data_types import add_timer
|
||||||
|
|
||||||
|
# Nerd Font glyphs (VictorMono Nerd Font Mono covers all of these).
|
||||||
|
FOLDER = '\uf07b' # nf-fa-folder
|
||||||
|
BRANCH = '\ue0a0' # nf-pl-branch
|
||||||
|
AHEAD = '\u21e1'
|
||||||
|
BEHIND = '\u21e3'
|
||||||
|
|
||||||
|
# A cached git summary is reused for this long; a working dir that stopped being
|
||||||
|
# displayed is dropped after PRUNE_AFTER so closed tabs stop being polled.
|
||||||
|
TTL = 4.0
|
||||||
|
PRUNE_AFTER = 30.0
|
||||||
|
POLL_INTERVAL = 1.0
|
||||||
|
MAX_CONCURRENT = 4
|
||||||
|
KILL_AFTER = 15.0
|
||||||
|
MAX_DIR_LEN = 24
|
||||||
|
|
||||||
|
# wd -> git summary spec: (branch, ahead, behind, staged, unstaged, untracked,
|
||||||
|
# conflicts) or None when wd is not a work tree.
|
||||||
|
_git: dict[str, tuple[str, int, int, int, int, int, int] | None] = {}
|
||||||
|
_fresh_until: dict[str, float] = {}
|
||||||
|
_wanted: dict[str, float] = {}
|
||||||
|
_running: dict[str, tuple[subprocess.Popen[bytes], Any, float]] = {}
|
||||||
|
_timer = 0
|
||||||
|
|
||||||
|
|
||||||
|
def draw_title(data: dict[str, Any]) -> str:
|
||||||
|
global _timer
|
||||||
|
if not _timer:
|
||||||
|
_timer = add_timer(_tick, POLL_INTERVAL, True)
|
||||||
|
wd = data['tab'].active_wd
|
||||||
|
fmt = data['fmt']
|
||||||
|
parts = [str(data['index'])]
|
||||||
|
if wd:
|
||||||
|
_wanted[wd] = _now()
|
||||||
|
parts.append(f'{FOLDER} {_dir_label(wd)}')
|
||||||
|
summary = _git.get(wd)
|
||||||
|
if summary is not None:
|
||||||
|
parts.append(_render_git(summary, fmt))
|
||||||
|
else:
|
||||||
|
parts.append(data['title'])
|
||||||
|
return ' '.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_label(wd: str) -> str:
|
||||||
|
home = os.path.expanduser('~')
|
||||||
|
if wd == home:
|
||||||
|
return '~'
|
||||||
|
name = os.path.basename(wd.rstrip('/')) or wd
|
||||||
|
if len(name) > MAX_DIR_LEN:
|
||||||
|
name = name[: MAX_DIR_LEN - 1] + '…'
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _render_git(
|
||||||
|
summary: tuple[str, int, int, int, int, int, int], fmt: Any
|
||||||
|
) -> str:
|
||||||
|
branch, ahead, behind, staged, unstaged, untracked, conflicts = summary
|
||||||
|
# color2/3/4/1 are palette slots, so the active Omarchy theme picks the
|
||||||
|
# actual greens and reds; fg.tab restores the tab's own foreground so the
|
||||||
|
# powerline chrome keeps drawing in its own colors.
|
||||||
|
tab = fmt.fg.tab
|
||||||
|
out = [f'{BRANCH} {branch}' if branch else BRANCH]
|
||||||
|
for count, mark, color in (
|
||||||
|
(ahead, AHEAD, fmt.fg.color6),
|
||||||
|
(behind, BEHIND, fmt.fg.color6),
|
||||||
|
(staged, '+', fmt.fg.color2),
|
||||||
|
(unstaged, '~', fmt.fg.color3),
|
||||||
|
(untracked, '?', fmt.fg.color4),
|
||||||
|
(conflicts, '!', fmt.fg.color1),
|
||||||
|
):
|
||||||
|
if count:
|
||||||
|
out.append(f'{color}{mark}{count}{tab}')
|
||||||
|
return ' '.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> float:
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _tick(timer_id: int | None) -> None:
|
||||||
|
now = _now()
|
||||||
|
changed = _reap(now)
|
||||||
|
for wd, last_wanted in tuple(_wanted.items()):
|
||||||
|
if now - last_wanted > PRUNE_AFTER:
|
||||||
|
del _wanted[wd]
|
||||||
|
_git.pop(wd, None)
|
||||||
|
_fresh_until.pop(wd, None)
|
||||||
|
continue
|
||||||
|
if len(_running) >= MAX_CONCURRENT:
|
||||||
|
break
|
||||||
|
if wd in _running or _fresh_until.get(wd, 0.0) > now:
|
||||||
|
continue
|
||||||
|
_start(wd, now)
|
||||||
|
if changed:
|
||||||
|
for tm in get_boss().os_window_map.values():
|
||||||
|
tm.mark_tab_bar_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
def _start(wd: str, now: float) -> None:
|
||||||
|
try:
|
||||||
|
out = tempfile.TemporaryFile()
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
(
|
||||||
|
'git',
|
||||||
|
'--no-optional-locks',
|
||||||
|
'status',
|
||||||
|
'--porcelain=v2',
|
||||||
|
'--branch',
|
||||||
|
'--untracked-files=normal',
|
||||||
|
),
|
||||||
|
cwd=wd,
|
||||||
|
stdout=out,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
# start_new_session, not preexec_fn: kitty is multi-threaded, and
|
||||||
|
# only the former is implemented without running Python between
|
||||||
|
# fork and exec.
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
# No git, or wd vanished: remember "not a repo" so we stop retrying
|
||||||
|
# until the TTL lapses.
|
||||||
|
_git[wd] = None
|
||||||
|
_fresh_until[wd] = now + TTL
|
||||||
|
return
|
||||||
|
_running[wd] = (proc, out, now)
|
||||||
|
|
||||||
|
|
||||||
|
def _reap(now: float) -> bool:
|
||||||
|
changed = False
|
||||||
|
for wd, (proc, out, started) in tuple(_running.items()):
|
||||||
|
rc = proc.poll()
|
||||||
|
if rc is None:
|
||||||
|
if now - started > KILL_AFTER:
|
||||||
|
proc.kill()
|
||||||
|
continue
|
||||||
|
del _running[wd]
|
||||||
|
summary = None
|
||||||
|
if rc == 0:
|
||||||
|
try:
|
||||||
|
out.seek(0)
|
||||||
|
summary = _parse(out.read().decode('utf-8', 'replace'))
|
||||||
|
except OSError:
|
||||||
|
summary = None
|
||||||
|
out.close()
|
||||||
|
if _git.get(wd, ...) != summary:
|
||||||
|
changed = True
|
||||||
|
_git[wd] = summary
|
||||||
|
_fresh_until[wd] = now + TTL
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(text: str) -> tuple[str, int, int, int, int, int, int]:
|
||||||
|
"""git status --porcelain=v2 --branch -> title counts.
|
||||||
|
|
||||||
|
Header lines carry the branch and the ahead/behind pair; `1`/`2` records
|
||||||
|
carry a two-char XY status (X = index, Y = work tree), `?` is untracked and
|
||||||
|
`u` is unmerged. Renames (`2`) put the old path after a tab on the same
|
||||||
|
line, so line-splitting stays correct.
|
||||||
|
"""
|
||||||
|
head = oid = ''
|
||||||
|
ahead = behind = staged = unstaged = untracked = conflicts = 0
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith('# branch.oid '):
|
||||||
|
oid = line[13:20]
|
||||||
|
elif line.startswith('# branch.head '):
|
||||||
|
head = line[14:]
|
||||||
|
elif line.startswith('# branch.ab '):
|
||||||
|
for token in line[12:].split():
|
||||||
|
if token.startswith('+'):
|
||||||
|
ahead = int(token[1:] or 0)
|
||||||
|
elif token.startswith('-'):
|
||||||
|
behind = int(token[1:] or 0)
|
||||||
|
elif line[:2] in ('1 ', '2 '):
|
||||||
|
xy = line[2:4]
|
||||||
|
if xy[0] != '.':
|
||||||
|
staged += 1
|
||||||
|
if xy[1] != '.':
|
||||||
|
unstaged += 1
|
||||||
|
elif line.startswith('? '):
|
||||||
|
untracked += 1
|
||||||
|
elif line.startswith('u '):
|
||||||
|
conflicts += 1
|
||||||
|
branch = oid if head in ('', '(detached)') else head
|
||||||
|
return branch, ahead, behind, staged, unstaged, untracked, conflicts
|
||||||
Reference in new issue
Block a user