feat: workspace lives in the terminal view and keeps its sessions across pages; terminal panes split out

This commit is contained in:
redtea committed 2026-10-15 20:10:28 +03:00
1 parent 113c63fd1b
commit 012c0af7f9
9 files changed
+791 -736

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
// 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.
constructor(agent, ssh) {
this.agent = agent;
this.ssh = ssh || null;
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: 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.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.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 => new Session(agent);
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; }};
})();