import { escapeHtml } from "./html.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { openForceKillDialog } from "./killDialog.js"; import { openContextMenu } from "./contextMenu.js"; import { isTypingTarget, toggleHelp } from "./help.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { abbreviateFolderName, blockerStats, customSessionName, formatInTheWay, MAX_SESSION_NAME_LEN, normalizeSessionName, sessionBlockers, isProvisionalName, sessionName, } from "./types.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { activityColorSlug, activityDurationLabel, activityHeldMs, META_LINE_MIN_HOLD_MS, } from "./activity.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createPaletteSelect, createThemeToggle } from "./theme.js"; import type { DaemonLink } from "./daemonLink.js"; import { createStatusIndicator } from "./statusIndicator.js"; /** Ask the shell to open the spawn modal. 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)); } /** Ask the shell to hand the keyboard back to the focused pane's terminal — * what Tab in the sidebar list does. Handled in main.ts, which owns the * connection pool. */ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; /** Ask the shell to hand the keyboard back to the session list — what Escape * does when the pane's terminal is focused. Handled in main.ts. */ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar"; /** Ask the shell to open the spawn modal seeded from an existing session * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; /** A session row was clicked (issue #133). The row's `href` drives normal * hash navigation, but a split/drag can move focus without ever touching * `location.hash` — so a click back onto a session whose hash still * matches fires no `hashchange` and the app never hears about it. This * event is the click-driven path that doesn't depend on the hash actually * changing. */ export const SESSION_SELECT_EVENT = "catalog-session-select"; /** The rows a keyboard walk stops on, in visual (document) order. Expanded * folder headers are skipped: a directory is not something to focus, and the * cards under it are one row away anyway. A *collapsed* folder still counts — * its header is the only row it has, so skipping it would leave the folder * unreachable. Same rule as the TUI sidebar. */ const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]'; const collapsed = new Set(); /** Session whose card is currently being renamed inline (#125). The tree * re-renders on every catalog delta, and an activity update arriving mid-edit * would otherwise replace the field the user is typing into. */ let renamingId: string | null = null; // docs/SIDEBAR.md: the state word is never rendered — the rail's colour // carries it. It survives only in tooltips/aria labels so meaning is not // colour-only. const STATE_WORDS: Record = { active: "Active", idle: "Idle", "awaiting-input": "Awaiting input", error: "Error", }; /** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge, * full height. The row's own padding (`renderNode`) shifts outward with * nesting depth, carrying the rail with it. */ function activityRailMarkup(slug: string, stateWord: string): string { return ``; } function metaLine(s: Session, now: number): string { const dead = s.status === "Dead"; const blockers = sessionBlockers(s); const slug = activityColorSlug(s.activity ?? "Idle"); const held = activityHeldMs(s.activity_since, now); const duration = held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; const fragments = [ duration ? `${duration}` : "", s.activity_detail ? `${escapeHtml(s.activity_detail)}` : "", commandFragment(s.command), blockers ? blockedFragment(blockers) : "", dead ? `exited` : "", ]; return `${fragments.join("")}`; } /** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2` * — with the spelled-out breakdown kept in the tooltip. */ function blockedFragment(blockers: InTheWay): string { const stats = blockerStats(blockers) .map((st) => `${escapeHtml(st.text)}`) .join(""); const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`; return `${stats}`; } /** docs/SIDEBAR.md: the card names the program only — arguments never reach * the card. The program becomes its tool icon when we have a mark for it, and * stays as its bare name when we do not. The full command line, arguments and * all, stays in the tooltip either way. */ function commandFragment(command: string): string { const { tool, program } = splitCommand(command); const icon = tool ? toolIconSvg(tool) : null; const text = icon ? "" : program; return `${icon ?? ""}${text ? `${escapeHtml(text)}` : ""}`; } 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)); header.dataset.navKey = `folder:${node.path}`; // 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 now = Date.now(); const row = document.createElement("a"); row.className = "tree-leaf session-card"; row.href = `#/attach/${encodeURIComponent(s.id)}`; row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; row.dataset.navKey = `session:${s.id}`; const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); const slug = activityColorSlug(s.activity ?? "Idle"); const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; row.setAttribute( "aria-label", `${name}, ${running ? "running" : "exited"}, ${stateWord}`, ); // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. row.innerHTML = ` ${activityRailMarkup(slug, stateWord)} ${escapeHtml(name)} ${metaLine(s, now)} `; const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); void requestKill(s, name); }); row.addEventListener("click", () => { document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id })); }); row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); openContextMenu(ev.clientX, ev.clientY, [ { label: "Rename…", onSelect: () => beginRename(row, s) }, { label: "Spawn similar", onSelect: () => document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), }, { label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) }, ]); }); return row; } /** * Kill on the first click (#81): the daemon is the one that decides whether a * session may go, and it refuses over a worktree holding unsaved work. Only * that refusal is worth a prompt, and it needs one `confirm()` cannot give — * a forced kill. Anything else surfaces as an alert. */ async function requestKill(s: Session, name: string): Promise { try { await killSession(s.id); } catch (e) { if (e instanceof ApiRequestError && e.code === "kill_guarded") { openForceKillDialog({ name, blockers: e.unsavedWork ?? sessionBlockers(s), reason: e.detail, onForce: () => killSession(s.id, true), }); return; } alert(`Failed to kill: ${(e as Error).message}`); } } /** * Swap the card's name for a text field (#125). Enter commits, Escape and * blur cancel. An emptied field clears the name, so the card falls back to * the command — the same rule the spawn form's blank name field follows. */ function beginRename(row: HTMLElement, s: Session): void { const nameEl = row.querySelector(".leaf-name"); if (!nameEl) return; const input = document.createElement("input"); input.type = "text"; input.className = "leaf-name leaf-rename"; input.value = customSessionName(s) ?? ""; input.maxLength = MAX_SESSION_NAME_LEN; input.setAttribute("aria-label", `Rename ${sessionName(s)}`); // The card is an , and the tree starts a drag on pointerdown: without // these, clicking into the field navigates away or begins dragging the row. for (const type of ["click", "pointerdown", "dblclick"]) { input.addEventListener(type, (ev) => { ev.preventDefault(); ev.stopPropagation(); input.focus(); }); } renamingId = s.id; let settled = false; const finish = () => { if (settled) return false; settled = true; renamingId = null; document.dispatchEvent(new CustomEvent("catalog-rerender")); return true; }; input.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); // Normalised here so what is sent is what the daemon will store. const name = normalizeSessionName(input.value); if (!finish()) return; void renameSession(s.id, name).catch((e: Error) => { alert(`Failed to rename: ${e.message}`); }); } else if (ev.key === "Escape") { ev.preventDefault(); finish(); } }); input.addEventListener("blur", () => finish()); nameEl.replaceWith(input); input.focus(); input.select(); } 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; } /** Two-tier sidebar highlight state (issue #33): which sessions are open in some * pane (quiet), and which one is focused (loud). */ export interface SidebarHighlights { open: Set; focused: string | null; } /** Live handle to the persistent sidebar (built once, survives navigation). */ export interface SidebarHandle { /** The `