Files
lpra/internal/server/web/static/workspace.js
T

788 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
const KIND_ICON = {
term: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 7 5 5-5 5M12 17h7"/></svg>',
files: '<svg width="13" height="13" 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>'
};
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-kinds"></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) +
18 * new Set(t.panes.map(p => /files$/.test(p.kind))).size)));
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);
// Before the name, what the tab holds: a terminal, files, or both.
const kinds = [...new Set(t.panes.map(p => /files$/.test(p.kind) ? 'files' : 'term'))];
const k = t.el.querySelector('.tab-kinds');
if (k.dataset.k !== kinds.join()) {
k.dataset.k = kinds.join();
k.innerHTML = kinds.map(x => KIND_ICON[x]).join('');
k.title = kinds.map(x => x === 'files' ? 'файлы' : 'терминал').join(' и ');
}
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}); }
async function closeTab(t) {
const i = tabs.indexOf(t);
if (i < 0) return;
if (t.panes.length > 1 && !await UI.confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?', {ok: 'Закрыть', danger: true})) 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>';
// Both groups keep their contents mounted so they can animate in both directions.
const group = (icon, label, kind, mode) => {
let loaded = false;
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
const opening = g.getAttribute('aria-expanded') !== 'true';
addMenu.querySelectorAll('.item[aria-expanded="true"]').forEach(b => {
b.setAttribute('aria-expanded', 'false');
b.nextSibling.classList.remove('open');
b.nextSibling.inert = true;
});
if (!opening) { placeMenu(anchor); return; }
g.setAttribute('aria-expanded', 'true');
list.classList.add('open');
list.inert = false;
placeMenu(anchor);
if (loaded) return;
loaded = true;
(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;
});
if (list.isConnected) placeMenu(anchor);
})
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
});
g.setAttribute('aria-expanded', 'false');
const list = document.createElement('div');
list.className = 'mlist';
list.inert = true;
const inner = document.createElement('div');
inner.className = 'mlist-inner';
const content = document.createElement('div');
content.className = 'mlist-content';
inner.appendChild(content);
list.appendChild(inner);
g.after(list);
if (here) item(content, '<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>';
content.appendChild(box);
item(content, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here, mode); });
if (!paneTypes[kind]) g.disabled = true;
return g;
};
group('&gt;_', 'Терминал', 'term', 'term');
group(FILES, 'Файловый менеджер', 'files', '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="f saved-row"><span>Сохранённый хост</span><div class="frow"><select name="saved" class="grow"><option value="">новый хост</option></select>' +
'<button type="button" class="btn ghost small icon close x" data-delsaved title="Удалить из сохранённых" aria-label="Удалить из сохранённых" hidden>' + X + '</button></div></div>' +
'<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 auth-row"><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-----&#10;…вставьте ключ или загрузите файл"></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>' +
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
'<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');
// say: a line under the form - red for what went wrong, quiet for what goes on.
const say = (t, going) => { msg.textContent = t; msg.classList.toggle('going', !!going); };
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) { say('файл великоват для ключа'); return; }
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; say(''); });
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 = [], saved = [];
// A saved host: its address shows, the way in stays on the server.
const useSaved = () => {
const h = saved.find(x => String(x.id) === f.saved.value);
d.querySelector('[data-delsaved]').hidden = !h;
d.querySelectorAll('.auth-row, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; });
if (!h) { setAuth(auth); return; }
f.host.value = h.host; f.port.value = h.port; f.user.value = h.user;
};
f.saved.onchange = useSaved;
const loadSaved = () => fetch('/api/panel/ssh/hosts', { cache: 'no-store' }).then(r => r.json()).then(list => {
saved = list || [];
const keep = f.saved.value;
f.saved.innerHTML = '<option value="">новый хост</option>' + saved.map(h => '<option value="' + h.id + '">' +
String(h.label).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) + '</option>').join('');
f.saved.value = saved.some(h => String(h.id) === keep) ? keep : '';
d.querySelector('.saved-row').hidden = !saved.length;
useSaved();
}).catch(() => {});
d.querySelector('[data-delsaved]').onclick = async () => {
const h = saved.find(x => String(x.id) === f.saved.value);
if (!h || !await UI.confirm('Удалить «' + h.label + '» из сохранённых?', { ok: 'Удалить', danger: true })) return;
await fetch('/api/panel/ssh/hosts/' + h.id + '/delete', { method: 'POST' });
f.saved.value = '';
loadSaved();
};
f.onsubmit = async ev => {
ev.preventDefault();
const a = agents.find(x => x.id === f.agent.value);
if (!a) { say('выберите машину'); return; }
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
if (f.saved.value) {
t.saved = Number(f.saved.value);
} else if (auth === 'password') {
if (!f.password.value) { say('введите пароль'); f.password.focus(); return; }
t.password = f.password.value;
} else if (auth === 'key') {
if (!/PRIVATE KEY/.test(f.key.value)) { say('вставьте закрытый ключ или загрузите файл'); return; }
t.key = f.key.value;
if (f.passphrase.value) t.passphrase = f.passphrase.value;
}
if (!t.saved && f.save.checked) {
// Saved first, then used by its id like any saved host.
const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' }));
if (!r.ok) { say(r.error || 'не сохранилось'); return; }
f.save.checked = false;
loadSaved();
}
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth, saved: t.saved || 0 })); } 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;
say('подключение…', true);
window.Workspace.sshFiles(a, t, sshInto).then(r => {
btn.disabled = false;
if (!r.ok) { say(r.error || 'не подключилось'); return; }
say('');
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-подключение';
say('');
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, saved: last.saved };
f.saved.value = '';
loadSaved().then(() => {
// The host used last, if it was a saved one and still is.
if (last.saved && saved.some(h => h.id === last.saved && h.host === last.host)) { f.saved.value = String(last.saved); useSaved(); }
});
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 !== '/workspace' && window.App) App.go('/workspace');
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
};
})();