2026-05-24 06:37:59 +00:00
|
|
|
// WORKSPACE_ID and AUTHED injected as inline <script> in shell.html
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
const TERM_THEME = {
|
|
|
|
|
background: '#0d0f14', foreground: '#e2e8f0',
|
|
|
|
|
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
|
|
|
|
|
selectionBackground: 'rgba(110,231,183,0.25)',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Global state ──────────────────────────────────────────────────────
|
2026-05-24 07:44:54 +00:00
|
|
|
let tabs = [];
|
|
|
|
|
let activeTab = null;
|
|
|
|
|
let tabCounter = 0;
|
|
|
|
|
let saveTimer = null;
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
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() {
|
2026-05-24 06:37:59 +00:00
|
|
|
const leaf = activeTab && activeTab.activeLeaf;
|
2026-05-23 15:29:05 +00:00
|
|
|
navigator.clipboard.readText().then(
|
2026-05-24 06:37:59 +00:00
|
|
|
text => { if (text && leaf) leaf.term.paste(text); },
|
2026-05-23 15:29:05 +00:00
|
|
|
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Workspace API ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function loadWorkspace() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/workspace/' + WORKSPACE_ID);
|
|
|
|
|
if (res.status === 404) return null;
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
return await res.json();
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveWorkspace() {
|
|
|
|
|
clearTimeout(saveTimer);
|
|
|
|
|
saveTimer = setTimeout(_doSaveWorkspace, 300);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _doSaveWorkspace() {
|
2026-05-24 07:44:54 +00:00
|
|
|
if (!tabs.length) return;
|
2026-05-24 06:37:59 +00:00
|
|
|
const ws = {
|
|
|
|
|
id: WORKSPACE_ID,
|
|
|
|
|
tabs: tabs.map(t => ({
|
|
|
|
|
tab_id: t.tabId,
|
|
|
|
|
label: t.label,
|
|
|
|
|
root: serializePane(t.root),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
await fetch('/api/workspace/' + WORKSPACE_ID, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(ws),
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function endSession() {
|
|
|
|
|
if (!confirm('End session? Saved layout will be cleared — this link will start a fresh session.')) return;
|
|
|
|
|
try {
|
|
|
|
|
await fetch('/api/workspace/' + WORKSPACE_ID, { method: 'DELETE' });
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
// Close all sockets silently
|
|
|
|
|
tabs.forEach(t => walkLeaves(t.root, l => {
|
|
|
|
|
if (l.socket) { l.socket.onclose = null; l.socket.close(); }
|
|
|
|
|
}));
|
|
|
|
|
location.href = '/';
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
function copyLink() {
|
|
|
|
|
const url = location.protocol + '//' + location.host + '/s/' + WORKSPACE_ID;
|
|
|
|
|
navigator.clipboard.writeText(url).then(
|
|
|
|
|
() => toast('Workspace link copied!', 'ok'),
|
|
|
|
|
() => toast('Copy failed', 'err')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Pane serialization ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function serializePane(node) {
|
|
|
|
|
if (node.type === 'leaf') return { type: 'leaf', id: node.id };
|
|
|
|
|
return {
|
|
|
|
|
type: 'split',
|
|
|
|
|
dir: node.dir,
|
|
|
|
|
ratio: node.ratio,
|
|
|
|
|
a: serializePane(node.a),
|
|
|
|
|
b: serializePane(node.b),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deserializePane(data) {
|
|
|
|
|
if (data.type === 'leaf') {
|
|
|
|
|
return createLeaf(data.id);
|
|
|
|
|
}
|
|
|
|
|
// Split node — build children first, then container
|
|
|
|
|
const a = deserializePane(data.a);
|
|
|
|
|
const b = deserializePane(data.b);
|
|
|
|
|
|
|
|
|
|
const splitEl = document.createElement('div');
|
|
|
|
|
splitEl.className = data.dir === 'h' ? 'split-h' : 'split-v';
|
|
|
|
|
|
|
|
|
|
const divEl = document.createElement('div');
|
|
|
|
|
divEl.className = data.dir === 'h' ? 'split-div-h' : 'split-div-v';
|
|
|
|
|
|
|
|
|
|
const splitNode = {
|
|
|
|
|
type: 'split', dir: data.dir, ratio: data.ratio,
|
|
|
|
|
a, b, el: splitEl, divEl, parent: null,
|
|
|
|
|
};
|
|
|
|
|
a.parent = splitNode;
|
|
|
|
|
b.parent = splitNode;
|
|
|
|
|
|
|
|
|
|
splitEl.appendChild(a.el);
|
|
|
|
|
splitEl.appendChild(divEl);
|
|
|
|
|
splitEl.appendChild(b.el);
|
|
|
|
|
applyFlex(splitNode);
|
|
|
|
|
setupDivider(splitNode);
|
|
|
|
|
return splitNode;
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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) {
|
2026-05-24 06:37:59 +00:00
|
|
|
if (!S) {
|
|
|
|
|
switch (e.code) {
|
|
|
|
|
case 'KeyT': e.preventDefault(); newTab(); return;
|
|
|
|
|
case 'KeyW': e.preventDefault(); if (activeTab) closeTab(activeTab); return;
|
|
|
|
|
case 'KeyX': e.preventDefault(); closePane(); return;
|
2026-05-24 07:44:54 +00:00
|
|
|
case 'KeyH': e.preventDefault(); splitPane('h'); return; // Alt+H → side-by-side
|
|
|
|
|
case 'KeyV': e.preventDefault(); splitPane('v'); return; // Alt+V → top/bottom
|
2026-05-24 06:37:59 +00:00
|
|
|
case 'ArrowLeft':
|
|
|
|
|
case 'ArrowRight':
|
2026-05-24 07:44:54 +00:00
|
|
|
return; // let xterm pass Ctrl+Arrow to shell readline
|
2026-05-24 06:37:59 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (e.code === 'ArrowLeft') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const idx = tabs.indexOf(activeTab);
|
|
|
|
|
if (idx > 0) switchTab(tabs[idx - 1]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.code === 'ArrowRight') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const idx = tabs.indexOf(activeTab);
|
|
|
|
|
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 07:44:54 +00:00
|
|
|
// Block browser Ctrl+key defaults (find, save, etc.); xterm still gets event.
|
2026-05-23 15:29:05 +00:00
|
|
|
if (C && !A && !M) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// Block function keys
|
2026-05-23 15:29:05 +00:00
|
|
|
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
function sendToActive(data) {
|
|
|
|
|
const leaf = activeTab && activeTab.activeLeaf;
|
|
|
|
|
if (leaf && leaf.socket && leaf.socket.readyState === WebSocket.OPEN) leaf.socket.send(data);
|
|
|
|
|
}
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// Guard accidental tab close
|
2026-05-23 15:29:05 +00:00
|
|
|
window.addEventListener('beforeunload', function(e) {
|
2026-05-24 06:37:59 +00:00
|
|
|
const anyOpen = tabs.some(t => {
|
|
|
|
|
let f = false;
|
|
|
|
|
walkLeaves(t.root, l => { if (l.socket && l.socket.readyState === WebSocket.OPEN) f = true; });
|
|
|
|
|
return f;
|
|
|
|
|
});
|
|
|
|
|
if (anyOpen) { e.preventDefault(); return e.returnValue = ''; }
|
2026-05-23 15:29:05 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Toast ──────────────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
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() {
|
2026-05-24 06:37:59 +00:00
|
|
|
const leaf = activeTab && activeTab.activeLeaf;
|
|
|
|
|
const wsShort = '/s/' + WORKSPACE_ID.slice(0, 8) + '...';
|
|
|
|
|
if (!leaf) { setStatus(wsShort, ''); return; }
|
|
|
|
|
const s = leaf.socket;
|
2026-05-23 15:29:05 +00:00
|
|
|
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
|
2026-05-24 06:37:59 +00:00
|
|
|
else if (s.readyState === WebSocket.OPEN)
|
|
|
|
|
setStatus(wsShort + ' pane:' + leaf.id.slice(0, 8), 'ok');
|
2026-05-23 15:29:05 +00:00
|
|
|
else setStatus('disconnected', 'err');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Modal helpers ──────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
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');
|
2026-05-24 06:37:59 +00:00
|
|
|
const leaf = activeTab && activeTab.activeLeaf;
|
|
|
|
|
if (leaf) leaf.term.focus();
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
function bgClose(evt, id) {
|
|
|
|
|
if (evt.target.id === id) closeModal(id);
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
|
|
|
if (e.key === 'Escape') {
|
2026-05-24 07:44:54 +00:00
|
|
|
['upOverlay', 'dlOverlay', 'infoOverlay'].forEach(id => {
|
2026-05-23 15:29:05 +00:00
|
|
|
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// PANE TREE
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
2026-05-23 15:29:05 +00:00
|
|
|
//
|
2026-05-24 06:37:59 +00:00
|
|
|
// Leaf node: { type:'leaf', id, term, fit, socket, el, parent }
|
|
|
|
|
// Split node: { type:'split', dir:'h'|'v', a, b, ratio, el, divEl, parent }
|
2026-05-23 15:29:05 +00:00
|
|
|
//
|
2026-05-24 06:37:59 +00:00
|
|
|
// dir 'h' → side-by-side (flex-direction:row, vertical divider)
|
|
|
|
|
// dir 'v' → stacked (flex-direction:column, horizontal divider)
|
|
|
|
|
|
|
|
|
|
function walkLeaves(node, fn) {
|
|
|
|
|
if (!node) return;
|
|
|
|
|
if (node.type === 'leaf') { fn(node); return; }
|
|
|
|
|
walkLeaves(node.a, fn);
|
|
|
|
|
walkLeaves(node.b, fn);
|
|
|
|
|
}
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
function countLeaves(node) {
|
|
|
|
|
if (!node || node.type === 'leaf') return node ? 1 : 0;
|
|
|
|
|
return countLeaves(node.a) + countLeaves(node.b);
|
|
|
|
|
}
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
function findFirstLeaf(node) {
|
|
|
|
|
return node.type === 'leaf' ? node : findFirstLeaf(node.a);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refitAll(tab) {
|
|
|
|
|
if (tab) walkLeaves(tab.root, l => { try { l.fit.fit(); } catch(_) {} });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Replace oldNode with newNode in the JS tree and DOM.
|
|
|
|
|
function replaceNode(tab, oldNode, newNode) {
|
|
|
|
|
const p = oldNode.parent;
|
|
|
|
|
newNode.parent = p;
|
|
|
|
|
if (!p) {
|
|
|
|
|
tab.root = newNode;
|
|
|
|
|
tab.pane.innerHTML = '';
|
|
|
|
|
tab.pane.appendChild(newNode.el);
|
|
|
|
|
} else {
|
|
|
|
|
if (p.a === oldNode) p.a = newNode; else p.b = newNode;
|
|
|
|
|
p.el.replaceChild(newNode.el, oldNode.el);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyFlex(splitNode) {
|
|
|
|
|
const { a, b, dir, ratio } = splitNode;
|
|
|
|
|
const cross = dir === 'h' ? 'height' : 'width';
|
|
|
|
|
a.el.style.flex = `0 0 calc(${ratio * 100}% - 2px)`;
|
|
|
|
|
a.el.style[cross] = '100%';
|
|
|
|
|
a.el.style.minWidth = '0';
|
|
|
|
|
a.el.style.minHeight = '0';
|
|
|
|
|
a.el.style.overflow = 'hidden';
|
|
|
|
|
b.el.style.flex = '1 1 0%';
|
|
|
|
|
b.el.style[cross] = '100%';
|
|
|
|
|
b.el.style.minWidth = '0';
|
|
|
|
|
b.el.style.minHeight = '0';
|
|
|
|
|
b.el.style.overflow = 'hidden';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshActiveHighlight(tab) {
|
|
|
|
|
const multi = countLeaves(tab.root) > 1;
|
|
|
|
|
walkLeaves(tab.root, l => {
|
|
|
|
|
l.el.classList.toggle('pane-active', multi && l === tab.activeLeaf);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActiveLeaf(tab, leaf) {
|
|
|
|
|
tab.activeLeaf = leaf;
|
|
|
|
|
refreshActiveHighlight(tab);
|
|
|
|
|
try { leaf.fit.fit(); } catch(_) {}
|
|
|
|
|
leaf.term.focus();
|
|
|
|
|
updateStatus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Create leaf ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function createLeaf(sessionId) {
|
|
|
|
|
const id = sessionId || randHexClient(16);
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
el.className = 'term-pane';
|
2026-05-23 15:29:05 +00:00
|
|
|
|
|
|
|
|
const term = new Terminal({
|
2026-05-24 06:37:59 +00:00
|
|
|
theme: TERM_THEME, cursorBlink: true, fontSize: 14,
|
|
|
|
|
scrollback: 20000, fontFamily: '"JetBrains Mono","Fira Mono",monospace',
|
2026-05-23 15:29:05 +00:00
|
|
|
});
|
|
|
|
|
const fit = new FitAddon.FitAddon();
|
|
|
|
|
term.loadAddon(fit);
|
2026-05-24 06:37:59 +00:00
|
|
|
term.open(el);
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
const leaf = { type: 'leaf', id, term, fit, socket: null, el, parent: null };
|
|
|
|
|
|
|
|
|
|
el.addEventListener('mousedown', () => {
|
|
|
|
|
if (activeTab && activeTab.activeLeaf !== leaf) setActiveLeaf(activeTab, leaf);
|
|
|
|
|
});
|
2026-05-23 15:29:05 +00:00
|
|
|
|
|
|
|
|
term.attachCustomKeyEventHandler(evt => {
|
|
|
|
|
if (evt.type !== 'keydown') return true;
|
|
|
|
|
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
|
2026-05-24 07:44:54 +00:00
|
|
|
|
|
|
|
|
// Copy selection
|
2026-05-23 15:29:05 +00:00
|
|
|
if (C && S && evt.code === 'KeyC') {
|
|
|
|
|
const sel = term.getSelection();
|
|
|
|
|
if (sel) navigator.clipboard.writeText(sel).then(
|
2026-05-24 06:37:59 +00:00
|
|
|
() => toast('Copied!', 'ok'), () => toast('Copy failed', 'err'));
|
2026-05-23 15:29:05 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-05-24 07:44:54 +00:00
|
|
|
// Paste
|
2026-05-23 15:29:05 +00:00
|
|
|
if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; }
|
2026-05-24 07:44:54 +00:00
|
|
|
|
|
|
|
|
// Block app-level Alt shortcuts from reaching the PTY
|
|
|
|
|
if (A && !C) {
|
|
|
|
|
if (!S && ['KeyT','KeyW','KeyX','KeyH','KeyV'].includes(evt.code)) return false;
|
|
|
|
|
if ( S && (evt.code === 'ArrowLeft' || evt.code === 'ArrowRight')) return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:29:05 +00:00
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
return leaf;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Split ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function splitPane(dir) {
|
|
|
|
|
const tab = activeTab;
|
|
|
|
|
if (!tab || !tab.activeLeaf) return;
|
|
|
|
|
const leaf = tab.activeLeaf;
|
|
|
|
|
const newLeaf = createLeaf();
|
|
|
|
|
|
|
|
|
|
const splitEl = document.createElement('div');
|
|
|
|
|
splitEl.className = dir === 'h' ? 'split-h' : 'split-v';
|
|
|
|
|
|
|
|
|
|
const divEl = document.createElement('div');
|
|
|
|
|
divEl.className = dir === 'h' ? 'split-div-h' : 'split-div-v';
|
|
|
|
|
|
|
|
|
|
const splitNode = {
|
|
|
|
|
type: 'split', dir, a: leaf, b: newLeaf,
|
|
|
|
|
ratio: 0.5, el: splitEl, divEl, parent: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// replaceNode uses leaf.parent (old parent) — set new parents AFTER
|
|
|
|
|
replaceNode(tab, leaf, splitNode);
|
|
|
|
|
leaf.parent = splitNode;
|
|
|
|
|
newLeaf.parent = splitNode;
|
|
|
|
|
|
|
|
|
|
splitEl.appendChild(leaf.el);
|
|
|
|
|
splitEl.appendChild(divEl);
|
|
|
|
|
splitEl.appendChild(newLeaf.el);
|
|
|
|
|
|
|
|
|
|
applyFlex(splitNode);
|
|
|
|
|
setupDivider(splitNode);
|
|
|
|
|
|
2026-05-24 07:44:54 +00:00
|
|
|
connectLeaf(newLeaf);
|
2026-05-24 06:37:59 +00:00
|
|
|
|
|
|
|
|
setActiveLeaf(tab, newLeaf);
|
|
|
|
|
refreshActiveHighlight(tab);
|
|
|
|
|
setTimeout(() => refitAll(tab), 30);
|
|
|
|
|
saveWorkspace();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupDivider(splitNode) {
|
|
|
|
|
const { divEl, el, dir } = splitNode;
|
|
|
|
|
divEl.addEventListener('mousedown', e => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
const total = dir === 'h' ? rect.width : rect.height;
|
|
|
|
|
const origin = dir === 'h' ? rect.left : rect.top;
|
|
|
|
|
|
|
|
|
|
function onMove(ev) {
|
|
|
|
|
const pos = (dir === 'h' ? ev.clientX : ev.clientY) - origin;
|
|
|
|
|
splitNode.ratio = Math.max(0.1, Math.min(0.9, pos / total));
|
|
|
|
|
applyFlex(splitNode);
|
|
|
|
|
refitAll(activeTab);
|
|
|
|
|
}
|
|
|
|
|
function onUp() {
|
|
|
|
|
document.removeEventListener('mousemove', onMove);
|
|
|
|
|
document.removeEventListener('mouseup', onUp);
|
|
|
|
|
document.body.style.cursor = document.body.style.userSelect = '';
|
|
|
|
|
saveWorkspace(); // persist ratio after drag
|
|
|
|
|
}
|
|
|
|
|
document.body.style.cursor = dir === 'h' ? 'col-resize' : 'row-resize';
|
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
|
|
|
document.addEventListener('mouseup', onUp);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Close pane ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function closePane() {
|
|
|
|
|
const tab = activeTab;
|
|
|
|
|
if (!tab || !tab.activeLeaf || !tab.activeLeaf.parent) return;
|
|
|
|
|
|
|
|
|
|
const leaf = tab.activeLeaf;
|
|
|
|
|
const splitNode = leaf.parent;
|
|
|
|
|
const sibling = splitNode.a === leaf ? splitNode.b : splitNode.a;
|
|
|
|
|
|
|
|
|
|
if (leaf.socket) { leaf.socket.onclose = null; leaf.socket.close(); }
|
|
|
|
|
leaf.term.dispose();
|
|
|
|
|
|
|
|
|
|
replaceNode(tab, splitNode, sibling);
|
|
|
|
|
sibling.el.removeAttribute('style');
|
|
|
|
|
|
|
|
|
|
const next = findFirstLeaf(sibling);
|
|
|
|
|
setActiveLeaf(tab, next);
|
|
|
|
|
refreshActiveHighlight(tab);
|
|
|
|
|
refitAll(tab);
|
|
|
|
|
saveWorkspace();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// TAB MANAGEMENT
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
// Build the label span with click + right-click rename handlers.
|
|
|
|
|
function buildLabelSpan(tab) {
|
|
|
|
|
const span = document.createElement('span');
|
|
|
|
|
span.className = 'tab-label';
|
|
|
|
|
span.textContent = tab.label;
|
|
|
|
|
span.addEventListener('click', () => switchTab(tab));
|
|
|
|
|
span.addEventListener('contextmenu', e => { e.preventDefault(); startRename(tab); });
|
|
|
|
|
return span;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inline tab rename: right-click → input → Enter/blur commit, Escape cancel.
|
|
|
|
|
function startRename(tab) {
|
|
|
|
|
const labelSpan = tab.tabEl.querySelector('.tab-label');
|
|
|
|
|
if (!labelSpan) return;
|
|
|
|
|
const oldLabel = tab.label;
|
|
|
|
|
|
|
|
|
|
const input = document.createElement('input');
|
|
|
|
|
input.className = 'tab-rename-input';
|
|
|
|
|
input.value = oldLabel;
|
|
|
|
|
labelSpan.replaceWith(input);
|
|
|
|
|
input.focus();
|
|
|
|
|
input.select();
|
|
|
|
|
|
|
|
|
|
let committed = false;
|
|
|
|
|
|
|
|
|
|
function commit() {
|
|
|
|
|
if (committed) return;
|
|
|
|
|
committed = true;
|
|
|
|
|
const val = input.value.trim();
|
|
|
|
|
tab.label = val || oldLabel;
|
|
|
|
|
input.replaceWith(buildLabelSpan(tab));
|
|
|
|
|
saveWorkspace();
|
|
|
|
|
}
|
|
|
|
|
function cancel() {
|
|
|
|
|
if (committed) return;
|
|
|
|
|
committed = true;
|
|
|
|
|
input.replaceWith(buildLabelSpan(tab));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input.addEventListener('keydown', e => {
|
|
|
|
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
|
|
|
|
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
|
|
|
|
});
|
|
|
|
|
input.addEventListener('blur', commit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create and insert the tab bar button for a tab.
|
|
|
|
|
function addTabButton(tab) {
|
|
|
|
|
const tabNewBtn = document.getElementById('tabNew');
|
2026-05-23 15:29:05 +00:00
|
|
|
const tabEl = document.createElement('div');
|
|
|
|
|
tabEl.className = 'tab-item';
|
2026-05-24 06:37:59 +00:00
|
|
|
|
|
|
|
|
const closeBtn = document.createElement('button');
|
|
|
|
|
closeBtn.className = 'tab-x';
|
|
|
|
|
closeBtn.title = 'Close tab (Alt+W)';
|
|
|
|
|
closeBtn.innerHTML = '×';
|
|
|
|
|
closeBtn.addEventListener('click', e => { e.stopPropagation(); closeTab(tab); });
|
|
|
|
|
|
|
|
|
|
tabEl.appendChild(buildLabelSpan(tab));
|
|
|
|
|
tabEl.appendChild(closeBtn);
|
|
|
|
|
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn);
|
2026-05-23 15:29:05 +00:00
|
|
|
tab.tabEl = tabEl;
|
2026-05-24 06:37:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a new tab with fresh PTY leaf.
|
|
|
|
|
function newTab(tabId, sessionId) {
|
|
|
|
|
tabId = tabId || randHexClient(16);
|
|
|
|
|
tabCounter++;
|
|
|
|
|
const label = 'bash ' + tabCounter;
|
|
|
|
|
|
|
|
|
|
const pane = document.createElement('div');
|
|
|
|
|
pane.className = 'tab-pane';
|
|
|
|
|
pane.style.display = 'none';
|
|
|
|
|
document.getElementById('termContainer').appendChild(pane);
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
const rootLeaf = createLeaf(sessionId);
|
|
|
|
|
pane.appendChild(rootLeaf.el);
|
|
|
|
|
|
|
|
|
|
const tab = {
|
|
|
|
|
tabId,
|
|
|
|
|
label,
|
|
|
|
|
pane,
|
|
|
|
|
root: rootLeaf,
|
|
|
|
|
activeLeaf: rootLeaf,
|
|
|
|
|
tabEl: null,
|
|
|
|
|
};
|
|
|
|
|
tabs.push(tab);
|
|
|
|
|
addTabButton(tab);
|
2026-05-23 15:29:05 +00:00
|
|
|
switchTab(tab);
|
2026-05-24 06:37:59 +00:00
|
|
|
saveWorkspace();
|
2026-05-24 07:44:54 +00:00
|
|
|
connectLeaf(rootLeaf);
|
2026-05-24 06:37:59 +00:00
|
|
|
return tab;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore a single tab from workspace data (without calling saveWorkspace).
|
|
|
|
|
function restoreTab(tabData) {
|
|
|
|
|
tabCounter++;
|
|
|
|
|
const pane = document.createElement('div');
|
|
|
|
|
pane.className = 'tab-pane';
|
|
|
|
|
pane.style.display = 'none';
|
|
|
|
|
document.getElementById('termContainer').appendChild(pane);
|
|
|
|
|
|
|
|
|
|
const root = deserializePane(tabData.root);
|
|
|
|
|
pane.appendChild(root.el);
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
const tab = {
|
|
|
|
|
tabId: tabData.tab_id,
|
|
|
|
|
label: tabData.label || ('bash ' + tabCounter),
|
|
|
|
|
pane,
|
|
|
|
|
root,
|
|
|
|
|
activeLeaf: findFirstLeaf(root),
|
|
|
|
|
tabEl: null,
|
|
|
|
|
};
|
|
|
|
|
tabs.push(tab);
|
|
|
|
|
addTabButton(tab);
|
|
|
|
|
|
2026-05-24 07:44:54 +00:00
|
|
|
walkLeaves(root, l => connectLeaf(l));
|
2026-05-23 15:29:05 +00:00
|
|
|
return tab;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// Restore all tabs from saved workspace data.
|
|
|
|
|
function restoreWorkspace(ws) {
|
|
|
|
|
ws.tabs.forEach(tabData => restoreTab(tabData));
|
|
|
|
|
if (!tabs.length) { newTab(); return; }
|
|
|
|
|
switchTab(tabs[0]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:29:05 +00:00
|
|
|
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');
|
2026-05-24 06:37:59 +00:00
|
|
|
refitAll(tab);
|
|
|
|
|
if (tab.activeLeaf) tab.activeLeaf.term.focus();
|
2026-05-23 15:29:05 +00:00
|
|
|
updateStatus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeTab(tab) {
|
2026-05-24 06:37:59 +00:00
|
|
|
if (tabs.length === 1) return;
|
|
|
|
|
walkLeaves(tab.root, l => {
|
|
|
|
|
if (l.socket) { l.socket.onclose = null; l.socket.close(); }
|
|
|
|
|
l.term.dispose();
|
|
|
|
|
});
|
2026-05-23 15:29:05 +00:00
|
|
|
tab.pane.remove();
|
|
|
|
|
tab.tabEl.remove();
|
|
|
|
|
const idx = tabs.indexOf(tab);
|
|
|
|
|
tabs.splice(idx, 1);
|
2026-05-24 06:37:59 +00:00
|
|
|
if (activeTab === tab) switchTab(tabs[Math.min(idx, tabs.length - 1)]);
|
|
|
|
|
saveWorkspace();
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── WebSocket per leaf ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function connectLeaf(leaf) {
|
|
|
|
|
leaf.socket = new WebSocket('wss://' + location.host + '/ws/' + leaf.id);
|
|
|
|
|
leaf.socket.binaryType = 'arraybuffer';
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.socket.onopen = () => {
|
|
|
|
|
leaf.fit.fit();
|
|
|
|
|
sendResizeFor(leaf);
|
|
|
|
|
if (activeTab && activeTab.activeLeaf === leaf) { updateStatus(); leaf.term.focus(); }
|
2026-05-23 15:29:05 +00:00
|
|
|
};
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.socket.onmessage = e => {
|
2026-05-23 15:29:05 +00:00
|
|
|
if (typeof e.data === 'string') {
|
|
|
|
|
try {
|
|
|
|
|
const msg = JSON.parse(e.data);
|
2026-05-24 06:37:59 +00:00
|
|
|
if (msg.type === 'session' && activeTab && activeTab.activeLeaf === leaf)
|
|
|
|
|
setStatus((msg.new ? 'new' : 'resumed') + ' pane:' + leaf.id.slice(0, 8), 'ok');
|
|
|
|
|
} catch(_) {}
|
2026-05-23 15:29:05 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.term.write(new Uint8Array(e.data));
|
2026-05-23 15:29:05 +00:00
|
|
|
};
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.socket.onclose = () => {
|
|
|
|
|
if (activeTab && activeTab.activeLeaf === leaf) setStatus('reconnecting...', 'err');
|
|
|
|
|
setTimeout(() => connectLeaf(leaf), 2000);
|
2026-05-23 15:29:05 +00:00
|
|
|
};
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.socket.onerror = () => leaf.socket.close();
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.term.onData(d => {
|
|
|
|
|
if (leaf.socket && leaf.socket.readyState === WebSocket.OPEN) leaf.socket.send(d);
|
2026-05-23 15:29:05 +00:00
|
|
|
});
|
2026-05-24 06:37:59 +00:00
|
|
|
leaf.term.onResize(() => sendResizeFor(leaf));
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
function sendResizeFor(leaf) {
|
|
|
|
|
if (leaf.socket && leaf.socket.readyState === WebSocket.OPEN)
|
|
|
|
|
leaf.socket.send(JSON.stringify({ type: 'resize', cols: leaf.term.cols, rows: leaf.term.rows }));
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
window.addEventListener('resize', () => { if (activeTab) refitAll(activeTab); });
|
2026-05-23 15:29:05 +00:00
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Upload ─────────────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
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; }
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
const leaf = activeTab && activeTab.activeLeaf;
|
|
|
|
|
if (!leaf) { modalFb('upFb', 'No active terminal', 'err'); return; }
|
|
|
|
|
|
2026-05-23 15:29:05 +00:00
|
|
|
const btn = document.getElementById('upBtn');
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
form.append('file', f);
|
|
|
|
|
form.append('dest', document.getElementById('upDest').value.trim());
|
2026-05-24 06:37:59 +00:00
|
|
|
form.append('sid', leaf.id);
|
2026-05-23 15:29:05 +00:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Download ───────────────────────────────────────────────────────────
|
2026-05-23 15:29:05 +00:00
|
|
|
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 : '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// ── Bootstrap ──────────────────────────────────────────────────────────
|
2026-05-24 07:44:54 +00:00
|
|
|
// Terminal page is only served to authenticated users (server redirects
|
|
|
|
|
// unauthenticated requests to /login). Load saved workspace or start fresh.
|
2026-05-24 06:37:59 +00:00
|
|
|
(async function bootstrap() {
|
2026-05-24 07:44:54 +00:00
|
|
|
const ws = await loadWorkspace();
|
|
|
|
|
if (ws && ws.tabs && ws.tabs.length) {
|
|
|
|
|
restoreWorkspace(ws);
|
2026-05-23 15:29:05 +00:00
|
|
|
} else {
|
2026-05-24 07:44:54 +00:00
|
|
|
newTab();
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
})();
|