Pressing Spawn while reading a session split that pane in half. It now opens the draft in its own tab, leaving the current tab untouched. Scoped to the explicit Spawn affordances (the header button and the sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the fast-forward key deliberately keep splitting: both act on the session in front of you, so a pane beside it is the point. Reusing an empty pane (#72) is unchanged — that path never split anything. Closes #100
427 lines
14 KiB
TypeScript
427 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();
|
|
// 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.
|
|
mv.openDraft(undefined, { newTab: true });
|
|
}
|
|
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
|