Connor McCutcheon
/ SkyCode
terminal.js
js
// 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">&times;</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();
  }
});
No comments yet.