Files
byre/web/dist/terminal.js
T

165 lines
5.3 KiB
JavaScript

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