added tabs to terminal, split single .go to separate files
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
|
||||
font-family: 'JetBrains Mono','Fira Mono',monospace; overflow: hidden; }
|
||||
|
||||
/* ── Tab bar ── */
|
||||
#tabBar {
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 36px;
|
||||
background: #0a0c11; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex; align-items: center;
|
||||
padding: 0 4px; z-index: 50; overflow: hidden;
|
||||
}
|
||||
/* tabList fills bar, scrolls horizontally when many tabs.
|
||||
The "+" button lives at the end of this list. */
|
||||
#tabList {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
flex: 1; overflow-x: auto; overflow-y: hidden; height: 100%;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#tabList::-webkit-scrollbar { display: none; }
|
||||
.tab-item {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 0 8px 0 12px; height: 28px; border-radius: 5px;
|
||||
background: transparent; color: #4b5563;
|
||||
font-size: 11px; font-family: inherit; cursor: default;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.tab-item:hover { background: rgba(255,255,255,0.05); color: #9ca3af; }
|
||||
.tab-item.active {
|
||||
background: #13161e; color: #e2e8f0;
|
||||
border-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
.tab-label { cursor: pointer; }
|
||||
.tab-x {
|
||||
width: 16px; height: 16px; border: none; border-radius: 3px;
|
||||
background: transparent; color: inherit; cursor: pointer;
|
||||
font-size: 14px; line-height: 1; padding: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0.4; transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.tab-x:hover { opacity: 1; background: rgba(255,255,255,0.12); }
|
||||
.tab-new {
|
||||
height: 28px; width: 28px; border: none; border-radius: 5px;
|
||||
background: transparent; color: #4b5563;
|
||||
font-size: 20px; line-height: 1; cursor: pointer; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tab-new:hover { background: rgba(255,255,255,0.07); color: #6ee7b7; }
|
||||
|
||||
/* ── Terminal container + panes ── */
|
||||
#termContainer {
|
||||
position: fixed; top: 36px; left: 0; right: 0; bottom: 48px;
|
||||
}
|
||||
.term-pane { width: 100%; height: 100%; }
|
||||
|
||||
/* ── Compact toolbar ── */
|
||||
.toolbar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; height: 48px;
|
||||
background: #13161e; border-top: 1px solid rgba(255,255,255,0.07);
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px; gap: 6px; z-index: 50;
|
||||
}
|
||||
.tb-left { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
|
||||
.tb-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
/* connection dot */
|
||||
.dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
background: #f59e0b; box-shadow: 0 0 5px #f59e0b; transition: all 0.3s;
|
||||
}
|
||||
.dot.ok { background: #6ee7b7; box-shadow: 0 0 5px #6ee7b7; }
|
||||
.dot.err { background: #ef4444; box-shadow: 0 0 5px #ef4444; }
|
||||
|
||||
/* status label — truncates on narrow screens */
|
||||
.status-label {
|
||||
font-size: 11px; color: #4b5563; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||
}
|
||||
|
||||
/* toolbar icon buttons */
|
||||
.tb-btn {
|
||||
height: 32px; padding: 0 10px; border: none; border-radius: 6px;
|
||||
background: rgba(255,255,255,0.05); color: #9ca3af;
|
||||
font-size: 11px; font-family: inherit; font-weight: 600;
|
||||
cursor: pointer; display: flex; align-items: center; gap: 5px;
|
||||
transition: background 0.15s, color 0.15s; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
||||
.tb-btn svg { flex-shrink: 0; }
|
||||
.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7;
|
||||
border: 1px solid rgba(110,231,183,0.15); }
|
||||
.tb-btn.accent:hover { background: rgba(110,231,183,0.18); }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
position: fixed; bottom: 56px; left: 50%;
|
||||
transform: translateX(-50%) translateY(6px);
|
||||
opacity: 0; pointer-events: none; z-index: 400;
|
||||
background: #1e2330; border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 7px 16px; border-radius: 20px; font-size: 12px;
|
||||
white-space: nowrap; transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.toast.ok { border-color: rgba(110,231,183,0.35); color: #6ee7b7; }
|
||||
.toast.err { border-color: rgba(239,68,68,0.35); color: #f87171; }
|
||||
|
||||
/* ── Shared modal base ── */
|
||||
.m-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(5,7,11,0.88); backdrop-filter: blur(6px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.m-overlay.hidden { display: none; }
|
||||
|
||||
.m-card {
|
||||
width: 100%; max-width: 380px;
|
||||
background: #13161e; border-radius: 14px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.m-title {
|
||||
font-size: 13px; font-weight: 600; color: #e2e8f0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.m-title svg { color: #6ee7b7; }
|
||||
.m-x {
|
||||
width: 28px; height: 28px; border: none; border-radius: 6px;
|
||||
background: transparent; color: #6b7280; cursor: pointer; font-size: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.m-x:hover { background: rgba(255,255,255,0.07); color: #e2e8f0; }
|
||||
|
||||
.m-body { padding: 20px; }
|
||||
|
||||
.m-label {
|
||||
display: block; font-size: 11px; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 6px;
|
||||
}
|
||||
.m-input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: #0d0f14; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 7px; color: #e2e8f0; font-size: 13px;
|
||||
font-family: inherit; outline: none; margin-bottom: 14px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.m-input:focus {
|
||||
border-color: rgba(110,231,183,0.45);
|
||||
box-shadow: 0 0 0 3px rgba(110,231,183,0.07);
|
||||
}
|
||||
.m-input::placeholder { color: #374151; }
|
||||
|
||||
/* file drop zone */
|
||||
.file-zone {
|
||||
border: 1px dashed rgba(255,255,255,0.12); border-radius: 7px;
|
||||
padding: 18px 16px; text-align: center; cursor: pointer;
|
||||
margin-bottom: 14px; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.file-zone:hover { border-color: rgba(110,231,183,0.35); background: rgba(110,231,183,0.03); }
|
||||
.file-zone.has-file { border-color: rgba(110,231,183,0.4); background: rgba(110,231,183,0.04); }
|
||||
.file-zone-icon { color: #4b5563; margin-bottom: 6px; }
|
||||
.file-zone.has-file .file-zone-icon { color: #6ee7b7; }
|
||||
.file-zone-name { font-size: 12px; color: #6b7280; }
|
||||
.file-zone.has-file .file-zone-name { color: #9ca3af; }
|
||||
|
||||
.m-btn {
|
||||
width: 100%; padding: 11px; border: none; border-radius: 7px;
|
||||
background: #6ee7b7; color: #0d0f14; font-size: 13px; font-weight: 700;
|
||||
font-family: inherit; cursor: pointer; display: flex;
|
||||
align-items: center; justify-content: center; gap: 8px;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.m-btn:hover { background: #34d399; }
|
||||
.m-btn:active { transform: scale(0.98); }
|
||||
.m-btn:disabled { background: #1f2937; color: #374151; cursor: not-allowed; transform: none; }
|
||||
.m-btn.ghost {
|
||||
background: rgba(255,255,255,0.06); color: #9ca3af;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.m-btn.ghost:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
||||
.m-fb { font-size: 11px; margin-top: 10px; text-align: center; min-height: 16px; }
|
||||
.m-fb.ok { color: #6ee7b7; }
|
||||
.m-fb.err { color: #f87171; }
|
||||
|
||||
/* spinner */
|
||||
.spin {
|
||||
width: 14px; height: 14px; border: 2px solid rgba(13,15,20,0.3);
|
||||
border-top-color: #0d0f14; border-radius: 50%;
|
||||
animation: spin 0.65s linear infinite; display: none;
|
||||
}
|
||||
.m-btn.busy .spin { display: block; }
|
||||
.m-btn.busy .btn-text { display: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Auth modal specifics ── */
|
||||
.auth-card { padding: 40px 36px; }
|
||||
.auth-logo { font-size: 22px; font-weight: 700; color: #6ee7b7;
|
||||
letter-spacing: -0.5px; margin-bottom: 4px; }
|
||||
.auth-logo em { color: rgba(110,231,183,0.4); font-style: normal; }
|
||||
.auth-sub { font-size: 11px; color: #374151; margin-bottom: 32px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.auth-err {
|
||||
font-size: 12px; color: #f87171; display: none;
|
||||
padding: 9px 13px; margin-bottom: 16px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.2); border-radius: 7px;
|
||||
}
|
||||
.auth-err.show { display: block; }
|
||||
.auth-btn {
|
||||
width: 100%; padding: 13px; border: none; border-radius: 7px;
|
||||
background: #6ee7b7; color: #0d0f14; font-size: 14px; font-weight: 700;
|
||||
font-family: inherit; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.auth-btn:hover { background: #34d399; }
|
||||
.auth-btn:active { transform: scale(0.98); }
|
||||
.auth-btn:disabled { background: #1f2937; color: #374151; cursor: not-allowed; transform: none; }
|
||||
.auth-spin {
|
||||
width: 15px; height: 15px; border: 2px solid rgba(13,15,20,0.25);
|
||||
border-top-color: #0d0f14; border-radius: 50%;
|
||||
animation: spin 0.65s linear infinite; display: none;
|
||||
}
|
||||
.auth-btn.busy .auth-spin { display: block; }
|
||||
.auth-btn.busy .btn-text { display: none; }
|
||||
@@ -0,0 +1,447 @@
|
||||
// ── Session context (injected as inline <script> in shell.html) ─────
|
||||
// SESSION_ID and AUTHED are defined before this file loads.
|
||||
|
||||
// ── Terminal theme ──────────────────────────────────────────────────
|
||||
const TERM_THEME = {
|
||||
background: '#0d0f14', foreground: '#e2e8f0',
|
||||
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
|
||||
selectionBackground: 'rgba(110,231,183,0.25)',
|
||||
};
|
||||
|
||||
// ── Global state ─────────────────────────────────────────────────────
|
||||
let tabs = [];
|
||||
let activeTab = null;
|
||||
let tabCounter = 0;
|
||||
// Runtime auth flag — AUTHED is the page-load value; this tracks whether
|
||||
// the user has authenticated in THIS session (including via the auth modal
|
||||
// after page load, where AUTHED remains false but we are now connected).
|
||||
let isAuthenticated = AUTHED;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
function randHexClient(n) {
|
||||
const arr = new Uint8Array(n);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function pasteToTerminal() {
|
||||
navigator.clipboard.readText().then(
|
||||
text => { if (text && activeTab) activeTab.term.paste(text); },
|
||||
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
|
||||
);
|
||||
}
|
||||
|
||||
// ── URL hash: persist tab layout ──────────────────────────────────────
|
||||
//
|
||||
// Format: /s/<firstTabId>#t=id1,id2,id3
|
||||
// When another browser opens this URL it reads the hash and opens the
|
||||
// same sessions. The server only sees /s/<firstTabId> (hash is client-only).
|
||||
function updateURLHash() {
|
||||
if (tabs.length === 0) return;
|
||||
const ids = tabs.map(t => t.id).join(',');
|
||||
history.replaceState(null, '', '/s/' + tabs[0].id + '#t=' + ids);
|
||||
}
|
||||
|
||||
function getTabIDsFromHash() {
|
||||
const hash = location.hash; // e.g. "#t=aabb...,ccdd..."
|
||||
if (!hash.startsWith('#t=')) return null;
|
||||
const ids = hash.slice(3).split(',').filter(id => /^[0-9a-f]{32}$/.test(id));
|
||||
return ids.length ? ids : null;
|
||||
}
|
||||
|
||||
// ── Global keyboard handler (capture phase) ───────────────────────────
|
||||
document.addEventListener('keydown', function(e) {
|
||||
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
|
||||
|
||||
if (A && !C && !M) {
|
||||
// Alt+T → new tab
|
||||
if (!S && e.code === 'KeyT') { e.preventDefault(); newTab(); return; }
|
||||
// Alt+W → close active tab (last tab stays)
|
||||
if (!S && e.code === 'KeyW') { e.preventDefault(); if (activeTab) closeTab(activeTab); return; }
|
||||
// Alt+Shift+← / Alt+Shift+→ → prev / next tab
|
||||
// (plain Alt+← / Alt+→ is word-nav below; Shift disambiguates)
|
||||
if (S && e.code === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const idx = tabs.indexOf(activeTab);
|
||||
if (idx > 0) switchTab(tabs[idx - 1]);
|
||||
return;
|
||||
}
|
||||
if (S && e.code === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const idx = tabs.indexOf(activeTab);
|
||||
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
|
||||
return;
|
||||
}
|
||||
// Alt+← / Alt+→ → readline word-backward / word-forward
|
||||
if (!S && (e.code === 'ArrowLeft' || e.code === 'ArrowRight')) {
|
||||
e.preventDefault();
|
||||
const t = activeTab;
|
||||
if (t && t.socket && t.socket.readyState === WebSocket.OPEN)
|
||||
t.socket.send(e.code === 'ArrowLeft' ? '\x1bb' : '\x1bf');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Block all Ctrl+key browser shortcuts; xterm still receives the event.
|
||||
if (C && !A && !M) {
|
||||
e.preventDefault();
|
||||
if (!S && e.code === 'KeyW') {
|
||||
const t = activeTab;
|
||||
if (t && t.socket && t.socket.readyState === WebSocket.OPEN) t.socket.send('\x17');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all function keys
|
||||
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
|
||||
|
||||
}, true); // capture phase
|
||||
|
||||
// Chrome processes Ctrl+W before dispatching keydown — last guard.
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (tabs.some(t => t.socket && t.socket.readyState === WebSocket.OPEN)) {
|
||||
e.preventDefault();
|
||||
return e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────
|
||||
let toastTimer;
|
||||
function toast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show ' + (type || '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2200);
|
||||
}
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────────
|
||||
const dotEl = document.getElementById('dot');
|
||||
const labelEl = document.getElementById('statusLabel');
|
||||
|
||||
function setStatus(text, state) {
|
||||
labelEl.textContent = text;
|
||||
dotEl.className = 'dot' + (state === 'ok' ? ' ok' : state === 'err' ? ' err' : '');
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
if (!activeTab) return;
|
||||
const s = activeTab.socket;
|
||||
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
|
||||
else if (s.readyState === WebSocket.OPEN) setStatus('connected ' + activeTab.id.slice(0, 8) + '...', 'ok');
|
||||
else setStatus('disconnected', 'err');
|
||||
}
|
||||
|
||||
// copyLink encodes all open tab IDs in the URL hash so the recipient
|
||||
// browser opens the same session layout.
|
||||
function copyLink() {
|
||||
const ids = tabs.map(t => t.id).join(',');
|
||||
const url = location.protocol + '//' + location.host +
|
||||
'/s/' + tabs[0].id + '#t=' + ids;
|
||||
navigator.clipboard.writeText(url).then(
|
||||
() => toast('Session link copied!', 'ok'),
|
||||
() => toast('Copy failed', 'err')
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal helpers ─────────────────────────────────────────────────────
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
const inp = document.querySelector('#' + id + ' .m-input');
|
||||
if (inp) setTimeout(() => inp.focus(), 60);
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
if (activeTab) activeTab.term.focus();
|
||||
}
|
||||
function bgClose(evt, id) {
|
||||
if (evt.target.id === id) closeModal(id);
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
['upOverlay', 'dlOverlay'].forEach(id => {
|
||||
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────
|
||||
async function doAuth() {
|
||||
const username = document.getElementById('fUser').value.trim();
|
||||
const password = document.getElementById('fPass').value;
|
||||
const btn = document.getElementById('authBtn');
|
||||
const errDiv = document.getElementById('authErr');
|
||||
|
||||
if (!username || !password) { showAuthErr('Please enter username and password'); return; }
|
||||
|
||||
btn.disabled = true; btn.classList.add('busy');
|
||||
errDiv.classList.remove('show');
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth', { method: 'POST', body: form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
isAuthenticated = true; // runtime flag: future newTab() calls will connect
|
||||
document.getElementById('authOverlay').classList.add('hidden');
|
||||
tabs.forEach(t => { if (!t.socket) connectTab(t); });
|
||||
if (activeTab) activeTab.term.focus();
|
||||
} else {
|
||||
showAuthErr(data.error || 'Authentication failed');
|
||||
}
|
||||
} catch (_) {
|
||||
showAuthErr('Network error — try again');
|
||||
} finally {
|
||||
btn.disabled = false; btn.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthErr(msg) {
|
||||
const e = document.getElementById('authErr');
|
||||
e.textContent = msg; e.classList.add('show');
|
||||
document.getElementById('fPass').value = '';
|
||||
document.getElementById('fPass').focus();
|
||||
}
|
||||
|
||||
document.getElementById('fUser').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('fPass').focus();
|
||||
});
|
||||
document.getElementById('fPass').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doAuth();
|
||||
});
|
||||
|
||||
// ── Tab management ────────────────────────────────────────────────────
|
||||
//
|
||||
// Each tab:
|
||||
// id 32-char hex session ID (server PTY key)
|
||||
// term xterm.js Terminal
|
||||
// fit FitAddon
|
||||
// pane .term-pane <div>
|
||||
// socket WebSocket /ws/<id>
|
||||
// tabEl .tab-item <div>
|
||||
//
|
||||
// The "+" button lives inside #tabList; new tab elements are inserted
|
||||
// before it so "+" always appears right after the last tab.
|
||||
//
|
||||
// Tab IDs are encoded in the URL hash (#t=id1,id2,...) so the link
|
||||
// button produces a URL that reopens the same sessions in another browser.
|
||||
|
||||
function newTab(sessionId) {
|
||||
const id = sessionId || randHexClient(16);
|
||||
const tabNewBtn = document.getElementById('tabNew');
|
||||
tabCounter++;
|
||||
const tabLabel = 'bash ' + tabCounter;
|
||||
|
||||
// Terminal pane
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane';
|
||||
pane.style.display = 'none';
|
||||
document.getElementById('termContainer').appendChild(pane);
|
||||
|
||||
const term = new Terminal({
|
||||
theme: TERM_THEME,
|
||||
cursorBlink: true, fontSize: 14, scrollback: 20000,
|
||||
fontFamily: '"JetBrains Mono","Fira Mono",monospace',
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(pane);
|
||||
|
||||
const tab = { id, label: tabLabel, term, fit, pane, socket: null, tabEl: null };
|
||||
tabs.push(tab);
|
||||
|
||||
// Per-terminal key handler
|
||||
term.attachCustomKeyEventHandler(evt => {
|
||||
if (evt.type !== 'keydown') return true;
|
||||
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
|
||||
|
||||
if (C && S && evt.code === 'KeyC') {
|
||||
const sel = term.getSelection();
|
||||
if (sel) navigator.clipboard.writeText(sel).then(
|
||||
() => toast('Copied!', 'ok'),
|
||||
() => toast('Copy failed', 'err')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; }
|
||||
if (C && !S && !A && evt.code === 'KeyW') return false;
|
||||
if (C && S && evt.code === 'KeyW') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Tab button — inserted BEFORE the "+" button so "+" stays at end
|
||||
const tabEl = document.createElement('div');
|
||||
tabEl.className = 'tab-item';
|
||||
tabEl.title = 'Session: ' + id;
|
||||
tabEl.innerHTML =
|
||||
`<span class="tab-label">${tabLabel}</span>` +
|
||||
`<button class="tab-x" title="Close tab (Alt+W)">×</button>`;
|
||||
tabEl.querySelector('.tab-label').addEventListener('click', () => switchTab(tab));
|
||||
tabEl.querySelector('.tab-x').addEventListener('click', ev => {
|
||||
ev.stopPropagation(); closeTab(tab);
|
||||
});
|
||||
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn); // right before "+"
|
||||
tab.tabEl = tabEl;
|
||||
|
||||
switchTab(tab);
|
||||
updateURLHash();
|
||||
|
||||
if (isAuthenticated) connectTab(tab); // connect immediately if already authed
|
||||
return tab;
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
if (activeTab) {
|
||||
activeTab.pane.style.display = 'none';
|
||||
activeTab.tabEl.classList.remove('active');
|
||||
}
|
||||
activeTab = tab;
|
||||
tab.pane.style.display = 'block';
|
||||
tab.tabEl.classList.add('active');
|
||||
tab.fit.fit();
|
||||
tab.term.focus();
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function closeTab(tab) {
|
||||
if (tabs.length === 1) return; // never close the last tab
|
||||
if (tab.socket) {
|
||||
tab.socket.onclose = null; // prevent auto-reconnect
|
||||
tab.socket.close();
|
||||
}
|
||||
tab.term.dispose();
|
||||
tab.pane.remove();
|
||||
tab.tabEl.remove();
|
||||
|
||||
const idx = tabs.indexOf(tab);
|
||||
tabs.splice(idx, 1);
|
||||
|
||||
if (activeTab === tab)
|
||||
switchTab(tabs[Math.min(idx, tabs.length - 1)]);
|
||||
|
||||
updateURLHash();
|
||||
}
|
||||
|
||||
// ── WebSocket (per tab) ───────────────────────────────────────────────
|
||||
function connectTab(tab) {
|
||||
tab.socket = new WebSocket('wss://' + location.host + '/ws/' + tab.id);
|
||||
tab.socket.binaryType = 'arraybuffer';
|
||||
|
||||
tab.socket.onopen = () => {
|
||||
tab.fit.fit();
|
||||
sendResizeFor(tab);
|
||||
if (activeTab === tab) { updateStatus(); tab.term.focus(); }
|
||||
};
|
||||
|
||||
tab.socket.onmessage = e => {
|
||||
if (typeof e.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'session' && activeTab === tab)
|
||||
setStatus((msg.new ? 'new' : 'resumed') + ' ' + tab.id.slice(0, 8) + '...', 'ok');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
tab.term.write(new Uint8Array(e.data));
|
||||
};
|
||||
|
||||
tab.socket.onclose = () => {
|
||||
if (activeTab === tab) setStatus('reconnecting...', 'err');
|
||||
setTimeout(() => connectTab(tab), 2000);
|
||||
};
|
||||
tab.socket.onerror = () => tab.socket.close();
|
||||
|
||||
tab.term.onData(d => {
|
||||
if (tab.socket && tab.socket.readyState === WebSocket.OPEN) tab.socket.send(d);
|
||||
});
|
||||
tab.term.onResize(() => sendResizeFor(tab));
|
||||
}
|
||||
|
||||
function sendResizeFor(tab) {
|
||||
if (tab.socket && tab.socket.readyState === WebSocket.OPEN)
|
||||
tab.socket.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => { if (activeTab) activeTab.fit.fit(); });
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────
|
||||
function onFileChosen() {
|
||||
const f = document.getElementById('upFile').files[0];
|
||||
const zone = document.getElementById('fileZone');
|
||||
const name = document.getElementById('upFileName');
|
||||
if (f) { zone.classList.add('has-file'); name.textContent = f.name; }
|
||||
else { zone.classList.remove('has-file'); name.textContent = 'Click to select file'; }
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
const f = document.getElementById('upFile').files[0];
|
||||
if (!f) { modalFb('upFb', 'Select a file first', 'err'); return; }
|
||||
|
||||
const btn = document.getElementById('upBtn');
|
||||
const form = new FormData();
|
||||
form.append('file', f);
|
||||
form.append('dest', document.getElementById('upDest').value.trim());
|
||||
form.append('sid', activeTab ? activeTab.id : SESSION_ID);
|
||||
|
||||
btn.disabled = true; btn.classList.add('busy');
|
||||
modalFb('upFb', '', '');
|
||||
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
modalFb('upFb', data.error, 'err');
|
||||
} else {
|
||||
modalFb('upFb', 'Staged to ' + data.dest, 'ok');
|
||||
document.getElementById('upFile').value = '';
|
||||
onFileChosen();
|
||||
setTimeout(() => closeModal('upOverlay'), 1400);
|
||||
}
|
||||
} catch (_) {
|
||||
modalFb('upFb', 'Network error', 'err');
|
||||
} finally {
|
||||
btn.disabled = false; btn.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────────────
|
||||
function doDownload() {
|
||||
const p = document.getElementById('dlPath').value.trim();
|
||||
if (!p) { modalFb('dlFb', 'Enter a file path', 'err'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = '/download?path=' + encodeURIComponent(p);
|
||||
a.download = '';
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
modalFb('dlFb', 'Download started', 'ok');
|
||||
setTimeout(() => closeModal('dlOverlay'), 1000);
|
||||
}
|
||||
|
||||
function modalFb(id, msg, type) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = msg;
|
||||
el.className = 'm-fb' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// If the URL hash encodes tab IDs, open all of them (another browser
|
||||
// shared the link). Otherwise open a single tab with the server-provided
|
||||
// SESSION_ID.
|
||||
(function bootstrap() {
|
||||
const hashIds = getTabIDsFromHash();
|
||||
if (hashIds && hashIds.length > 0) {
|
||||
// Restore all sessions from the shared link.
|
||||
// SESSION_ID might or might not be in the list — use hash as source of truth.
|
||||
hashIds.forEach(id => newTab(id));
|
||||
} else {
|
||||
newTab(SESSION_ID);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setTimeout(() => document.getElementById('fUser').focus(), 80);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#13161e"/>
|
||||
<rect x="1" y="1" width="30" height="30" rx="5" fill="none" stroke="#6ee7b7" stroke-width="0.75" stroke-opacity="0.35"/>
|
||||
<rect x="3" y="6" width="26" height="3" rx="1.5" fill="#1a1f2e"/>
|
||||
<circle cx="7" cy="7.5" r="1.2" fill="#ef4444" fill-opacity="0.7"/>
|
||||
<circle cx="11" cy="7.5" r="1.2" fill="#f59e0b" fill-opacity="0.7"/>
|
||||
<circle cx="15" cy="7.5" r="1.2" fill="#6ee7b7" fill-opacity="0.7"/>
|
||||
<text x="4" y="26" font-family="monospace" font-size="11" font-weight="700" fill="#6ee7b7">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,162 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoTermix</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Auth modal ─────────────────────────────────────────────────── -->
|
||||
<div class="m-overlay" id="authOverlay">
|
||||
<div class="m-card">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo"><em>>_</em> GoTermix</div>
|
||||
<div class="auth-sub">Authentication required</div>
|
||||
|
||||
<label class="m-label" for="fUser">Username</label>
|
||||
<input class="m-input" type="text" id="fUser"
|
||||
autocomplete="username" placeholder="username" spellcheck="false">
|
||||
|
||||
<label class="m-label" for="fPass">Password</label>
|
||||
<input class="m-input" type="password" id="fPass"
|
||||
autocomplete="current-password" placeholder="password">
|
||||
|
||||
<div class="auth-err" id="authErr"></div>
|
||||
|
||||
<button class="auth-btn" id="authBtn" onclick="doAuth()">
|
||||
<div class="auth-spin"></div>
|
||||
<span class="btn-text">Sign in</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload modal ───────────────────────────────────────────────── -->
|
||||
<div class="m-overlay hidden" id="upOverlay" onclick="bgClose(event,'upOverlay')">
|
||||
<div class="m-card">
|
||||
<div class="m-head">
|
||||
<span class="m-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Upload File
|
||||
</span>
|
||||
<button class="m-x" onclick="closeModal('upOverlay')">×</button>
|
||||
</div>
|
||||
<div class="m-body">
|
||||
<div class="file-zone" id="fileZone" onclick="document.getElementById('upFile').click()">
|
||||
<div class="file-zone-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="file-zone-name" id="upFileName">Click to select file</div>
|
||||
<input type="file" id="upFile" style="display:none" onchange="onFileChosen()">
|
||||
</div>
|
||||
|
||||
<label class="m-label">Destination directory</label>
|
||||
<input class="m-input" type="text" id="upDest" placeholder="leave empty for cwd, or /opt/myapp">
|
||||
|
||||
<button class="m-btn" id="upBtn" onclick="doUpload()">
|
||||
<div class="spin"></div>
|
||||
<span class="btn-text">Upload</span>
|
||||
</button>
|
||||
<div class="m-fb" id="upFb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Download modal ─────────────────────────────────────────────── -->
|
||||
<div class="m-overlay hidden" id="dlOverlay" onclick="bgClose(event,'dlOverlay')">
|
||||
<div class="m-card">
|
||||
<div class="m-head">
|
||||
<span class="m-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download File
|
||||
</span>
|
||||
<button class="m-x" onclick="closeModal('dlOverlay')">×</button>
|
||||
</div>
|
||||
<div class="m-body">
|
||||
<label class="m-label">File path on server</label>
|
||||
<input class="m-input" type="text" id="dlPath"
|
||||
placeholder="/var/log/syslog"
|
||||
onkeydown="if(event.key==='Enter') doDownload()">
|
||||
<button class="m-btn ghost" onclick="doDownload()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<div class="m-fb" id="dlFb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab bar ────────────────────────────────────────────────────── -->
|
||||
<div id="tabBar">
|
||||
<div id="tabList">
|
||||
<!-- tab items injected before this button -->
|
||||
<button class="tab-new" id="tabNew" title="New tab (Alt+T)" onclick="newTab()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Terminal container (panes injected by JS) ─────────────────── -->
|
||||
<div id="termContainer"></div>
|
||||
|
||||
<!-- ── Compact toolbar ────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="tb-left">
|
||||
<div class="dot" id="dot"></div>
|
||||
<span class="status-label" id="statusLabel">connecting...</span>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<!-- Copy session link -->
|
||||
<button class="tb-btn accent" id="copyBtn" onclick="copyLink()" title="Copy session link">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
Link
|
||||
</button>
|
||||
<!-- Upload -->
|
||||
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Upload
|
||||
</button>
|
||||
<!-- Download -->
|
||||
<button class="tb-btn" onclick="openModal('dlOverlay')" title="Download file">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Down
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toast ──────────────────────────────────────────────────────── -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// Session context injected server-side — consumed by app.js
|
||||
const SESSION_ID = "[[SESSION_ID]]";
|
||||
const AUTHED = [[AUTHED]];
|
||||
</script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user