// 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 = ''; 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 = '
' + '
' + '
'; 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(); // 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.exit) { this.finish('[сессия завершена]'); return false; } if (f.data) { if (!this.connected) { this.connected = true; this.setState('ok', 'подключено'); } this.term.write(b64ToBytes(f.data)); } } return true; } 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; 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 */ } } } 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(); } } // ---------- 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 = '' + ''; 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); renderTab(t); fitTabs(); requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter'))); 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; } // Tab widths: as wide as the title wants, from a comfortable minimum up to // a maximum; when they do not fit, all give way evenly down to a floor, // titles cut with "…", and below the floor the strip scrolls instead. const ruler = document.createElement('canvas').getContext('2d'); const W = touch ? { min: 76, base: 108, max: 170, font: 12 } : { min: 96, base: 150, max: 280, font: 13 }; const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders function fitTabs() { ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily; const live = tabs; const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME))); const strip = $('tabs'); const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1); let cap = W.max; if (want.reduce((a, b) => a + b, 0) > room) { // The widest give way first, to one width for all at the most. let lo = W.min, hi = W.max; for (let k = 0; k < 20; k++) { const mid = (lo + hi) / 2; if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid; } cap = Math.max(W.min, Math.floor(lo)); } live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; }); } window.addEventListener('resize', () => fitTabs()); function renderTab(t) { t.el.querySelector('.tab-title').textContent = tabTitle(t); requestAnimationFrame(fitTabs); 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('+Новый терминал здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', () => { closeMenu(); openTerminal(here); }); const other = item('⇄На другой машиневыбрать из списка›', () => { other.remove(); const list = document.createElement('div'); list.className = 'mlist'; list.innerHTML = '
загрузка…
'; 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 = '' + esc(a.name) + '' + esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + ''; b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); }; list.appendChild(b); }); if (!all.length) list.innerHTML = '
машин нет
'; placeMenu(anchor); }) .catch(() => { list.innerHTML = '
не загрузилось
'; }); }); 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(); })();