165 lines
5.3 KiB
JavaScript
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
|