472 lines
22 KiB
JavaScript
472 lines
22 KiB
JavaScript
// 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 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
|
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 = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
|
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
|
'<div class="pane-body"></div>';
|
|
this.el.querySelector('.pane-title').textContent = who(this);
|
|
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
|
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
|
this.term = new Terminal({
|
|
cursorBlink: true, fontSize: window.Prefs ? Prefs.termFont() : (touch ? 13 : 14),
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
|
});
|
|
this.fitter = new FitAddon.FitAddon();
|
|
this.term.loadAddon(this.fitter);
|
|
this.term.open(this.el.querySelector('.pane-body'));
|
|
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
|
this.term.onData(d => this.send(TermKeys.withMods(d)));
|
|
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
|
|
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
|
|
this.el.addEventListener('pointerdown', () => W.setActivePane(this), true);
|
|
this.term.textarea.addEventListener('focus', () => W.setActivePane(this));
|
|
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); };
|
|
if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this));
|
|
this.el.querySelector('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
|
|
this.open();
|
|
}
|
|
setState(state, text) {
|
|
this.state = state; this.text = text;
|
|
this.el.querySelector('.dot').className = 'dot ' + state;
|
|
const t = W.tabOf(this);
|
|
if (t) W.renderTab(t);
|
|
W.showStatus();
|
|
}
|
|
async open() {
|
|
try {
|
|
let opt = { method: 'POST' };
|
|
if (this.dir) opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: this.dir }) };
|
|
if (this.ssh) {
|
|
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
|
// The password or key goes once, to the agent; the page keeps no copy.
|
|
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
|
}
|
|
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
|
if (!r.ok) {
|
|
let why = 'HTTP ' + r.status;
|
|
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
|
this.setState('err', 'ошибка открытия: ' + why);
|
|
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
|
return;
|
|
}
|
|
this.sid = (await r.json()).session_id;
|
|
this.setState('wait', 'ожидание агента…');
|
|
// An agent from before SSH terminals does not answer the request.
|
|
if (this.ssh) setTimeout(() => {
|
|
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
|
this.setState('err', 'агент не начал SSH-подключение');
|
|
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
|
}
|
|
}, 20000);
|
|
this.fit();
|
|
// A WebSocket if it connects; the long polls otherwise, and from a
|
|
// dropped WebSocket on for the rest of this session.
|
|
if (!(await this.openWS())) this.poll();
|
|
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
|
} catch (e) {
|
|
this.setState('err', 'ошибка сети');
|
|
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
|
}
|
|
}
|
|
openWS() {
|
|
return new Promise(resolve => {
|
|
if (!window.WebSocket) { resolve(false); return; }
|
|
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
|
let opened = false;
|
|
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
|
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
|
ws.onmessage = ev => {
|
|
let j;
|
|
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
|
this.take(j);
|
|
};
|
|
ws.onclose = () => {
|
|
clearTimeout(timer);
|
|
if (!opened) { resolve(false); return; }
|
|
this.ws = null;
|
|
if (!this.closed && !this.ended) this.poll();
|
|
};
|
|
});
|
|
}
|
|
// take applies one answer of the output channel, either transport's.
|
|
take(j) {
|
|
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
|
for (const f of j.frames || []) {
|
|
if (f.status) this.sshStatus(f.status, f.note || '');
|
|
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
|
if (f.data) {
|
|
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
|
this.term.write(b64ToBytes(f.data));
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
// sshStatus shows how the agent's SSH connection goes.
|
|
sshStatus(st, note) {
|
|
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
|
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
|
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
|
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
|
}
|
|
async poll() {
|
|
while (!this.closed && this.sid) {
|
|
let r;
|
|
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
|
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
|
if (this.closed) return;
|
|
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
|
if (!this.take(await r.json())) return;
|
|
}
|
|
}
|
|
finish(msg) {
|
|
if (this.ended) return;
|
|
this.ended = true;
|
|
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
|
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
|
}
|
|
// Input goes out one request at a time, so on a slow link a keystroke can
|
|
// never overtake the one before it; a request that fails goes again under
|
|
// the same number, and the server drops one it already has.
|
|
queueIn(m) {
|
|
if (this.closed || !this.sid) return;
|
|
const q = this.inQueue, last = q[q.length - 1];
|
|
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
|
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
|
else q.push(m);
|
|
this.pumpIn();
|
|
}
|
|
async pumpIn() {
|
|
if (this.inBusy || !this.inQueue.length) return;
|
|
this.inBusy = true;
|
|
while (this.inQueue.length && !this.closed) {
|
|
const m = this.inQueue.shift();
|
|
const body = m.t === 'i'
|
|
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
|
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
|
// On the WebSocket a message goes out in order with no answer to wait
|
|
// for; numbered as well, so a repeat by the long polls is dropped.
|
|
if (this.ws && this.ws.readyState === 1) {
|
|
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
|
}
|
|
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
|
let r = null;
|
|
try {
|
|
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
} catch (e) { /* no answer: send it again */ }
|
|
if (r && r.status < 500) break;
|
|
await new Promise(res => setTimeout(res, wait));
|
|
}
|
|
}
|
|
this.inBusy = false;
|
|
}
|
|
send(data) { this.queueIn({ t: 'i', text: data }); }
|
|
fit() {
|
|
const b = this.el.querySelector('.pane-body');
|
|
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
|
}
|
|
title() { return this.ssh ? this.ssh.host : this.agent.name; }
|
|
tooltip() { return who(this); }
|
|
live() { return !!this.sid && !this.ended && !this.closed; }
|
|
focus() { this.term.focus(); }
|
|
dropTarget() { return {kind: 'term', source: this.source}; }
|
|
// Files dropped here from the same place: their paths, quoted, typed in.
|
|
drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); }
|
|
close() {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
|
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
|
this.term.dispose();
|
|
this.el.remove();
|
|
}
|
|
}
|
|
|
|
// who is what a shell is signed in as, for titles.
|
|
function who(s) {
|
|
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
|
}
|
|
|
|
|
|
W.paneTypes.term = (agent, opts) => new Session(agent, null, opts.dir);
|
|
W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);
|
|
// For the touch helpers: the terminal being typed in.
|
|
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
|
|
})();
|