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 { 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 = `
No session open

Pick a session from the list, or start a new one. Open sessions keep streaming in the background — switching back is instant.

`; 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 = `

Unknown route

#${raw.replace(/[<&]/g, "")} doesn't match anything.

Back to catalog`; 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