Files
claude-code-gnome-extension/lib/zellij.js
T
av ca42d69704 Cap the chips at three, and make the menu report-only
Two changes that pull in the same direction: the panel says less, and the
menu stops pretending to do anything.

The chip row had no bound. It sits in the centre box next to the clock, so
enough open sessions would have shoved the clock off centre. It now shows
the first N, settable and three by default, and counts the rest as "+N".
Chips are already ordered by urgency, so the ones that survive the cut are
the ones that need you soonest. Labels are still assigned across every
session, including hidden ones, so a chip does not change when the cap
does or when a session ahead of it disappears.

Clicking a menu row used to switch the zellij tab and raise a terminal.
That is gone. It cost real machinery for what it saved -- gnome-terminal
runs every window under one shared server process, so windows cannot be
matched by pid and the code fell back to matching the zellij session name
against window titles, with all the ways that misses. The menu reports
status; alt-tab is not the bottleneck. Rows are built inert rather than
demoted after the fact, because PopupBaseMenuItem latches _activatable in
its constructor.

zellij tab lookup stays: naming the tab is the better half of that feature
and costs one process every couple of minutes.

The preferences test now asserts a control per settings key rather than a
switch per key, so the new spin row counts and a future non-boolean
setting cannot slip in without one.
2026-08-09 19:37:38 +03:00

152 lines
5.8 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
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;
}