// Terminal multiplexing module
// Requires: xterm.js, FitAddon, escapeAttr, escapeHtml, setStatus from global scope
const terminals = new Map(); // id -> { term, ws, fitAddon, element, name }
let activeTerminalId = null;
// Create a new terminal instance
async function createNewTerminal() {
try {
setStatus('Creating terminal...');
const res = await fetch('/api/terminals', { method: 'POST' });
if (!res.ok) {
const err = await res.json();
setStatus('Failed: ' + err.error, false);
return;
}
const { id, name } = await res.json();
initializeTerminal(id, name);
switchToTerminal(id);
setStatus('Terminal created');
} catch (err) {
setStatus('Failed to create terminal', false);
}
}
// Initialize a terminal with given ID
function initializeTerminal(id, name) {
// Create terminal element
const element = document.createElement('div');
element.id = `terminal-${id}`;
element.style.position = 'absolute';
element.style.top = '0';
element.style.left = '0';
element.style.width = '100%';
element.style.height = '100%';
element.style.display = 'none';
document.getElementById('terminals-wrapper').appendChild(element);
// Create xterm instance
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1d232a',
foreground: '#a6adba'
}
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
// Temporarily show element so xterm can calculate dimensions
element.style.display = 'block';
term.open(element);
fitAddon.fit();
element.style.display = 'none';
// Connect WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/api/terminal?id=${id}`);
ws.onopen = () => {
console.log(`Terminal ${id} connected`);
const dims = fitAddon.proposeDimensions();
if (dims) {
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
}
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.text().then(text => term.write(text));
} else {
term.write(event.data);
}
};
ws.onclose = () => {
console.log(`Terminal ${id} disconnected`);
term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
};
ws.onerror = (err) => console.error(`Terminal ${id} error:`, err);
// Terminal input - send all data to PTY via WebSocket
term.onData(data => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
terminals.set(id, { term, ws, fitAddon, element, name });
renderTerminalTabs();
}
// Switch to a specific terminal
function switchToTerminal(id) {
terminals.forEach((t, tid) => {
t.element.style.display = tid === id ? 'block' : 'none';
});
activeTerminalId = id;
// Fit the active terminal
const active = terminals.get(id);
if (active) {
setTimeout(() => {
active.fitAddon.fit();
if (active.ws && active.ws.readyState === WebSocket.OPEN) {
const dims = active.fitAddon.proposeDimensions();
if (dims) {
active.ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
}
}
active.term.focus();
}, 10);
}
renderTerminalTabs();
}
// Close a terminal
async function closeTerminal(id) {
const t = terminals.get(id);
if (!t) return;
try {
await fetch(`/api/terminals/${id}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to delete terminal:', err);
}
if (t.ws) t.ws.close();
t.term.dispose();
t.element.remove();
terminals.delete(id);
// Switch to another terminal or create new
if (terminals.size === 0) {
createNewTerminal();
} else if (activeTerminalId === id) {
switchToTerminal(terminals.keys().next().value);
}
renderTerminalTabs();
}
// Render terminal tabs
function renderTerminalTabs() {
const tabsContainer = document.getElementById('terminal-tabs');
tabsContainer.innerHTML = '';
terminals.forEach((t, id) => {
const tab = document.createElement('button');
tab.className = `btn btn-xs ${id === activeTerminalId ? 'btn-primary' : 'btn-ghost'}`;
tab.innerHTML = `
<span onclick="switchToTerminal('${escapeAttr(id)}')">${escapeHtml(t.name)}</span>
${terminals.size > 1 ? `<span onclick="event.stopPropagation(); closeTerminal('${escapeAttr(id)}')" class="ml-1 hover:text-error">×</span>` : ''}
`;
tab.onclick = () => switchToTerminal(id);
tabsContainer.appendChild(tab);
});
}
// Clear active terminal
function clearTerminal() {
const active = terminals.get(activeTerminalId);
if (active) active.term.clear();
}
// Handle terminal resize
function sendResize() {
const active = terminals.get(activeTerminalId);
if (active && active.ws && active.ws.readyState === WebSocket.OPEN) {
const dims = active.fitAddon.proposeDimensions();
if (dims) {
active.ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
}
}
}
// Load existing terminals or create initial one on page load
async function loadExistingTerminals() {
try {
const res = await fetch('/api/terminals');
if (!res.ok) {
createNewTerminal();
return;
}
const existingTerminals = await res.json();
if (existingTerminals.length === 0) {
createNewTerminal();
return;
}
// Reconnect to existing terminals
for (const t of existingTerminals) {
initializeTerminal(t.id, t.name);
}
// Switch to first terminal
if (existingTerminals.length > 0) {
switchToTerminal(existingTerminals[0].id);
}
} catch (err) {
console.error('Failed to load terminals:', err);
createNewTerminal();
}
}
// Terminal panel visibility
let terminalVisible = true; // Terminal starts visible
const defaultTerminalHeight = '200px';
function openTerminalPanel() {
const terminalContainer = document.getElementById('terminal-container');
const resizer = document.getElementById('resizer');
if (!terminalVisible) {
terminalContainer.style.display = '';
resizer.style.display = '';
terminalVisible = true;
// Reset height to default if it was minimized
const currentHeight = parseInt(terminalContainer.style.height) || 0;
if (currentHeight < 100) {
terminalContainer.style.height = defaultTerminalHeight;
}
// Re-layout editor
if (typeof editor !== 'undefined' && editor) {
editor.layout();
}
// Focus and fit active terminal
const active = terminals.get(activeTerminalId);
if (active) {
setTimeout(() => {
active.fitAddon.fit();
sendResize();
active.term.focus();
}, 50);
}
} else {
// Just focus the terminal if already visible
const active = terminals.get(activeTerminalId);
if (active) {
active.term.focus();
}
}
}
function closeTerminalPanel() {
const terminalContainer = document.getElementById('terminal-container');
const resizer = document.getElementById('resizer');
terminalContainer.style.display = 'none';
resizer.style.display = 'none';
terminalVisible = false;
terminalMaximized = false;
// Re-layout editor to take full space
if (typeof editor !== 'undefined' && editor) {
editor.layout();
}
// Focus editor when closing terminal
if (typeof editor !== 'undefined' && editor) {
editor.focus();
}
}
function toggleTerminal() {
if (terminalVisible) {
closeTerminalPanel();
} else {
openTerminalPanel();
}
}
let terminalMaximized = false;
let terminalPreviousHeight = '200px';
function maximizeTerminal() {
const terminalContainer = document.getElementById('terminal-container');
const parent = terminalContainer.parentElement;
if (!terminalMaximized) {
// Save current height before maximizing
terminalPreviousHeight = terminalContainer.style.height || '200px';
// Maximize: take most of the available space (leave 100px for editor minimum)
const maxHeight = parent.offsetHeight - 100;
terminalContainer.style.height = maxHeight + 'px';
terminalMaximized = true;
} else {
// Restore to previous height
terminalContainer.style.height = terminalPreviousHeight;
terminalMaximized = false;
}
// Re-layout editor and terminals
if (typeof editor !== 'undefined' && editor) {
editor.layout();
}
const active = terminals.get(activeTerminalId);
if (active) {
setTimeout(() => {
active.fitAddon.fit();
sendResize();
}, 10);
}
}
// Terminal resizer setup
function setupTerminalResizer() {
const resizer = document.getElementById('resizer');
const terminalContainer = document.getElementById('terminal-container');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
e.preventDefault();
});
function resize(e) {
if (!isResizing) return;
const containerRect = terminalContainer.parentElement.getBoundingClientRect();
const newHeight = containerRect.bottom - e.clientY;
const minHeight = 100;
const maxHeight = containerRect.height - 100;
terminalContainer.style.height = Math.max(minHeight, Math.min(maxHeight, newHeight)) + 'px';
// Reset maximized state on manual resize
terminalMaximized = false;
// Resize editor
if (typeof editor !== 'undefined' && editor) {
editor.layout();
}
// Resize active terminal
const active = terminals.get(activeTerminalId);
if (active) {
active.fitAddon.fit();
sendResize();
}
}
function stopResize() {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResize);
}
}
// Window resize handler for terminals
window.addEventListener('resize', () => {
const active = terminals.get(activeTerminalId);
if (active) {
active.fitAddon.fit();
sendResize();
}
});
// Keyboard shortcut for terminal (Ctrl+`)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
toggleTerminal();
}
});