Integration review (#168): the content pane opened a pane for whatever the sidebar highlighted with no status check. After a host reboot every session is dormant, so arrowing down the sidebar opened a pane per session that immediately died with WS 1006 (the daemon answers a bridgeless attach with 404/409, never a completed handshake). Gate every path that fills a pane (preview, open, mobile's single pane) on the session's own status: a session that isn't Running gets the same metadata-and-resume view its sidebar card shows (docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The shell also reacts to onNeverAttached (previous commit) by dropping the dead connection and re-rendering, and to an open session's Running-ness flipping — most commonly a resume completing — so a placeholder and a live pane swap in step with the catalog.
627 lines
24 KiB
TypeScript
627 lines
24 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 } from "./help.js";
|
|
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
|
|
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.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,
|
|
gitRoot,
|
|
projects as fetchProjects,
|
|
resumeSession,
|
|
} 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 { createPreviewSchedule } from "./previewSchedule.js";
|
|
import {
|
|
FOCUS_PANE_EVENT,
|
|
FOCUS_SIDEBAR_EVENT,
|
|
OPEN_COMMAND_BAR_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 home dir, for the command bar's `~/…` directory abbreviation.
|
|
// Absolute paths are a fine fallback until it lands.
|
|
let homeDir: string | undefined;
|
|
void getDaemonInfo()
|
|
.then((info) => {
|
|
homeDir = info.home_dir;
|
|
// The leader is the operator's, not this module's (#159): a daemon that
|
|
// names one rebinds the chords hanging off it, and the `?` overlay and the
|
|
// command bar re-render from the rebuilt set.
|
|
if (info.leader) useLeader(info.leader);
|
|
})
|
|
.catch(() => {
|
|
/* absolute paths and the default leader are fine fallbacks */
|
|
});
|
|
|
|
// 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;
|
|
|
|
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
|
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
|
// costs a couple of pane mounts instead of one per row walked.
|
|
const schedulePreview = createPreviewSchedule((sessionId) => {
|
|
ensureMultiView();
|
|
mv?.previewSession(sessionId);
|
|
updateShellRoute();
|
|
});
|
|
|
|
const sidebar = renderSidebar(store, {
|
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
|
link,
|
|
onPreview: schedulePreview,
|
|
});
|
|
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),
|
|
// #168 review: a handshake that never opened (a dormant session's `409
|
|
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
|
|
// connection and re-render so the leaf gets a fresh attempt if the catalog
|
|
// still says Running, or the resume affordance if it doesn't.
|
|
onNeverAttached: (id) => {
|
|
pool.close(id);
|
|
mv?.render();
|
|
},
|
|
shouldReconnect: (id, code, everOpened) =>
|
|
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
|
|
});
|
|
|
|
// 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.
|
|
//
|
|
// A pane can also be showing the dormant placeholder (or, briefly, a dead
|
|
// socket) rather than a live attach (#168): when an *open* session's
|
|
// Running-ness flips — most commonly a resume completing — the pane and the
|
|
// placeholder need to swap without waiting on an unrelated layout change.
|
|
let lastOpenRunning = new Map<string, boolean>();
|
|
store.subscribe((state) => {
|
|
for (const id of mv?.openSessions() ?? []) {
|
|
if (!state.sessions.has(id)) closePane(id);
|
|
}
|
|
const open = mv?.openSessions() ?? new Set<string>();
|
|
const nextRunning = new Map<string, boolean>();
|
|
let runningChanged = false;
|
|
for (const id of open) {
|
|
const running = state.sessions.get(id)?.status === "Running";
|
|
nextRunning.set(id, running);
|
|
if (lastOpenRunning.get(id) !== running) runningChanged = true;
|
|
}
|
|
lastOpenRunning = nextRunning;
|
|
if (runningChanged) mv?.render();
|
|
});
|
|
|
|
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 {
|
|
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
|
|
// not swap the list out for a terminal on the way past a row.
|
|
const hasPanes = (mv?.openSessions() ?? 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,
|
|
sessionFor: (id) => store.snapshot().sessions.get(id),
|
|
onResume: (id) => resumeSession(id),
|
|
});
|
|
let contentShowsMv = false;
|
|
/** Put the multi-view in the content pane, without rendering it. A preview
|
|
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
|
|
* first re-mount the pane it is about to replace, and that mount takes the
|
|
* keyboard away from the list being walked. */
|
|
function ensureMultiView(): void {
|
|
if (!contentShowsMv) {
|
|
content.replaceChildren(mv.el);
|
|
contentShowsMv = true;
|
|
}
|
|
}
|
|
function showMultiView(): void {
|
|
ensureMultiView();
|
|
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 spawnPresets(): Promise<SpawnPreset[]> {
|
|
try {
|
|
const info = await getDaemonInfo();
|
|
if (info.presets?.length) return info.presets;
|
|
} catch {
|
|
/* keep fallbacks */
|
|
}
|
|
return FALLBACK_PRESETS;
|
|
}
|
|
|
|
async function requestFastForward(): Promise<void> {
|
|
const [discovered, handle] = await Promise.all([
|
|
fetchProjects().catch(() => [] as Project[]),
|
|
fetchNames(1)
|
|
.then((batch) => batch.names[0])
|
|
.catch(() => undefined),
|
|
]);
|
|
const presets = await spawnPresets();
|
|
const resolved = resolveFastForward(discovered, presets, handle, 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),
|
|
});
|
|
}
|
|
|
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
|
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
|
// project. A directory the project scan never saw stands in as its own project;
|
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
|
const [discovered, handle] = await Promise.all([
|
|
fetchProjects().catch(() => [] as Project[]),
|
|
fetchNames(1)
|
|
.then((batch) => batch.names[0])
|
|
.catch(() => undefined),
|
|
]);
|
|
const presets = await spawnPresets();
|
|
const known = discovered.find((p) => p.path === path);
|
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
|
const resolved = resolveFastForward([project], presets, handle, path);
|
|
const req = buildFastForwardRequest(resolved);
|
|
if (req) {
|
|
const inRepo =
|
|
known !== undefined ||
|
|
(await gitRoot(path)
|
|
.then((r) => !!r.root)
|
|
.catch(() => false));
|
|
if (!inRepo) delete req.worktree;
|
|
try {
|
|
mountSpawned(await createSession(req));
|
|
return;
|
|
} catch {
|
|
// A failed POST degrades to the pre-filled form, never a dead row.
|
|
}
|
|
}
|
|
openSpawnModal({
|
|
seed: fastForwardSeed(resolved),
|
|
onSuccess: (session) => mountSpawned(session),
|
|
});
|
|
}
|
|
|
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
|
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
|
});
|
|
|
|
// The global half of the command registry (#148). Two sets, because they
|
|
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
|
// the plain keys go deaf behind it while the focus chord does not — an
|
|
// attached terminal must never be able to trap the focus keys.
|
|
//
|
|
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
|
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
|
|
// hidden textarea.
|
|
const spawnKeys = new CommandSet(
|
|
defineCommands({
|
|
"spawn.open": () => requestOpenSpawn(),
|
|
"spawn.fastForward": () => 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.
|
|
//
|
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
|
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
|
// (#149).
|
|
let currentLeader = DEFAULT_LEADER;
|
|
|
|
function buildFocusKeys(leader: string): CommandSet {
|
|
return new CommandSet(
|
|
defineCommands(
|
|
{
|
|
"focus.toggle": () => {
|
|
if (sidebar.hasListFocus()) {
|
|
pool.focusTerminal();
|
|
} else {
|
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
sidebar.focusList();
|
|
}
|
|
},
|
|
// The escape hatch: binding ctrl-k takes readline's
|
|
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
|
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
|
// this set, ahead of the bar's, so the chord never opens the bar.
|
|
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
|
// The leader itself, pressed twice, tmux-style: sends one literal
|
|
// leader byte and clears the pending chord instead of resolving as a
|
|
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
|
// hardcoded byte, so a rebound leader sends its own.
|
|
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
|
},
|
|
leader,
|
|
),
|
|
{ leader },
|
|
);
|
|
}
|
|
|
|
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
|
|
|
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
|
* same command ids replaces them, so the help overlay and the command bar —
|
|
* both of which render the published registry when they open — show the
|
|
* configured chord rather than the default. */
|
|
function useLeader(leader: string): void {
|
|
if (leader === currentLeader) return;
|
|
currentLeader = leader;
|
|
focusKeys = buildFocusKeys(leader);
|
|
}
|
|
|
|
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
|
// like any other, so the `?` overlay lists it without a second list.
|
|
const openBar = () => {
|
|
// Opening while a session is previewed discards the preview first, like
|
|
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
|
schedulePreview.cancel();
|
|
mv?.previewSession(null);
|
|
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
|
};
|
|
|
|
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
|
|
|
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
|
// phone, so the field the operator already reaches for opens the same bar.
|
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
|
|
|
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
|
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
|
window.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
// The bar owns the keyboard while it is up; its own input handles the keys.
|
|
if (isCommandBarOpen()) return;
|
|
if (focusKeys.handle(e)) return;
|
|
// The help overlay owns the keyboard while it is up.
|
|
if (isHelpOpen()) return;
|
|
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
|
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
|
// spawn form's inputs — keeps its own Ctrl+K.
|
|
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
|
|
spawnKeys.handle(e);
|
|
},
|
|
true,
|
|
);
|
|
|
|
|
|
// 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, () => {
|
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
|
// being provisional rather than snapping back the moment the list blurs.
|
|
const previewed = mv.previewedSessionId();
|
|
if (previewed) focusSession(previewed);
|
|
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 {
|
|
schedulePreview.cancel();
|
|
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
|