diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go
index b414672..2b25934 100644
--- a/internal/server/handlers_panel.go
+++ b/internal/server/handlers_panel.go
@@ -151,14 +151,17 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
return
}
type item struct {
- ID string `json:"id"`
- Online bool `json:"online"`
- Seen string `json:"seen"`
+ 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"`
+ Seen string `json:"seen"`
}
out := make([]item, 0, len(agents))
for _, a := range agents {
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)
}
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css
index 423c35d..c6086c9 100644
--- a/internal/server/web/static/app.css
+++ b/internal/server/web/static/app.css
@@ -25,6 +25,11 @@ header.topbar {
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
}
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:hover { color: var(--text); }
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
diff --git a/internal/server/web/static/terminal.js b/internal/server/web/static/terminal.js
new file mode 100644
index 0000000..de00d94
--- /dev/null
+++ b/internal/server/web/static/terminal.js
@@ -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 = '';
+
+ 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();
+ 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 = '' +
+ '';
+ 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('+Новый терминал здесь' + 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 = '