// 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);