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 { createSession, fetchNames, getDaemonInfo, listWorktrees, projects as fetchProjects, reclaimWorktree, } from "./api.js"; import { buildFastForwardRequest, fastForwardSeed, resolveFastForward, } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.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 { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.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.title = "Draft pane: n — fast-forward spawn: N"; spawn.addEventListener("click", () => requestOpenSpawn()); (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(); } // Spawn affordances (#62): the header button, the sidebar's empty state, and // `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the // cloned session's dir + command. function requestOpenSpawn(): void { showMultiView(); mv.openDraft(); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener("catalog-spawn-similar", (ev) => { const s = (ev as CustomEvent).detail; showMultiView(); mv.openDraft({ cwd: s.cwd, command: s.command }); }); // Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns // immediately with every default resolved — most recent project, its last // command (or the first non-custom preset), a fresh name, worktree on with // a new branch named after that name. Anything unresolvable (no projects // known yet, no command, no generated name) opens a draft pre-filled with // what did resolve instead. async function requestFastForward(): Promise { showMultiView(); const [discovered, name] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), fetchNames(1) .then((batch) => batch.names[0]) .catch(() => undefined), ]); let presets: SpawnPreset[] = FALLBACK_PRESETS; try { const info = await getDaemonInfo(); if (info.presets?.length) presets = info.presets; } catch { /* keep fallbacks */ } const resolved = resolveFastForward(discovered, presets, name); const req = buildFastForwardRequest(resolved); if (req) { try { const session = await createSession(req); // Mount it, the way the TUI's fast path attaches. Without this the // keystroke creates a session and leaves the UI untouched, so it reads // as a dead key (#67 review). mv.openSessionBeside(session.id); updateShellRoute(); return; } catch { // A failed POST degrades to the same pre-filled draft as an // unresolvable default — never a silent dead keystroke. } } mv.openDraft(fastForwardSeed(resolved)); } // Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. // Ignored while typing anywhere (form fields, the terminal's hidden // textarea, search) so an attached session's keys are never hijacked. document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.altKey || e.metaKey) return; const target = e.target as HTMLElement | null; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable) ) { return; } if (e.key === "n") { e.preventDefault(); requestOpenSpawn(); } else if (e.key === "N") { e.preventDefault(); void requestFastForward(); } }); type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } | { kind: "worktrees" } | { kind: "not-found"; raw: string }; function parseRoute(): Route { const hash = location.hash.replace(/^#/, ""); if (hash === "" || hash === "/") return { kind: "catalog" }; if (hash === "/worktrees") return { kind: "worktrees" }; const m = hash.match(/^\/attach\/([^/?#]+)$/); if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; return { kind: "not-found", raw: hash }; } // Retained worktrees (#84): built lazily on first visit, then kept so a // revisit re-enters instantly. The daemon's home dir abbreviates paths once // `/api/info` answers. let worktreesView: WorktreesViewHandle | undefined; let homeDir: string | undefined; void getDaemonInfo() .then((info) => { homeDir = info.home_dir; }) .catch(() => { /* absolute paths are a fine fallback */ }); function showWorktrees(): void { pool.setVisible([]); pool.setFocus(null); contentShowsMv = false; if (!worktreesView) { worktreesView = createWorktreesView({ load: listWorktrees, reclaim: reclaimWorktree, homeDir: () => homeDir, }); } content.replaceChildren(worktreesView.el); shellEl.dataset.route = "worktrees"; void worktreesView.refresh(); } 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; case "worktrees": showWorktrees(); return; } } window.addEventListener("hashchange", dispatch); dispatch(); } // bootApp