712 lines
37 KiB
JavaScript
712 lines
37 KiB
JavaScript
// The workspace of the Терминал view: panes - terminals, file managers - of
|
||
// one or more machines, in tabs. It lives as long as the page, so moving to
|
||
// Машины or Настройки and back keeps every session. With one tab there is
|
||
// only 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
|
||
// panes, split side by side or one above the other - and a pane dragged out
|
||
// of it back to the strip. Splits resize by their gutters; a tab is renamed
|
||
// by a double click on its title. Pane types register in Workspace.paneTypes.
|
||
(function () {
|
||
'use strict';
|
||
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>';
|
||
|
||
// ---------- 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); });
|
||
// Its machine's Порты, Статус, Изменить: a right click on the title.
|
||
t.el.addEventListener('contextmenu', ev => {
|
||
const p = t.panes.includes(activePane) ? activePane : t.panes[0];
|
||
if (window.MachineDialog && !ev.target.closest('input')) MachineDialog.menu(ev, p.agent);
|
||
});
|
||
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 name = t.panes[0].title();
|
||
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : 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: 124, max: 180, font: 12 } : { min: 96, base: 180, max: 300, 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 + (t.panes.some(p => p.ssh) ? 46 : 0))));
|
||
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);
|
||
const ssh = t.panes.some(s => s.ssh);
|
||
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||
else t.el.querySelector('.badge-ssh').remove();
|
||
}
|
||
requestAnimationFrame(fitTabs);
|
||
t.el.title = t.panes.map(p => p.tooltip ? p.tooltip() : p.title()).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);
|
||
syncFloat();
|
||
}
|
||
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.focus();
|
||
updateMode();
|
||
// The tab comes in moving a little; the "+" goes by where it ends up.
|
||
setTimeout(placeFloat, 260);
|
||
}
|
||
function setActivePane(s) {
|
||
activePane = s;
|
||
// The phone's extra keys are a terminal's: a file pane goes without.
|
||
const term = s.kind === 'term' || s.kind === 'ssh';
|
||
if (touch && document.body.classList.contains('term-active') !== term) {
|
||
document.body.classList.toggle('term-active', term);
|
||
setTimeout(() => active && layout(active, false), 0);
|
||
}
|
||
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||
s.el.classList.add('active');
|
||
showStatus();
|
||
}
|
||
function showStatus() {
|
||
// The header's status line is the workspace's only while it is shown.
|
||
if (!activePane || document.querySelector('main#app').dataset.view !== 'terminal') { statusEl.textContent = ''; return; }
|
||
statusEl.textContent = activePane.text;
|
||
statusEl.title = activePane.text;
|
||
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||
}
|
||
// The corner "+" moves down out of the way of a split tab's title bars.
|
||
function syncFloat() {
|
||
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
|
||
placeFloat();
|
||
}
|
||
// With one tab and a file pane in the top right corner, the "+" joins
|
||
// that pane's top bar: right of "Обновить", above "Удалить выбранное",
|
||
// a button of the same size. Otherwise it is back in the corner.
|
||
function placeFloat() {
|
||
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
|
||
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];
|
||
// A bar folded in two (a phone) keeps the corner "+".
|
||
const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48;
|
||
floatAdd.classList.toggle('in-files', on);
|
||
if (!on) { floatAdd.style.top = floatAdd.style.right = ''; return; }
|
||
const a = area.getBoundingClientRect(), top = bars[0].getBoundingClientRect(), row = bars[1].getBoundingClientRect();
|
||
floatAdd.style.top = (top.top - a.top - area.clientTop) + 'px';
|
||
floatAdd.style.right = (a.left + area.clientLeft + area.clientWidth - row.right) + 'px';
|
||
}
|
||
// One tab: a plain terminal, the strip folded into the corner "+".
|
||
function updateMode() {
|
||
syncFloat();
|
||
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 openPane(kind, agent, opts) {
|
||
const make = paneTypes[kind];
|
||
if (!make) return null;
|
||
$('empty').hidden = true;
|
||
const t = makeTab([make(agent, opts || {})]);
|
||
activate(t);
|
||
return t;
|
||
}
|
||
function openTerminal(agent, ssh) { return openPane(ssh ? 'ssh' : 'term', agent, {ssh}); }
|
||
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; document.body.classList.remove('term-active'); }
|
||
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);
|
||
// The pane in the top right corner sits under the corner "+": it keeps
|
||
// its first bar clear of it.
|
||
let corner = 0;
|
||
L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; });
|
||
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';
|
||
s.el.classList.toggle('under-plus', i === corner);
|
||
});
|
||
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()); placeFloat(); }, 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.tooltip ? drag.obj.tooltip() : drag.obj.title());
|
||
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.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;
|
||
addMenu.innerHTML = '';
|
||
const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const item = (parent, html, fn, cls) => {
|
||
const b = document.createElement('button');
|
||
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||
b.onclick = fn;
|
||
parent.appendChild(b);
|
||
return b;
|
||
};
|
||
const FILES = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
||
// A group: «Терминал ›» or «Файловый менеджер ›», opening to here, the
|
||
// other machines and an SSH host.
|
||
const group = (icon, label, kind, sshKind, mode) => {
|
||
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b><small>здесь, на другой машине или по SSH</small></span><span class="chev">›</span>', () => {
|
||
if (g.nextSibling && g.nextSibling.classList && g.nextSibling.classList.contains('mlist')) { g.nextSibling.remove(); placeMenu(anchor); return; }
|
||
addMenu.querySelectorAll('.mlist').forEach(x => x.remove());
|
||
const list = document.createElement('div');
|
||
list.className = 'mlist';
|
||
g.after(list);
|
||
if (here) item(list, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||
() => { closeMenu(); openPane(kind, here); });
|
||
const box = document.createElement('div');
|
||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||
list.appendChild(box);
|
||
item(list, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
|
||
() => { closeMenu(); openSSH(here, mode); });
|
||
placeMenu(anchor);
|
||
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||
.then(all => {
|
||
agentsCache = all;
|
||
box.innerHTML = '';
|
||
all.filter(a => !here || a.id !== here.id).forEach(a => {
|
||
const b = item(box, '<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>',
|
||
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
|
||
b.disabled = !a.online;
|
||
});
|
||
placeMenu(anchor);
|
||
})
|
||
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||
});
|
||
if (!paneTypes[kind]) g.disabled = true;
|
||
return g;
|
||
};
|
||
group('>_', 'Терминал', 'term', 'ssh', 'term');
|
||
group(FILES, 'Файловый менеджер', 'files', 'sshfiles', 'files');
|
||
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(); });
|
||
|
||
// ---------- the SSH dialog ----------
|
||
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||
let sshInto = null, sshDlg = null, sshMode = 'term';
|
||
// openSSH asks where to connect; mode says what opens there: a terminal
|
||
// ('term') or a file manager ('files', through Workspace.sshFiles).
|
||
// into: a file pane whose SSH connection is lost - the dialog comes with
|
||
// its host, port and user, and connects that pane again.
|
||
function openSSH(here, mode, into) {
|
||
if (!sshDlg) sshDlg = makeSSHDialog();
|
||
sshDlg.show(here, mode || 'term', into);
|
||
}
|
||
function makeSSHDialog() {
|
||
const d = document.createElement('dialog');
|
||
d.className = 'sshdlg';
|
||
d.innerHTML =
|
||
'<form method="dialog" autocomplete="off">' +
|
||
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
|
||
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
||
'<div class="dlg-b">' +
|
||
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
||
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||
'<div class="auth" data-for="key" hidden>' +
|
||
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||
'<input type="file" hidden></div>' +
|
||
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||
'<p class="sshmsg"></p>' +
|
||
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||
'</div></form>';
|
||
document.body.appendChild(d);
|
||
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
|
||
let auth = 'password';
|
||
const setAuth = a => {
|
||
auth = a;
|
||
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||
};
|
||
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
|
||
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
|
||
const file = d.querySelector('input[type=file]');
|
||
d.querySelector('[data-load]').onclick = () => file.click();
|
||
file.onchange = () => {
|
||
const fl = file.files[0];
|
||
if (!fl) return;
|
||
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||
file.value = '';
|
||
};
|
||
// then runs once the dialog is shut and focus is back where it was, so
|
||
// what it focuses stays focused.
|
||
function close(then) {
|
||
d.close();
|
||
if (then) then();
|
||
}
|
||
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||
let agents = [];
|
||
f.onsubmit = ev => {
|
||
ev.preventDefault();
|
||
const a = agents.find(x => x.id === f.agent.value);
|
||
if (!a) { msg.textContent = 'выберите машину'; return; }
|
||
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
||
if (auth === 'password') {
|
||
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||
t.password = f.password.value;
|
||
} else if (auth === 'key') {
|
||
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
|
||
t.key = f.key.value;
|
||
if (f.passphrase.value) t.passphrase = f.passphrase.value;
|
||
}
|
||
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
|
||
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||
d.querySelector('.keyname').textContent = '';
|
||
if (sshMode === 'files') {
|
||
// The machine connects first; the pane opens once it is in.
|
||
const btn = f.querySelector('button[type=submit]');
|
||
btn.disabled = true;
|
||
msg.textContent = 'подключение…';
|
||
window.Workspace.sshFiles(a, t, sshInto).then(r => {
|
||
btn.disabled = false;
|
||
if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; }
|
||
msg.textContent = '';
|
||
close();
|
||
});
|
||
return;
|
||
}
|
||
close(() => openTerminal(a, t));
|
||
};
|
||
return {
|
||
show(here, mode, into) {
|
||
sshMode = mode;
|
||
sshInto = into || null;
|
||
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
|
||
msg.textContent = '';
|
||
let last = {};
|
||
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||
agents = here ? [here] : [];
|
||
const fill = () => {
|
||
f.agent.innerHTML = '';
|
||
agents.forEach(a => {
|
||
const o = document.createElement('option');
|
||
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
|
||
o.disabled = a.online === false;
|
||
f.agent.appendChild(o);
|
||
});
|
||
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : (here ? here.id : (agents.find(a => a.online !== false) || {}).id || '');
|
||
};
|
||
fill();
|
||
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
|
||
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
|
||
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
||
fill();
|
||
}).catch(() => {});
|
||
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth };
|
||
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
|
||
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||
d.showModal();
|
||
// On a phone the keyboard waits for a tap.
|
||
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------- 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');
|
||
// A file pane on an SSH host lets the machine drop its connection.
|
||
if (s.src && s.src.close) s.src.close();
|
||
}));
|
||
});
|
||
|
||
// The view coming into sight: fit the panes, show the status line.
|
||
document.addEventListener('app:view', ev => {
|
||
if (ev.detail === 'terminal') { refit(); showStatus(); } else statusEl.textContent = '';
|
||
});
|
||
$('empty').hidden = tabs.length > 0;
|
||
|
||
const paneTypes = {};
|
||
// open opens a pane of kind on agent in a new tab and shows the view.
|
||
function open(kind, agent, opts) {
|
||
const t = openPane(kind, agent, opts);
|
||
if (location.pathname !== '/terminal' && window.App) App.go('/terminal');
|
||
return t;
|
||
}
|
||
const in_ = (el, x, y) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; };
|
||
window.Workspace = {
|
||
paneTypes, desk, touch, open, activate,
|
||
hasLiveSessions: () => tabs.some(t => t.panes.some(p => p.live())),
|
||
panes: () => tabs.flatMap(t => t.panes),
|
||
activePane: () => activePane,
|
||
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
|
||
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag, openSSH,
|
||
// paneAt, tabAt: the pane of the shown tab, the tab title at a point.
|
||
paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)),
|
||
tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null
|
||
};
|
||
})();
|