// Terminal panes of the workspace: a shell on a machine, or a shell the // machine opens by SSH. Also the touch helpers for typing in them (extra // keys, long-press copy). The workspace (workspace.js) holds the tabs. // Touch helpers, for the terminal being typed in: the extra keys with their // CTRL / ALT, and selecting and copying with a long press. window.TermKeys = (function () { const touch = window.matchMedia('(pointer: coarse)').matches; const cur = () => window.TermPane && window.TermPane.active(); // CTRL and ALT: a tap arms the key for the next key press, a long press // keeps it on until tapped again. const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked const modBtn = {}; document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; }); function showMods() { for (const m in mods) { modBtn[m].classList.toggle('on', mods[m] === 1); modBtn[m].classList.toggle('lock', mods[m] === 2); } } function useMods() { const used = { ctrl: !!mods.ctrl, alt: !!mods.alt }; for (const m in mods) if (mods[m] === 1) mods[m] = 0; showMods(); return used; } function ctrlChar(c) { if (c === ' ' || c === '@') return '\x00'; if (c === '?') return '\x7f'; const code = c.toUpperCase().charCodeAt(0); return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c; } function withMods(data) { if (!mods.ctrl && !mods.alt) return data; const m = useMods(); if (m.ctrl && data.length === 1) data = ctrlChar(data); return m.alt ? '\x1b' + data : data; } // xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4). const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' }; const TILDE = { pgup: 5, pgdn: 6 }; function keySeq(k, term) { const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0); if (CSI[k]) { if (p > 1) return '\x1b[1;' + p + CSI[k]; return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k]; } if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~'; let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k; if (m.ctrl && s.length === 1) s = ctrlChar(s); return m.alt ? '\x1b' + s : s; } let press = null; document.querySelectorAll('#keys button').forEach(function (b) { // Keep the focus (and the on-screen keyboard) on the terminal. b.addEventListener('pointerdown', function (e) { e.preventDefault(); const mod = b.dataset.mod; if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) }; }); b.addEventListener('pointerup', function (e) { e.preventDefault(); const mod = b.dataset.mod; if (mod) { if (press && press.mod === mod) { clearTimeout(press.timer); press = null; mods[mod] = mods[mod] ? 0 : 1; showMods(); } return; } const s = cur(); if (s) s.send(keySeq(b.dataset.k, s.term)); }); b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } }); b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); }); // --- Selecting and copying on touch screens --- // xterm.js has no touch selection of its own. A long press selects the word // under the finger and dragging on from there stretches the selection; a // menu then copies it or pastes. A plain tap or swipe still types and scrolls. const menu = document.getElementById('selmenu'); let selecting = false; function showMenu() { const s = cur(); const keys = document.getElementById('keys').getBoundingClientRect(); menu.style.top = ''; menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px'; menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection()); menu.hidden = false; } function selectionChanged(s) { if (!touch || s !== cur()) return; if (s.term.hasSelection()) showMenu(); else if (!selecting) menu.hidden = true; } if (touch) { const box = document.getElementById('area'); let timer = null, start = null, anchor = null; function cellAt(term, el, x, y) { const r = el.querySelector('.xterm-screen').getBoundingClientRect(); const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols)))); const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows)))); return { col: col, row: row + term.buffer.active.viewportY }; } function selectWord(term, c) { const line = term.buffer.active.getLine(c.row); const text = line ? line.translateToString(false) : ''; const word = ch => ch && !/\s/.test(ch); let a = c.col, b = c.col; if (word(text[c.col])) { while (a > 0 && word(text[a - 1])) a--; while (b < text.length - 1 && word(text[b + 1])) b++; } term.select(a, c.row, b - a + 1); anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b }; } function stretch(term, c) { const p = c.row * term.cols + c.col; const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p); term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1); } function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; } box.addEventListener('touchstart', function (e) { if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; } const t = e.touches[0]; start = { x: t.clientX, y: t.clientY }; if (!menu.hidden) hideMenu(); clearTimeout(timer); timer = setTimeout(function () { const s = cur(); if (!s) return; selecting = true; selectWord(s.term, cellAt(s.term, s.el, start.x, start.y)); showMenu(); if (navigator.vibrate) navigator.vibrate(15); }, 450); }, { passive: true, capture: true }); box.addEventListener('touchmove', function (e) { const t = e.touches[0], s = cur(); if (selecting && s) { e.preventDefault(); // no scrolling while the selection stretches e.stopPropagation(); stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY)); } else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) { clearTimeout(timer); // a swipe: let it scroll } }, { passive: false, capture: true }); box.addEventListener('touchend', function (e) { clearTimeout(timer); if (!selecting) return; selecting = false; e.preventDefault(); // no tap: the keyboard stays as it was e.stopPropagation(); showMenu(); }, { passive: false, capture: true }); box.addEventListener('contextmenu', function (e) { e.preventDefault(); }); const statusEl = document.getElementById('status'); function flash(t, cls) { statusEl.textContent = t; statusEl.className = cls; setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500); } function copyText(text) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); // Plain http: the old way, through a field that is there for a moment. const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); } menu.addEventListener('pointerdown', function (e) { e.preventDefault(); }); menu.addEventListener('click', function (e) { const b = e.target.closest('button'), s = cur(); if (!b || !s) return; const a = b.dataset.a; if (a === 'copy') { const text = s.term.getSelection(); hideMenu(); copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); }); } else if (a === 'paste') { hideMenu(); if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; } navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); }); } else if (a === 'all') { s.term.selectAll(); showMenu(); } else { hideMenu(); } }); } return { withMods: withMods, selectionChanged: selectionChanged }; })(); (function () { 'use strict'; const W = window.Workspace; const touch = W.touch; const statusEl = document.getElementById('status'); const base = '/api/panel/term/'; const X = ''; function bytesToB64(bytes) { let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function b64ToBytes(b64) { const bin = atob(b64), u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u; } // ---------- copy and paste by keys ---------- // Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and // interrupts as ever when there is none; Ctrl+Shift+C always copies (and is // kept from the browser, whose developer tools it would open); Ctrl+V and // Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too. const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); function copyText(text) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); // Plain http: the old way, through a field that is there for a moment. const ta = document.createElement('textarea'), back = document.activeElement; ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); if (back && back.focus) back.focus(); return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); } function clipKeys(s, ev) { if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true; const c = ev.code === 'KeyC', v = ev.code === 'KeyV'; if (!c && !v) return true; if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell // From here the key is ours: xterm does not turn it into a control code. if (ev.type === 'keydown' && c) { ev.preventDefault(); if (s.term.hasSelection()) { const text = s.term.getSelection(); s.term.clearSelection(); copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err')); } } // Ctrl+V: the browser's paste goes on into the terminal's text field. return false; } function flashStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls; clearTimeout(flashStatus.t); flashStatus.t = setTimeout(W.showStatus, 1500); } // ---------- one shell ---------- class Session { // ssh, when given, is where the agent connects by SSH for this shell: // {host, port, user} and the secret to sign in with, sent once on open. // dir, for a shell on the machine, is the folder it starts in. constructor(agent, ssh, dir) { this.agent = agent; this.ssh = ssh || null; this.dir = dir || ''; this.kind = ssh ? 'ssh' : 'term'; // Where its files are, for dropped paths: the machine, or the SSH host // through it. this.source = ssh ? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host} : {kind: 'agent', id: agent.id, label: agent.name}; this.sid = null; this.closed = false; this.connected = false; this.state = 'wait'; this.text = 'подключение…'; this.inSeq = 0; this.inBusy = false; this.inQueue = []; this.el = document.createElement('div'); this.el.className = 'pane'; this.el.innerHTML = '