// Maps a session's working directory to the zellij tab it is running in. // // Knowing a session waits for you is only half the answer; the other half is // where to look. When sessions live in zellij tabs, the tab name answers that // better than a path does. // // `zellij action dump-layout` prints tab names with each pane's cwd but no pane // ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the // match goes through the working directory instead. That is best-effort by // nature: two sessions in one tab are indistinguishable, and a session whose // cwd has moved since the pane opened will not match. import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async'); // Layouts only change when tabs are rearranged, and opening the menu forces a // refresh anyway -- so the background TTL is long enough that the periodic tick // does not spawn a zellij process every time it runs. const CACHE_TTL = 120; // seconds export class ZellijTabs { constructor() { this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] } this._inFlight = new Map(); this._available = null; this._cancellable = new Gio.Cancellable(); } /** Tab name for a working directory, or null when unknown. */ tabFor(zellijSession, cwd) { const entry = this._cache.get(zellijSession); if (!entry || !cwd) return null; // Longest matching prefix wins: a pane opened at the repo root must not // outrank one opened directly in the subdirectory the session runs in. let best = null; let bestLen = -1; for (const tab of entry.tabs) { for (const paneCwd of tab.cwds) { if (cwd !== paneCwd && !cwd.startsWith(`${paneCwd}/`)) continue; if (paneCwd.length > bestLen) { bestLen = paneCwd.length; best = tab.name; } } } return best; } /** Refresh the layout of every zellij session in use, at most once per TTL. * `force` bypasses the TTL for the one moment it matters: the user just * opened the menu and may have rearranged tabs since the last look. */ async refresh(zellijSessions, force = false) { if (this._available === false) return; const now = GLib.get_monotonic_time() / 1e6; const work = []; for (const name of new Set(zellijSessions)) { if (!name) continue; const entry = this._cache.get(name); if (!force && entry && now - entry.at < CACHE_TTL) continue; work.push(this._refreshOne(name, now)); } await Promise.all(work); } async _refreshOne(name, now) { // Collapse concurrent refreshes of the same session; the menu opening // and the periodic tick can otherwise fire two subprocesses at once. if (this._inFlight.has(name)) return this._inFlight.get(name); const promise = this._dumpLayout(name) .then(layout => { if (layout !== null) this._available = true; // A failure is cached as an empty layout, not left uncached: // otherwise a zellij session that was renamed or killed while // its claude process lives on fails the TTL check every time // and forks a process on every tick, forever. this._cache.set(name, { at: now, tabs: layout === null ? [] : parseLayout(layout), }); }) .finally(() => this._inFlight.delete(name)); this._inFlight.set(name, promise); return promise; } async _dumpLayout(session) { try { const proc = Gio.Subprocess.new( ['zellij', '--session', session, 'action', 'dump-layout'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE); const [stdout] = await proc.communicate_utf8_async(null, this._cancellable); if (!proc.get_successful()) return null; return stdout ?? ''; } catch (e) { // zellij not installed, or not on the shell's PATH: stop trying. if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT)) this._available = false; return null; } } /** Abandon any layout dump still running; the extension is going away. */ destroy() { this._cancellable.cancel(); this._cache.clear(); this._inFlight.clear(); } } /** Extract tab names and their pane working directories from a KDL layout. * * Parsed with line matching rather than a KDL parser: the two constructs that * matter are one line each, and a dependency-free extension cannot pull one in. */ export function parseLayout(text) { const tabs = []; // A layout-level `cwd "..."` is the base for panes that store a relative one. const baseMatch = text.match(/^\s*cwd\s+"([^"]*)"/m); const base = baseMatch ? baseMatch[1] : ''; let current = null; for (const line of text.split('\n')) { const tabMatch = line.match(/^\s*tab\s.*?name="([^"]*)"/); if (tabMatch) { current = { name: tabMatch[1], cwds: [] }; tabs.push(current); continue; } if (!current) continue; const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/); if (paneMatch) { const cwd = paneMatch[1]; const absolute = cwd.startsWith('/') ? cwd : GLib.build_filenamev([base, cwd]); if (!current.cwds.includes(absolute)) current.cwds.push(absolute); } } return tabs; }