feat: workspace lives in the terminal view and keeps its sessions across pages; terminal panes split out
This commit is contained in:
1 parent
113c63fd1b
commit
012c0af7f9
9 files changed
+782
-727
No files matched your search
@@ -177,19 +177,6 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "terminal.html", map[string]any{
|
||||
"Agent": a,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
|
||||
@@ -262,3 +262,163 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* The one-page shell: the header's current view. */
|
||||
header.topbar nav a.on { color: var(--text); }
|
||||
|
||||
/* ---- The workspace (Терминал view) ---- */
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
#status { display:inline-block; vertical-align:middle; margin-left:18px; min-width:0; max-width:32vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||
dialog.sshdlg { max-width:460px; }
|
||||
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||
.sshdlg .frow .port { width:92px; flex:none; }
|
||||
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||
.sshdlg .auth[hidden] { display:none; }
|
||||
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||
transition:background .18s, color .18s; }
|
||||
.sshdlg .seg button:hover { color:var(--text); }
|
||||
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||
/* The shell: the header, then the view to the bottom. Машины and
|
||||
Настройки scroll inside it; the terminal view fills it. */
|
||||
body.app { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
body.app main#app { flex:1; min-height:0; overflow:auto; }
|
||||
.view-term { height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
||||
header.topbar { flex:none; }
|
||||
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||
pointer-events:none; transition:left .12s ease; }
|
||||
.tabmark[hidden] { display:none; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||
/* The terminals' area. */
|
||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
|
||||
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
|
||||
body.split1 .float-add { top:42px; right:16px; }
|
||||
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||
.pane-head { display:none; }
|
||||
.tabview.split .pane { border:1px solid var(--border); }
|
||||
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||
/* A lone terminal's scrollbar sits in the margin by the frame, right of
|
||||
the corner "+", not over the text. */
|
||||
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
|
||||
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
|
||||
.pane.dragging { opacity:.45; }
|
||||
.gutter { position:absolute; z-index:3; }
|
||||
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||
.hint[hidden] { display:none; }
|
||||
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||
.empty[hidden] { display:none; }
|
||||
/* The "+" menu: a terminal here, or on another machine. */
|
||||
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||
animation:menu-in .14s ease; }
|
||||
.addmenu[hidden] { display:none; }
|
||||
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||
background:transparent; color:var(--text); font-weight:400; }
|
||||
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||
smaller, and there is no splitting. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||
body.touch .area { margin:6px; }
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.keys button {
|
||||
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.keys button:active { background:var(--border); }
|
||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||
/* Copy and paste on touch screens: a long press selects, this menu acts. */
|
||||
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||||
.selmenu[hidden] { display:none; }
|
||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
#status:empty { display:none; }
|
||||
@@ -128,6 +128,9 @@
|
||||
function start() {
|
||||
document.querySelectorAll('main#app > .view').forEach(mount);
|
||||
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
|
||||
// /agents/{id}/terminal or /files: that pane, at once.
|
||||
const o = window.APP && window.APP.open;
|
||||
if (o && window.Workspace) window.Workspace.open(o.kind, o.agent);
|
||||
}
|
||||
window.App = {go, load, register, put, start, scope};
|
||||
})();
|
||||
@@ -30,6 +30,13 @@
|
||||
// the tab is hidden.
|
||||
App.register('dashboard', {
|
||||
mount(root, S) {
|
||||
// Terminal and files buttons open a tab in the workspace, no reload.
|
||||
S.on(root, 'click', function (ev) {
|
||||
var b = ev.target.closest('[data-open]');
|
||||
if (!b || !window.Workspace || !Workspace.paneTypes[b.dataset.open]) return;
|
||||
ev.preventDefault();
|
||||
Workspace.open(b.dataset.open, {id: b.dataset.id, name: b.dataset.name, host: b.dataset.host, user: b.dataset.user});
|
||||
});
|
||||
var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch;
|
||||
|
||||
var rows = {};
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
// Terminal panes of the workspace: a shell on a machine, or a shell the
|
||||
// machine opens by SSH. Also the touch helpers for typing in them (extra
|
||||
// keys, long-press copy). The workspace (workspace.js) holds the tabs.
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
const modBtn = {};
|
||||
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||
function showMods() {
|
||||
for (const m in mods) {
|
||||
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||
}
|
||||
}
|
||||
function useMods() {
|
||||
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||
showMods();
|
||||
return used;
|
||||
}
|
||||
function ctrlChar(c) {
|
||||
if (c === ' ' || c === '@') return '\x00';
|
||||
if (c === '?') return '\x7f';
|
||||
const code = c.toUpperCase().charCodeAt(0);
|
||||
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||
}
|
||||
function withMods(data) {
|
||||
if (!mods.ctrl && !mods.alt) return data;
|
||||
const m = useMods();
|
||||
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||
return m.alt ? '\x1b' + data : data;
|
||||
}
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||
}
|
||||
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||
return m.alt ? '\x1b' + s : s;
|
||||
}
|
||||
let press = null;
|
||||
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||
b.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||
});
|
||||
b.addEventListener('pointerup', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) {
|
||||
if (press && press.mod === mod) {
|
||||
clearTimeout(press.timer);
|
||||
press = null;
|
||||
mods[mod] = mods[mod] ? 0 : 1;
|
||||
showMods();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
// --- Selecting and copying on touch screens ---
|
||||
// xterm.js has no touch selection of its own. A long press selects the word
|
||||
// under the finger and dragging on from there stretches the selection; a
|
||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
if (touch) {
|
||||
const box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||
return { col: col, row: row + term.buffer.active.viewportY };
|
||||
}
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
let a = c.col, b = c.col;
|
||||
if (word(text[c.col])) {
|
||||
while (a > 0 && word(text[a - 1])) a--;
|
||||
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||
}
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(term, c) {
|
||||
const p = c.row * term.cols + c.col;
|
||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||
clearTimeout(timer); // a swipe: let it scroll
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('touchend', function (e) {
|
||||
clearTimeout(timer);
|
||||
if (!selecting) return;
|
||||
selecting = false;
|
||||
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||
e.stopPropagation();
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = s.term.getSelection();
|
||||
hideMenu();
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||
} else if (a === 'paste') {
|
||||
hideMenu();
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
const W = window.Workspace;
|
||||
const touch = W.touch;
|
||||
const statusEl = document.getElementById('status');
|
||||
const base = '/api/panel/term/';
|
||||
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64), u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
// ---------- copy and paste by keys ----------
|
||||
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
|
||||
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
|
||||
// kept from the browser, whose developer tools it would open); Ctrl+V and
|
||||
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
|
||||
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea'), back = document.activeElement;
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
if (back && back.focus) back.focus();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
function clipKeys(s, ev) {
|
||||
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
|
||||
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
|
||||
if (!c && !v) return true;
|
||||
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
|
||||
// From here the key is ours: xterm does not turn it into a control code.
|
||||
if (ev.type === 'keydown' && c) {
|
||||
ev.preventDefault();
|
||||
if (s.term.hasSelection()) {
|
||||
const text = s.term.getSelection();
|
||||
s.term.clearSelection();
|
||||
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
|
||||
}
|
||||
}
|
||||
// Ctrl+V: the browser's paste goes on into the terminal's text field.
|
||||
return false;
|
||||
}
|
||||
function flashStatus(t, cls) {
|
||||
statusEl.textContent = t;
|
||||
statusEl.className = cls;
|
||||
clearTimeout(flashStatus.t);
|
||||
flashStatus.t = setTimeout(W.showStatus, 1500);
|
||||
}
|
||||
|
||||
// ---------- one shell ----------
|
||||
class Session {
|
||||
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||
constructor(agent, ssh) {
|
||||
this.agent = agent;
|
||||
this.ssh = ssh || null;
|
||||
this.kind = ssh ? 'ssh' : 'term';
|
||||
// Where its files are, for dropped paths: the machine, or the SSH host
|
||||
// through it.
|
||||
this.source = ssh
|
||||
? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host}
|
||||
: {kind: 'agent', id: agent.id, label: agent.name};
|
||||
this.sid = null; this.closed = false; this.connected = false;
|
||||
this.state = 'wait'; this.text = 'подключение…';
|
||||
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'pane';
|
||||
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||
'<div class="pane-body"></div>';
|
||||
this.el.querySelector('.pane-title').textContent = who(this);
|
||||
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
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.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
||||
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', () => W.setActivePane(this), true);
|
||||
this.term.textarea.addEventListener('focus', () => W.setActivePane(this));
|
||||
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); };
|
||||
if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this));
|
||||
this.open();
|
||||
}
|
||||
setState(state, text) {
|
||||
this.state = state; this.text = text;
|
||||
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||
const t = W.tabOf(this);
|
||||
if (t) W.renderTab(t);
|
||||
W.showStatus();
|
||||
}
|
||||
async open() {
|
||||
try {
|
||||
let opt = { method: 'POST' };
|
||||
if (this.ssh) {
|
||||
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||
// The password or key goes once, to the agent; the page keeps no copy.
|
||||
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||
}
|
||||
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||
if (!r.ok) {
|
||||
let why = 'HTTP ' + r.status;
|
||||
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||
this.setState('err', 'ошибка открытия: ' + why);
|
||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
this.sid = (await r.json()).session_id;
|
||||
this.setState('wait', 'ожидание агента…');
|
||||
// An agent from before SSH terminals does not answer the request.
|
||||
if (this.ssh) setTimeout(() => {
|
||||
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||
this.setState('err', 'агент не начал SSH-подключение');
|
||||
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||
}
|
||||
}, 20000);
|
||||
this.fit();
|
||||
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||
// dropped WebSocket on for the rest of this session.
|
||||
if (!(await this.openWS())) this.poll();
|
||||
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||
} catch (e) {
|
||||
this.setState('err', 'ошибка сети');
|
||||
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
openWS() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.WebSocket) { resolve(false); return; }
|
||||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
||||
let opened = false;
|
||||
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
||||
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
||||
ws.onmessage = ev => {
|
||||
let j;
|
||||
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
||||
this.take(j);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
if (!opened) { resolve(false); return; }
|
||||
this.ws = null;
|
||||
if (!this.closed && !this.ended) this.poll();
|
||||
};
|
||||
});
|
||||
}
|
||||
// take applies one answer of the output channel, either transport's.
|
||||
take(j) {
|
||||
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||
for (const f of j.frames || []) {
|
||||
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||
if (f.data) {
|
||||
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||
this.term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// sshStatus shows how the agent's SSH connection goes.
|
||||
sshStatus(st, note) {
|
||||
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||
}
|
||||
async poll() {
|
||||
while (!this.closed && this.sid) {
|
||||
let r;
|
||||
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
||||
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
||||
if (this.closed) return;
|
||||
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
||||
if (!this.take(await r.json())) return;
|
||||
}
|
||||
}
|
||||
finish(msg) {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||
// never overtake the one before it; a request that fails goes again under
|
||||
// the same number, and the server drops one it already has.
|
||||
queueIn(m) {
|
||||
if (this.closed || !this.sid) return;
|
||||
const q = this.inQueue, last = q[q.length - 1];
|
||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
||||
else q.push(m);
|
||||
this.pumpIn();
|
||||
}
|
||||
async pumpIn() {
|
||||
if (this.inBusy || !this.inQueue.length) return;
|
||||
this.inBusy = true;
|
||||
while (this.inQueue.length && !this.closed) {
|
||||
const m = this.inQueue.shift();
|
||||
const body = m.t === 'i'
|
||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
||||
// On the WebSocket a message goes out in order with no answer to wait
|
||||
// for; numbered as well, so a repeat by the long polls is dropped.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
||||
}
|
||||
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
||||
let r = null;
|
||||
try {
|
||||
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
} catch (e) { /* no answer: send it again */ }
|
||||
if (r && r.status < 500) break;
|
||||
await new Promise(res => setTimeout(res, wait));
|
||||
}
|
||||
}
|
||||
this.inBusy = false;
|
||||
}
|
||||
send(data) { this.queueIn({ t: 'i', text: data }); }
|
||||
fit() {
|
||||
const b = this.el.querySelector('.pane-body');
|
||||
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
||||
}
|
||||
title() { return this.ssh ? this.ssh.host : this.agent.name; }
|
||||
tooltip() { return who(this); }
|
||||
live() { return !!this.sid && !this.ended && !this.closed; }
|
||||
focus() { this.term.focus(); }
|
||||
dropTarget() { return {kind: 'term', source: this.source}; }
|
||||
// Files dropped here from the same place: their paths, quoted, typed in.
|
||||
drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); }
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
||||
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||
this.term.dispose();
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// who is what a shell is signed in as, for titles.
|
||||
function who(s) {
|
||||
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||
}
|
||||
|
||||
|
||||
W.paneTypes.term = agent => new Session(agent);
|
||||
W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);
|
||||
// For the touch helpers: the terminal being typed in.
|
||||
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
|
||||
})();
|
||||
@@ -1,12 +1,13 @@
|
||||
// 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.
|
||||
// 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 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');
|
||||
@@ -17,248 +18,6 @@
|
||||
const GAP = 6;
|
||||
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64), u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
// ---------- copy and paste by keys ----------
|
||||
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
|
||||
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
|
||||
// kept from the browser, whose developer tools it would open); Ctrl+V and
|
||||
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
|
||||
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea'), back = document.activeElement;
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
if (back && back.focus) back.focus();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
function clipKeys(s, ev) {
|
||||
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
|
||||
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
|
||||
if (!c && !v) return true;
|
||||
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
|
||||
// From here the key is ours: xterm does not turn it into a control code.
|
||||
if (ev.type === 'keydown' && c) {
|
||||
ev.preventDefault();
|
||||
if (s.term.hasSelection()) {
|
||||
const text = s.term.getSelection();
|
||||
s.term.clearSelection();
|
||||
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
|
||||
}
|
||||
}
|
||||
// Ctrl+V: the browser's paste goes on into the terminal's text field.
|
||||
return false;
|
||||
}
|
||||
function flashStatus(t, cls) {
|
||||
statusEl.textContent = t;
|
||||
statusEl.className = cls;
|
||||
clearTimeout(flashStatus.t);
|
||||
flashStatus.t = setTimeout(showStatus, 1500);
|
||||
}
|
||||
|
||||
// ---------- one shell ----------
|
||||
class Session {
|
||||
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||
constructor(agent, ssh) {
|
||||
this.agent = agent;
|
||||
this.ssh = ssh || null;
|
||||
this.sid = null; this.closed = false; this.connected = false;
|
||||
this.state = 'wait'; this.text = 'подключение…';
|
||||
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'pane';
|
||||
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||
'<div class="pane-body"></div>';
|
||||
this.el.querySelector('.pane-title').textContent = who(this);
|
||||
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
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.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
||||
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 {
|
||||
let opt = { method: 'POST' };
|
||||
if (this.ssh) {
|
||||
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||
// The password or key goes once, to the agent; the page keeps no copy.
|
||||
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||
}
|
||||
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||
if (!r.ok) {
|
||||
let why = 'HTTP ' + r.status;
|
||||
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||
this.setState('err', 'ошибка открытия: ' + why);
|
||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
this.sid = (await r.json()).session_id;
|
||||
this.setState('wait', 'ожидание агента…');
|
||||
// An agent from before SSH terminals does not answer the request.
|
||||
if (this.ssh) setTimeout(() => {
|
||||
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||
this.setState('err', 'агент не начал SSH-подключение');
|
||||
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||
}
|
||||
}, 20000);
|
||||
this.fit();
|
||||
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||
// dropped WebSocket on for the rest of this session.
|
||||
if (!(await this.openWS())) this.poll();
|
||||
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||
} catch (e) {
|
||||
this.setState('err', 'ошибка сети');
|
||||
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
openWS() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.WebSocket) { resolve(false); return; }
|
||||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
||||
let opened = false;
|
||||
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
||||
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
||||
ws.onmessage = ev => {
|
||||
let j;
|
||||
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
||||
this.take(j);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
if (!opened) { resolve(false); return; }
|
||||
this.ws = null;
|
||||
if (!this.closed && !this.ended) this.poll();
|
||||
};
|
||||
});
|
||||
}
|
||||
// take applies one answer of the output channel, either transport's.
|
||||
take(j) {
|
||||
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||
for (const f of j.frames || []) {
|
||||
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||
if (f.data) {
|
||||
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||
this.term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// sshStatus shows how the agent's SSH connection goes.
|
||||
sshStatus(st, note) {
|
||||
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||
}
|
||||
async poll() {
|
||||
while (!this.closed && this.sid) {
|
||||
let r;
|
||||
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
||||
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
||||
if (this.closed) return;
|
||||
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
||||
if (!this.take(await r.json())) return;
|
||||
}
|
||||
}
|
||||
finish(msg) {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||
// never overtake the one before it; a request that fails goes again under
|
||||
// the same number, and the server drops one it already has.
|
||||
queueIn(m) {
|
||||
if (this.closed || !this.sid) return;
|
||||
const q = this.inQueue, last = q[q.length - 1];
|
||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
||||
else q.push(m);
|
||||
this.pumpIn();
|
||||
}
|
||||
async pumpIn() {
|
||||
if (this.inBusy || !this.inQueue.length) return;
|
||||
this.inBusy = true;
|
||||
while (this.inQueue.length && !this.closed) {
|
||||
const m = this.inQueue.shift();
|
||||
const body = m.t === 'i'
|
||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
||||
// On the WebSocket a message goes out in order with no answer to wait
|
||||
// for; numbered as well, so a repeat by the long polls is dropped.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
||||
}
|
||||
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
||||
let r = null;
|
||||
try {
|
||||
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
} catch (e) { /* no answer: send it again */ }
|
||||
if (r && r.status < 500) break;
|
||||
await new Promise(res => setTimeout(res, wait));
|
||||
}
|
||||
}
|
||||
this.inBusy = false;
|
||||
}
|
||||
send(data) { this.queueIn({ t: 'i', text: data }); }
|
||||
fit() {
|
||||
const b = this.el.querySelector('.pane-body');
|
||||
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
||||
}
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
||||
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||
this.term.dispose();
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// who is what a shell is signed in as, for titles.
|
||||
function who(s) {
|
||||
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||
}
|
||||
|
||||
// ---------- tabs ----------
|
||||
const tabs = [];
|
||||
let active = null, activePane = null;
|
||||
@@ -288,7 +47,7 @@
|
||||
}
|
||||
function tabTitle(t) {
|
||||
if (t.title) return t.title;
|
||||
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||
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
|
||||
@@ -325,7 +84,7 @@
|
||||
else t.el.querySelector('.badge-ssh').remove();
|
||||
}
|
||||
requestAnimationFrame(fitTabs);
|
||||
t.el.title = t.panes.map(who).join(', ');
|
||||
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');
|
||||
@@ -340,7 +99,7 @@
|
||||
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();
|
||||
if (desk && activePane) activePane.focus();
|
||||
updateMode();
|
||||
}
|
||||
function setActivePane(s) {
|
||||
@@ -350,7 +109,8 @@
|
||||
showStatus();
|
||||
}
|
||||
function showStatus() {
|
||||
if (!activePane) { statusEl.textContent = ''; return; }
|
||||
// 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';
|
||||
@@ -367,11 +127,15 @@
|
||||
document.body.classList.toggle('multi', multi);
|
||||
setTimeout(() => active && layout(active, false), 280);
|
||||
}
|
||||
function openTerminal(agent, ssh) {
|
||||
function openPane(kind, agent, opts) {
|
||||
const make = paneTypes[kind];
|
||||
if (!make) return null;
|
||||
$('empty').hidden = true;
|
||||
const t = makeTab([new Session(agent, ssh)]);
|
||||
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;
|
||||
@@ -666,43 +430,53 @@
|
||||
// ---------- the "+" menu ----------
|
||||
let agentsCache = null;
|
||||
function openMenu(anchor) {
|
||||
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||
const here = activePane && activePane.agent;
|
||||
addMenu.innerHTML = '';
|
||||
const item = (html, fn, cls) => {
|
||||
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;
|
||||
addMenu.appendChild(b);
|
||||
parent.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => { closeMenu(); openTerminal(here); });
|
||||
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here); });
|
||||
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||
other.remove();
|
||||
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';
|
||||
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
addMenu.appendChild(list);
|
||||
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;
|
||||
list.innerHTML = '';
|
||||
all.forEach(a => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
||||
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||
list.appendChild(b);
|
||||
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 (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||
placeMenu(anchor);
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||
.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);
|
||||
@@ -725,10 +499,12 @@
|
||||
|
||||
// ---------- 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 sshDlg = null;
|
||||
function openSSH(here) {
|
||||
let 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).
|
||||
function openSSH(here, mode) {
|
||||
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||
sshDlg.show(here);
|
||||
sshDlg.show(here, mode || 'term');
|
||||
}
|
||||
function makeSSHDialog() {
|
||||
const d = document.createElement('dialog');
|
||||
@@ -798,14 +574,29 @@
|
||||
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).then(r => {
|
||||
btn.disabled = false;
|
||||
if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; }
|
||||
msg.textContent = '';
|
||||
close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
close(() => openTerminal(a, t));
|
||||
};
|
||||
return {
|
||||
show(here) {
|
||||
show(here, mode) {
|
||||
sshMode = mode;
|
||||
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];
|
||||
agents = here ? [here] : [];
|
||||
const fill = () => {
|
||||
f.agent.innerHTML = '';
|
||||
agents.forEach(a => {
|
||||
@@ -814,12 +605,12 @@
|
||||
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.id;
|
||||
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 (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
fill();
|
||||
}).catch(() => {});
|
||||
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
|
||||
@@ -851,8 +642,25 @@
|
||||
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
|
||||
});
|
||||
|
||||
// For the touch helpers in the page: the terminal being typed in.
|
||||
window.TermPane = { active: () => activePane };
|
||||
openTerminal(PAGE.agent);
|
||||
refit();
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
};
|
||||
})();
|
||||
@@ -9,12 +9,58 @@
|
||||
{{template "topbar" .}}
|
||||
<main id="app" data-view="{{.View}}">
|
||||
<section class="view" data-view="machines"{{if ne .View "dashboard"}} hidden{{end}}>{{if eq .View "dashboard"}}{{.Body}}{{end}}</section>
|
||||
<section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}></section>
|
||||
<section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
|
||||
</main>
|
||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}};</script>
|
||||
<script src="{{asset "wsutil.js"}}"></script>
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script src="{{asset "app.js"}}"></script>
|
||||
<script src="{{asset "workspace.js"}}"></script>
|
||||
<script src="{{asset "termpane.js"}}"></script>
|
||||
<script src="{{asset "pages.js"}}"></script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||
<td class="actions">
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-open="term" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Терминал" aria-label="Терминал">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы">
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-open="files" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Файлы" aria-label="Файлы">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты">
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||
dialog.sshdlg { max-width:460px; }
|
||||
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||
.sshdlg .frow .port { width:92px; flex:none; }
|
||||
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||
.sshdlg .auth[hidden] { display:none; }
|
||||
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||
transition:background .18s, color .18s; }
|
||||
.sshdlg .seg button:hover { color:var(--text); }
|
||||
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
header.topbar { flex:none; }
|
||||
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||
pointer-events:none; transition:left .12s ease; }
|
||||
.tabmark[hidden] { display:none; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||
/* The terminals' area. */
|
||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
|
||||
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
|
||||
body.split1 .float-add { top:42px; right:16px; }
|
||||
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||
.pane-head { display:none; }
|
||||
.tabview.split .pane { border:1px solid var(--border); }
|
||||
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||
/* A lone terminal's scrollbar sits in the margin by the frame, right of
|
||||
the corner "+", not over the text. */
|
||||
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
|
||||
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
|
||||
.pane.dragging { opacity:.45; }
|
||||
.gutter { position:absolute; z-index:3; }
|
||||
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||
.hint[hidden] { display:none; }
|
||||
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||
.empty[hidden] { display:none; }
|
||||
/* The "+" menu: a terminal here, or on another machine. */
|
||||
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||
animation:menu-in .14s ease; }
|
||||
.addmenu[hidden] { display:none; }
|
||||
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||
background:transparent; color:var(--text); font-weight:400; }
|
||||
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||
smaller, and there is no splitting. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||
body.touch .area { margin:6px; }
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.keys button {
|
||||
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.keys button:active { background:var(--border); }
|
||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||
/* Copy and paste on touch screens: a long press selects, this menu acts. */
|
||||
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||||
.selmenu[hidden] { display:none; }
|
||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script>
|
||||
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
const modBtn = {};
|
||||
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||
function showMods() {
|
||||
for (const m in mods) {
|
||||
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||
}
|
||||
}
|
||||
function useMods() {
|
||||
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||
showMods();
|
||||
return used;
|
||||
}
|
||||
function ctrlChar(c) {
|
||||
if (c === ' ' || c === '@') return '\x00';
|
||||
if (c === '?') return '\x7f';
|
||||
const code = c.toUpperCase().charCodeAt(0);
|
||||
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||
}
|
||||
function withMods(data) {
|
||||
if (!mods.ctrl && !mods.alt) return data;
|
||||
const m = useMods();
|
||||
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||
return m.alt ? '\x1b' + data : data;
|
||||
}
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||
}
|
||||
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||
return m.alt ? '\x1b' + s : s;
|
||||
}
|
||||
let press = null;
|
||||
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||
b.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||
});
|
||||
b.addEventListener('pointerup', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) {
|
||||
if (press && press.mod === mod) {
|
||||
clearTimeout(press.timer);
|
||||
press = null;
|
||||
mods[mod] = mods[mod] ? 0 : 1;
|
||||
showMods();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
// --- Selecting and copying on touch screens ---
|
||||
// xterm.js has no touch selection of its own. A long press selects the word
|
||||
// under the finger and dragging on from there stretches the selection; a
|
||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
if (touch) {
|
||||
const box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||
return { col: col, row: row + term.buffer.active.viewportY };
|
||||
}
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
let a = c.col, b = c.col;
|
||||
if (word(text[c.col])) {
|
||||
while (a > 0 && word(text[a - 1])) a--;
|
||||
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||
}
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(term, c) {
|
||||
const p = c.row * term.cols + c.col;
|
||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||
clearTimeout(timer); // a swipe: let it scroll
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('touchend', function (e) {
|
||||
clearTimeout(timer);
|
||||
if (!selecting) return;
|
||||
selecting = false;
|
||||
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||
e.stopPropagation();
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = s.term.getSelection();
|
||||
hideMenu();
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||
} else if (a === 'paste') {
|
||||
hideMenu();
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "terminal.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user