This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/main.ts
T

295 lines
10 KiB
TypeScript

import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { openSpawnModal } from "./spawn.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
// offline. Production only — in dev the SW would fight Vite's HMR.
function registerServiceWorker(): void {
if (import.meta.env.DEV) return;
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// Best-effort: a failed registration must never break the app.
});
});
}
const rootEl = document.getElementById("app");
if (!rootEl) {
throw new Error("missing #app");
}
const root: HTMLElement = rootEl;
// Dev-only: `?kit` renders the standalone design kit instead of the app shell,
// so the component system can be reviewed as a whole. Stripped from production
// builds by Vite (dynamic import behind import.meta.env.DEV).
if (import.meta.env.DEV && location.search.includes("kit")) {
void import("./kit.js").then((m) => root.replaceChildren(m.renderKit()));
} else {
void bootApp();
}
async function bootApp(): Promise<void> {
const store = new CatalogStore();
store.start();
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
void import("./mock.js").then((m) => m.seedMock(store));
}
// ---- persistent shell: sidebar (list) + content (welcome | attach) ----
const shellEl = document.createElement("div");
shellEl.className = "app-shell";
shellEl.dataset.route = "catalog";
// Forward-declared so the sidebar's drag hook can reach the controller (created
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
});
const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header; a backdrop covers the content while it's open, and tapping a
// session closes it. On desktop these are inert (CSS gates the drawer styling).
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
function closeDrawer(): void {
shellEl.classList.remove("drawer-open");
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
backdrop.addEventListener("click", closeDrawer);
sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
// It can mount several panes at once — the layout tree decides how many. For now
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
// routes the existing single-session view through that model.
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
// disconnect callback below also references it.
let hud: Hud | undefined;
const pool = new ConnectionPool({
onFocusChange: () => {
// Sidebar highlighting is driven by the multi-view controller; the pool only
// nudges the HUD here.
hud?.refresh();
},
// Both the explicit disconnect (⏻) and a session ending on its own close the
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
});
// Session death used to reach the UI on two uncoordinated channels — the row
// from the events socket, the pane from `ws.onclose` — so either could happen
// without the other. The catalog is the authority: a pane for a session that is
// no longer listed gets closed here.
store.subscribe((state) => {
for (const id of mv?.openSessions() ?? []) {
if (!state.sessions.has(id)) closePane(id);
}
});
function closePane(id: string): void {
pool.close(id);
mv?.closeSession(id);
updateShellRoute();
}
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
hud = mountHud(root, store, () => pool.focused());
// ---- layout state ----
// Single source of truth for what's mounted: tabs of split trees. The hash route
// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored
// from localStorage per device (issue #34); dead sessions self-prune on attach.
const layout: Layout = loadLayout() ?? emptyLayout();
// Debounced persist — the controller calls this after every layout change.
let saveTimer: number | undefined;
function scheduleSave(): void {
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayout(layout), 400);
}
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
function renderWelcome(): HTMLElement {
const el = document.createElement("div");
el.className = "empty content-welcome";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌘</div>
<div class="empty-title">No session open</div>
<p class="empty-hint">
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
</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", () => openSpawnModal());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
function renderNotFound(raw: string): HTMLElement {
const el = document.createElement("div");
el.className = "not-found";
el.innerHTML = `
<h1>Unknown route</h1>
<p><code>#${raw.replace(/[<&]/g, "")}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>`;
return el;
}
// The multi-view controller renders tabs + splits inside the content pane and
// mounts a live attach into each session leaf.
mv = createMultiView({
pool,
layout,
sidebar,
infoFor,
onDisconnect: closePane,
renderWelcome,
// A draft-pane submit (#57) fills its leaf in place; the shell route may flip
// from catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
mv.render();
updateShellRoute();
}
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
case "attach":
// Deep-link: open/focus the session, then show the layout.
showMultiView();
mv.openSession(route.sessionId);
updateShellRoute();
return;
case "not-found":
shellEl.dataset.route = "notfound";
pool.setVisible([]);
pool.setFocus(null);
content.replaceChildren(renderNotFound(route.raw));
contentShowsMv = false;
return;
case "catalog":
// Show the current layout (welcome if empty) — navigating to #/ no longer
// tears panes down.
showMultiView();
return;
}
}
window.addEventListener("hashchange", dispatch);
dispatch();
} // bootApp