Add per-task terminals over WebSocket with an xterm.js Terminal tab
This commit is contained in:
Vendored
+165
@@ -0,0 +1,165 @@
|
||||
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
|
||||
Reference in New Issue
Block a user