import { socket } from "./api.js"; import { errorText, h, replace } from "./dom.js"; import { findTask, onEach, state } from "./state.js"; const exitedCode = 4000; const internalErrorCode = 1011; const retryAfterMs = 2_000; let loading; function load() { loading ??= (async () => { document.head.append(h("link", { rel: "stylesheet", href: "/vendor/xterm.css" })); const xtermUrl = "/vendor/xterm.mjs"; const fitUrl = "/vendor/addon-fit.mjs"; const [x, f] = (await Promise.all([import(xtermUrl), import(fitUrl)])); await document.fonts.load(`13px ${getComputedStyle(document.documentElement).getPropertyValue("--font")}`); return { Terminal: x.Terminal, FitAddon: f.FitAddon }; })(); return loading; } function attach(taskId, screen, show) { const encoder = new TextEncoder(); let term; let ws; let observer; let retry = 0; let done = false; const send = (data) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(data); } }; const connect = () => { if (!term || done) { return; } clearTimeout(retry); show("connecting…", false); const t = term; const s = socket("/api/tasks/{taskId}/terminal", { path: { taskId }, query: { cols: t.cols, rows: t.rows } }); ws = s; s.onopen = () => { t.reset(); show("", false); }; s.onmessage = (e) => { t.write(new Uint8Array(e.data)); }; s.onclose = (e) => { if (ws !== s || done) { return; } ws = undefined; if (e.code === exitedCode) { show(`shell ended: ${e.reason}`, true); return; } if (e.code === internalErrorCode) { show(e.reason, true); return; } if (findTask(taskId)?.status !== "active") { show("task closed", false); return; } show("disconnected, reconnecting…", false); retry = setTimeout(connect, retryAfterMs); }; }; load() .then(({ Terminal, FitAddon }) => { if (done) { return; } const css = getComputedStyle(document.documentElement); const v = (name) => css.getPropertyValue(name).trim(); const t = new Terminal({ fontFamily: v("--font"), fontSize: 13, cursorBlink: true, scrollback: 5000, theme: { background: v("--bg"), foreground: v("--text"), cursor: v("--accent"), cursorAccent: v("--bg"), selectionBackground: v("--accent-tint"), }, }); const fit = new FitAddon(); t.loadAddon(fit); t.open(screen); fit.fit(); t.onData((data) => send(encoder.encode(data))); t.onBinary((data) => send(Uint8Array.from(data, (c) => c.charCodeAt(0)))); t.onResize(({ cols, rows }) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ cols, rows })); } }); observer = new ResizeObserver(() => fit.fit()); observer.observe(screen); term = t; t.focus(); connect(); }) .catch((err) => show(`terminal not loaded: ${errorText(err)}`, false)); return () => { done = true; clearTimeout(retry); observer?.disconnect(); ws?.close(); term?.dispose(); }; } export function mountTerminal(root) { const status = h("span", { class: "terminal-status" }); const restart = h("button", { type: "button", hidden: true }, "New shell"); const screen = h("div", { class: "terminal-screen" }); let attached; let detach = () => { }; const show = (text, exited) => { status.textContent = text; restart.hidden = !exited; }; const open = () => { detach(); detach = () => { }; attached = undefined; replace(screen); show("", false); const taskId = state.selectedTaskId; if (!taskId) { replace(screen, h("div", { class: "empty" }, "No task selected.")); return; } if (findTask(taskId)?.status !== "active") { replace(screen, h("div", { class: "empty" }, "The task is closed; its worktree is gone.")); return; } attached = taskId; detach = attach(taskId, screen, show); }; restart.onclick = open; screen.addEventListener("keydown", (e) => { if (e.ctrlKey && !e.metaKey) { e.stopPropagation(); } }); const offs = [ onEach(["selection"], open), onEach(["tasks"], () => { if (attached && findTask(attached)?.status !== "active") { open(); } }), ]; replace(root, h("div", { class: "terminal-view" }, h("header", { class: "terminal-header" }, h("span", { class: "terminal-title" }, "Shell in the task's worktree"), status, restart), screen)); open(); return () => { detach(); for (const off of offs) { off(); } }; } //# sourceMappingURL=terminal.js.map