Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.
Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.
The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
import { killSession } from "./api.js";
|
|
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import type { DaemonInfo, Session } from "./types.js";
|
|
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
|
import { openSpawnModal } from "./spawn.js";
|
|
import { createThemeToggle } from "./theme.js";
|
|
|
|
interface TreeFolder {
|
|
kind: "folder";
|
|
name: string;
|
|
path: string;
|
|
children: TreeNode[];
|
|
}
|
|
|
|
interface TreeLeaf {
|
|
kind: "leaf";
|
|
session: Session;
|
|
}
|
|
|
|
type TreeNode = TreeFolder | TreeLeaf;
|
|
|
|
const collapsed = new Set<string>();
|
|
|
|
function compareNodes(a: TreeNode, b: TreeNode): number {
|
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
|
const an = a.kind === "folder" ? a.name : sessionName(a.session);
|
|
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
|
|
return an.localeCompare(bn);
|
|
}
|
|
|
|
function buildTree(
|
|
sessions: Session[],
|
|
daemons: Map<string, DaemonInfo>,
|
|
filter: string,
|
|
): TreeNode {
|
|
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
|
const lc = filter.trim().toLowerCase();
|
|
|
|
for (const s of sessions) {
|
|
const home = daemons.get(s.daemon_id)?.home_dir;
|
|
const cwd = sessionCwd(s);
|
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
|
if (lc) {
|
|
const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase();
|
|
if (!haystack.includes(lc)) continue;
|
|
}
|
|
// Host is the first level of nesting; the working directory nests beneath
|
|
// it, so the daemon no longer needs to ride along on each leaf.
|
|
const dirParts = dir ? dir.split("/").filter(Boolean) : [];
|
|
const parts = [s.daemon_id, ...dirParts];
|
|
let cursor = root;
|
|
for (const part of parts) {
|
|
const path = cursor.path ? `${cursor.path}/${part}` : part;
|
|
let next = cursor.children.find(
|
|
(n): n is TreeFolder => n.kind === "folder" && n.name === part,
|
|
);
|
|
if (!next) {
|
|
next = { kind: "folder", name: part, path, children: [] };
|
|
cursor.children.push(next);
|
|
}
|
|
cursor = next;
|
|
}
|
|
cursor.children.push({ kind: "leaf", session: s });
|
|
}
|
|
|
|
const sortRec = (n: TreeFolder) => {
|
|
n.children.sort(compareNodes);
|
|
for (const c of n.children) if (c.kind === "folder") sortRec(c);
|
|
};
|
|
sortRec(root);
|
|
// Collapse linear directory chains *within* each daemon (but never the daemon
|
|
// node itself) so `~/git.naps.pt/naps62/maestro` reads as one compact folder
|
|
// rather than four nested ones.
|
|
for (const daemon of root.children) {
|
|
if (daemon.kind === "folder") {
|
|
daemon.children = daemon.children.map((c) =>
|
|
c.kind === "folder" ? mergeLinear(c) : c,
|
|
);
|
|
}
|
|
}
|
|
return root;
|
|
}
|
|
|
|
/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
|
|
* repeating down the chain, then recurse into the surviving branches. The merged
|
|
* node keeps the deepest path so collapse/expand state stays stable as it grows. */
|
|
function mergeLinear(folder: TreeFolder): TreeFolder {
|
|
let cur = folder;
|
|
while (cur.children.length === 1 && cur.children[0].kind === "folder") {
|
|
const child = cur.children[0];
|
|
cur = {
|
|
kind: "folder",
|
|
name: `${cur.name}/${child.name}`,
|
|
path: child.path,
|
|
children: child.children,
|
|
};
|
|
}
|
|
cur.children = cur.children.map((c) =>
|
|
c.kind === "folder" ? mergeLinear(c) : c,
|
|
);
|
|
return cur;
|
|
}
|
|
|
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|
if (node.kind === "folder") {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "tree-folder";
|
|
|
|
if (node.name) {
|
|
const header = document.createElement("button");
|
|
header.type = "button";
|
|
header.className = "tree-folder-header";
|
|
header.style.paddingLeft = `${depth * 16}px`;
|
|
const isCollapsed = collapsed.has(node.path);
|
|
header.setAttribute("aria-expanded", String(!isCollapsed));
|
|
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
|
// glyph and the rotation never fight each other.
|
|
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
|
|
header.addEventListener("click", () => {
|
|
if (collapsed.has(node.path)) collapsed.delete(node.path);
|
|
else collapsed.add(node.path);
|
|
// re-render handled by caller observing the store; trigger a refresh:
|
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
});
|
|
wrap.appendChild(header);
|
|
if (isCollapsed) return wrap;
|
|
}
|
|
|
|
for (const child of node.children) {
|
|
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
const s = node.session;
|
|
const row = document.createElement("a");
|
|
row.className = "tree-leaf";
|
|
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
|
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
row.dataset.sessionId = s.id;
|
|
const running = s.status === "Running";
|
|
row.dataset.status = running ? "running" : "dead";
|
|
const name = sessionName(s);
|
|
row.setAttribute(
|
|
"aria-label",
|
|
`${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`,
|
|
);
|
|
// The host is now the enclosing folder, so only surface the command — and
|
|
// only when it isn't already what the name shows.
|
|
const showCmd = s.command !== name;
|
|
row.innerHTML = `
|
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
|
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
|
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
|
<span class="leaf-actions">
|
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
|
</span>
|
|
`;
|
|
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
|
similarBtn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
// The sidebar (which has the daemon list) opens the spawn modal pre-seeded
|
|
// from this session.
|
|
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
|
});
|
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
|
killBtn.addEventListener("click", async (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
|
|
try {
|
|
await killSession(s.id);
|
|
} catch (e) {
|
|
alert(`Failed to kill: ${(e as Error).message}`);
|
|
}
|
|
});
|
|
return row;
|
|
}
|
|
|
|
function countLeaves(node: TreeNode): number {
|
|
if (node.kind === "leaf") return 1;
|
|
return node.children.reduce((n, c) => n + countLeaves(c), 0);
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "statusbar";
|
|
|
|
const total = sessions.length;
|
|
const live = sessions.filter((s) => s.status === "Running").length;
|
|
const tally = document.createElement("div");
|
|
tally.className = "session-tally";
|
|
tally.innerHTML =
|
|
total === 0
|
|
? `<span>No sessions</span>`
|
|
: `<span><b>${total}</b> session${total === 1 ? "" : "s"}</span>` +
|
|
`<span class="tally-live"><b>${live}</b> live</span>`;
|
|
wrap.appendChild(tally);
|
|
|
|
const spacer = document.createElement("div");
|
|
spacer.className = "spacer";
|
|
wrap.appendChild(spacer);
|
|
|
|
const daemonsWrap = document.createElement("div");
|
|
daemonsWrap.className = "daemons";
|
|
if (daemons.length === 0) {
|
|
daemonsWrap.innerHTML = `<span class="daemon-empty">no daemons connected</span>`;
|
|
} else {
|
|
for (const d of daemons) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "daemon-chip";
|
|
chip.title = `${d.hostname} · ${d.daemon_url}`;
|
|
chip.textContent = d.daemon_id;
|
|
daemonsWrap.appendChild(chip);
|
|
}
|
|
}
|
|
wrap.appendChild(daemonsWrap);
|
|
return wrap;
|
|
}
|
|
|
|
function renderEmpty(
|
|
noDaemons: boolean,
|
|
filtering: boolean,
|
|
onSpawn: () => void,
|
|
): HTMLElement {
|
|
const el = document.createElement("div");
|
|
el.className = "empty";
|
|
if (filtering) {
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
|
<div class="empty-title">No matches</div>
|
|
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
|
return el;
|
|
}
|
|
if (noDaemons) {
|
|
el.classList.add("connecting");
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">◴</div>
|
|
<div class="empty-title">Waiting for a daemon</div>
|
|
<p class="empty-hint">No daemons are connected yet. Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
|
|
return el;
|
|
}
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
|
<div class="empty-title">No sessions yet</div>
|
|
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
|
<div class="empty-actions"></div>`;
|
|
const spawn = document.createElement("button");
|
|
spawn.type = "button";
|
|
spawn.className = "btn btn-primary";
|
|
spawn.textContent = "+ Spawn session";
|
|
spawn.addEventListener("click", onSpawn);
|
|
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* The global app header (full width, above the sidebar/content split): brand +
|
|
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
|
* stay put and reachable regardless of which pane is showing.
|
|
*/
|
|
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
|
|
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
|
|
function createDebugToggle(): HTMLButtonElement {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "btn btn-ghost debug-toggle";
|
|
btn.textContent = "debug";
|
|
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
|
|
const sync = () => {
|
|
const on = isHudEnabled();
|
|
btn.setAttribute("aria-pressed", String(on));
|
|
btn.classList.toggle("is-active", on);
|
|
};
|
|
btn.addEventListener("click", () => toggleHud());
|
|
window.addEventListener(HUD_TOGGLE_EVENT, sync);
|
|
sync();
|
|
return btn;
|
|
}
|
|
|
|
export function renderTopbar(store: CatalogStore): HTMLElement {
|
|
const header = document.createElement("header");
|
|
header.className = "app-header";
|
|
header.innerHTML = `
|
|
<a class="brand" href="#/" aria-label="maestro — clear selection">
|
|
<span class="brand-dot" aria-hidden="true"></span>
|
|
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
|
|
</a>
|
|
<div class="app-status"></div>
|
|
<div class="app-actions"></div>
|
|
`;
|
|
|
|
// Session tally + daemon chips live centered in the header (not the sidebar).
|
|
const statusHost = header.querySelector(".app-status") as HTMLElement;
|
|
const renderStatus = () => {
|
|
const state = store.snapshot();
|
|
statusHost.replaceChildren(
|
|
renderStatusbar([...state.daemons.values()], [...state.sessions.values()]),
|
|
);
|
|
};
|
|
store.subscribe(renderStatus);
|
|
renderStatus();
|
|
|
|
const actions = header.querySelector(".app-actions") as HTMLElement;
|
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
|
if (import.meta.env.DEV) {
|
|
const kit = document.createElement("a");
|
|
kit.className = "btn btn-ghost";
|
|
kit.href = "?kit";
|
|
kit.textContent = "kit";
|
|
kit.title = "Open the design kit (DEV)";
|
|
actions.appendChild(kit);
|
|
}
|
|
actions.appendChild(createDebugToggle());
|
|
actions.appendChild(createThemeToggle());
|
|
const spawnBtn = document.createElement("button");
|
|
spawnBtn.type = "button";
|
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
|
spawnBtn.textContent = "+ Spawn";
|
|
spawnBtn.addEventListener("click", () => {
|
|
const snap = store.snapshot();
|
|
openSpawnModal(snap.daemons, undefined, snap.sessions);
|
|
});
|
|
actions.appendChild(spawnBtn);
|
|
return header;
|
|
}
|
|
|
|
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
|
export interface SidebarHandle {
|
|
/** The `<aside>` element to mount into the shell. */
|
|
el: HTMLElement;
|
|
/** Highlight the row for `id` (or clear it with `null`) as the open session. */
|
|
setActive(id: string | null): void;
|
|
/** Tear down the store subscription + listeners. */
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
|
* live daemon/session status line, and the folder-grouped session tree. Unlike
|
|
* the old full-screen catalog this is mounted once by the shell and stays put
|
|
* while the content pane swaps between sessions — so the store subscription and
|
|
* scroll position survive navigation. The active session is highlighted.
|
|
*/
|
|
export function renderSidebar(store: CatalogStore): SidebarHandle {
|
|
const el = document.createElement("aside");
|
|
el.className = "sidebar";
|
|
|
|
const searchHeader = document.createElement("header");
|
|
searchHeader.className = "sidebar-search";
|
|
searchHeader.innerHTML = `
|
|
<input
|
|
type="search"
|
|
class="search"
|
|
placeholder="Search sessions…"
|
|
aria-label="Search sessions"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
/>
|
|
`;
|
|
el.appendChild(searchHeader);
|
|
|
|
const treeHost = document.createElement("div");
|
|
treeHost.className = "tree";
|
|
el.appendChild(treeHost);
|
|
|
|
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
|
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
|
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
|
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
|
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
|
const handle = document.createElement("div");
|
|
handle.className = "sidebar-resize";
|
|
handle.setAttribute("aria-hidden", "true");
|
|
el.appendChild(handle);
|
|
let dragStartX = 0;
|
|
let dragStartW = 0;
|
|
let dragging = false;
|
|
handle.addEventListener("pointerdown", (e) => {
|
|
dragging = true;
|
|
dragStartX = e.clientX;
|
|
dragStartW = el.getBoundingClientRect().width;
|
|
handle.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
e.preventDefault();
|
|
});
|
|
handle.addEventListener("pointermove", (e) => {
|
|
if (!dragging) return;
|
|
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
|
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
|
});
|
|
const endDrag = (e: PointerEvent) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
try {
|
|
handle.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// pointer may already be released
|
|
}
|
|
document.body.style.userSelect = "";
|
|
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
|
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
|
};
|
|
handle.addEventListener("pointerup", endDrag);
|
|
handle.addEventListener("pointercancel", endDrag);
|
|
|
|
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
|
let filter = "";
|
|
search.addEventListener("input", () => {
|
|
filter = search.value;
|
|
refresh();
|
|
});
|
|
|
|
let activeId: string | null = null;
|
|
const applyActive = () => {
|
|
for (const n of treeHost.querySelectorAll(".tree-leaf.is-active")) {
|
|
n.classList.remove("is-active");
|
|
}
|
|
if (activeId) {
|
|
const row = treeHost.querySelector(
|
|
`.tree-leaf[data-session-id="${CSS.escape(activeId)}"]`,
|
|
);
|
|
row?.classList.add("is-active");
|
|
}
|
|
};
|
|
|
|
const refresh = () => {
|
|
const state = store.snapshot();
|
|
const sessions = [...state.sessions.values()];
|
|
const tree = buildTree(sessions, state.daemons, filter);
|
|
treeHost.replaceChildren();
|
|
if (sessions.length === 0) {
|
|
treeHost.appendChild(
|
|
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => {
|
|
const snap = store.snapshot();
|
|
openSpawnModal(snap.daemons, undefined, snap.sessions);
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (tree.kind === "folder") {
|
|
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
|
}
|
|
applyActive();
|
|
};
|
|
|
|
const rerenderHandler = () => refresh();
|
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
|
|
|
// Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir,
|
|
// and command (matched back to a preset by the form) — but with a freshly
|
|
// generated name rather than the original's, since it's a new session.
|
|
const spawnSimilarHandler = (ev: Event) => {
|
|
const s = (ev as CustomEvent<Session>).detail;
|
|
const snap = store.snapshot();
|
|
openSpawnModal(
|
|
snap.daemons,
|
|
{
|
|
daemon_id: s.daemon_id,
|
|
cwd: s.cwd,
|
|
command: s.command,
|
|
},
|
|
snap.sessions,
|
|
);
|
|
};
|
|
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
|
|
|
const unsubscribe = store.subscribe(refresh);
|
|
|
|
return {
|
|
el,
|
|
setActive(id: string | null) {
|
|
activeId = id;
|
|
applyActive();
|
|
},
|
|
dispose() {
|
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
|
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
|
unsubscribe();
|
|
},
|
|
};
|
|
}
|