feat: file manager on SSH hosts through a machine - open from the plus menu

This commit is contained in:
redtea committed 2026-10-15 21:32:18 +03:00
1 parent 2859e69abe
commit 7e4daeda4c
8 files changed
+202 -11

No files matched your search

+3 -2
View File
@@ -757,5 +757,6 @@ p.mv-a { margin:0; font-size:13px; }
/* The pane: its bar and list scroll inside it. */
.fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; }
.tabview:not(.split) .fpane-body { padding:12px 16px 16px; }
/* A lone file pane: its first bar keeps clear of the corner "+". */
.tabview:not(.split) .fpane .fm-bar:first-child, body.split1 .fpane .fm-bar:first-child { margin-right:40px; }
/* The file pane under the corner "+" keeps its first bar clear of it,
while the "+" is there (one tab; with the tab strip it is hidden). */
body:not(.multi) .fpane.under-plus .fm-bar:first-child { margin-right:40px; }
+24
View File
@@ -55,6 +55,21 @@
close() {}
}
// An SSH host's files, through the machine that holds the connection.
class SSHSource {
constructor(agent, conn, home) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = {zip: false, sqlite: false}; }
base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; }
url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch(this.base() + name, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
}
home() { return this.homeDir; }
// The pane closing lets the machine drop the connection at once.
close() { fetch(this.base() + 'close', {method: 'POST', keepalive: true, credentials: 'same-origin'}).catch(() => {}); }
}
const UP_CAP = 512 * 1024 * 1024;
const SORT_DEF = {by: 'name', dir: 1};
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
@@ -366,4 +381,13 @@
window.FilePane = FilePane;
window.MachineSource = MachineSource;
Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent});
Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home)});
// sshFiles connects agent to the SSH host t, then opens its files in a
// pane; the password or key goes to the machine once and is not kept here.
Workspace.sshFiles = (agent, t) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => {
if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, ssh: {host: t.host, port: t.port, user: t.user}});
return r;
}).catch(() => ({ok: false, error: 'ошибка сети'}));
})();
+4 -1
View File
@@ -269,7 +269,7 @@
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return;
}
if (/^(db|sqlite3?|db3)$/.test(ext)) {
if (/^(db|sqlite3?|db3)$/.test(ext) && cur.src.caps.sqlite) {
// The agent opens the database and edits it; one that predates that
// gets the file read here, for viewing only.
box.innerHTML = '<p class="dv-note">загрузка…</p>';
@@ -858,6 +858,9 @@
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
setOp('move');
if (!M.job) { busy(false); setRemote(false); }
// From an SSH host the file goes elsewhere by dragging it to a pane;
// this dialog sends a machine's files only.
$('mv-net').hidden = cur.kind === 'sshfiles';
$('mv-dir').value = cur.cwd === '/' ? '/' : cur.cwd + '/';
$('mv-name').value = e.name;
$('mv-conflict').value = 'error';
+10 -1
View File
@@ -198,10 +198,15 @@
if (!W || !H) return;
const L = rects(t.layout, t.c, t.r, W, H);
t.view.classList.toggle('instant', !animate);
// The pane in the top right corner sits under the corner "+": it keeps
// its first bar clear of it.
let corner = 0;
L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; });
t.panes.forEach((s, i) => {
const p = L.panes[i];
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
s.el.classList.toggle('under-plus', i === corner);
});
while (t.gutters.length < L.gutters.length) {
const g = document.createElement('div');
@@ -639,7 +644,11 @@
window.visualViewport.addEventListener('scroll', refit);
}
window.addEventListener('pagehide', () => {
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
tabs.forEach(t => t.panes.forEach(s => {
if (s.sid) navigator.sendBeacon(base + s.sid + '/close');
// A file pane on an SSH host lets the machine drop its connection.
if (s.src && s.src.close) s.src.close();
}));
});
// The view coming into sight: fit the panes, show the status line.