feat: terminal tabs - several machines at once, up to four terminals split in a tab by drag and drop, resizable, renamable; the top bar keeps its status in sight
This commit is contained in:
1 parent
e7b5d28470
commit
67222e2e05
4 files changed
+766
-207
No files matched your search
@@ -152,13 +152,16 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
type item struct {
|
type item struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
User string `json:"user"` // who a terminal opens as
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
Seen string `json:"seen"`
|
Seen string `json:"seen"`
|
||||||
}
|
}
|
||||||
out := make([]item, 0, len(agents))
|
out := make([]item, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)})
|
out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
writeJSON(w, out)
|
writeJSON(w, out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ header.topbar {
|
|||||||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||||
}
|
}
|
||||||
header.topbar .brand { font-weight: 600; }
|
header.topbar .brand { font-weight: 600; }
|
||||||
|
/* A long machine name gives way: the links and the status stay in sight. */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
header.topbar nav { flex: none; white-space: nowrap; }
|
||||||
|
}
|
||||||
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
||||||
header.topbar nav a:hover { color: var(--text); }
|
header.topbar nav a:hover { color: var(--text); }
|
||||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
// Terminal page: shells on one or more machines, in tabs. With one tab the
|
||||||
|
// page is a plain terminal and a faint "+" in the corner; a second tab brings
|
||||||
|
// the tab strip in. On a computer a tab can be dragged onto the current one
|
||||||
|
// to share it - up to four terminals, split side by side or one above the
|
||||||
|
// other - and a terminal dragged out of it back to the strip. Splits resize
|
||||||
|
// by their gutters; a tab is renamed by a double click on its title.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const PAGE = window.TERM_PAGE;
|
||||||
|
const touch = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const desk = !touch; // drag and drop, splits: computers only
|
||||||
|
if (touch) document.body.classList.add('touch');
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const statusEl = $('status'), area = $('area'), tablist = $('tablist'), addBtn = $('tabadd'),
|
||||||
|
floatAdd = $('floatadd'), addMenu = $('addmenu'), hint = $('hint'), mark = $('tabmark');
|
||||||
|
const base = '/api/panel/term/';
|
||||||
|
const GAP = 6;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- one shell ----------
|
||||||
|
class Session {
|
||||||
|
constructor(agent) {
|
||||||
|
this.agent = agent;
|
||||||
|
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 = agent.user + '@' + agent.host;
|
||||||
|
this.el.querySelector('.pane-title').title = agent.name;
|
||||||
|
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.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', () => setActivePane(this), true);
|
||||||
|
this.term.textarea.addEventListener('focus', () => setActivePane(this));
|
||||||
|
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
|
||||||
|
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
setState(state, text) {
|
||||||
|
this.state = state; this.text = text;
|
||||||
|
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||||
|
const t = tabOf(this);
|
||||||
|
if (t) renderTab(t);
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', { method: 'POST' });
|
||||||
|
if (!r.ok) {
|
||||||
|
this.setState('err', 'ошибка открытия (' + r.status + ')');
|
||||||
|
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sid = (await r.json()).session_id;
|
||||||
|
this.setState('wait', 'ожидание агента…');
|
||||||
|
this.fit();
|
||||||
|
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||||
|
this.poll();
|
||||||
|
} catch (e) {
|
||||||
|
this.setState('err', 'ошибка сети');
|
||||||
|
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.closed) { this.finish('[сессия завершена]'); return; }
|
||||||
|
for (const f of j.frames || []) {
|
||||||
|
if (f.exit) { this.finish('[сессия завершена]'); return; }
|
||||||
|
if (f.data) {
|
||||||
|
if (!this.connected) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||||
|
this.term.write(b64ToBytes(f.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish(msg) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
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 };
|
||||||
|
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 */ } }
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||||
|
this.term.dispose();
|
||||||
|
this.el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tabs ----------
|
||||||
|
const tabs = [];
|
||||||
|
let active = null, activePane = null;
|
||||||
|
const tabOf = s => tabs.find(t => t.panes.includes(s));
|
||||||
|
|
||||||
|
function makeTab(panes, index) {
|
||||||
|
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
|
||||||
|
t.view = document.createElement('div');
|
||||||
|
t.view.className = 'tabview';
|
||||||
|
area.insertBefore(t.view, hint);
|
||||||
|
panes.forEach(s => t.view.appendChild(s.el));
|
||||||
|
t.el = document.createElement('div');
|
||||||
|
t.el.className = 'tab enter';
|
||||||
|
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' +
|
||||||
|
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
|
||||||
|
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
|
||||||
|
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
|
||||||
|
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
|
||||||
|
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
|
||||||
|
const at = index == null ? tabs.length : index;
|
||||||
|
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||||
|
tabs.splice(at, 0, t);
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||||
|
renderTab(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function tabTitle(t) {
|
||||||
|
if (t.title) return t.title;
|
||||||
|
const a = t.panes[0].agent;
|
||||||
|
return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name;
|
||||||
|
}
|
||||||
|
function renderTab(t) {
|
||||||
|
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||||
|
t.el.title = t.panes.map(s => s.agent.user + '@' + s.agent.host).join(', ');
|
||||||
|
// The tab's dot: red if any shell failed, amber while one connects.
|
||||||
|
const st = t.panes.map(s => s.state);
|
||||||
|
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
|
||||||
|
t.el.classList.toggle('on', t === active);
|
||||||
|
t.view.classList.toggle('split', t.panes.length > 1);
|
||||||
|
}
|
||||||
|
function activate(t) {
|
||||||
|
if (!t) return;
|
||||||
|
active = t;
|
||||||
|
tabs.forEach(x => { x.view.classList.toggle('on', x === t); renderTab(x); });
|
||||||
|
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
|
||||||
|
layout(t, false);
|
||||||
|
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||||
|
if (desk && activePane) activePane.term.focus();
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function setActivePane(s) {
|
||||||
|
activePane = s;
|
||||||
|
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||||||
|
s.el.classList.add('active');
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
function showStatus() {
|
||||||
|
if (!activePane) { statusEl.textContent = ''; return; }
|
||||||
|
statusEl.textContent = activePane.text;
|
||||||
|
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||||
|
}
|
||||||
|
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||||
|
function updateMode() {
|
||||||
|
const multi = tabs.length > 1;
|
||||||
|
if (document.body.classList.contains('multi') === multi) return;
|
||||||
|
document.body.classList.toggle('multi', multi);
|
||||||
|
setTimeout(() => active && layout(active, false), 280);
|
||||||
|
}
|
||||||
|
function openTerminal(agent) {
|
||||||
|
$('empty').hidden = true;
|
||||||
|
const t = makeTab([new Session(agent)]);
|
||||||
|
activate(t);
|
||||||
|
}
|
||||||
|
function closeTab(t) {
|
||||||
|
const i = tabs.indexOf(t);
|
||||||
|
if (i < 0) return;
|
||||||
|
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return;
|
||||||
|
tabs.splice(i, 1);
|
||||||
|
t.panes.forEach(s => s.close());
|
||||||
|
t.el.classList.add('leave');
|
||||||
|
t.view.classList.remove('on');
|
||||||
|
setTimeout(() => { t.el.remove(); t.view.remove(); }, 220);
|
||||||
|
if (active === t) {
|
||||||
|
active = null;
|
||||||
|
activate(tabs[Math.min(i, tabs.length - 1)]);
|
||||||
|
}
|
||||||
|
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; }
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function rename(t) {
|
||||||
|
const span = t.el.querySelector('.tab-title');
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.className = 'tab-edit';
|
||||||
|
inp.value = tabTitle(t);
|
||||||
|
inp.size = Math.max(6, inp.value.length);
|
||||||
|
span.replaceWith(inp);
|
||||||
|
inp.focus(); inp.select();
|
||||||
|
let done = false;
|
||||||
|
const end = save => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (save) t.title = inp.value.trim() || null;
|
||||||
|
inp.replaceWith(span);
|
||||||
|
renderTab(t);
|
||||||
|
};
|
||||||
|
inp.onkeydown = ev => { if (ev.key === 'Enter') end(true); else if (ev.key === 'Escape') end(false); ev.stopPropagation(); };
|
||||||
|
inp.onblur = () => end(true);
|
||||||
|
inp.onpointerdown = ev => ev.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- splits ----------
|
||||||
|
// Where each terminal of a tab goes, and its gutters, for a layout:
|
||||||
|
// 1, h (side by side), v (one above the other), top2 (two over one),
|
||||||
|
// bottom2 (one over two), grid (two over two). c and r are where the
|
||||||
|
// column and the row divide.
|
||||||
|
function rects(layout, c, r, W, H) {
|
||||||
|
const g = GAP, cw = Math.round((W - g) * c), rh = Math.round((H - g) * r);
|
||||||
|
const top = (y, h) => [{ x: 0, y, w: cw, h }, { x: cw + g, y, w: W - cw - g, h }];
|
||||||
|
const colG = (y, h) => ({ k: 'c', x: cw, y, w: g, h });
|
||||||
|
const rowG = { k: 'r', x: 0, y: rh, w: W, h: g };
|
||||||
|
const bottomY = rh + g, bottomH = H - rh - g;
|
||||||
|
switch (layout) {
|
||||||
|
case 'h': return { panes: top(0, H), gutters: [colG(0, H)] };
|
||||||
|
case 'v': return { panes: [{ x: 0, y: 0, w: W, h: rh }, { x: 0, y: bottomY, w: W, h: bottomH }], gutters: [rowG] };
|
||||||
|
case 'top2': return { panes: top(0, rh).concat([{ x: 0, y: bottomY, w: W, h: bottomH }]), gutters: [colG(0, rh), rowG] };
|
||||||
|
case 'bottom2': return { panes: [{ x: 0, y: 0, w: W, h: rh }].concat(top(bottomY, bottomH)), gutters: [colG(bottomY, bottomH), rowG] };
|
||||||
|
case 'grid': return { panes: top(0, rh).concat(top(bottomY, bottomH)), gutters: [colG(0, H), rowG] };
|
||||||
|
default: return { panes: [{ x: 0, y: 0, w: W, h: H }], gutters: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function layout(t, animate) {
|
||||||
|
const W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const L = rects(t.layout, t.c, t.r, W, H);
|
||||||
|
t.view.classList.toggle('instant', !animate);
|
||||||
|
t.panes.forEach((s, i) => {
|
||||||
|
const p = L.panes[i];
|
||||||
|
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
|
||||||
|
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
|
||||||
|
});
|
||||||
|
while (t.gutters.length < L.gutters.length) {
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'gutter';
|
||||||
|
g.addEventListener('pointerdown', ev => startResize(ev, t, g));
|
||||||
|
t.view.appendChild(g);
|
||||||
|
t.gutters.push(g);
|
||||||
|
}
|
||||||
|
t.gutters.forEach((g, i) => {
|
||||||
|
const d = L.gutters[i];
|
||||||
|
g.hidden = !d;
|
||||||
|
if (!d) return;
|
||||||
|
g.className = 'gutter ' + d.k;
|
||||||
|
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
|
||||||
|
});
|
||||||
|
clearTimeout(t.fitTimer);
|
||||||
|
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0);
|
||||||
|
}
|
||||||
|
function startResize(ev, t, g) {
|
||||||
|
ev.preventDefault();
|
||||||
|
g.setPointerCapture(ev.pointerId);
|
||||||
|
const box = t.view.getBoundingClientRect(), k = g.classList.contains('c') ? 'c' : 'r';
|
||||||
|
g.classList.add('drag');
|
||||||
|
let raf = 0;
|
||||||
|
const move = e => {
|
||||||
|
const v = k === 'c' ? (e.clientX - box.left) / box.width : (e.clientY - box.top) / box.height;
|
||||||
|
t[k] = Math.min(0.85, Math.max(0.15, v));
|
||||||
|
if (!raf) raf = requestAnimationFrame(() => { raf = 0; layout(t, false); });
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
g.classList.remove('drag');
|
||||||
|
g.removeEventListener('pointermove', move);
|
||||||
|
g.removeEventListener('pointerup', up);
|
||||||
|
layout(t, false);
|
||||||
|
};
|
||||||
|
g.addEventListener('pointermove', move);
|
||||||
|
g.addEventListener('pointerup', up);
|
||||||
|
}
|
||||||
|
// A layout with one terminal fewer, the others keeping their places.
|
||||||
|
function withoutPane(layout, i) {
|
||||||
|
if (layout === 'h' || layout === 'v') return '1';
|
||||||
|
if (layout === 'top2') return i === 2 ? 'h' : 'v';
|
||||||
|
if (layout === 'bottom2') return i === 0 ? 'h' : 'v';
|
||||||
|
if (layout === 'grid') return i < 2 ? 'bottom2' : 'top2';
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
function takePane(t, s) {
|
||||||
|
const i = t.panes.indexOf(s);
|
||||||
|
t.layout = withoutPane(t.layout, i);
|
||||||
|
t.panes.splice(i, 1);
|
||||||
|
}
|
||||||
|
function closePane(s) {
|
||||||
|
const t = tabOf(s);
|
||||||
|
if (!t) return;
|
||||||
|
if (t.panes.length === 1) { closeTab(t); return; }
|
||||||
|
takePane(t, s);
|
||||||
|
s.close();
|
||||||
|
if (activePane === s) setActivePane(t.panes[0]);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
// Where a terminal dropped on tab t at (x, y) would go: the new layout,
|
||||||
|
// the order of its terminals ('P' the new one), and its place.
|
||||||
|
const SIDE = { left: 'слева', right: 'справа', top: 'сверху', bottom: 'снизу' };
|
||||||
|
function mergePlan(t, x, y) {
|
||||||
|
const n = t.panes.length, W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
const fx = x / W, fy = y / H;
|
||||||
|
let lay, order, label;
|
||||||
|
if (n === 1) {
|
||||||
|
const d = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||||
|
const k = Object.keys(d).reduce((a, b) => d[a] <= d[b] ? a : b);
|
||||||
|
lay = k === 'left' || k === 'right' ? 'h' : 'v';
|
||||||
|
order = k === 'left' || k === 'top' ? ['P', 0] : [0, 'P'];
|
||||||
|
label = SIDE[k];
|
||||||
|
} else if (n === 2 && t.layout === 'h') {
|
||||||
|
if (fy < 0.5) { lay = 'bottom2'; order = ['P', 0, 1]; label = 'сверху'; }
|
||||||
|
else { lay = 'top2'; order = [0, 1, 'P']; label = 'снизу'; }
|
||||||
|
} else if (n === 2) {
|
||||||
|
if (fy < 0.5) { lay = 'top2'; order = fx < 0.5 ? ['P', 0, 1] : [0, 'P', 1]; }
|
||||||
|
else { lay = 'bottom2'; order = fx < 0.5 ? [0, 'P', 1] : [0, 1, 'P']; }
|
||||||
|
label = (fy < 0.5 ? 'сверху ' : 'снизу ') + (fx < 0.5 ? 'слева' : 'справа');
|
||||||
|
} else if (n === 3) {
|
||||||
|
lay = 'grid';
|
||||||
|
if (t.layout === 'top2') { order = fx < 0.5 ? [0, 1, 'P', 2] : [0, 1, 2, 'P']; label = 'снизу ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
else { order = fx < 0.5 ? ['P', 0, 1, 2] : [0, 'P', 1, 2]; label = 'сверху ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
} else return null;
|
||||||
|
return { layout: lay, order, label, rect: rects(lay, t.c, t.r, W, H).panes[order.indexOf('P')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- drag and drop ----------
|
||||||
|
// A tab: along the strip to reorder it, or onto the current tab to join
|
||||||
|
// it. A terminal from a split, by its title bar: to the strip to make it a
|
||||||
|
// tab of its own, or onto another terminal of its tab to swap places.
|
||||||
|
let drag = null, dragJustEnded = false;
|
||||||
|
function startDrag(ev, kind, obj) {
|
||||||
|
if (ev.button !== 0) return;
|
||||||
|
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
|
||||||
|
}
|
||||||
|
function beginDrag() {
|
||||||
|
drag.on = true;
|
||||||
|
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'drag-ghost ' + drag.kind;
|
||||||
|
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
|
||||||
|
document.body.appendChild(g);
|
||||||
|
drag.ghost = g;
|
||||||
|
drag.src = src;
|
||||||
|
src.classList.add('dragging');
|
||||||
|
document.body.classList.add('dnd');
|
||||||
|
// Pulling a terminal out: the strip shows, one tab or not, to drop it on.
|
||||||
|
if (drag.kind === 'pane') document.body.classList.add('multi-hint');
|
||||||
|
}
|
||||||
|
function stripIndex(x) {
|
||||||
|
const others = tabs.filter(t => !(drag.kind === 'tab' && t === drag.obj));
|
||||||
|
let i = others.findIndex(t => { const r = t.el.getBoundingClientRect(); return x < r.left + r.width / 2; });
|
||||||
|
if (i < 0) i = others.length;
|
||||||
|
const ref = others[i] ? others[i].el.getBoundingClientRect() : (others.length ? others[others.length - 1].el.getBoundingClientRect() : null);
|
||||||
|
const lr = tablist.getBoundingClientRect();
|
||||||
|
const mx = others[i] ? ref.left - 3 : ref ? ref.right + 1 : lr.left;
|
||||||
|
return { i, others, mx: mx - lr.left + tablist.scrollLeft };
|
||||||
|
}
|
||||||
|
function showHint(r, label, bad) {
|
||||||
|
hint.hidden = false;
|
||||||
|
hint.classList.toggle('bad', !!bad);
|
||||||
|
const v = active.view;
|
||||||
|
hint.style.left = (v.offsetLeft + r.x) + 'px'; hint.style.top = (v.offsetTop + r.y) + 'px';
|
||||||
|
hint.style.width = r.w + 'px'; hint.style.height = r.h + 'px';
|
||||||
|
hint.textContent = label;
|
||||||
|
}
|
||||||
|
function onDragMove(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
if (!drag.on) {
|
||||||
|
if (Math.hypot(ev.clientX - drag.x0, ev.clientY - drag.y0) < 6) return;
|
||||||
|
beginDrag();
|
||||||
|
}
|
||||||
|
drag.ghost.style.transform = 'translate(' + (ev.clientX + 10) + 'px,' + (ev.clientY + 8) + 'px)';
|
||||||
|
drag.target = null;
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
const strip = $('tabs').getBoundingClientRect();
|
||||||
|
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
|
||||||
|
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
|
||||||
|
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
|
||||||
|
if (inStrip) {
|
||||||
|
const s = stripIndex(ev.clientX);
|
||||||
|
mark.hidden = false; mark.style.left = s.mx + 'px';
|
||||||
|
drag.target = { kind: 'strip', index: s.i, others: s.others };
|
||||||
|
drag.ghost.dataset.hint = drag.kind === 'tab' ? 'переставить' : 'отдельной вкладкой';
|
||||||
|
} else if (inArea && drag.kind === 'tab' && drag.obj !== active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
if (drag.obj.panes.length > 1) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'вкладку с несколькими терминалами не объединить', true);
|
||||||
|
} else if (active.panes.length >= 4) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'в одной вкладке не больше 4 терминалов', true);
|
||||||
|
} else {
|
||||||
|
const plan = mergePlan(active, ev.clientX - r.left, ev.clientY - r.top);
|
||||||
|
showHint(plan.rect, plan.label);
|
||||||
|
drag.target = { kind: 'merge', plan };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
} else if (inArea && drag.kind === 'pane' && tabOf(drag.obj) === active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
const over = active.panes.find(s => { const p = s.el.getBoundingClientRect(); return s !== drag.obj && ev.clientX >= p.left && ev.clientX <= p.right && ev.clientY >= p.top && ev.clientY <= p.bottom; });
|
||||||
|
if (over) {
|
||||||
|
const p = over.el.getBoundingClientRect();
|
||||||
|
showHint({ x: p.left - r.left, y: p.top - r.top, w: p.width, h: p.height }, 'поменять местами');
|
||||||
|
drag.target = { kind: 'swap', with: over };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = over ? '' : 'к вкладкам - отдельной вкладкой';
|
||||||
|
} else {
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onDragEnd(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
const d = drag;
|
||||||
|
drag = null;
|
||||||
|
if (!d.on) return;
|
||||||
|
d.ghost.remove();
|
||||||
|
d.src.classList.remove('dragging');
|
||||||
|
document.body.classList.remove('dnd', 'multi-hint');
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
dragJustEnded = true;
|
||||||
|
setTimeout(() => { dragJustEnded = false; }, 0);
|
||||||
|
const tg = d.target;
|
||||||
|
if (!tg) return;
|
||||||
|
if (d.kind === 'tab' && tg.kind === 'strip') {
|
||||||
|
const t = d.obj, ref = tg.others[tg.index];
|
||||||
|
tabs.splice(tabs.indexOf(t), 1);
|
||||||
|
tabs.splice(ref ? tabs.indexOf(ref) : tabs.length, 0, t);
|
||||||
|
tablist.insertBefore(t.el, ref ? ref.el : mark);
|
||||||
|
} else if (d.kind === 'tab' && tg.kind === 'merge') {
|
||||||
|
const src = d.obj, s = src.panes[0], t = active, plan = tg.plan;
|
||||||
|
// The joining terminal starts where the hint showed it.
|
||||||
|
s.el.classList.add('instant');
|
||||||
|
s.el.style.left = plan.rect.x + 'px'; s.el.style.top = plan.rect.y + 'px';
|
||||||
|
s.el.style.width = plan.rect.w + 'px'; s.el.style.height = plan.rect.h + 'px';
|
||||||
|
t.view.appendChild(s.el);
|
||||||
|
requestAnimationFrame(() => s.el.classList.remove('instant'));
|
||||||
|
t.panes = plan.order.map(o => o === 'P' ? s : t.panes[o]);
|
||||||
|
t.layout = plan.layout;
|
||||||
|
tabs.splice(tabs.indexOf(src), 1);
|
||||||
|
src.el.classList.add('leave');
|
||||||
|
setTimeout(() => { src.el.remove(); src.view.remove(); }, 220);
|
||||||
|
setActivePane(s);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
updateMode();
|
||||||
|
s.term.focus();
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||||
|
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||||
|
takePane(from, s);
|
||||||
|
renderTab(from);
|
||||||
|
const t = makeTab([s], ref ? tabs.indexOf(ref) : tabs.length);
|
||||||
|
layout(from, true);
|
||||||
|
activate(t);
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'swap') {
|
||||||
|
const t = active, a = t.panes.indexOf(d.obj), b = t.panes.indexOf(tg.with);
|
||||||
|
[t.panes[a], t.panes[b]] = [t.panes[b], t.panes[a]];
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onDragMove);
|
||||||
|
window.addEventListener('pointerup', onDragEnd);
|
||||||
|
window.addEventListener('pointercancel', onDragEnd);
|
||||||
|
|
||||||
|
// ---------- the "+" menu ----------
|
||||||
|
let agentsCache = null;
|
||||||
|
function openMenu(anchor) {
|
||||||
|
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||||
|
addMenu.innerHTML = '';
|
||||||
|
const item = (html, fn, cls) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||||
|
b.onclick = fn;
|
||||||
|
addMenu.appendChild(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
|
() => { closeMenu(); openTerminal(here); });
|
||||||
|
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||||
|
other.remove();
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'mlist';
|
||||||
|
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||||
|
addMenu.appendChild(list);
|
||||||
|
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||||
|
.then(all => {
|
||||||
|
agentsCache = all;
|
||||||
|
list.innerHTML = '';
|
||||||
|
all.forEach(a => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||||
|
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||||
|
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
||||||
|
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||||
|
list.appendChild(b);
|
||||||
|
});
|
||||||
|
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||||
|
placeMenu(anchor);
|
||||||
|
})
|
||||||
|
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||||
|
});
|
||||||
|
addMenu.hidden = false;
|
||||||
|
placeMenu(anchor);
|
||||||
|
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||||
|
}
|
||||||
|
function placeMenu(anchor) {
|
||||||
|
const r = anchor.getBoundingClientRect(), m = addMenu.getBoundingClientRect();
|
||||||
|
addMenu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, r.right - m.width)) + 'px';
|
||||||
|
addMenu.style.top = Math.min(innerHeight - m.height - 8, r.bottom + 6) + 'px';
|
||||||
|
}
|
||||||
|
function outside(ev) { if (!addMenu.contains(ev.target)) closeMenu(); }
|
||||||
|
function closeMenu() {
|
||||||
|
addMenu.hidden = true;
|
||||||
|
agentsCache = null;
|
||||||
|
document.removeEventListener('pointerdown', outside, true);
|
||||||
|
}
|
||||||
|
addBtn.onclick = () => openMenu(addBtn);
|
||||||
|
floatAdd.onclick = () => openMenu(floatAdd);
|
||||||
|
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||||
|
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
||||||
|
|
||||||
|
// ---------- page ----------
|
||||||
|
function refit() {
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
// Follow the visible area, so the extra keys sit right above the
|
||||||
|
// on-screen keyboard.
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
document.body.style.top = vv.offsetTop + 'px';
|
||||||
|
document.body.style.height = vv.height + 'px';
|
||||||
|
}
|
||||||
|
if (active) layout(active, false);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', refit);
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', refit);
|
||||||
|
window.visualViewport.addEventListener('scroll', refit);
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// For the touch helpers in the page: the terminal being typed in.
|
||||||
|
window.TermPane = { active: () => activePane };
|
||||||
|
openTerminal(PAGE.agent);
|
||||||
|
refit();
|
||||||
|
})();
|
||||||
@@ -9,12 +9,103 @@
|
|||||||
<style>
|
<style>
|
||||||
#status.st-ok { color:#37c871; }
|
#status.st-ok { color:#37c871; }
|
||||||
#status.st-err { color:#ff5c5c; }
|
#status.st-err { color:#ff5c5c; }
|
||||||
|
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||||
|
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||||
|
header.topbar { flex:none; }
|
||||||
|
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||||
|
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||||
|
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||||
|
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||||
|
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||||
|
.tablist::-webkit-scrollbar { display:none; }
|
||||||
|
.tab { display:flex; align-items:center; gap:8px; flex:0 1 auto; min-width:0; max-width:220px; height:34px; padding:0 6px 0 12px;
|
||||||
|
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||||
|
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||||
|
transition:background .18s, color .18s, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||||
|
.tab:hover { color:var(--text); background:#151a21; }
|
||||||
|
.tab.on { background:var(--panel); color:var(--text); }
|
||||||
|
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||||
|
.tab.dragging { opacity:.3; }
|
||||||
|
.tab-title { overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||||
|
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||||
|
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||||
|
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||||
|
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||||
|
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||||
|
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||||
|
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||||
|
pointer-events:none; transition:left .12s ease; }
|
||||||
|
.tabmark[hidden] { display:none; }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||||
|
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); } .dot.wait { background:#e6a23c; }
|
||||||
|
/* The terminals' area. */
|
||||||
|
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||||
|
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||||
|
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||||
|
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||||
|
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease; }
|
||||||
|
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||||
|
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||||
|
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||||
|
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||||
|
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||||
|
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||||
|
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||||
|
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||||
|
.pane-head { display:none; }
|
||||||
|
.tabview.split .pane { border:1px solid var(--border); }
|
||||||
|
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||||
|
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||||
|
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||||
|
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||||
|
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||||
|
.pane.dragging { opacity:.45; }
|
||||||
|
.gutter { position:absolute; z-index:3; }
|
||||||
|
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||||
|
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||||
|
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||||
|
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||||
|
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||||
|
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||||
|
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||||
|
.hint[hidden] { display:none; }
|
||||||
|
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||||
|
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||||
|
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||||
|
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||||
|
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||||
|
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||||
|
.empty[hidden] { display:none; }
|
||||||
|
/* The "+" menu: a terminal here, or on another machine. */
|
||||||
|
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||||
|
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||||
|
animation:menu-in .14s ease; }
|
||||||
|
.addmenu[hidden] { display:none; }
|
||||||
|
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||||
|
background:transparent; color:var(--text); font-weight:400; }
|
||||||
|
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||||
|
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||||
|
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||||
|
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||||
|
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||||
|
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||||
with a row of extra keys at the bottom, the way Termux does it. */
|
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||||
|
smaller, and there is no splitting. */
|
||||||
.keys { display:none; }
|
.keys { display:none; }
|
||||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||||
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
body.touch .area { margin:6px; }
|
||||||
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
body.touch.multi .area { margin-top:0; }
|
||||||
|
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||||
|
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||||
|
body.touch .tab { height:28px; max-width:140px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||||
|
body.touch .tab-add { width:30px; height:28px; }
|
||||||
|
body.touch .tabview { inset:4px; }
|
||||||
body.touch .keys {
|
body.touch .keys {
|
||||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||||
@@ -33,7 +124,7 @@
|
|||||||
.selmenu[hidden] { display:none; }
|
.selmenu[hidden] { display:none; }
|
||||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||||
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -45,12 +136,19 @@
|
|||||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap" style="max-width:none">
|
<div class="tabs" id="tabs">
|
||||||
<div id="terminal"></div>
|
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||||
|
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="area" id="area">
|
||||||
|
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||||
|
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||||
|
<div class="hint" id="hint" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="addmenu" id="addmenu" hidden></div>
|
||||||
<div class="selmenu" id="selmenu" hidden>
|
<div class="selmenu" id="selmenu" hidden>
|
||||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||||
@@ -85,144 +183,12 @@
|
|||||||
<script src="{{asset "xterm.js"}}"></script>
|
<script src="{{asset "xterm.js"}}"></script>
|
||||||
<script src="{{asset "addon-fit.js"}}"></script>
|
<script src="{{asset "addon-fit.js"}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||||
const agentID = {{.Agent.ID}};
|
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||||
const statusEl = document.getElementById('status');
|
// CTRL / ALT, and selecting and copying with a long press.
|
||||||
|
window.TermKeys = (function () {
|
||||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||||
if (touch) document.body.classList.add('touch');
|
const cur = () => window.TermPane && window.TermPane.active();
|
||||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
|
||||||
|
|
||||||
const term = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize: touch ? 13 : 14,
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
||||||
theme: { background: '#0c0f13' }
|
|
||||||
});
|
|
||||||
const fit = new FitAddon.FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(document.getElementById('terminal'));
|
|
||||||
fit.fit();
|
|
||||||
|
|
||||||
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);
|
|
||||||
const u = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = '/api/panel/term/';
|
|
||||||
let sid = null;
|
|
||||||
let closed = false;
|
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
function post(path, body) {
|
|
||||||
return fetch(path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
keepalive: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open() {
|
|
||||||
try {
|
|
||||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
|
||||||
if (!r.ok) {
|
|
||||||
const body = await r.text().catch(() => '');
|
|
||||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
|
||||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
|
||||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
|
||||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const j = await r.json();
|
|
||||||
sid = j.session_id;
|
|
||||||
setStatus('ожидание агента…');
|
|
||||||
sendResize();
|
|
||||||
pollLoop();
|
|
||||||
} catch (e) {
|
|
||||||
setStatus('ошибка сети', 'st-err');
|
|
||||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollLoop() {
|
|
||||||
while (!closed && sid) {
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
|
||||||
} catch (e) {
|
|
||||||
// transient network hiccup; brief pause then retry
|
|
||||||
await new Promise(res => setTimeout(res, 1000));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
|
||||||
const j = await r.json();
|
|
||||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
|
||||||
if (j.frames) {
|
|
||||||
for (const f of j.frames) {
|
|
||||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
|
||||||
if (f.data) {
|
|
||||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
|
||||||
term.write(b64ToBytes(f.data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish(msg) {
|
|
||||||
if (closed) return;
|
|
||||||
closed = true;
|
|
||||||
setStatus('соединение закрыто', 'muted');
|
|
||||||
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; what is typed meanwhile is sent together
|
|
||||||
// next. A request that fails goes again under the same number, and the
|
|
||||||
// server drops one it already has.
|
|
||||||
let inSeq = 0, inBusy = false;
|
|
||||||
const inQueue = [];
|
|
||||||
function queueIn(m) {
|
|
||||||
if (closed || !sid) return;
|
|
||||||
const last = inQueue[inQueue.length - 1];
|
|
||||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
|
||||||
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
|
|
||||||
else inQueue.push(m);
|
|
||||||
pumpIn();
|
|
||||||
}
|
|
||||||
async function pumpIn() {
|
|
||||||
if (inBusy || !inQueue.length) return;
|
|
||||||
inBusy = true;
|
|
||||||
while (inQueue.length && !closed) {
|
|
||||||
const m = inQueue.shift();
|
|
||||||
const body = m.t === 'i'
|
|
||||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
|
|
||||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
|
|
||||||
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
|
|
||||||
let r = null;
|
|
||||||
try {
|
|
||||||
r = await fetch(base + 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; // taken, or refused for good
|
|
||||||
await new Promise(res => setTimeout(res, wait));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inBusy = false;
|
|
||||||
}
|
|
||||||
function send(data) { queueIn({ t: 'i', text: data }); }
|
|
||||||
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
|
|
||||||
term.onData(function (data) { send(withMods(data)); });
|
|
||||||
|
|
||||||
// --- Extra keys (touch screens) ---
|
|
||||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||||
// keeps it on until tapped again.
|
// keeps it on until tapped again.
|
||||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||||
@@ -255,7 +221,7 @@
|
|||||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
// 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 CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||||
const TILDE = { pgup: 5, pgdn: 6 };
|
const TILDE = { pgup: 5, pgdn: 6 };
|
||||||
function keySeq(k) {
|
function keySeq(k, term) {
|
||||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||||
if (CSI[k]) {
|
if (CSI[k]) {
|
||||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||||
@@ -286,27 +252,42 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(keySeq(b.dataset.k));
|
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('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
|
|
||||||
// --- Selecting and copying on touch screens ---
|
// --- Selecting and copying on touch screens ---
|
||||||
// xterm.js has no touch selection of its own. A long press selects the word
|
// 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
|
// 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.
|
// 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) {
|
if (touch) {
|
||||||
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
|
const box = document.getElementById('area');
|
||||||
let timer = null, start = null, anchor = null, selecting = false;
|
let timer = null, start = null, anchor = null;
|
||||||
function cellAt(x, y) {
|
function cellAt(term, el, x, y) {
|
||||||
const r = box.querySelector('.xterm-screen').getBoundingClientRect();
|
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 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))));
|
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 };
|
return { col: col, row: row + term.buffer.active.viewportY };
|
||||||
}
|
}
|
||||||
function selectWord(c) {
|
function selectWord(term, c) {
|
||||||
const line = term.buffer.active.getLine(c.row);
|
const line = term.buffer.active.getLine(c.row);
|
||||||
const text = line ? line.translateToString(false) : '';
|
const text = line ? line.translateToString(false) : '';
|
||||||
const word = ch => ch && !/\s/.test(ch);
|
const word = ch => ch && !/\s/.test(ch);
|
||||||
@@ -318,40 +299,33 @@
|
|||||||
term.select(a, c.row, b - a + 1);
|
term.select(a, c.row, b - a + 1);
|
||||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||||
}
|
}
|
||||||
function stretch(c) {
|
function stretch(term, c) {
|
||||||
const p = c.row * term.cols + c.col;
|
const p = c.row * term.cols + c.col;
|
||||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
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);
|
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||||
}
|
}
|
||||||
// The menu shows as soon as there is a selection, however it came, and
|
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||||
// sits a little above the extra keys.
|
|
||||||
function showMenu() {
|
|
||||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
|
||||||
menu.style.top = '';
|
|
||||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
|
||||||
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
|
|
||||||
menu.hidden = false;
|
|
||||||
}
|
|
||||||
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
|
|
||||||
box.addEventListener('touchstart', function (e) {
|
box.addEventListener('touchstart', function (e) {
|
||||||
if (e.touches.length !== 1) { clearTimeout(timer); return; }
|
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
start = { x: t.clientX, y: t.clientY };
|
start = { x: t.clientX, y: t.clientY };
|
||||||
if (!menu.hidden) hideMenu();
|
if (!menu.hidden) hideMenu();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(function () {
|
timer = setTimeout(function () {
|
||||||
|
const s = cur();
|
||||||
|
if (!s) return;
|
||||||
selecting = true;
|
selecting = true;
|
||||||
selectWord(cellAt(start.x, start.y));
|
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||||
showMenu();
|
showMenu();
|
||||||
if (navigator.vibrate) navigator.vibrate(15);
|
if (navigator.vibrate) navigator.vibrate(15);
|
||||||
}, 450);
|
}, 450);
|
||||||
}, { passive: true, capture: true });
|
}, { passive: true, capture: true });
|
||||||
box.addEventListener('touchmove', function (e) {
|
box.addEventListener('touchmove', function (e) {
|
||||||
const t = e.touches[0];
|
const t = e.touches[0], s = cur();
|
||||||
if (selecting) {
|
if (selecting && s) {
|
||||||
e.preventDefault(); // no scrolling while the selection stretches
|
e.preventDefault(); // no scrolling while the selection stretches
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
stretch(cellAt(t.clientX, t.clientY));
|
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) {
|
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||||
clearTimeout(timer); // a swipe: let it scroll
|
clearTimeout(timer); // a swipe: let it scroll
|
||||||
}
|
}
|
||||||
@@ -365,11 +339,12 @@
|
|||||||
showMenu();
|
showMenu();
|
||||||
}, { passive: false, capture: true });
|
}, { passive: false, capture: true });
|
||||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
term.onSelectionChange(function () {
|
|
||||||
if (term.hasSelection()) showMenu();
|
|
||||||
else if (!selecting) menu.hidden = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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) {
|
function copyText(text) {
|
||||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(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.
|
// Plain http: the old way, through a field that is there for a moment.
|
||||||
@@ -384,52 +359,28 @@
|
|||||||
}
|
}
|
||||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||||
menu.addEventListener('click', function (e) {
|
menu.addEventListener('click', function (e) {
|
||||||
const b = e.target.closest('button');
|
const b = e.target.closest('button'), s = cur();
|
||||||
if (!b) return;
|
if (!b || !s) return;
|
||||||
const a = b.dataset.a;
|
const a = b.dataset.a;
|
||||||
if (a === 'copy') {
|
if (a === 'copy') {
|
||||||
const text = term.getSelection();
|
const text = s.term.getSelection();
|
||||||
hideMenu();
|
hideMenu();
|
||||||
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
|
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||||
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
|
|
||||||
} else if (a === 'paste') {
|
} else if (a === 'paste') {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
|
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||||
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
|
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||||
} else if (a === 'all') {
|
} else if (a === 'all') {
|
||||||
term.selectAll();
|
s.term.selectAll();
|
||||||
showMenu();
|
showMenu();
|
||||||
} else {
|
} else {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||||
function refit() {
|
|
||||||
if (touch && window.visualViewport) {
|
|
||||||
// Follow the visible area, so the extra keys sit right above the
|
|
||||||
// on-screen keyboard.
|
|
||||||
const vv = window.visualViewport;
|
|
||||||
document.body.style.top = vv.offsetTop + 'px';
|
|
||||||
document.body.style.height = vv.height + 'px';
|
|
||||||
}
|
|
||||||
fit.fit();
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', refit);
|
|
||||||
if (touch && window.visualViewport) {
|
|
||||||
window.visualViewport.addEventListener('resize', refit);
|
|
||||||
window.visualViewport.addEventListener('scroll', refit);
|
|
||||||
}
|
|
||||||
refit();
|
|
||||||
term.onResize(sendResize);
|
|
||||||
|
|
||||||
window.addEventListener('pagehide', function () {
|
|
||||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
|
||||||
});
|
|
||||||
|
|
||||||
term.focus();
|
|
||||||
open();
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{asset "terminal.js"}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user