// IDE Remote: the Terminal view (xterm.js). Classic script: window.IdeRemoteViews.terminal = {mount(container)}. // Needs vendor/xterm/xterm.js + addon-fit.js + xterm.css and vendor/ide-md.js (the el helper) loaded before. // Payloads as the IDE's ide/ui/js/terminal.js: terminal.output {id, data: base64}, terminal.exit {id}, // invoke terminal.open {cols, rows} -> {id, pty, shell}, terminal.write {id, data: string}, terminal.resize {id, cols, rows}, terminal.close {id}. (function (root, factory) { const api = factory(root); if (typeof module === 'object' && module.exports) module.exports = api; else { (root.IdeRemoteViews = root.IdeRemoteViews || {}).terminal = { mount: api.mount, pure: api.pure }; if (root.document && root.document.getElementById) api.boot('terminal', api.mount, ['vendor/ide-md.js', 'vendor/xterm/xterm.css', 'vendor/xterm/xterm.js', 'vendor/xterm/addon-fit.js']); } })(typeof window !== 'undefined' ? window : globalThis, function (root) { // Texts: the page's table (config/lang.yaml, 14 languages) through IdeRemote.t; the English given here is the fallback (and what tests see). const L = (key, en, vars) => (root.IdeRemote && root.IdeRemote.t ? root.IdeRemote.t(key, vars, en) : String(en).replace(/\{(\w+)\}/g, (m, k) => (vars && k in vars ? vars[k] : m))); const errText = (e) => (root.IdeRemote && root.IdeRemote.errorText ? root.IdeRemote.errorText(e) : (e && e.message) || String(e)); // ---------------------------------------------------------------- pure part /** base64 (as the IDE sends terminal output: raw bytes) -> Uint8Array. Tolerates url-safe base64 and missing padding. */ function decode(b64) { const s = String(b64 || '').replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, ''); const bin = atob(s + '='.repeat((4 - (s.length % 4)) % 4)); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } /** What a message of the host means for the terminals: {kind:'output'|'snap'|'exit', id, bytes?, cols?, rows?} or null. */ function interpret(type, msg) { if (!msg || typeof msg !== 'object') return null; if (type === 'snap') { if (msg.panel !== 'terminal' || msg.id === undefined || msg.id === null) return null; return { kind: 'snap', id: msg.id, bytes: decode(msg.data), cols: msg.cols || 0, rows: msg.rows || 0 }; } if (type === 'terminal.output') return msg.id === undefined ? null : { kind: 'output', id: msg.id, bytes: decode(msg.data) }; if (type === 'terminal.exit') return msg.id === undefined ? null : { kind: 'exit', id: msg.id }; return null; } /** What a key of the bar sends; `ctrl` (sticky) turns a letter into its control code. */ const KEYS = { Esc: '\x1b', Tab: '\t', Up: '\x1b[A', Down: '\x1b[B', Right: '\x1b[C', Left: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', PgUp: '\x1b[5~', PgDn: '\x1b[6~' }; function applyCtrl(data) { if (data.length === 1) { const c = data.toLowerCase().charCodeAt(0); if (c >= 97 && c <= 122) return String.fromCharCode(c - 96); if (data === ' ') return '\x00'; if (data === '[') return '\x1b'; } return data; } const pure = { decode, interpret, applyCtrl, KEYS }; // ---------------------------------------------------------------- DOM part function mount(container) { const R = root.IdeRemote, { el, clear } = root.IdeMd; const terms = new Map(); // id -> {id, label, term, fit, node, ended, pty} let active = null, ctrl = false, opening = false, count = 0, raf = 0; const tabs = el('div.rv-tabs'); const stage = el('div.rv-term-stage'); const empty = el('div.rv-empty', {}, L('empty_terminal', 'No terminal yet. Press + to open one on the computer.')); stage.append(empty); const keys = el('div.rv-keys'); clear(container); container.classList.add('rv-terminal'); container.append(tabs, stage, keys); const css = (n, f) => (getComputedStyle(root.document.documentElement).getPropertyValue(n).trim() || f); const theme = () => ({ background: css('--rv-bg', '#1e1f22'), foreground: css('--rv-text', '#dfe1e5'), cursor: css('--rv-text', '#dfe1e5'), selectionBackground: css('--rv-selection', '#2e436e') }); function write(data) { if (!active || active.ended) return; R.invoke('terminal.write', { id: active.id, data }).catch(() => {}); } function typed(data) { if (ctrl) { data = applyCtrl(data); setCtrl(false); } write(data); } function setCtrl(on) { ctrl = on; ctrlBtn.classList.toggle('on', on); ctrlBtn.setAttribute('aria-pressed', String(on)); } const keyBtn = (label, send) => el('button.rv-key', { type: 'button', // pointerdown + preventDefault: the on-screen keyboard and xterm's focus stay as they are onpointerdown: (e) => { e.preventDefault(); send(); } }, label); const ctrlBtn = el('button.rv-key', { type: 'button', 'aria-pressed': 'false', onpointerdown: (e) => { e.preventDefault(); setCtrl(!ctrl); } }, 'Ctrl'); keys.append(keyBtn('Esc', () => typed(KEYS.Esc)), keyBtn('Tab', () => typed(KEYS.Tab)), ctrlBtn, keyBtn('←', () => typed(KEYS.Left)), keyBtn('↓', () => typed(KEYS.Down)), keyBtn('↑', () => typed(KEYS.Up)), keyBtn('→', () => typed(KEYS.Right)), keyBtn('|', () => typed('|')), keyBtn('/', () => typed('/')), keyBtn('~', () => typed('~')), keyBtn('-', () => typed('-')), keyBtn('Ctrl-C', () => write('\x03'))); function renderTabs() { clear(tabs); for (const t of terms.values()) { tabs.append(el('button.rv-tab' + (t === active ? '.on' : ''), { type: 'button', onclick: () => select(t) }, t.label + (t.ended ? ' (ended)' : ''))); } tabs.append(el('button.rv-tab.rv-plus', { type: 'button', 'aria-label': L('new_terminal', 'New terminal'), onclick: openNew }, '+')); if (active && !active.ended) tabs.append(el('button.rv-tab.rv-close', { type: 'button', 'aria-label': L('close_terminal', 'Close terminal'), onclick: () => close(active) }, '×')); empty.hidden = terms.size > 0; } function select(t) { active = t; for (const x of terms.values()) x.node.hidden = x !== t; renderTabs(); scheduleFit(); } function make(id, label) { if (terms.has(id)) return terms.get(id); if (!root.Terminal || !root.FitAddon) { stage.append(el('div.rv-empty', {}, L('xterm_missing', 'The terminal display (xterm.js) is missing.'))); return null; } const term = new root.Terminal({ fontFamily: css('--rv-mono', 'monospace'), fontSize: 13, cursorBlink: true, scrollback: 5000, theme: theme() }); const fit = new root.FitAddon.FitAddon(); term.loadAddon(fit); const t = { id, label: label || `${L('panel_terminal', 'Terminal')}${count++ ? ' ' + count : ''}`, term, fit, node: el('div.rv-term'), ended: false }; terms.set(id, t); stage.append(t.node); term.open(t.node); term.onData((d) => { if (active === t) typed(d); else R.invoke('terminal.write', { id, data: d }).catch(() => {}); }); term.onResize(({ cols, rows }) => { if (!t.ended) R.invoke('terminal.resize', { id, cols, rows }).catch(() => {}); }); if (!active) select(t); else { t.node.hidden = true; renderTabs(); } return t; } async function openNew() { if (opening) return; opening = true; try { const size = fitSize(); const r = await R.invoke('terminal.open', { cols: size.cols, rows: size.rows }); const t = make(r.id, r.shell && !/^(bash|zsh|sh)$/.test(r.shell) ? String(r.shell).replace(/\.exe$/i, '') : null); if (t) select(t); } catch (e) { if (active) active.term.write(`\r\n\x1b[31m${errText(e)}\x1b[0m\r\n`); else { empty.hidden = false; empty.textContent = errText(e); } } finally { opening = false; } } function close(t) { R.invoke('terminal.close', { id: t.id }).catch(() => {}); t.term.dispose(); t.node.remove(); terms.delete(t.id); active = null; const next = [...terms.values()].pop(); if (next) select(next); else renderTabs(); } function fitSize() { const t = active; if (t && t.node.offsetWidth) { try { const d = t.fit.proposeDimensions(); if (d) return d; } catch (e) { /* not laid out */ } } return { cols: 80, rows: 24 }; } function scheduleFit() { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; if (!active || !container.offsetWidth || !active.node.offsetWidth) return; try { active.fit.fit(); } catch (e) { /* not laid out yet */ } }); } let autoAt = Date.now() + 1500, autoDone = false; function maybeAuto() { // a first shell, once the panel is shown and the host's snapshot had time to arrive if (autoDone || terms.size || !container.offsetWidth || R.state !== 'connected') return; if (R.panels && R.panels.length && !R.panels.some((p) => p.id === 'terminal')) return; if (Date.now() < autoAt) { setTimeout(maybeAuto, autoAt - Date.now() + 20); return; } autoDone = true; openNew(); } new ResizeObserver(() => { scheduleFit(); maybeAuto(); }).observe(stage); if (root.visualViewport) root.visualViewport.addEventListener('resize', scheduleFit); function handle(type) { return (msg) => { const m = interpret(type, msg); if (!m) return; if (m.kind === 'exit') { const t = terms.get(m.id); if (t) { t.ended = true; t.term.write('\r\n\x1b[2m[process ended]\x1b[0m\r\n'); renderTabs(); } return; } let t = terms.get(m.id); if (!t) t = make(m.id); // a terminal opened on the computer before this device came if (!t) return; if (m.kind === 'snap') t.term.reset(); t.term.write(m.bytes); }; } R.on('terminal.output', handle('terminal.output')); R.on('terminal.exit', handle('terminal.exit')); R.on('snap', handle('snap')); renderTabs(); setTimeout(maybeAuto, 1500); R.on('ready', () => { autoAt = Date.now() + 1500; try { if (R.snap) R.snap('terminal'); } catch (e) { /* ignore */ } setTimeout(maybeAuto, 1500); }); R.on('panel', () => setTimeout(maybeAuto, 50)); return { focus: () => active && active.term.focus(), refit: scheduleFit }; } // The shell loads this file as a module and never calls mount(): the view mounts itself in its
, loading its libraries first. function boot(id, mount, assets) { const doc = root.document; const load = (src) => { const cache = (root.__rvAssets = root.__rvAssets || {}); if (cache[src]) return cache[src]; return (cache[src] = new Promise((resolve, reject) => { if (src.endsWith('.css')) { const l = doc.createElement('link'); l.rel = 'stylesheet'; l.href = src; l.onload = resolve; l.onerror = resolve; doc.head.append(l); return; } const s = doc.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('cannot load ' + src)); doc.head.append(s); })); }; const run = async () => { const c = doc.getElementById('panel-' + id); if (!c || c.dataset.rvMounted) return; c.dataset.rvMounted = '1'; try { for (const a of assets) await load(a); mount(c); } catch (e) { c.textContent = errText(e); } }; if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run); else run(); } return { mount, pure, boot, ...pure }; });