Tab now moves the keyboard from the session list to the pane (was Escape); Escape moves it back from the pane to the list, intercepted via xterm's attachCustomKeyEventHandler so it never reaches the shell/app. Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers reserve it and never deliver it to page JS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import "@fontsource/jetbrains-mono/400.css";
|
|
import "@fontsource/jetbrains-mono/500.css";
|
|
import "@fontsource/jetbrains-mono/600.css";
|
|
import "@fontsource/jetbrains-mono/700.css";
|
|
import { renderSidebar } from "./catalog.js";
|
|
import { ConnectionPool } from "./connections.js";
|
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
|
import { installHudKeybind } from "./debug.js";
|
|
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
|
import { DaemonLink } from "./daemonLink.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import { installRestartOverlay } from "./restartOverlay.js";
|
|
import { shouldRetryClose } from "./reconnect.js";
|
|
import {
|
|
createSession,
|
|
fetchNames,
|
|
getDaemonInfo,
|
|
projects as fetchProjects,
|
|
} from "./api.js";
|
|
import {
|
|
buildFastForwardRequest,
|
|
fastForwardSeed,
|
|
resolveFastForward,
|
|
} from "./fastForward.js";
|
|
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
|
import { openSpawnModal } from "./spawnModal.js";
|
|
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.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 {
|
|
FOCUS_PANE_EVENT,
|
|
FOCUS_SIDEBAR_EVENT,
|
|
OPEN_SPAWN_EVENT,
|
|
SESSION_SELECT_EVENT,
|
|
SPAWN_SIMILAR_EVENT,
|
|
} from "./catalog.js";
|
|
import { initTheme } from "./theme.js";
|
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
|
import { installViewportSizing } from "./viewport.js";
|
|
import "./styles.css";
|
|
|
|
initTheme();
|
|
installViewportSizing();
|
|
installHudKeybind();
|
|
installHelpKeybind();
|
|
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();
|
|
|
|
// The daemon's identity and liveness: names the release in the sidebar's status
|
|
// dot, and blocks the UI behind the restart overlay while the daemon is away
|
|
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
|
const link = new DaemonLink();
|
|
installRestartOverlay(link);
|
|
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
|
|
void link.start();
|
|
|
|
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),
|
|
link,
|
|
});
|
|
const content = document.createElement("section");
|
|
content.className = "content";
|
|
|
|
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
|
|
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
|
// similar, backdrop); on desktop the class it toggles is inert.
|
|
const backdrop = document.createElement("div");
|
|
backdrop.className = "mv-backdrop";
|
|
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
|
installDrawerSwipe(drawer);
|
|
|
|
shellEl.append(sidebar.el, content, backdrop);
|
|
root.replaceChildren(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>
|
|
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
|
<div class="empty-actions"></div>`;
|
|
const actions = el.querySelector(".empty-actions") as HTMLElement;
|
|
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
|
|
// the drawer would be swipe-only. CSS hides this on desktop.
|
|
const sessions = document.createElement("button");
|
|
sessions.type = "button";
|
|
sessions.className = "btn btn-ghost welcome-sessions";
|
|
sessions.textContent = "☰ Sessions";
|
|
sessions.addEventListener("click", () => requestToggleDrawer());
|
|
actions.appendChild(sessions);
|
|
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());
|
|
actions.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 spawn fills a pane with its new session; 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: the sidebar's footer button, its empty state, and
|
|
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
|
|
// cloned session (see spawnSimilar.ts).
|
|
function requestOpenSpawn(): void {
|
|
// The session lands in a new tab, not a split of the pane you were reading
|
|
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
|
// the old behaviour: both act on the session in front of you, so a split
|
|
// beside it is the point.
|
|
// The session you were last on preselects its project: `n` then Enter spawns
|
|
// another session there without touching the picker.
|
|
const current = focusedSession();
|
|
openSpawnModal({
|
|
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
|
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
|
});
|
|
}
|
|
|
|
/** The session the operator was last on, if any. Shared by the plain spawn
|
|
* modal seed and fast-forward's project pick: both should land on "where I
|
|
* currently am". */
|
|
function focusedSession(): Session | undefined {
|
|
const focused = mv?.focusedSessionId() ?? null;
|
|
return focused ? store.snapshot().sessions.get(focused) : undefined;
|
|
}
|
|
|
|
/** The focused session's project — its base repo for a worktree session, its
|
|
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
|
|
function focusedProjectCwd(): string | undefined {
|
|
const s = focusedSession();
|
|
if (!s) return undefined;
|
|
return s.worktree ? s.worktree.base_repo : s.cwd;
|
|
}
|
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
|
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
|
const s = (ev as CustomEvent<Session>).detail;
|
|
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
|
|
});
|
|
|
|
/** Show a freshly spawned session: bring the multi-view up (the shell may still
|
|
* be on the catalog route) and give the session a pane. */
|
|
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
|
|
showMultiView();
|
|
mv.openSessionBeside(session.id, placement);
|
|
updateShellRoute();
|
|
}
|
|
|
|
// Fast-forward spawn (#67): the spawn modal'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 the form pre-filled with
|
|
// what did resolve instead.
|
|
async function requestFastForward(): Promise<void> {
|
|
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, focusedProjectCwd());
|
|
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).
|
|
mountSpawned(session);
|
|
return;
|
|
} catch {
|
|
// A failed POST degrades to the same pre-filled form as an
|
|
// unresolvable default — never a silent dead keystroke.
|
|
}
|
|
}
|
|
openSpawnModal({
|
|
seed: fastForwardSeed(resolved),
|
|
onSuccess: (session) => mountSpawned(session),
|
|
});
|
|
}
|
|
|
|
// Keyboard pair (#67): `n` opens the spawn modal, `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;
|
|
if (isTypingTarget(e.target)) return;
|
|
// The help overlay owns the keyboard while it is up.
|
|
if (isHelpOpen()) return;
|
|
if (e.key === "n") {
|
|
e.preventDefault();
|
|
requestOpenSpawn();
|
|
} else if (e.key === "N") {
|
|
e.preventDefault();
|
|
void requestFastForward();
|
|
}
|
|
});
|
|
|
|
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
|
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
|
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
|
// the focus back out of it.
|
|
window.addEventListener("keydown", (e) => {
|
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
|
if (e.key !== "H" && e.key !== "h") return;
|
|
e.preventDefault();
|
|
if (sidebar.hasListFocus()) {
|
|
pool.focusTerminal();
|
|
} else {
|
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
sidebar.focusList();
|
|
}
|
|
});
|
|
|
|
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
|
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
|
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
|
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
sidebar.focusList();
|
|
});
|
|
|
|
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
|
* changing, leaving a click back onto that session's row silently ignored. */
|
|
function focusSession(sessionId: string): void {
|
|
showMultiView();
|
|
mv.openSession(sessionId);
|
|
updateShellRoute();
|
|
}
|
|
|
|
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
|
|
focusSession((e as CustomEvent<string>).detail);
|
|
});
|
|
|
|
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.
|
|
focusSession(route.sessionId);
|
|
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
|