|
// retoor <retoor@molodetz.nl>
|
|
|
|
import FloatingWindow from "./FloatingWindow.js";
|
|
import { userScope } from "../userScope.js";
|
|
|
|
const XTERM_JS = "/static/vendor/xterm/xterm.js";
|
|
const XTERM_CSS = "/static/vendor/xterm/xterm.css";
|
|
const XTERM_FIT = "/static/vendor/xterm/addon-fit.js";
|
|
|
|
const FONT_MIN = 8;
|
|
const FONT_MAX = 30;
|
|
const FONT_DEFAULT = 13;
|
|
|
|
let xtermLoading = null;
|
|
let cascade = 0;
|
|
|
|
function fontKey() {
|
|
return `ct-fontsize:${userScope()}`;
|
|
}
|
|
|
|
function readFontSize() {
|
|
try {
|
|
const value = parseInt(window.localStorage.getItem(fontKey()), 10);
|
|
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
|
|
} catch (error) {
|
|
return FONT_DEFAULT;
|
|
}
|
|
}
|
|
|
|
function writeFontSize(size) {
|
|
try {
|
|
window.localStorage.setItem(fontKey(), String(size));
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function loadScript(src) {
|
|
return new Promise((resolve, reject) => {
|
|
if (document.querySelector(`script[src="${src}"]`)) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const script = document.createElement("script");
|
|
script.src = src;
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error(`failed to load ${src}`));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function loadCss(href, id) {
|
|
if (document.getElementById(id)) return;
|
|
const link = document.createElement("link");
|
|
link.id = id;
|
|
link.rel = "stylesheet";
|
|
link.href = href;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
function ensureXterm() {
|
|
if (window.Terminal && window.FitAddon) return Promise.resolve();
|
|
if (!xtermLoading) {
|
|
loadCss(XTERM_CSS, "xterm-css");
|
|
xtermLoading = loadScript(XTERM_JS).then(() => loadScript(XTERM_FIT));
|
|
}
|
|
return xtermLoading;
|
|
}
|
|
|
|
export default class ContainerTerminalElement extends FloatingWindow {
|
|
get windowKey() {
|
|
return "container-terminal";
|
|
}
|
|
|
|
get titleText() {
|
|
return this.getAttribute("label") || this.getAttribute("instance") || "terminal";
|
|
}
|
|
|
|
get fontControls() {
|
|
return true;
|
|
}
|
|
|
|
get isPersistent() {
|
|
return this.hasAttribute("persist");
|
|
}
|
|
|
|
setPersistent(on) {
|
|
if (on) {
|
|
this.setAttribute("persist", "");
|
|
} else {
|
|
this.removeAttribute("persist");
|
|
this._clearReconnect();
|
|
}
|
|
}
|
|
|
|
_clearReconnect() {
|
|
if (this._reconnectTimer) {
|
|
window.clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
setFontSize(size) {
|
|
if (!this.term) return;
|
|
this.term.options.fontSize = size;
|
|
this._onResize();
|
|
}
|
|
|
|
_changeFont(delta) {
|
|
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, readFontSize() + delta));
|
|
writeFontSize(next);
|
|
if (globalThis.app && globalThis.app.containerTerminals) {
|
|
globalThis.app.containerTerminals.applyFontSize(next);
|
|
} else {
|
|
this.setFontSize(next);
|
|
}
|
|
}
|
|
|
|
_defaultGeometry() {
|
|
const geometry = super._defaultGeometry();
|
|
const offset = (cascade++ % 6) * 26;
|
|
geometry.left = Math.max(12, geometry.left + offset);
|
|
geometry.top = Math.max(12, geometry.top + offset);
|
|
return geometry;
|
|
}
|
|
|
|
async _buildBody(body) {
|
|
body.classList.add("ct-body");
|
|
this._status = document.createElement("div");
|
|
this._status.className = "ct-status";
|
|
this._status.textContent = "connecting...";
|
|
body.appendChild(this._status);
|
|
this._mount = document.createElement("div");
|
|
this._mount.className = "ct-xterm";
|
|
body.appendChild(this._mount);
|
|
try {
|
|
await ensureXterm();
|
|
} catch (error) {
|
|
this._status.textContent = "failed to load terminal assets";
|
|
return;
|
|
}
|
|
if (!this.isConnected) return;
|
|
this._initTerm();
|
|
}
|
|
|
|
_initTerm() {
|
|
const Terminal = window.Terminal;
|
|
const FitAddon = window.FitAddon.FitAddon;
|
|
this.term = new Terminal({
|
|
cursorBlink: true,
|
|
fontFamily: '"SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace',
|
|
fontSize: readFontSize(),
|
|
theme: { background: "#000000", foreground: "#d0d0d0" },
|
|
scrollback: 5000,
|
|
});
|
|
this.fit = new FitAddon();
|
|
this.term.loadAddon(this.fit);
|
|
this.term.open(this._mount);
|
|
this.term.onData((data) => {
|
|
if (this._ws && this._ws.readyState === 1) this._ws.send(data);
|
|
});
|
|
this._fitSoon();
|
|
this._connect();
|
|
}
|
|
|
|
_fitSoon() {
|
|
[0, 60, 180, 400].forEach((delay) => window.setTimeout(() => this._onResize(), delay));
|
|
}
|
|
|
|
_connect() {
|
|
const slug = this.getAttribute("project-slug");
|
|
const instance = this.getAttribute("instance");
|
|
const session = this.getAttribute("session") || "";
|
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
const query = session ? `?session=${encodeURIComponent(session)}` : "";
|
|
const url = `${scheme}://${location.host}/projects/${encodeURIComponent(slug)}`
|
|
+ `/containers/instances/${encodeURIComponent(instance)}/exec/ws${query}`;
|
|
const ws = new WebSocket(url);
|
|
this._ws = ws;
|
|
ws.addEventListener("open", () => {
|
|
this._reconnects = 0;
|
|
if (this._status) this._status.style.display = "none";
|
|
this._fitSoon();
|
|
if (this.term) this.term.focus();
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
if (this.term) this.term.write(event.data);
|
|
});
|
|
ws.addEventListener("close", (event) => {
|
|
this._ws = null;
|
|
if (this._closing) return;
|
|
if (event.code === 1008) {
|
|
if (this.term) this.term.write("\r\n\x1b[31m[forbidden]\x1b[0m\r\n");
|
|
return;
|
|
}
|
|
if (this.isPersistent) {
|
|
this._scheduleReconnect();
|
|
} else if (this.term) {
|
|
this.term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
|
}
|
|
});
|
|
ws.addEventListener("error", () => {});
|
|
}
|
|
|
|
_scheduleReconnect() {
|
|
if (this._closing || this._reconnectTimer) return;
|
|
this._reconnects = (this._reconnects || 0) + 1;
|
|
const delay = Math.min(5000, 400 * this._reconnects);
|
|
if (this._status) {
|
|
this._status.style.display = "";
|
|
this._status.textContent = "reconnecting...";
|
|
}
|
|
this._reconnectTimer = window.setTimeout(() => {
|
|
this._reconnectTimer = null;
|
|
if (!this._closing && this.isConnected) this._connect();
|
|
}, delay);
|
|
}
|
|
|
|
_sendResize() {
|
|
if (!this.term || !this._ws || this._ws.readyState !== 1) return;
|
|
this._ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
|
|
}
|
|
|
|
_onResize() {
|
|
if (!this.fit || !this.term || !this.isConnected) return;
|
|
try {
|
|
this.fit.fit();
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
this._sendResize();
|
|
}
|
|
|
|
_onShow() {
|
|
if (this.term) this.term.focus();
|
|
}
|
|
|
|
_contextItems() {
|
|
const slug = this.getAttribute("project-slug");
|
|
return [
|
|
{ label: "Sync files", onSelect: () => this._lifecycle("sync", "Files synced") },
|
|
{ label: "Restart", onSelect: () => this._lifecycle("restart", "Restart requested") },
|
|
{ label: "Terminate", danger: true, onSelect: () => this._terminate() },
|
|
{ separator: true },
|
|
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
|
|
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
|
|
{ separator: true },
|
|
{ label: "Project", onSelect: () => this._navigate(`/projects/${slug}`) },
|
|
{ label: "Files", onSelect: () => this._navigate(`/projects/${slug}/files`) },
|
|
{ separator: true },
|
|
{ label: "Close", danger: true, onSelect: () => this.close() },
|
|
];
|
|
}
|
|
|
|
_post(action) {
|
|
const slug = this.getAttribute("project-slug");
|
|
const instance = this.getAttribute("instance");
|
|
return fetch(
|
|
`/projects/${encodeURIComponent(slug)}/containers/instances/${encodeURIComponent(instance)}/${action}`,
|
|
{ method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" } },
|
|
);
|
|
}
|
|
|
|
async _lifecycle(action, okMessage) {
|
|
try {
|
|
const res = await this._post(action);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.ok) this._toast(okMessage, "success");
|
|
else this._toast((data.error && data.error.message) || `${action} failed`, "error");
|
|
} catch (error) {
|
|
this._toast(`${action} failed`, "error");
|
|
}
|
|
}
|
|
|
|
async _terminate() {
|
|
if (globalThis.app && globalThis.app.dialog) {
|
|
const ok = await globalThis.app.dialog.confirm({
|
|
title: "Terminate container",
|
|
message: `Stop and remove "${this.titleText}"? Synced project files are kept.`,
|
|
confirmLabel: "Terminate",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
await this._lifecycle("delete", "Container terminated");
|
|
this.close();
|
|
}
|
|
|
|
_navigate(url) {
|
|
window.location.assign(url);
|
|
}
|
|
|
|
_toast(message, type) {
|
|
if (globalThis.app && globalThis.app.toast) globalThis.app.toast.show(message, { type: type || "info" });
|
|
}
|
|
|
|
_onClose() {
|
|
this._closing = true;
|
|
this._clearReconnect();
|
|
if (globalThis.app && globalThis.app.containerTerminals) {
|
|
globalThis.app.containerTerminals.forget(this);
|
|
}
|
|
try {
|
|
if (this._ws) this._ws.close();
|
|
} catch (error) {
|
|
return this._dispose();
|
|
}
|
|
this._dispose();
|
|
}
|
|
|
|
_dispose() {
|
|
try {
|
|
if (this.term) this.term.dispose();
|
|
} catch (error) {
|
|
// already disposed
|
|
}
|
|
this.remove();
|
|
}
|
|
}
|
|
|
|
customElements.define("container-terminal", ContainerTerminalElement);
|