Four agents reviewed this in parallel -- correctness, GNOME integration, edge cases, security. Everything below was reproduced before being fixed; several findings that survived the first reading did not survive a probe and are not here. State machine, the two that mattered most. A pending permission prompt was erased by any subagent bookkeeping event: SubagentStop or the next PreToolUse recomputed the state from scratch, so a session sat at "working" with a dialog open and nothing ever raised it again. Blocked now outlives everything except evidence the question was answered. Separately, the stale-event guard refused whole events, including the subagent counter's increments and decrements -- but those are deltas and deltas commute, so a "+1" that lost a timestamp race left the count short and the batch freed the session while a subagent was still running. The guard now gates the state decision only. Corrupt or hostile state files could wedge the panel or take the hook down for every session: a non-numeric pid raised inside sweep_dead before the hook wrote its own file, so one bad byte stopped new sessions appearing at all. Numbers read back from disk are coerced, one unreadable file no longer aborts the sweep, and a stored timestamp far in the future -- corruption, or a clock stepped backwards by NTP -- no longer refuses every later event forever. Resource bounds, all in the compositor process. A state file was read whole with no size check: a symlink to /dev/zero took a test process past 4 GB in three seconds, which in gnome-shell ends the session. Sizes are checked before the read, sessions and zellij subprocesses are capped, labels ellipsize, and cwd and messages are truncated at the hook. Teardown hung off an overridden destroy(), which only runs when JS calls it. An actor destroyed any other way -- another extension rebuilding the panel boxes -- left the timer and the file monitor running against a disposed actor. It is a destroy signal now. The zellij child is killed rather than merely abandoned. The glyph was pinned to physical pixels and rendered half-size on HiDPI; size comes from the stylesheet, and the foreground colour is normalised by inspection rather than assuming which colour struct the shell hands back. Chip labels: non-Latin names all collapsed to "?", because the split treated every Cyrillic letter as a separator -- notable for a tool whose own README is Russian. Seniority also ranked by time-in-state rather than session age, so after a shell restart the older session could take the digit; the hook now records when the session began. zellij: a dump ends with new_tab_template and swap_tiled_layout blocks whose tab lines carry no name, and their panes were being attached to the last real tab -- which then answered for every unmatched directory, confidently and wrongly. install.py no longer widens the mode of a settings.json someone narrowed to 0600, no longer overwrites the pristine .bak on a second run, no longer replaces a symlink out of a dotfiles repository with a regular file, and quotes the hook path. The debug log is capped and README now says plainly that it records prompts verbatim. Not fixed, deliberately: the panel does push the clock about 70 px left with three labelled chips, which is inherent to putting them in the centre box; two different projects abbreviating alike still read as one project with a digit; GNOME 48 remains unverified for the colour struct and for St.BoxLayout's vertical property, both flagged rather than guessed at.
180 lines
7.3 KiB
JavaScript
180 lines
7.3 KiB
JavaScript
// 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
|
|
|
|
// One subprocess per distinct zellij session named in the state directory.
|
|
// In real use that is one or two; the bound is there because the names come
|
|
// from files, and a directory full of them would fork a process per name.
|
|
const MAX_SESSIONS = 8;
|
|
|
|
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();
|
|
this._children = new Set();
|
|
}
|
|
|
|
/** 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)].slice(0, MAX_SESSIONS)) {
|
|
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);
|
|
this._children.add(proc);
|
|
try {
|
|
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
|
if (!proc.get_successful())
|
|
return null;
|
|
return stdout ?? '';
|
|
} finally {
|
|
this._children.delete(proc);
|
|
}
|
|
} 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();
|
|
// Cancelling only abandons the read; the child keeps running. A wedged
|
|
// zellij server would otherwise outlive the extension being disabled.
|
|
for (const proc of this._children)
|
|
proc.force_exit();
|
|
this._children.clear();
|
|
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;
|
|
}
|
|
// A dump ends with new_tab_template and swap_tiled_layout blocks, whose
|
|
// own `tab` lines carry no name. Their panes belong to no tab at all;
|
|
// left attached to whatever came before, they make the last tab in the
|
|
// dump answer for every unmatched directory -- confidently and wrongly.
|
|
if (/^\s*(tab\s|tab\s*\{|new_tab_template|swap_tiled_layout|swap_floating_layout)/.test(line)) {
|
|
current = null;
|
|
continue;
|
|
}
|
|
if (!current)
|
|
continue;
|
|
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
|
|
if (paneMatch) {
|
|
const cwd = paneMatch[1];
|
|
// A relative pane cwd is meaningless without the layout-level one;
|
|
// joining against "" yields a relative path that matches nothing.
|
|
if (!cwd.startsWith('/') && !base)
|
|
continue;
|
|
const absolute = cwd.startsWith('/')
|
|
? cwd
|
|
: GLib.build_filenamev([base, cwd]);
|
|
if (!current.cwds.includes(absolute))
|
|
current.cwds.push(absolute);
|
|
}
|
|
}
|
|
return tabs;
|
|
}
|