Add per-task terminals over WebSocket with an xterm.js Terminal tab
This commit is contained in:
Vendored
+10
-1
@@ -5,7 +5,7 @@ export class ApiError extends Error {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
export async function request(method, path, opts = {}) {
|
||||
function target(path, opts) {
|
||||
const pathParams = (opts.path ?? {});
|
||||
let url = path.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = pathParams[key];
|
||||
@@ -23,6 +23,10 @@ export async function request(method, path, opts = {}) {
|
||||
if (query.size > 0) {
|
||||
url += `?${query}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
export async function request(method, path, opts = {}) {
|
||||
const url = target(path, opts);
|
||||
const init = { method: method.toUpperCase(), keepalive: opts.keepalive ?? false };
|
||||
if (opts.body instanceof Blob) {
|
||||
init.headers = { "Content-Type": opts.body.type || "application/octet-stream" };
|
||||
@@ -50,6 +54,11 @@ export const get = (path, opts) => request("get", path, opts);
|
||||
export const post = (path, opts) => request("post", path, opts);
|
||||
export const put = (path, opts) => request("put", path, opts);
|
||||
export const del = (path, opts) => request("delete", path, opts);
|
||||
export function socket(path, opts) {
|
||||
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${target(path, opts)}`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
return ws;
|
||||
}
|
||||
const staleAfterMs = 40_000;
|
||||
const retryAfterMs = 2_000;
|
||||
export function subscribe(after, filter, onEvent, onState) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -8,10 +8,12 @@ import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
const views = {
|
||||
transcript: { label: "Transcript", mount: mountTranscript },
|
||||
changes: { label: "Changes", mount: (root) => mountChanges(root) },
|
||||
files: { label: "Files", mount: mountFiles },
|
||||
terminal: { label: "Terminal", mount: mountTerminal },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
project: { label: "Project", mount: mountProject },
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { get, put, subscribe } from "./api.js";
|
||||
export const tabs = ["transcript", "changes", "files", "inbox", "project"];
|
||||
export const tabs = ["transcript", "changes", "files", "terminal", "inbox", "project"];
|
||||
export const state = {
|
||||
me: undefined,
|
||||
projects: [],
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user