The draft pane covers every field the modal had; the modal was a second code path for the same job. The header Spawn button, sidebar empty state, welcome button, and ?mock&spawn now open a draft pane via MultiView.openDraft — reusing an empty focused pane, else splitting it. "Spawn similar" seeds a draft with the session's cwd + command. Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted; .modal-body form styling folds into .spawn-form, which also picks up the select styling the worktree branch picker was borrowing from the modal.
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
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, 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<string>();
|
|
|
|
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 = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</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"}`);
|
|
// 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;
|
|
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();
|
|
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 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 = `
|
|
<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;
|
|
}
|
|
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, onToggleSidebar?: () => void): HTMLElement {
|
|
const header = document.createElement("header");
|
|
header.className = "app-header";
|
|
header.innerHTML = `
|
|
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
|
|
<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>
|
|
`;
|
|
// 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;
|
|
// 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", () => 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<string>;
|
|
focused: string | null;
|
|
colors: Map<string, string>;
|
|
}
|
|
|
|
/** 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 focused session.
|
|
* Back-compat shim over {@link setHighlights}. */
|
|
setActive(id: string | null): void;
|
|
/** Apply the full open/focused/colour highlight state. */
|
|
setHighlights(h: SidebarHighlights): void;
|
|
/** Tear down the store subscription + listeners. */
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
|
* live 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 interface SidebarOptions {
|
|
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
|
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
|
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
|
}
|
|
|
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
|
const el = document.createElement("aside");
|
|
el.className = "sidebar";
|
|
|
|
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
|
// renders with absolute paths until it lands.
|
|
let homeDir: string | undefined;
|
|
|
|
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 a session row into the canvas (issue #32). Delegated so it survives
|
|
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
|
treeHost.addEventListener("pointerdown", (e) => {
|
|
if (e.button !== 0 || !opts.onLeafDrag) return;
|
|
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
|
const id = leaf?.dataset.sessionId;
|
|
if (id) opts.onLeafDrag(id, e);
|
|
});
|
|
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
|
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
|
// until release. Suppress it so the pointer-based drag runs from the start.
|
|
treeHost.addEventListener("dragstart", (e) => {
|
|
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
|
});
|
|
|
|
// 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 highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
|
|
const applyHighlights = () => {
|
|
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
const id = n.dataset.sessionId;
|
|
if (!id) continue;
|
|
const focused = highlights.focused === id;
|
|
const open = highlights.open.has(id);
|
|
n.classList.toggle("is-active", focused);
|
|
n.classList.toggle("is-open", open && !focused);
|
|
const color = highlights.colors.get(id);
|
|
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
|
|
else n.style.removeProperty("--mv-color");
|
|
}
|
|
};
|
|
|
|
const refresh = () => {
|
|
const state = store.snapshot();
|
|
const sessions = [...state.sessions.values()];
|
|
const tree = buildTree(sessions, homeDir, filter);
|
|
treeHost.replaceChildren();
|
|
if (sessions.length === 0) {
|
|
treeHost.appendChild(
|
|
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
|
);
|
|
return;
|
|
}
|
|
if (tree.kind === "folder") {
|
|
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
|
}
|
|
applyHighlights();
|
|
};
|
|
|
|
void getDaemonInfo()
|
|
.then((info) => {
|
|
homeDir = info.home_dir;
|
|
refresh();
|
|
})
|
|
.catch(() => {
|
|
/* absolute paths are a fine fallback */
|
|
});
|
|
|
|
const rerenderHandler = () => refresh();
|
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
|
|
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
|
// multi-view controller can seed a draft pane with the session's dir/command.
|
|
|
|
const unsubscribe = store.subscribe(refresh);
|
|
|
|
return {
|
|
el,
|
|
setActive(id: string | null) {
|
|
highlights = {
|
|
open: id ? new Set([id]) : new Set(),
|
|
focused: id,
|
|
colors: new Map(),
|
|
};
|
|
applyHighlights();
|
|
},
|
|
setHighlights(h: SidebarHighlights) {
|
|
highlights = h;
|
|
applyHighlights();
|
|
},
|
|
dispose() {
|
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
|
unsubscribe();
|
|
},
|
|
};
|
|
}
|