Connexion
FR
apps/jumphp-ide-remote / ui / js / views / terminal.js · commit b81e652
// 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 <section>, 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 };});