// File panes of the workspace: a folder of a machine - or of an SSH host
// through one - as a list to go through, with uploads, new folders and files,
// zip and delete. Each pane keeps its own folder, list and selection; the
// dialogs (viewer, properties, move) are shared and live in fileviews.js.
// A pane's data source does the work: MachineSource here, SSHSource for
// files over SSH.
(function () {
'use strict';
const FV = window.FileViews;
const {toast, join, parent, fmtSize, fmtTime, esc} = FV;
const X = '';
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '';
const ICON_MV = SVG + '';
const ICON_UNZIP = SVG + '';
const ICON_ZIP = SVG + '';
const ICON_DEL = SVG + '';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '',
file: FI + '',
text: FI + '',
code: FI + '',
img: FI + '',
arc: FI + '',
media:FI + '',
dirlink: FI + '',
link: FI + ''
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = e.link ? 'dirlink' : 'dir'; // a link to a folder opens like one
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '' + FICONS[k] + '';
}
// A machine's files: the panel's file API of that machine.
class MachineSource {
constructor(agent) { this.agent = agent; this.caps = {zip: true, unzip: true, sqlite: true}; }
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + 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.agent.user || ''); }
close() {}
}
// An SSH host's files, through the machine that holds the connection.
class SSHSource {
// caps: zip and unzip where the host has the commands.
constructor(agent, conn, home, caps) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = Object.assign({zip: false, unzip: false, sqlite: false}, caps); }
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);
class FilePane {
// o: {kind: 'agent'|'ssh', agent, src} - src made by the caller for SSH.
constructor(o) {
this.agent = o.agent;
this.src = o.src || new MachineSource(o.agent);
this.kind = o.kind === 'ssh' ? 'sshfiles' : 'files';
this.ssh = o.ssh || null;
this.source = o.kind === 'ssh'
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
this.cwd = '/';
// startDir: the folder to open first (or a promise of it), not home.
this.startDir = o.dir || '';
this.entries = [];
this.selected = new Set();
this.uploads = [];
this.upActive = false;
this.busyN = 0;
this.state = 'wait';
this.text = 'загрузка…';
this.el = document.createElement('div');
this.el.className = 'pane fpane';
this.el.innerHTML =
'
' + (this.ssh ? 'SSH' : '') +
'
' +
'
' +
'
' +
' ' +
'
' +
'
' +
' ' +
' ' +
'
' +
'
' +
' ' +
' ' +
' ' +
// What is done to the ticked rows: two small icons on the right,
// their words on hover, the count beside them.
' ' +
' ' +
' ' +
' ' +
'
' +
'
' +
'
' +
'
Имя
Размер
' +
'
Изменён
' +
'
' +
'' +
'
';
this.q('.fp-zip').hidden = !this.src.caps.zip;
this.bind();
// Narrow panes (a split tab) fold like the phone layout.
// A mid-width pane gives the time's room to the names.
if (window.ResizeObserver) new ResizeObserver(() => {
const w = this.el.clientWidth;
this.el.classList.toggle('narrow', w < 520);
this.el.classList.toggle('mid', w < 760);
this.fitSel();
}).observe(this.el);
this.start();
}
q(sel) { return this.el.querySelector(sel); }
// ---- the pane interface of the workspace ----
title() { return (this.ssh ? this.ssh.host : this.agent.name) + ' · ' + (this.cwd === '/' ? '/' : this.cwd.replace(/\/+$/, '').split('/').pop()); }
tooltip() { return (this.ssh ? this.source.label : this.agent.name) + ':' + this.cwd; }
live() { return false; }
fit() {}
focus() { this.q('.fp-path').focus({preventScroll: true}); }
close() { this.src.close(); this.el.remove(); }
selectedPaths() { return [...this.selected].map(n => join(this.cwd, n)); }
// dropTarget: a folder row under the point, else this folder.
dropTarget(x, y) {
const el = document.elementFromPoint(x, y);
const tr = el && el.closest && el.closest('tr[data-dir]');
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this, row: tr};
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
}
setState(state, text) {
this.state = state; this.text = text;
this.q('.pane-head .dot').className = 'dot ' + state;
this.q('.pane-title').textContent = this.title();
this.q('.pane-title').title = this.tooltip();
if (window.Workspace) Workspace.renderPane(this);
}
setBusy(on) {
this.busyN = Math.max(0, this.busyN + (on ? 1 : -1));
this.q('.fp-busy').classList.toggle('on', this.busyN > 0);
}
async api(op, body) {
this.setBusy(true);
try { return await this.src.op(op, body); } finally { this.setBusy(false); }
}
// message: a line over the list - why it is empty - with a button to
// do something about it.
message(text, label, fn) {
const m = this.q('.fp-msg');
m.hidden = !text;
m.textContent = text || '';
if (label) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'btn ghost small'; b.textContent = label;
b.onclick = fn;
m.append(' ', b);
}
}
// trouble: the machine offline, the access gone or the SSH connection
// lost - the pane says so and offers the way back.
trouble(err) {
// A machine that just went away first times out, then is offline.
this.offline = /офлайн|таймаут ожидания агента/.test(err);
if (this.offline) this.message((/офлайн/.test(err) ? 'Машина офлайн' : 'Машина не отвечает') + '. Панель обновится сама, когда машина вернётся.', 'Повторить', () => this.reload());
else if (/нет доступа/.test(err)) this.message('Нет доступа к машине.');
else if (this.ssh && /подключение (к хосту потеряно|закрыто)/.test(err)) this.message(err, 'Переподключиться', () => Workspace.openSSH(this.agent, 'files', this));
else return false;
this.entries = []; this.selected.clear(); this.render();
this.setState('err', err);
return true;
}
async start() {
// Start in the home folder of the machine's user (or the SSH home);
// a listing that fails there falls back to the root.
const dir = await this.startDir;
this.startDir = '';
if (dir) {
const d = await this.api('list', {path: dir});
if (d.ok) { this.show(d.data); return; }
}
const r = await this.api('list', {path: this.src.home()});
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
}
show(d) {
this.offline = false; this.loaded = true;
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
this.q('.fp-path').value = this.cwd;
this.message('');
this.render();
// The status says whether it is connected, as a terminal's does; the
// folder is in the pane itself.
this.setState('ok', this.ssh ? 'SSH: ' + this.source.label + ':' + (this.ssh.port || 22) : 'подключено');
}
async go(path) {
const r = await this.api('list', {path});
if (!r.ok) {
if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
return false;
}
this.show(r.data);
return true;
}
// Never listed yet (offline from the start): from the home folder.
reload() { return this.loaded ? this.go(this.cwd) : this.start(); }
// Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each place.
sortKey() { return 'lpra.sort:' + this.source.id + ':' + this.cwd; }
getSort() {
try { const v = JSON.parse(localStorage.getItem(this.sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) { /* default */ }
return SORT_DEF;
}
setSort(v) {
try {
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(this.sortKey());
else localStorage.setItem(this.sortKey(), JSON.stringify(v));
} catch (e) { /* not kept */ }
}
sorted() {
const s = this.getSort();
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
return this.entries.slice().sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
const d = key ? key(a) - key(b) : 0;
return (d || byName(a, b)) * s.dir;
});
}
render() {
const tb = this.q('.fp-list');
tb.innerHTML = '';
this.updateSel();
for (const e of this.sorted()) {
const tr = document.createElement('tr');
tr.draggable = false;
tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); });
if (e.is_dir) tr.dataset.dir = e.name;
tr.dataset.name = e.name;
const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
tr.innerHTML =
'