Term / templates /index.html
soxogvv's picture
Upload index.html
a48b9da verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>WebTerminal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Syne:wght@400;600;700;800&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<style>
/* ── Reset & Root ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #070b0f;
--surface: #0d1318;
--panel: #111820;
--border: #1e2d3d;
--border2: #243447;
--text: #cdd9e5;
--muted: #5a7a94;
--accent: #00e5a0;
--accent2: #00b8d9;
--red: #ff4d6a;
--yellow: #ffc947;
--tab-h: 38px;
--toolbar-h:44px;
--header-h: 46px;
--keys-h: 48px;
--font-mono:'JetBrains Mono', 'Courier New', monospace;
--font-ui: 'Syne', system-ui, sans-serif;
--radius: 6px;
--shadow: 0 4px 24px rgba(0,0,0,.6);
}
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font-family: var(--font-ui); }
/* ── Scrollbar ────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
/* ── Layout ───────────────────────────────────────────────────── */
#app { display: flex; flex-direction: column; height: 100vh; }
/* ── Header ───────────────────────────────────────────────────── */
#header {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 10px; gap: 10px;
flex-shrink: 0;
position: relative; z-index: 100;
}
.logo {
font-family: var(--font-ui);
font-weight: 800; font-size: 15px;
color: var(--accent);
letter-spacing: -0.5px;
display: flex; align-items: center; gap: 6px;
white-space: nowrap;
user-select: none;
}
.logo svg { width:18px; height:18px; }
.logo span { color: var(--text); font-weight: 400; }
#header-actions { display: flex; gap: 6px; margin-left: auto; align-items: center; }
/* ── Buttons ──────────────────────────────────────────────────── */
.btn {
font-family: var(--font-ui);
font-size: 12px; font-weight: 600;
letter-spacing: .3px;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 5px 11px;
cursor: pointer;
display: flex; align-items: center; gap: 5px;
transition: background .15s, border-color .15s, color .15s;
white-space: nowrap;
user-select: none;
}
.btn:hover { background: var(--border2); color: #fff; }
.btn.accent { background: var(--accent); color: #000; border-color: var(--accent); }
.btn.accent:hover { background: #00ffb3; }
.btn.danger { border-color: var(--red); }
.btn.danger:hover { background: var(--red); color: #fff; }
.btn svg { width:13px; height:13px; flex-shrink:0; }
/* ── Tabs ─────────────────────────────────────────────────────── */
#tab-bar {
height: var(--tab-h);
background: var(--bg);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-end;
overflow-x: auto; overflow-y: hidden;
flex-shrink: 0;
scrollbar-width: none;
}
#tab-bar::-webkit-scrollbar { display: none; }
#tabs { display: flex; align-items: flex-end; padding-left: 6px; gap: 2px; }
.tab {
font-family: var(--font-mono);
font-size: 11.5px;
background: var(--surface);
color: var(--muted);
border: 1px solid transparent;
border-bottom: none;
border-radius: 5px 5px 0 0;
padding: 6px 12px 6px 11px;
cursor: pointer;
display: flex; align-items: center; gap: 7px;
white-space: nowrap;
transition: color .15s, background .15s;
min-width: 0;
}
.tab:hover { color: var(--text); }
.tab.active {
background: var(--panel);
color: var(--accent);
border-color: var(--border);
}
.tab .t-icon { font-size: 10px; }
.tab .t-close {
background: none; border: none; color: var(--muted);
font-size: 15px; line-height: 1; cursor: pointer;
padding: 0 1px; border-radius: 3px;
transition: color .15s, background .15s;
margin-left: 2px;
}
.tab .t-close:hover { color: var(--red); background: rgba(255,77,106,.12); }
#add-tab {
background: none; border: none; color: var(--muted);
font-size: 20px; line-height: 1; cursor: pointer;
padding: 6px 10px;
transition: color .15s;
margin-bottom: 0;
align-self: center;
}
#add-tab:hover { color: var(--accent); }
/* ── Toolbar ──────────────────────────────────────────────────── */
#toolbar {
height: var(--toolbar-h);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 8px; gap: 4px;
overflow-x: auto; overflow-y: hidden;
flex-shrink: 0;
scrollbar-width: none;
}
#toolbar::-webkit-scrollbar { display: none; }
.k {
font-family: var(--font-mono);
font-size: 11px; font-weight: 500;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border2);
border-bottom: 3px solid var(--border2);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: background .1s, transform .07s, border-color .1s;
flex-shrink: 0;
min-height: 30px;
display: flex; align-items: center; justify-content: center;
}
.k:hover { background: var(--border2); color: #fff; }
.k:active { transform: translateY(2px); border-bottom-width: 1px; }
.k.ctrl { color: var(--yellow); border-color: rgba(255,201,71,.3); }
.k.ctrl:hover { background: rgba(255,201,71,.15); }
.k.special { color: var(--accent2); border-color: rgba(0,184,217,.3); }
.k.special:hover { background: rgba(0,184,217,.15); }
.k.danger-k { color: var(--red); border-color: rgba(255,77,106,.3); }
.k.danger-k:hover { background: rgba(255,77,106,.15); }
.toolbar-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; margin: 0 3px; }
/* ── Main area ────────────────────────────────────────────────── */
#main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Terminal area ────────────────────────────────────────────── */
#terminal-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.term-wrap {
flex: 1;
position: absolute; inset: 0;
display: none;
background: #000;
padding: 4px;
}
.term-wrap.active { display: block; }
.term-wrap .xterm { height: 100%; }
.term-wrap .xterm-viewport { scrollbar-width: thin; }
/* Connecting overlay */
.term-overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #000;
color: var(--muted);
font-family: var(--font-mono); font-size: 13px;
gap: 12px;
z-index: 5;
}
.term-overlay .spinner {
width: 24px; height: 24px;
border: 2px solid var(--border2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── File Panel ───────────────────────────────────────────────── */
#file-panel {
width: 260px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex; flex-direction: column;
flex-shrink: 0;
transition: width .25s, transform .25s;
overflow: hidden;
}
#file-panel.hidden { width: 0; border-left: none; }
#file-panel-header {
padding: 10px 12px 8px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
flex-shrink: 0;
}
#file-panel-header h3 {
font-size: 12px; font-weight: 700;
letter-spacing: .5px; text-transform: uppercase;
color: var(--muted); flex: 1;
}
.upload-zone {
margin: 8px;
border: 2px dashed var(--border2);
border-radius: var(--radius);
padding: 12px 8px;
text-align: center;
cursor: pointer;
color: var(--muted); font-size: 12px;
transition: border-color .2s, color .2s, background .2s;
flex-shrink: 0;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); color: var(--accent); background: rgba(0,229,160,.05); }
.upload-zone svg { display: block; margin: 0 auto 6px; }
#file-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.file-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--text);
cursor: pointer;
border-radius: 0;
transition: background .1s;
}
.file-item:hover { background: var(--panel); }
.file-item .fi-icon { color: var(--muted); flex-shrink:0; font-size:13px; }
.file-item.dir .fi-icon { color: var(--accent2); }
.file-item .fi-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .fi-size { color: var(--muted); font-size: 10px; white-space: nowrap; }
.file-item .fi-dl {
background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; padding: 1px 3px; border-radius: 3px;
opacity: 0; transition: opacity .15s, color .15s;
}
.file-item:hover .fi-dl { opacity: 1; }
.file-item .fi-dl:hover { color: var(--accent); }
.file-item .fi-rm {
background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; padding: 1px 3px; border-radius: 3px;
opacity: 0; transition: opacity .15s, color .15s;
}
.file-item:hover .fi-rm { opacity: 1; }
.file-item .fi-rm:hover { color: var(--red); }
#file-empty { padding: 20px; text-align: center; color: var(--muted); font-size: 12px; }
/* ── Toast ────────────────────────────────────────────────────── */
#toast-container {
position: fixed; bottom: 16px; right: 16px;
display: flex; flex-direction: column; gap: 6px;
z-index: 9999;
pointer-events: none;
}
.toast {
font-family: var(--font-mono); font-size: 12px;
background: var(--panel);
border: 1px solid var(--border2);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 8px 14px;
color: var(--text);
box-shadow: var(--shadow);
animation: toastIn .2s ease;
pointer-events: auto;
}
.toast.error { border-left-color: var(--red); }
.toast.warn { border-left-color: var(--yellow); }
@keyframes toastIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; } }
/* ── Mobile keyboard overlay ──────────────────────────────────── */
#mobile-keys {
display: none;
height: var(--keys-h);
background: var(--surface);
border-top: 1px solid var(--border);
overflow-x: auto; overflow-y: hidden;
align-items: center;
padding: 0 6px; gap: 4px;
flex-shrink: 0;
scrollbar-width: none;
}
#mobile-keys::-webkit-scrollbar { display:none; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 768px) {
:root { --toolbar-h: 42px; --header-h: 44px; }
#file-panel { position: absolute; right: 0; top: 0; bottom: 0; z-index: 200; width: 280px; transform: translateX(100%); }
#file-panel.hidden { width: 280px; transform: translateX(100%); }
#file-panel.open { transform: translateX(0); }
#file-panel-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 199; }
#file-panel-backdrop.show { display: block; }
#mobile-keys { display: flex; }
.logo span { display: none; }
.btn-label { display: none; }
}
/* ── Misc ─────────────────────────────────────────────────────── */
.flex-1 { flex: 1; }
.hidden { display: none !important; }
input[type=file] { display: none; }
/* Xterm customise */
.xterm-screen canvas { image-rendering: auto; }
</style>
</head>
<body>
<div id="app">
<!-- Header -->
<div id="header">
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
WebTerminal <span>/ workspace</span>
</div>
<div id="header-actions">
<button class="btn" id="btn-new-term" title="New Terminal (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><polyline points="8 21 12 17 16 21"/></svg>
<span class="btn-label">New</span>
</button>
<button class="btn" id="btn-files" title="File Manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="btn-label">Files</span>
</button>
<button class="btn accent" id="btn-upload-hdr" title="Upload File">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>
<span class="btn-label">Upload</span>
</button>
<input type="file" id="file-input-hdr" multiple>
</div>
</div>
<!-- Tab Bar -->
<div id="tab-bar">
<div id="tabs"></div>
<button id="add-tab" title="New Terminal">+</button>
</div>
<!-- Key Toolbar -->
<div id="toolbar">
<!-- Ctrl combos -->
<button class="k ctrl" data-send="\x03" title="Ctrl+C">^C</button>
<button class="k ctrl" data-send="\x04" title="Ctrl+D">^D</button>
<button class="k ctrl" data-send="\x1a" title="Ctrl+Z">^Z</button>
<button class="k ctrl" data-send="\x01" title="Ctrl+A">^A</button>
<button class="k ctrl" data-send="\x05" title="Ctrl+E">^E</button>
<button class="k ctrl" data-send="\x0b" title="Ctrl+K">^K</button>
<button class="k ctrl" data-send="\x15" title="Ctrl+U">^U</button>
<button class="k ctrl" data-send="\x07" title="Ctrl+G (nano quit)">^G</button>
<button class="k ctrl" data-send="\x18" title="Ctrl+X (nano)">^X</button>
<button class="k ctrl" data-send="\x13" title="Ctrl+S (nano save)">^S</button>
<button class="k ctrl" data-send="\x12" title="Ctrl+R">^R</button>
<button class="k ctrl" data-send="\x0c" title="Ctrl+L (clear)">^L</button>
<div class="toolbar-sep"></div>
<!-- Arrow keys -->
<button class="k special" data-send="\x1b[A" title="Up">↑</button>
<button class="k special" data-send="\x1b[B" title="Down">↓</button>
<button class="k special" data-send="\x1b[D" title="Left">←</button>
<button class="k special" data-send="\x1b[C" title="Right">β†’</button>
<div class="toolbar-sep"></div>
<!-- Navigation -->
<button class="k special" data-send="\x1b[H" title="Home">Home</button>
<button class="k special" data-send="\x1b[F" title="End">End</button>
<button class="k special" data-send="\x1b[5~" title="Page Up">PgUp</button>
<button class="k special" data-send="\x1b[6~" title="Page Down">PgDn</button>
<button class="k special" data-send="\x1b[3~" title="Delete">Del</button>
<button class="k special" data-send="\x7f" title="Backspace">⌫</button>
<button class="k special" data-send="\x1b" title="Escape">Esc</button>
<button class="k special" data-send="\t" title="Tab">β‡₯ Tab</button>
<button class="k special" data-send="\r" title="Enter">↡ Enter</button>
<div class="toolbar-sep"></div>
<!-- Word nav -->
<button class="k ctrl" data-send="\x1bb" title="Alt+B (word left)">←W</button>
<button class="k ctrl" data-send="\x1bf" title="Alt+F (word right)">W→</button>
<button class="k ctrl" data-send="\x17" title="Ctrl+W (delete word)">Del W</button>
<div class="toolbar-sep"></div>
<!-- History -->
<button class="k" data-send="\x1b[A" title="Prev command">↑ Hist</button>
<button class="k" data-send="\x1b[B" title="Next command">↓ Hist</button>
<div class="toolbar-sep"></div>
<!-- Danger -->
<button class="k danger-k" id="btn-clear-term" title="Clear screen">Clear</button>
</div>
<!-- Main -->
<div id="main">
<!-- Terminal Area -->
<div id="terminal-area"></div>
<!-- File Panel -->
<div id="file-panel" class="hidden">
<div id="file-panel-header">
<h3>πŸ“ Files</h3>
<button class="btn" id="btn-refresh-files" title="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="btn" id="btn-close-panel">βœ•</button>
</div>
<!-- Upload zone -->
<label class="upload-zone" for="file-input-panel" id="upload-zone">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>
Click or drop to upload
</label>
<input type="file" id="file-input-panel" multiple>
<div id="file-list"></div>
<div id="file-empty" class="hidden">No files in /workspace</div>
</div>
</div>
<!-- Mobile extra keys -->
<div id="mobile-keys">
<button class="k ctrl" data-send="\x03">^C</button>
<button class="k ctrl" data-send="\x04">^D</button>
<button class="k ctrl" data-send="\x18">^X</button>
<button class="k ctrl" data-send="\x13">^S</button>
<button class="k special" data-send="\x1b[A">↑</button>
<button class="k special" data-send="\x1b[B">↓</button>
<button class="k special" data-send="\x1b[D">←</button>
<button class="k special" data-send="\x1b[C">β†’</button>
<button class="k special" data-send="\x1b[H">Home</button>
<button class="k special" data-send="\x1b[F">End</button>
<button class="k special" data-send="\x7f">⌫</button>
<button class="k special" data-send="\x1b">Esc</button>
<button class="k special" data-send="\t">Tab</button>
<button class="k special" data-send="\r">↡</button>
<button class="k danger-k" data-send="\x03">Kill</button>
</div>
</div>
<!-- Backdrop for mobile file panel -->
<div id="file-panel-backdrop"></div>
<div id="toast-container"></div>
<input type="file" id="file-input-drop" multiple style="display:none">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-clipboard@0.3.0/lib/xterm-addon-clipboard.min.js"></script>
<script>
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Config
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
const TERM_THEME = {
background: '#000000',
foreground: '#cdd9e5',
cursor: '#00e5a0',
cursorAccent: '#000000',
selectionBackground: 'rgba(0,229,160,0.25)',
black: '#1e2d3d', red: '#ff4d6a',
green: '#00e5a0', yellow: '#ffc947',
blue: '#58a6ff', magenta: '#d2a8ff',
cyan: '#00b8d9', white: '#cdd9e5',
brightBlack: '#3d5a73', brightRed: '#ff6b82',
brightGreen: '#00ffb3', brightYellow: '#ffda79',
brightBlue: '#79b8ff', brightMagenta: '#e2bcff',
brightCyan: '#00d4f5', brightWhite: '#ffffff',
};
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
State
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
const terminals = {}; // id -> { term, fit, wrap, tabEl }
let activeId = null;
let tabCounter = 0;
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Socket
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
const socket = io({
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socket.on('connect', () => { toast('Connected', 'ok'); if (!activeId) newTerminal(); });
socket.on('disconnect', () => toast('Disconnected β€” reconnecting…', 'warn'));
socket.on('connect_error', () => toast('Connection error', 'error'));
socket.on('terminal_created', ({ id }) => {
if (terminals[id]) initTerminal(id);
});
socket.on('terminal_output', ({ id, data }) => {
if (terminals[id]) terminals[id].term.write(data);
});
socket.on('terminal_closed', ({ id }) => {
if (terminals[id]) {
terminals[id].term.write('\r\n\x1b[31m[Session ended]\x1b[0m\r\n');
}
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Terminal Creation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
function newTerminal() {
tabCounter++;
const id = '__pending__' + tabCounter;
// Create wrapper
const wrap = document.createElement('div');
wrap.className = 'term-wrap';
wrap.dataset.id = id;
wrap.innerHTML = `<div class="term-overlay"><div class="spinner"></div><div>Connecting…</div></div>`;
document.getElementById('terminal-area').appendChild(wrap);
// Create xterm
const term = new Terminal({
theme: TERM_THEME,
fontFamily: "'JetBrains Mono', 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.25,
letterSpacing: 0,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowTransparency: false,
allowProposedApi: true,
macOptionIsMeta: true,
rightClickSelectsWord: true,
});
const fit = new FitAddon.FitAddon();
const links = new WebLinksAddon.WebLinksAddon(undefined, undefined, true);
term.loadAddon(fit);
term.loadAddon(links);
try {
const clip = new ClipboardAddon.ClipboardAddon();
term.loadAddon(clip);
} catch(e) {}
// Create tab
const tabEl = document.createElement('div');
tabEl.className = 'tab';
tabEl.dataset.id = id;
tabEl.innerHTML = `<span class="t-icon">⬑</span><span class="t-label">term ${tabCounter}</span><button class="t-close" title="Close">Γ—</button>`;
tabEl.querySelector('.t-close').addEventListener('click', e => { e.stopPropagation(); closeTerminal(id); });
tabEl.addEventListener('click', () => switchTab(id));
document.getElementById('tabs').appendChild(tabEl);
terminals[id] = { term, fit, wrap, tabEl, ready: false };
switchTab(id);
// Request actual terminal from server
const { cols, rows } = getTermSize(wrap);
socket.emit('create_terminal', { cols, rows });
// Listen for terminal_created to associate real ID
socket.once('terminal_created', ({ id: realId }) => {
// Re-key
const data = terminals[id];
if (!data) return;
delete terminals[id];
terminals[realId] = data;
wrap.dataset.id = realId;
tabEl.dataset.id = realId;
if (activeId === id) activeId = realId;
data.ready = true;
// Open terminal in DOM
const overlay = wrap.querySelector('.term-overlay');
term.open(wrap);
if (overlay) overlay.remove();
fit.fit();
term.focus();
// Events
term.onData(d => socket.emit('terminal_input', { id: realId, data: d }));
term.onResize(s => socket.emit('terminal_resize', { id: realId, ...s }));
// Better paste in nano/apps: send raw to PTY
term.attachCustomKeyEventHandler((ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
navigator.clipboard.readText().then(text => {
if (text) socket.emit('terminal_input', { id: realId, data: text });
}).catch(() => {});
return false; // let xterm handle it too
}
return true;
});
});
}
function getTermSize(wrap) {
const el = wrap || document.getElementById('terminal-area');
const W = el.clientWidth || 800;
const H = el.clientHeight || 400;
const cols = Math.max(20, Math.floor((W - 8) / 8.4));
const rows = Math.max(10, Math.floor((H - 8) / 17.5));
return { cols, rows };
}
function initTerminal(id) {
// handled inside newTerminal via socket.once
}
function switchTab(id) {
if (!terminals[id]) return;
activeId = id;
// Update tab classes
Object.values(terminals).forEach(t => {
t.wrap.classList.remove('active');
t.tabEl.classList.remove('active');
});
terminals[id].wrap.classList.add('active');
terminals[id].tabEl.classList.add('active');
if (terminals[id].ready) {
requestAnimationFrame(() => {
terminals[id].fit.fit();
terminals[id].term.focus();
});
}
}
function closeTerminal(id) {
const data = terminals[id];
if (!data) return;
socket.emit('disconnect_terminal', { id });
data.term.dispose();
data.wrap.remove();
data.tabEl.remove();
delete terminals[id];
const ids = Object.keys(terminals);
activeId = ids.length ? ids[ids.length - 1] : null;
if (activeId) switchTab(activeId);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Key toolbar
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
function sendToActive(str) {
if (!activeId || !terminals[activeId]) return;
// Unescape sequences stored as \xNN in data-send
const decoded = str.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\n/g, '\n')
.replace(/\\u001b/g, '\x1b');
socket.emit('terminal_input', { id: activeId, data: decoded });
terminals[activeId].term.focus();
}
document.querySelectorAll('[data-send]').forEach(btn => {
btn.addEventListener('pointerdown', e => {
e.preventDefault();
const raw = btn.dataset.send;
// Parse JS escape literals properly
sendKey(raw);
});
});
function sendKey(raw) {
if (!activeId || !terminals[activeId]) return;
// Evaluate the escape sequences exactly as JS string literals
// raw values are like \x03, \x1b[A, \t, \r, etc.
let decoded;
try {
decoded = JSON.parse('"' + raw.replace(/"/g, '\\"') + '"');
} catch (e) {
decoded = raw;
}
socket.emit('terminal_input', { id: activeId, data: decoded });
terminals[activeId].term.focus();
}
document.getElementById('btn-clear-term').addEventListener('pointerdown', e => {
e.preventDefault();
sendKey('\\x0c');
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
New terminal buttons
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
document.getElementById('btn-new-term').addEventListener('click', newTerminal);
document.getElementById('add-tab').addEventListener('click', newTerminal);
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Alt+T shortcut
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
document.addEventListener('keydown', e => {
if (e.altKey && e.key === 't') { e.preventDefault(); newTerminal(); }
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Resize
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (activeId && terminals[activeId]?.ready) {
terminals[activeId].fit.fit();
}
}, 80);
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Paste to terminal
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
document.addEventListener('paste', e => {
if (!activeId || !terminals[activeId]?.ready) return;
const focused = document.activeElement;
// Only intercept if not in a text input
if (focused && (focused.tagName === 'INPUT' || focused.tagName === 'TEXTAREA')) return;
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (text) socket.emit('terminal_input', { id: activeId, data: text });
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
File Manager
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
const filePanel = document.getElementById('file-panel');
const fileList = document.getElementById('file-list');
const fileEmpty = document.getElementById('file-empty');
const backdrop = document.getElementById('file-panel-backdrop');
function openFilePanel() {
filePanel.classList.remove('hidden');
filePanel.classList.add('open');
backdrop.classList.add('show');
refreshFiles();
}
function closeFilePanel() {
filePanel.classList.add('hidden');
filePanel.classList.remove('open');
backdrop.classList.remove('show');
}
document.getElementById('btn-files').addEventListener('click', () => {
if (filePanel.classList.contains('hidden')) openFilePanel();
else closeFilePanel();
});
document.getElementById('btn-close-panel').addEventListener('click', closeFilePanel);
backdrop.addEventListener('click', closeFilePanel);
document.getElementById('btn-refresh-files').addEventListener('click', refreshFiles);
function fmtSize(b) {
if (b < 1024) return b + ' B';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
if (b < 1024*1024*1024) return (b/1024/1024).toFixed(1) + ' MB';
return (b/1024/1024/1024).toFixed(2) + ' GB';
}
async function refreshFiles() {
try {
const r = await fetch('/list-files');
const { files } = await r.json();
fileList.innerHTML = '';
if (!files || files.length === 0) {
fileEmpty.classList.remove('hidden');
return;
}
fileEmpty.classList.add('hidden');
files.forEach(f => {
const div = document.createElement('div');
div.className = 'file-item' + (f.is_dir ? ' dir' : '');
div.innerHTML = `
<span class="fi-icon">${f.is_dir ? 'πŸ“' : 'πŸ“„'}</span>
<span class="fi-name" title="${f.name}">${f.name}</span>
<span class="fi-size">${f.is_dir ? '' : fmtSize(f.size)}</span>
${!f.is_dir ? `<button class="fi-dl" title="Download" onclick="dlFile('${f.name}')">⬇</button>` : ''}
${!f.is_dir ? `<button class="fi-rm" title="Delete" onclick="rmFile('${f.name}', this)">πŸ—‘</button>` : ''}
`;
if (!f.is_dir) {
div.querySelector('.fi-name').addEventListener('click', () => dlFile(f.name));
}
fileList.appendChild(div);
});
} catch(e) {
toast('Failed to list files', 'error');
}
}
function dlFile(name) {
const a = document.createElement('a');
a.href = '/download/' + encodeURIComponent(name);
a.download = name;
a.click();
}
async function rmFile(name, btn) {
if (!confirm(`Delete "${name}"?`)) return;
try {
const r = await fetch('/delete/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) { toast('Deleted ' + name); refreshFiles(); }
else toast('Delete failed', 'error');
} catch(e) { toast('Delete failed', 'error'); }
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
File Upload
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
async function uploadFiles(fileList_) {
if (!fileList_ || fileList_.length === 0) return;
const fd = new FormData();
for (const f of fileList_) fd.append('file', f);
try {
const r = await fetch('/upload', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
toast(`Uploaded ${d.count} file(s)`);
refreshFiles();
} else {
toast('Upload failed', 'error');
}
} catch(e) { toast('Upload error', 'error'); }
}
// Header upload button
const fileInputHdr = document.getElementById('file-input-hdr');
document.getElementById('btn-upload-hdr').addEventListener('click', () => fileInputHdr.click());
fileInputHdr.addEventListener('change', e => { uploadFiles(e.target.files); e.target.value=''; });
// Panel upload
const fileInputPanel = document.getElementById('file-input-panel');
fileInputPanel.addEventListener('change', e => { uploadFiles(e.target.files); e.target.value=''; });
// Drag & drop on upload zone
const uploadZone = document.getElementById('upload-zone');
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag'));
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('drag');
uploadFiles(e.dataTransfer.files);
});
// Global drag & drop onto terminal
const termArea = document.getElementById('terminal-area');
termArea.addEventListener('dragover', e => e.preventDefault());
termArea.addEventListener('drop', e => {
e.preventDefault();
uploadFiles(e.dataTransfer.files);
});
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Toast Notifications
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
function toast(msg, type='ok', dur=2800) {
const el = document.createElement('div');
el.className = 'toast' + (type==='error' ? ' error' : type==='warn' ? ' warn' : '');
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), dur);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Link copy helper (click URL in terminal)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
document.addEventListener('click', e => {
const target = e.target.closest('a[href]');
if (target && target.href.startsWith('http')) {
e.preventDefault();
navigator.clipboard.writeText(target.href)
.then(() => toast('Link copied: ' + target.href))
.catch(() => window.open(target.href, '_blank'));
}
});
</script>
</body>
</html>