feat: add project fork async job service with cli prune/clear commands and shared container image build target

This commit is contained in:
2026-06-09 14:06:02 +00:00
parent c565e3b55c
commit 05c6fa7f3b
90 changed files with 6889 additions and 1146 deletions
+12
View File
@@ -858,6 +858,18 @@ button.comment-form-submit:hover {
display: flex;
}
.topnav,
.topnav-mobile-overlay,
.topnav-mobile-panel,
.modal-overlay,
.dialog-overlay,
.context-menu,
.dp-toast-host,
.feed-fab,
.attachment-lightbox {
will-change: transform;
}
@media (max-width: 480px) {
.modal-card {
margin: 0.5rem;
+2 -2
View File
@@ -3,7 +3,7 @@
position: fixed;
inset: 0;
background: var(--overlay-dark);
z-index: 2100;
z-index: 2147483647;
align-items: center;
justify-content: center;
}
@@ -65,7 +65,7 @@
.context-menu {
display: none;
position: fixed;
z-index: 1900;
z-index: 2147483500;
min-width: 180px;
max-width: 280px;
background: var(--bg-card);
+124 -21
View File
@@ -1,31 +1,134 @@
/* retoor <retoor@molodetz.nl> */
.cm-page { max-width: 1400px; margin: 0 auto; padding: 1rem; }
.cm-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.cm-title { font-size: 1.4rem; margin: 0; }
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
.cm-col { background: var(--surface, #1b1f24); border: 1px solid var(--border, #2a2f36); border-radius: 8px; padding: 1rem; }
/* ---------------- shared ---------------- */
.cm-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
background: var(--border);
color: var(--text-secondary);
}
.cm-badge.cm-running { background: var(--success); color: var(--white); }
.cm-badge.cm-crashed { background: var(--danger); color: var(--white); }
.cm-badge.cm-paused { background: var(--warning); color: var(--white); }
.cm-badge.cm-stopped { background: var(--text-muted); color: var(--white); }
.cm-badge.cm-removed { background: var(--text-muted); color: var(--white); }
.cm-badge.cm-created,
.cm-badge.cm-starting,
.cm-badge.cm-restarting { background: var(--info); color: var(--white); }
.cm-badge.cm-success { background: var(--success); color: var(--white); }
.cm-badge.cm-failed { background: var(--danger); color: var(--white); }
.cm-badge.cm-building,
.cm-badge.cm-queued { background: var(--info); color: var(--white); }
.cm-badge.cm-cancelled { background: var(--text-muted); color: var(--white); }
.cm-muted { color: var(--text-muted); }
/* ---------------- project manager ---------------- */
.cm-page { display: flex; flex-direction: column; gap: 1rem; }
.cm-header { display: flex; align-items: center; gap: 1rem; }
.cm-header-link { margin-left: auto; color: var(--text-secondary); font-size: 0.875rem; }
.cm-header-link:hover { color: var(--accent); }
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
.cm-col { min-width: 0; }
.cm-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.cm-col-head h2 { font-size: 1.1rem; margin: 0; }
.cm-list { list-style: none; margin: 0; padding: 0; }
.cm-item { padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
.cm-item:hover { background: var(--surface-2, #232830); }
.cm-item.active { background: var(--accent-soft, #2d3748); }
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border, #2a2f36); padding-top: 1rem; }
.cm-item { border-radius: var(--radius); }
.cm-item + .cm-item { margin-top: 0.25rem; }
.cm-item:not(:has(.cm-item-link)) { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
.cm-item:not(:has(.cm-item-link)):hover { background: var(--bg-card-hover); }
.cm-item.active { background: var(--accent-light); }
.cm-item-link { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; color: var(--text-primary); border-radius: var(--radius); }
.cm-item-link:hover { background: var(--bg-card-hover); }
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 1rem; }
.cm-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.cm-detail-name { font-weight: 600; flex: 1; }
.cm-editor { width: 100%; min-height: 260px; font-family: monospace; font-size: 0.85rem; background: #0f1216; color: #d7dde3; border: 1px solid var(--border, #2a2f36); border-radius: 6px; padding: 0.5rem; resize: vertical; }
.cm-editor { width: 100%; min-height: 260px; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem; resize: vertical; }
.cm-builds { margin-top: 1rem; }
.cm-builds h3 { font-size: 0.95rem; margin: 0 0 0.5rem; }
.cm-log { background: #0f1216; color: #cfe3cf; font-family: monospace; font-size: 0.8rem; padding: 0.75rem; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
.cm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
.cm-metrics { font-family: monospace; font-size: 0.8rem; color: var(--muted, #9aa4b2); margin-bottom: 0.5rem; }
.cm-exec { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.cm-exec input { flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border, #2a2f36); background: #0f1216; color: #d7dde3; font-family: monospace; }
.cm-badge { font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 10px; background: #2a2f36; text-transform: uppercase; letter-spacing: 0.03em; }
.cm-badge.cm-running { background: #1f7a3d; color: #fff; }
.cm-badge.cm-crashed { background: #8a2b2b; color: #fff; }
.cm-badge.cm-stopped { background: #4a5160; color: #fff; }
.cm-badge.cm-paused { background: #8a6d1f; color: #fff; }
@media (max-width: 900px) { .cm-body { grid-template-columns: 1fr; } }
.cm-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
.cm-modal-form .auth-field select { width: 100%; }
.cm-build-label { cursor: pointer; flex: 1; }
.cm-build-link { color: var(--text-muted); font-size: 0.8rem; }
.cm-build-link:hover { color: var(--accent); }
.cm-modal-form .topic-label { display: flex; align-items: center; justify-content: flex-start; gap: 0.5rem; }
.cm-modal-form .topic-label input { width: auto; margin: 0; }
/* ---------------- admin list ---------------- */
.admin-toolbar-action { margin-left: auto; }
.cm-row-name { color: var(--accent); font-weight: 600; }
.cm-row-project { color: var(--text-secondary); }
.cm-row-project:hover { color: var(--accent); }
/* ---------------- instance detail ---------------- */
.ci-page { display: flex; flex-direction: column; gap: 1rem; }
.ci-header { display: flex; flex-direction: column; gap: 0.5rem; }
.ci-titlebar { display: flex; align-items: center; gap: 0.75rem; }
.ci-title { font-size: 1.5rem; margin: 0; }
.ci-project { color: var(--text-secondary); font-size: 0.875rem; }
.ci-project:hover { color: var(--accent); }
.ci-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
.ci-card { min-width: 0; }
.ci-card h2 { font-size: 1.05rem; margin: 0 0 0.75rem; }
.ci-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.ci-card-head h2 { margin: 0; }
.ci-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.ci-actions .ci-danger { color: var(--danger); border-color: var(--danger); }
.ci-runtime { display: flex; flex-direction: column; gap: 0.4rem; }
.ci-kv { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.85rem; }
.ci-kv span { color: var(--text-muted); }
.ci-kv code { font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; text-align: right; }
.ci-ingress { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
.ci-ingress-label { color: var(--text-muted); }
.ci-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.ci-metric { display: flex; flex-direction: column; gap: 0.2rem; }
.ci-metric-label { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
.ci-metric-value { font-family: var(--font-mono); font-size: 1.1rem; color: var(--text-primary); }
.ci-schedules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.ci-schedule { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.ci-schedule-action { font-weight: 600; text-transform: uppercase; font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: var(--accent-light); color: var(--accent); }
.ci-schedule-next { color: var(--text-secondary); font-family: var(--font-mono); }
.ci-schedule button { margin-left: auto; }
.ci-exec { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.ci-exec input { flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-family: var(--font-mono); }
.ci-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 360px; overflow: auto; white-space: pre-wrap; margin: 0; }
.ci-log:focus { outline: 1px solid var(--accent); }
.ci-log.ci-empty { color: var(--text-muted); font-style: italic; }
.cb-tag { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
.cb-error { color: var(--danger); font-size: 0.85rem; margin: 0.75rem 0 0; }
.cb-dockerfile { max-height: 420px; }
.ci-terminal { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.ci-terminal .ci-term-output { border: none; border-radius: 0; max-height: 320px; }
.ci-term-bar { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-input); border-top: 1px solid var(--border); padding: 0.25rem 0.6rem; }
.ci-term-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 600; }
.ci-term-input { flex: 1; background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.4rem 0; }
.ci-term-input:focus { outline: none; }
.ci-term-input:disabled { color: var(--text-muted); }
@media (max-width: 1024px) {
.cm-body { grid-template-columns: 1fr; }
.ci-grid { grid-template-columns: 1fr; }
}
+8
View File
@@ -27,6 +27,14 @@ devii-terminal {
font-size: var(--devii-font-size);
}
devii-terminal .devii-launcher,
devii-terminal .devii-window,
.devii-hl-box,
.devii-hl-callout,
.devii-toast {
will-change: transform;
}
/* Launcher FAB (shown only when closed) */
devii-terminal .devii-launcher {
position: fixed;
+1 -1
View File
@@ -323,7 +323,7 @@
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
z-index: 100;
z-index: 900;
border: none;
cursor: pointer;
}
+182
View File
@@ -0,0 +1,182 @@
/* retoor <retoor@molodetz.nl> */
.fw-host {
--fw-bg: #000000;
--fw-fg: #d0d0d0;
--fw-dim: #6a737d;
--fw-accent: #3fb950;
--fw-bar-bg: #0b0b0b;
--fw-border: #1c1f24;
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
--fw-radius: 10px;
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
--fw-font-size: 14px;
--fw-z: 2147480000;
position: fixed;
inset: 0;
pointer-events: none;
z-index: var(--fw-z);
font-family: var(--fw-font);
font-size: var(--fw-font-size);
}
.fw-host .fw-window {
position: fixed;
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 320px;
min-height: 220px;
max-width: 100vw;
max-height: 100vh;
background: var(--fw-bg);
color: var(--fw-fg);
border: 1px solid var(--fw-border);
border-radius: var(--fw-radius);
box-shadow: var(--fw-shadow);
line-height: 1.5;
animation: fw-pop 0.16s ease;
transition: top 0.18s ease, left 0.18s ease, width 0.18s ease, height 0.18s ease;
}
@keyframes fw-pop {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.fw-host.fw-dragging .fw-window {
transition: none;
user-select: none;
}
.fw-host.fw-state-normal .fw-titlebar {
cursor: move;
}
.fw-host .fw-resize {
display: none;
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
z-index: 6;
cursor: nwse-resize;
pointer-events: auto;
background-image: linear-gradient(135deg, transparent 0 46%, var(--fw-dim) 46% 54%,
transparent 54% 66%, var(--fw-dim) 66% 74%, transparent 74%);
opacity: 0.55;
}
.fw-host.fw-state-normal .fw-resize {
display: block;
}
.fw-host.fw-state-maximized .fw-window {
top: 12px;
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
height: auto;
resize: none;
}
.fw-host.fw-state-fullscreen .fw-window {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border: none;
border-radius: 0;
resize: none;
}
.fw-host .fw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 8px 12px;
background: var(--fw-bar-bg);
border-bottom: 1px solid var(--fw-border);
flex: 0 0 auto;
user-select: none;
}
.fw-host .fw-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--fw-accent);
font-weight: bold;
letter-spacing: 0.5px;
}
.fw-host .fw-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fw-host .fw-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--fw-accent);
}
.fw-host .fw-winctl {
flex: 0 0 auto;
}
.fw-host .fw-winctl button {
background: transparent;
color: var(--fw-dim);
border: none;
width: 28px;
height: 24px;
margin-left: 2px;
border-radius: 5px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.fw-host .fw-winctl button:hover {
color: var(--fw-fg);
background: #1b1f25;
}
.fw-host .fw-winctl button[data-win="close"]:hover {
background: #c23b3b;
color: #fff;
}
.fw-host .fw-body {
flex: 1 1 auto;
overflow: hidden;
position: relative;
}
/* Container terminal body */
.ct-body {
background: #000000;
}
.ct-status {
padding: 6px 10px;
color: var(--fw-dim);
font-size: 12px;
}
.ct-xterm {
position: absolute;
inset: 0;
padding: 4px 2px 4px 6px;
}
.ct-xterm .xterm {
height: 100%;
}
+7
View File
@@ -20,6 +20,9 @@ import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
class Application {
constructor() {
@@ -46,10 +49,14 @@ class Application {
this.apiKey = new ApiKeyManager();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
this.windows = new WindowManager();
this.containerTerminals = new ContainerTerminalManager();
this.devii = new DeviiTerminal();
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
}
}
const app = new Application();
window.app = app;
app.containerTerminals.restore();
+257
View File
@@ -0,0 +1,257 @@
// retoor <retoor@molodetz.nl>
export class ContainerInstance {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.uid = root.dataset.uid;
this.status = root.dataset.status;
this.base = `/projects/${this.slug}/containers`;
this.name = root.dataset.name || this.uid;
this.detailPoll = null;
this.logPoll = null;
}
init() {
this.renderActions(this.status);
this.updateTerminalAvailability();
this.bind();
this.startDetailPoll();
this.startLogPoll();
}
bind() {
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
const form = document.getElementById("ci-schedule-form");
if (form) {
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
const kind = form.elements.kind;
kind.addEventListener("change", () => this.syncScheduleFields(kind.value));
this.syncScheduleFields(kind.value);
}
this.q("#ci-schedules").addEventListener("click", (e) => {
const btn = e.target.closest("[data-schedule-delete]");
if (btn) this.deleteSchedule(btn.dataset.scheduleDelete);
});
}
q(sel) { return this.root.querySelector(sel); }
async getJson(url) {
const res = await fetch(url, { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
}
async post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body || {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
return data;
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
// ---------------- actions ----------------
renderActions(status) {
const running = status === "running";
const paused = status === "paused";
const spec = [
{ action: "start", label: "Start", disabled: running },
{ action: "stop", label: "Stop", disabled: status === "stopped" || status === "created" },
{ action: "restart", label: "Restart", disabled: !running },
{ action: "pause", label: "Pause", disabled: !running },
{ action: "resume", label: "Resume", disabled: !paused },
{ action: "sync", label: "Sync files", disabled: false },
{ action: "delete", label: "Delete", disabled: false },
];
const box = this.q("#ci-actions");
box.innerHTML = "";
for (const item of spec) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-secondary" + (item.action === "delete" ? " ci-danger" : "");
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener("click", () => this.instanceAction(item.action));
box.appendChild(btn);
}
}
async instanceAction(action) {
if (action === "delete" && !window.confirm("Delete this instance? The container is removed.")) return;
try {
const url = action === "delete" ? `${this.base}/instances/${this.uid}/delete`
: action === "sync" ? `${this.base}/instances/${this.uid}/sync`
: `${this.base}/instances/${this.uid}/${action}`;
const res = await this.post(url, {});
if (action === "delete") { window.location.href = "/admin/containers"; return; }
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
else this.toast(`${action} requested`, "success");
} catch (err) { this.toast(err.message, "error"); }
}
// ---------------- polling ----------------
startDetailPoll() {
const tick = async () => {
try {
const detail = await this.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
} catch { /* ignore */ }
};
tick();
this.detailPoll = setInterval(tick, 4000);
}
applyDetail(detail) {
const inst = detail.instance || {};
if (inst.status && inst.status !== this.status) {
this.status = inst.status;
const badge = this.q("#ci-status");
badge.textContent = inst.status;
badge.className = `cm-badge cm-${inst.status}`;
this.renderActions(inst.status);
this.updateTerminalAvailability();
}
const stats = detail.stats || {};
this.setMetric("samples", stats.samples);
this.setMetric("cpu_avg", `${stats.cpu_avg ?? 0}%`);
this.setMetric("cpu_p95", `${stats.cpu_p95 ?? 0}%`);
this.setMetric("mem_max", stats.mem_max);
const runtime = detail.runtime || {};
const cid = this.q("#ci-container-id");
if (cid) cid.textContent = runtime.container_id || "-";
if (Array.isArray(detail.schedules)) this.renderSchedules(detail.schedules);
}
setMetric(key, value) {
const el = this.q(`[data-metric="${key}"]`);
if (el) el.textContent = value == null ? "0" : value;
}
startLogPoll() {
const pre = this.q("#ci-log");
const tick = async () => {
try {
const data = await this.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const logs = data.logs || "";
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
} catch {
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
};
tick();
this.logPoll = setInterval(tick, 3000);
}
// ---------------- exec ----------------
async execOnce() {
const input = this.q("#ci-exec-input");
const cmd = input.value.trim();
if (!cmd) return;
this.q("#ci-terminal").hidden = false;
const pre = this.q("#ci-term");
try {
const res = await this.post(`${this.base}/instances/${this.uid}/exec`, { command: cmd });
pre.textContent += `$ ${cmd}\n${this.cleanTerm(res.output || "")}\n`;
pre.scrollTop = pre.scrollHeight;
input.value = "";
} catch (err) { this.toast(err.message, "error"); }
}
updateTerminalAvailability() {
const toggle = this.q("#ci-term-toggle");
const running = this.status === "running";
toggle.disabled = !running;
toggle.title = running ? "" : "Start the instance to open an interactive shell";
}
openTerminal() {
if (this.status !== "running") return;
if (window.app && window.app.containerTerminals) {
window.app.containerTerminals.open(this.slug, this.uid, this.name);
}
}
cleanTerm(text) {
return text
.replace(/\x1b\][0-9];[^\x07\x1b]*(\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "")
.replace(/\x1b[()#][0-9A-Za-z]/g, "")
.replace(/\x1b[=>]/g, "");
}
// ---------------- schedules ----------------
syncScheduleFields(kind) {
const form = document.getElementById("ci-schedule-form");
form.querySelectorAll("[data-sched-field]").forEach((field) => {
field.hidden = field.dataset.schedField !== kind;
});
}
async addSchedule(form) {
const kind = form.elements.kind.value;
const body = { action: form.elements.action.value, kind };
if (kind === "cron") body.cron = form.elements.cron.value.trim();
if (kind === "interval") body.every_seconds = form.elements.every_seconds.value;
if (kind === "once") body.delay_seconds = form.elements.delay_seconds.value;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules`, body);
const modal = document.getElementById("ci-schedule-modal");
if (modal) modal.classList.remove("visible");
form.reset();
this.syncScheduleFields(form.elements.kind.value);
this.toast("Schedule added", "success");
} catch (err) { this.toast(err.message, "error"); }
}
async deleteSchedule(sid) {
const ok = await window.app.dialog.confirm({
title: "Delete schedule",
message: "Delete this schedule? The automated task is removed.",
confirmLabel: "Delete",
danger: true,
});
if (!ok) return;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
this.toast("Schedule removed", "success");
} catch (err) { this.toast(err.message, "error"); }
}
renderSchedules(schedules) {
const list = this.q("#ci-schedules");
if (!schedules.length) {
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
return;
}
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
<span class="ci-schedule-action">${this.escape(s.action)}</span>
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
</li>`).join("");
}
}
+53
View File
@@ -0,0 +1,53 @@
// retoor <retoor@molodetz.nl>
export class ContainerList {
constructor(root) {
this.root = root;
this.endpoint = root.dataset.endpoint;
this.body = document.getElementById("cm-admin-rows");
this.pollMs = 4000;
}
init() {
setInterval(() => this.refresh(), this.pollMs);
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
async refresh() {
let instances;
try {
const res = await fetch(this.endpoint, { headers: { "Accept": "application/json" } });
if (!res.ok) return;
instances = (await res.json()).instances || [];
} catch { return; }
if (!this.body) return;
if (!instances.length) {
this.body.innerHTML = `<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>`;
return;
}
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
}
row(inst) {
const uid = this.escape(inst.uid);
const project = inst.project_slug
? `<a class="cm-row-project" href="/projects/${this.escape(inst.project_slug)}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
: `<span class="cm-muted">-</span>`;
const ingress = inst.ingress_slug
? `<a href="/p/${this.escape(inst.ingress_slug)}" target="_blank" rel="noopener">/p/${this.escape(inst.ingress_slug)}</a>`
: `<span class="cm-muted">-</span>`;
return `<tr class="cm-row" data-uid="${uid}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${this.escape(inst.name)}</a></td>
<td>${project}</td>
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
<td>${ingress}</td>
<td>${this.escape(inst.restart_policy || "never")}</td>
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}">Manage &rarr;</a></td>
</tr>`;
}
}
-130
View File
@@ -5,29 +5,19 @@ export class ContainerManager {
this.root = root;
this.slug = root.dataset.slug;
this.base = `/projects/${this.slug}/containers`;
this.dockerfiles = [];
this.instances = [];
this.selectedDockerfile = null;
this.pollMs = 3000;
this.buildPoll = null;
}
init() {
const data = JSON.parse(this.root.querySelector("#cm-data").textContent || "{}");
this.dockerfiles = data.dockerfiles || [];
this.instances = data.instances || [];
this.bind();
this.renderDockerfiles();
this.renderInstances();
this.fillDockerfileSelect();
setInterval(() => this.refresh(), this.pollMs);
}
bind() {
this.q("#cm-save-version").addEventListener("click", () => this.saveVersion());
this.q("#cm-rebuild").addEventListener("click", () => this.rebuild());
const dfForm = document.getElementById("cm-new-dockerfile-form");
if (dfForm) dfForm.addEventListener("submit", (e) => { e.preventDefault(); this.createDockerfile(dfForm); });
const instForm = document.getElementById("cm-new-instance-form");
if (instForm) instForm.addEventListener("submit", (e) => { e.preventDefault(); this.createInstance(instForm); });
}
@@ -63,129 +53,11 @@ export class ContainerManager {
async refresh() {
try {
const data = await this.getJson(`${this.base}/data`);
this.dockerfiles = data.dockerfiles || [];
this.instances = data.instances || [];
this.renderDockerfiles();
this.renderInstances();
this.fillDockerfileSelect();
} catch { /* ignore poll errors */ }
}
// ---------------- dockerfiles ----------------
renderDockerfiles() {
const list = this.q("#cm-dockerfile-list");
list.innerHTML = "";
for (const df of this.dockerfiles) {
const li = document.createElement("li");
li.className = "cm-item" + (this.selectedDockerfile === df.uid ? " active" : "");
li.textContent = `${df.name} (v${df.current_version}, build ${df.latest_build_number})`;
li.addEventListener("click", () => this.openDockerfile(df.uid));
list.appendChild(li);
}
}
fillDockerfileSelect() {
const select = document.getElementById("cm-inst-new-df");
if (!select) return;
const current = select.value;
select.innerHTML = "";
for (const df of this.dockerfiles) {
const option = document.createElement("option");
option.value = df.uid;
option.textContent = df.name;
select.appendChild(option);
}
if (current) select.value = current;
else if (this.selectedDockerfile) select.value = this.selectedDockerfile;
}
async createDockerfile(form) {
const name = form.elements.name.value.trim();
if (!name) return;
try {
const res = await this.post(`${this.base}/dockerfiles`, {
name, description: form.elements.description.value.trim(), tags: "",
});
this.closeModal("cm-new-dockerfile-modal");
form.reset();
this.toast("Dockerfile created, build queued", "success");
await this.refresh();
if (res.data && res.data.dockerfile) this.openDockerfile(res.data.dockerfile.uid);
} catch (err) { this.toast(err.message, "error"); }
}
async openDockerfile(uid) {
this.selectedDockerfile = uid;
try {
const detail = await this.getJson(`${this.base}/dockerfiles/${uid}`);
this.q("#cm-dockerfile-detail").hidden = false;
this.q("#cm-df-name").textContent = detail.dockerfile.name;
this.q("#cm-df-editor").value = detail.content || "";
this.renderBuilds(detail.builds || []);
this.renderDockerfiles();
this.fillDockerfileSelect();
} catch (err) { this.toast(err.message, "error"); }
}
renderBuilds(builds) {
const list = this.q("#cm-build-list");
list.innerHTML = "";
for (const b of builds) {
const li = document.createElement("li");
li.className = "cm-item";
li.textContent = `${b.image_tag} [${b.status}]`;
li.addEventListener("click", () => this.watchBuild(b.uid));
list.appendChild(li);
}
}
async saveVersion() {
if (!this.selectedDockerfile) return;
const content = this.q("#cm-df-editor").value;
try {
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/version`, { content });
if (res.data && res.data.changed) {
this.toast("New version saved, build queued", "success");
if (res.data.build_uid) this.watchBuild(res.data.build_uid);
} else {
this.toast("No change in content", "info");
}
await this.refresh();
} catch (err) { this.toast(err.message, "error"); }
}
async rebuild() {
if (!this.selectedDockerfile) return;
try {
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/build`, {});
this.toast("Rebuild queued", "success");
if (res.data && res.data.build_uid) this.watchBuild(res.data.build_uid);
} catch (err) { this.toast(err.message, "error"); }
}
watchBuild(uid) {
if (this.buildPoll) clearInterval(this.buildPoll);
const pre = this.q("#cm-build-log");
pre.hidden = false;
const tick = async () => {
try {
const build = await this.getJson(`${this.base}/builds/${uid}`);
pre.textContent = `[${build.status}] ${build.image_tag}\n\n${build.logs || ""}${build.error || ""}`;
pre.scrollTop = pre.scrollHeight;
if (["success", "failed", "cancelled"].includes(build.status)) {
clearInterval(this.buildPoll);
this.buildPoll = null;
this.refresh();
}
} catch { /* ignore */ }
};
tick();
this.buildPoll = setInterval(tick, 1500);
}
// ---------------- instances ----------------
renderInstances() {
const list = this.q("#cm-instance-list");
list.innerHTML = "";
@@ -202,12 +74,10 @@ export class ContainerManager {
}
async createInstance(form) {
if (!this.dockerfiles.length) { this.toast("Create a Dockerfile first", "error"); return; }
const name = form.elements.name.value.trim();
if (!name) return;
try {
await this.post(`${this.base}/instances`, {
dockerfile_uid: form.elements.dockerfile_uid.value,
name,
boot_command: form.elements.boot_command.value.trim(),
restart_policy: form.elements.restart_policy.value,
@@ -0,0 +1,99 @@
// retoor <retoor@molodetz.nl>
import { userScope } from "./userScope.js";
const SESSION_NAME = "devplace";
function persistKey() {
return `ct-persistent:${userScope()}`;
}
export class ContainerTerminalManager {
constructor() {
this._terminals = new Map();
this._persistent = null;
}
open(projectSlug, instance, label) {
if (!projectSlug || !instance) return null;
const key = `${projectSlug}::${instance}`;
const existing = this._terminals.get(key);
if (existing && existing.isConnected) {
this._makePersistent(existing, projectSlug, instance, label);
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(existing);
return existing;
}
this._demoteCurrent(null);
const element = document.createElement("container-terminal");
element.setAttribute("project-slug", projectSlug);
element.setAttribute("instance", instance);
if (label) element.setAttribute("label", label);
element.setAttribute("session", SESSION_NAME);
element.setAttribute("persist", "");
document.body.appendChild(element);
this._terminals.set(key, element);
this._persistent = element;
this._remember(projectSlug, instance, label);
return element;
}
_makePersistent(element, projectSlug, instance, label) {
this._demoteCurrent(element);
element.setAttribute("session", SESSION_NAME);
if (typeof element.setPersistent === "function") element.setPersistent(true);
this._persistent = element;
this._remember(projectSlug, instance, label);
}
_demoteCurrent(except) {
if (this._persistent && this._persistent !== except && this._persistent.isConnected) {
if (typeof this._persistent.setPersistent === "function") this._persistent.setPersistent(false);
}
}
forget(element) {
this._terminals.forEach((el, key) => {
if (el === element) this._terminals.delete(key);
});
if (element === this._persistent) {
this._persistent = null;
this._clearRemember();
}
}
restore() {
let record = null;
try {
record = JSON.parse(window.localStorage.getItem(persistKey()) || "null");
} catch (error) {
record = null;
}
if (record && record.slug && record.instance) {
this.open(record.slug, record.instance, record.label || record.instance);
}
}
applyFontSize(size) {
this._terminals.forEach((element) => {
if (element.isConnected && typeof element.setFontSize === "function") {
element.setFontSize(size);
}
});
}
_remember(slug, instance, label) {
try {
window.localStorage.setItem(persistKey(), JSON.stringify({ slug, instance, label: label || instance }));
} catch (error) {
return;
}
}
_clearRemember() {
try {
window.localStorage.removeItem(persistKey());
} catch (error) {
return;
}
}
}
+23 -1
View File
@@ -62,9 +62,31 @@ export class FormManager {
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
let previous = select.value;
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
if (!form) return;
const message = select.dataset.confirm;
if (!message) {
previous = select.value;
form.submit();
return;
}
const dialog = window.app && window.app.dialog;
if (!dialog) {
if (confirm(message)) { previous = select.value; form.submit(); }
else select.value = previous;
return;
}
dialog.confirm({
message,
title: select.dataset.confirmTitle || "Please confirm",
confirmLabel: select.dataset.confirmLabel,
danger: select.dataset.confirmDanger !== undefined,
}).then((ok) => {
if (ok) { previous = select.value; form.submit(); }
else select.value = previous;
});
});
});
}
+74
View File
@@ -0,0 +1,74 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class ProjectForker {
constructor() {
this.pollMs = 1500;
this.maxPolls = 200;
this.active = new Set();
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-fork-project]");
if (!trigger) return;
event.preventDefault();
this.start(trigger.dataset.forkProject, trigger.dataset.forkName || "");
});
}
async start(enqueueUrl, sourceName) {
if (this.active.has(enqueueUrl)) return;
const title = await window.app.dialog.prompt({
title: "Fork project",
label: "New project name",
value: sourceName ? `${sourceName} (fork)` : "",
confirmLabel: "Fork",
});
if (title === null) return;
const name = title.trim();
if (!name) {
window.app.toast.show("A project name is required", { type: "error" });
return;
}
this.active.add(enqueueUrl);
window.app.toast.show("Forking project...", { type: "info", ms: 4000 });
try {
const job = await Http.sendForm(enqueueUrl, { title: name });
await this.poll(job.status_url);
} catch (err) {
window.app.toast.show("Could not start the fork", { type: "error" });
} finally {
this.active.delete(enqueueUrl);
}
}
poll(statusUrl) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > this.maxPolls) {
clearInterval(timer);
window.app.toast.show("Fork timed out", { type: "error" });
resolve();
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch {
return;
}
if (status.status === "done") {
clearInterval(timer);
window.app.toast.show("Fork ready", { type: "success" });
window.location.href = status.project_url;
resolve();
} else if (status.status === "failed") {
clearInterval(timer);
window.app.toast.show("Fork failed: " + (status.error || "unknown error"), { type: "error" });
resolve();
}
}, this.pollMs);
});
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ export class AppDialog extends Component {
this.messageEl.hidden = !opts.message;
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.hidden = mode === "alert";
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
if (mode === "prompt") {
@@ -0,0 +1,321 @@
// 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);
@@ -0,0 +1,356 @@
// retoor <retoor@molodetz.nl>
const FW_CSS_ID = "floating-window-css";
const MOBILE_QUERY = "(max-width: 640px)";
export default class FloatingWindow extends HTMLElement {
constructor() {
super();
this.state = "normal";
this.geometry = null;
this._drag = null;
this._rafResize = null;
this._mobile = window.matchMedia(MOBILE_QUERY);
}
get windowKey() {
return "floating-window";
}
get titleText() {
return this.getAttribute("label") || "Window";
}
get fontControls() {
return false;
}
connectedCallback() {
this._ensureBaseCss();
this.classList.add("fw-host");
this._buildChrome();
this._registerWindow();
this._viewportHandler = () => this._onViewportChange();
this._mobile.addEventListener("change", this._viewportHandler);
this._winResize = () => {
this._clampWindow();
this._scheduleResize();
};
window.addEventListener("resize", this._winResize);
const saved = this._loadGeometry();
if (saved) {
this.geometry = saved;
this._clampGeometry();
}
this._setState(this._mobile.matches ? "fullscreen" : "normal");
this._buildBody(this.body);
this._observeResize();
this._onShow();
}
disconnectedCallback() {
window.removeEventListener("resize", this._winResize);
this._mobile.removeEventListener("change", this._viewportHandler);
if (this._ro) this._ro.disconnect();
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.unregister(this);
this._onHide();
}
_ensureBaseCss() {
if (document.getElementById(FW_CSS_ID)) return;
const link = document.createElement("link");
link.id = FW_CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/floating-window.css";
document.head.appendChild(link);
}
_registerWindow() {
if (globalThis.app && globalThis.app.windows) {
globalThis.app.windows.register(this);
}
this.addEventListener("pointerdown", () => {
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
});
if (globalThis.app && globalThis.app.contextMenu) {
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
}
}
_buildChrome() {
this.innerHTML = "";
this.win = document.createElement("div");
this.win.className = "fw-window";
const fontButtons = this.fontControls ? [
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
] : [];
this.win.innerHTML = [
'<div class="fw-titlebar">',
' <span class="fw-title"><span class="fw-dot"></span><span class="fw-title-text"></span></span>',
' <span class="fw-winctl">',
...fontButtons,
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
" </span>",
"</div>",
'<div class="fw-body"></div>',
'<div class="fw-resize"></div>',
].join("");
this.appendChild(this.win);
this.titlebar = this.win.querySelector(".fw-titlebar");
this.body = this.win.querySelector(".fw-body");
this.win.querySelector(".fw-title-text").textContent = this.titleText;
this.win.querySelectorAll(".fw-winctl button").forEach((button) => {
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
const grip = this.win.querySelector(".fw-resize");
grip.addEventListener("mousedown", (event) => this._startResize(event));
grip.addEventListener("touchstart", (event) => this._startResize(event), { passive: false });
}
setTitle(text) {
const el = this.win && this.win.querySelector(".fw-title-text");
if (el) el.textContent = text;
}
_buildBody() {}
_onShow() {}
_onHide() {}
_onResize() {}
_onClose() { this.remove(); }
_changeFont() {}
close() {
this._onClose();
}
_window(action) {
if (action === "close") {
this.close();
} else if (action === "maximize") {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
} else if (action === "font-inc") {
this._changeFont(1);
} else if (action === "font-dec") {
this._changeFont(-1);
} else if (action === "minimize") {
this.minimizeWindow();
} else if (action === "normalize") {
this.normalizeWindow();
}
}
minimizeWindow() {
this._presetGeometry(400, 260);
}
normalizeWindow() {
this._presetGeometry(820, 560);
}
_presetGeometry(width, height) {
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
};
this._setState("normal");
this._persistGeometry();
}
_contextItems() {
return [
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_setState(state) {
let resolved = state;
if (this._mobile.matches) resolved = "fullscreen";
this.state = resolved;
this.classList.toggle("fw-mobile", this._mobile.matches);
["normal", "maximized", "fullscreen"].forEach((name) => {
this.classList.toggle(`fw-state-${name}`, name === resolved);
});
if (resolved === "normal") {
this._applyGeometry();
} else {
this.win.style.left = "";
this.win.style.top = "";
this.win.style.width = "";
this.win.style.height = "";
}
this._scheduleResize();
}
_defaultGeometry() {
const width = Math.min(820, Math.round(window.innerWidth * 0.94));
const height = Math.min(560, Math.round(window.innerHeight * 0.82));
return {
width,
height,
left: Math.max(12, Math.round((window.innerWidth - width) / 2)),
top: Math.max(12, Math.round((window.innerHeight - height) / 2)),
};
}
_applyGeometry() {
if (!this.geometry) this.geometry = this._defaultGeometry();
const g = this.geometry;
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_clampGeometry() {
if (!this.geometry) return;
const g = this.geometry;
g.width = Math.min(g.width, window.innerWidth - 16);
g.height = Math.min(g.height, window.innerHeight - 16);
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
}
_clampWindow() {
if (this.state !== "normal" || !this.geometry) return;
this._clampGeometry();
this._applyGeometry();
this._persistGeometry();
this._scheduleResize();
}
_onViewportChange() {
this._setState("normal");
}
_startDrag(event) {
if (this.state !== "normal") return;
if (event.target.tagName === "BUTTON") return;
event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const rect = this.win.getBoundingClientRect();
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
this.classList.add("fw-dragging");
this._moveHandler = (move) => this._onDrag(move);
this._upHandler = () => this._endDrag();
window.addEventListener("mousemove", this._moveHandler);
window.addEventListener("mouseup", this._upHandler);
window.addEventListener("touchmove", this._moveHandler, { passive: false });
window.addEventListener("touchend", this._upHandler);
}
_onDrag(event) {
if (!this._drag) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_endDrag() {
this._drag = null;
this.classList.remove("fw-dragging");
window.removeEventListener("mousemove", this._moveHandler);
window.removeEventListener("mouseup", this._upHandler);
window.removeEventListener("touchmove", this._moveHandler);
window.removeEventListener("touchend", this._upHandler);
this._persistGeometry();
}
_startResize(event) {
if (this.state !== "normal") return;
event.preventDefault();
event.stopPropagation();
if (!this.geometry) this.geometry = this._defaultGeometry();
const point = event.touches ? event.touches[0] : event;
this._resize = { x: point.clientX, y: point.clientY, w: this.geometry.width, h: this.geometry.height };
this.classList.add("fw-dragging");
this._resizeMove = (move) => this._onResizeDrag(move);
this._resizeUp = () => this._endResize();
window.addEventListener("mousemove", this._resizeMove);
window.addEventListener("mouseup", this._resizeUp);
window.addEventListener("touchmove", this._resizeMove, { passive: false });
window.addEventListener("touchend", this._resizeUp);
}
_onResizeDrag(event) {
if (!this._resize) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.width = Math.max(320, Math.min(this._resize.w + (point.clientX - this._resize.x), window.innerWidth - g.left - 8));
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), window.innerHeight - g.top - 8));
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this._scheduleResize();
}
_endResize() {
this._resize = null;
this.classList.remove("fw-dragging");
window.removeEventListener("mousemove", this._resizeMove);
window.removeEventListener("mouseup", this._resizeUp);
window.removeEventListener("touchmove", this._resizeMove);
window.removeEventListener("touchend", this._resizeUp);
this._persistGeometry();
}
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this._ro = new ResizeObserver(() => {
if (this.state === "normal" && this.geometry && !this._drag) {
const rect = this.win.getBoundingClientRect();
this.geometry.width = Math.round(rect.width);
this.geometry.height = Math.round(rect.height);
this._persistGeometry();
}
this._scheduleResize();
});
this._ro.observe(this.win);
if (this.body) this._ro.observe(this.body);
}
_scheduleResize() {
if (this._rafResize) return;
this._rafResize = window.requestAnimationFrame(() => {
this._rafResize = null;
this._onResize();
});
}
_loadGeometry() {
try {
const raw = window.localStorage.getItem(`fw-geo:${this.windowKey}`);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
_persistGeometry() {
try {
window.localStorage.setItem(`fw-geo:${this.windowKey}`, JSON.stringify(this.geometry));
} catch (error) {
return;
}
}
}
@@ -0,0 +1,46 @@
// retoor <retoor@molodetz.nl>
const BASE_Z = 2147480000;
const CEILING_Z = 2147483400;
export default class WindowManager {
constructor() {
this._z = BASE_Z;
this._windows = new Set();
}
register(win) {
this._windows.add(win);
this.focus(win);
}
unregister(win) {
this._windows.delete(win);
}
focus(win) {
if (!win) return;
if (this._z >= CEILING_Z) this._renormalize();
this._z += 1;
try {
win.style.zIndex = String(this._z);
} catch (error) {
return;
}
}
_renormalize() {
const ordered = Array.from(this._windows).sort(
(a, b) => (parseInt(a.style.zIndex, 10) || 0) - (parseInt(b.style.zIndex, 10) || 0),
);
this._z = BASE_Z;
ordered.forEach((win) => {
this._z += 1;
try {
win.style.zIndex = String(this._z);
} catch (error) {
return;
}
});
}
}
+1
View File
@@ -7,3 +7,4 @@ import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
import "./ContainerTerminal.js";
+17
View File
@@ -28,6 +28,8 @@ export default class DeviiClient {
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
case "open_terminal":
return this._openTerminal(args);
default:
return { error: `unknown client action '${action}'` };
}
@@ -52,9 +54,12 @@ export default class DeviiClient {
loggedIn = true;
}
}
const pageTypeEl = document.querySelector("[data-page-type]");
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
return {
url: location.href,
path: location.pathname + location.search,
pageType,
title: document.title,
referrer: document.referrer || null,
viewport: { width: window.innerWidth, height: window.innerHeight },
@@ -203,4 +208,16 @@ export default class DeviiClient {
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
return "reloading";
}
_openTerminal(args) {
const projectSlug = String(args.project_slug || "").trim();
const instance = String(args.instance || "").trim();
const label = String(args.label || instance || "").trim();
if (!projectSlug || !instance) return { error: "project_slug and instance are required" };
if (!globalThis.app || !globalThis.app.containerTerminals) {
return { error: "terminal manager unavailable" };
}
globalThis.app.containerTerminals.open(projectSlug, instance, label);
return { opened: true, instance };
}
}
+13 -3
View File
@@ -1,6 +1,8 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export default class DeviiSocket {
constructor(handlers) {
@@ -8,6 +10,7 @@ export default class DeviiSocket {
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
this._shouldRun = true;
this._settled = false;
}
connect() {
@@ -16,12 +19,15 @@ export default class DeviiSocket {
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", () => {
this._emit("onClose");
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
window.setTimeout(() => this._open(), RECONNECT_DELAY_MS);
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
@@ -32,6 +38,10 @@ export default class DeviiSocket {
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady");
}
this._emit("onMessage", payload);
});
}
+87 -1
View File
@@ -3,10 +3,15 @@
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
import { userScope } from "../userScope.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
const HISTORY_KEY = "devii-terminal-history";
const FONT_KEY = "devii-fontsize";
const FONT_MIN = 10;
const FONT_MAX = 26;
const FONT_DEFAULT = 14;
const HISTORY_LIMIT = 500;
const MOBILE_QUERY = "(max-width: 640px)";
const TRACE_GLYPHS = {
@@ -55,9 +60,16 @@ export default class DeviiTerminalElement extends HTMLElement {
connectedCallback() {
this._ensureCss();
this._build();
this._applyFont(this._readFont());
if (globalThis.app && typeof globalThis.app.register === "function") {
globalThis.app.register(this);
}
if (globalThis.app && globalThis.app.windows) {
globalThis.app.windows.register(this);
this.addEventListener("pointerdown", () => {
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
});
}
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
window.addEventListener("pagehide", () => this._captureFocusForUnload());
@@ -77,7 +89,8 @@ export default class DeviiTerminalElement extends HTMLElement {
this._reportVisibility();
});
this.socket = new DeviiSocket({
onOpen: () => {
onOpen: () => this._reportVisibility(),
onReady: () => {
this._notice("connected.");
this._reportVisibility();
},
@@ -220,6 +233,10 @@ export default class DeviiTerminalElement extends HTMLElement {
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
@@ -261,6 +278,9 @@ export default class DeviiTerminalElement extends HTMLElement {
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
if (globalThis.app && globalThis.app.contextMenu) {
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
}
}
open() {
@@ -285,9 +305,75 @@ export default class DeviiTerminalElement extends HTMLElement {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
} else if (action === "font-inc") {
this._changeFont(1);
} else if (action === "font-dec") {
this._changeFont(-1);
} else if (action === "minimize") {
this.minimizeWindow();
} else if (action === "normalize") {
this.normalizeWindow();
}
}
minimizeWindow() {
this._presetGeometry(400, 260);
}
normalizeWindow() {
this._presetGeometry(760, 600);
}
_presetGeometry(width, height) {
const w = Math.max(300, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
};
this._clampGeometry();
this._setState("normal");
this._persist();
}
_contextItems() {
return [
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_fontKey() {
return `${FONT_KEY}:${userScope()}`;
}
_readFont() {
try {
const value = parseInt(window.localStorage.getItem(this._fontKey()), 10);
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
} catch (error) {
return FONT_DEFAULT;
}
}
_applyFont(size) {
this.style.setProperty("--devii-font-size", `${size}px`);
}
_changeFont(delta) {
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, this._readFont() + delta));
try {
window.localStorage.setItem(this._fontKey(), String(next));
} catch (error) {
return this._applyFont(next);
}
this._applyFont(next);
}
_setState(state) {
let resolved = state;
if (state === "open") {
+11
View File
@@ -0,0 +1,11 @@
// retoor <retoor@molodetz.nl>
export function userScope() {
const docs = window.DEVPLACE_DOCS || {};
let username = docs.username || "";
if (!username) {
const el = document.querySelector(".topnav-user-name");
if (el) username = el.textContent.trim();
}
return username ? `user:${username}` : "guest";
}
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long