423 lines
14 KiB
TypeScript
423 lines
14 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 {
|
|
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<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.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 = `
|
|
<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();
|
|
}
|
|
|
|
// 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<Session>).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<void> {
|
|
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
|