import { escapeHtml } from "./html.js"; import { getDaemonInfo, killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; /** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the * multi-view controller lives; dispatched from every Spawn affordance. */ export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; export function requestOpenSpawn(): void { document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); } const collapsed = new Set(); 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. // A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading // segments to initials (`~/g/n/maestro`) so the dir name stays readable in // a narrow sidebar; the full path lives in the tooltip. header.innerHTML = `▾${abbreviateFolderName(node.name)}`; 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"}`); // The directory is the enclosing folder, so only surface the command — and // only when it isn't already what the name shows. const showCmd = s.command !== name; // #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so // "this will be refused" is visible before the kill is attempted. const blockers = sessionBlockers(s); row.innerHTML = ` ${escapeHtml(name)} ${showCmd ? `${escapeHtml(s.command)}` : ""} ${running ? "" : `exited`} ${blockers ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : ""} `; const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); 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(); // #83: the confirm restates the warning at the moment of decision. const blockers = sessionBlockers(s); const question = blockers ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` : `Kill session "${name}"?`; if (!confirm(question)) return; try { await killSession(s.id); } catch (e) { alert(`Failed to kill: ${(e as Error).message}`); } }); return row; } function renderStatusbar(sessions: Session[]): HTMLElement { const wrap = document.createElement("div"); wrap.className = "statusbar"; const live = sessions.filter((s) => s.status === "Running").length; const tally = document.createElement("span"); tally.className = "session-tally"; tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`; wrap.appendChild(tally); return wrap; } function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { const el = document.createElement("div"); el.className = "empty"; if (filtering) { el.innerHTML = `
No matches

No sessions match your search. Clear it to see everything.

`; return el; } el.innerHTML = `
No sessions yet

Start your first session — it'll show up here.

`; 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, onToggleSidebar?: () => void): HTMLElement { const header = document.createElement("header"); header.className = "app-header"; header.innerHTML = `

maestro

`; // Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer. (header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () => onToggleSidebar?.(), ); // Session tally lives 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.sessions.values()])); }; store.subscribe(renderStatus); renderStatus(); const actions = header.querySelector(".app-actions") as HTMLElement; // Retained worktrees (#84): a section reachable from the header, not a // sidebar fixture — PRODUCT.md's scan is the product, so this must not // compete with the session catalog for attention. const worktreesLink = document.createElement("a"); worktreesLink.className = "btn btn-ghost"; worktreesLink.href = "#/worktrees"; worktreesLink.textContent = "worktrees"; worktreesLink.title = "Retained worktrees"; actions.appendChild(worktreesLink); // 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"; // The keyboard pair sits right here in the tooltip (#67). spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; } /** Two-tier sidebar highlight state (issue #33): which sessions are open in some * pane (quiet), which one is focused (loud), and a stable per-session colour so * a row maps to its pane. */ export interface SidebarHighlights { open: Set; focused: string | null; colors: Map; } /** Live handle to the persistent sidebar (built once, survives navigation). */ export interface SidebarHandle { /** The `