395 lines
24 KiB
JavaScript
395 lines
24 KiB
JavaScript
// 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 = '<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>';
|
||
|
||
// Monochrome inline icons (inherit button color via currentColor).
|
||
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
|
||
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
|
||
const ICON_ZIP = SVG + '<path d="M21 8v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"/><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M10 12h4"/></svg>';
|
||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||
// File icons: one colour, the line style of the buttons; a few kinds by
|
||
// the name's ending.
|
||
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
|
||
const FICONS = {
|
||
dir: FI + '<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>',
|
||
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
|
||
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
|
||
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
|
||
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
|
||
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
|
||
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
|
||
};
|
||
function fileIcon(e) {
|
||
let k = 'file';
|
||
if (e.is_dir) k = 'dir';
|
||
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|conf|xml)$/i.test(e.name)) k = 'code';
|
||
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
|
||
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
|
||
}
|
||
|
||
// A machine's files: the panel's file API of that machine.
|
||
class MachineSource {
|
||
constructor(agent) { this.agent = agent; this.caps = {zip: 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 {
|
||
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);
|
||
|
||
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 = '/';
|
||
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 =
|
||
'<div class="pane-head"><span class="dot wait"></span>' + (this.ssh ? '<span class="badge-ssh">SSH</span>' : '') +
|
||
'<span class="pane-title"></span><button type="button" class="pane-x" title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||
'<div class="fpane-body">' +
|
||
'<div class="fm-bar">' +
|
||
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>' +
|
||
' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
|
||
' <button type="button" class="btn ghost small icon sq fp-dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button></div>' +
|
||
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span><span class="fp-uppct"></span></button>' +
|
||
' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><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="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
|
||
'</div>' +
|
||
'<div class="fm-bar">' +
|
||
' <button class="btn small ico fp-mkdir" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>' +
|
||
' <button class="btn small ico fp-newfile" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>' +
|
||
' <label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" class="fp-upl" multiple hidden></label>' +
|
||
// What is done to the ticked rows: two small icons on the right,
|
||
// their words on hover, the count beside them.
|
||
' <span class="spacer"></span>' +
|
||
' <span class="fp-sel"><span class="muted fp-selinfo"></span>' +
|
||
' <button class="btn ghost small icon sq fp-zip" type="button" disabled title="В zip" aria-label="В zip">' + ICON_ZIP + '</button>' +
|
||
' <button class="btn danger small icon sq fp-delsel" type="button" disabled title="Удалить выбранное" aria-label="Удалить выбранное">' + ICON_DEL + '</button></span>' +
|
||
'</div>' +
|
||
'<div class="card fp-drop" style="padding:0"><table class="fm"><thead><tr>' +
|
||
' <th style="width:28px"><input type="checkbox" class="fp-all" aria-label="Выбрать все"></th>' +
|
||
' <th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>' +
|
||
' <th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>' +
|
||
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
|
||
'<p class="muted fp-msg" hidden></p>' +
|
||
'</div>';
|
||
if (!this.src.caps.zip) this.q('.fp-zip').hidden = true;
|
||
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);
|
||
}).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(text) {
|
||
const m = this.q('.fp-msg');
|
||
m.hidden = !text;
|
||
m.textContent = text || '';
|
||
}
|
||
|
||
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 r = await this.api('list', {path: this.src.home()});
|
||
if (r.ok) this.show(r.data); else this.go('/');
|
||
}
|
||
show(d) {
|
||
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
|
||
this.q('.fp-path').value = this.cwd;
|
||
this.message('');
|
||
this.render();
|
||
this.setState('ok', this.cwd);
|
||
}
|
||
async go(path) {
|
||
const r = await this.api('list', {path});
|
||
if (!r.ok) {
|
||
toast(r.error || 'ошибка', true);
|
||
if (/офлайн|нет доступа/.test(r.error || '')) { this.message(r.error); this.setState('err', r.error); }
|
||
this.q('.fp-path').value = this.cwd;
|
||
return false;
|
||
}
|
||
this.show(r.data);
|
||
return true;
|
||
}
|
||
reload() { return this.go(this.cwd); }
|
||
|
||
// 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');
|
||
if (e.is_dir) tr.dataset.dir = e.name;
|
||
tr.dataset.name = e.name;
|
||
const isZip = this.src.caps.zip && !e.is_dir && /\.zip$/i.test(e.name);
|
||
tr.innerHTML =
|
||
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
|
||
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
|
||
'<td class="sz c-sz">' + (e.is_dir ? '' : fmtSize(e.size)) + '</td>' +
|
||
'<td class="sz mt">' + fmtTime(e.mtime) + '</td>' +
|
||
'<td><div class="act">' +
|
||
(isZip ? '<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">' + ICON_UNZIP + '</button>' : '') +
|
||
(e.is_dir ? '' : '<a class="btn ghost small icon" title="Скачать" download>' + ICON_DL + '</a>') +
|
||
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">' + ICON_MV + '</button>' +
|
||
'<button class="btn danger small icon" data-act="del" title="Удалить">' + ICON_DEL + '</button>' +
|
||
'</div></td>';
|
||
const dl = tr.querySelector('a[download]');
|
||
if (dl) dl.href = this.src.url('download', join(this.cwd, e.name));
|
||
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
|
||
tr.querySelector('.ck').checked = this.selected.has(e.name);
|
||
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
|
||
tr.querySelector('[data-act="del"]').onclick = () => FV.del(this, e);
|
||
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
|
||
const uz = tr.querySelector('[data-act="unzip"]');
|
||
if (uz) uz.onclick = () => this.unzip(e);
|
||
if (window.FileDnD) tr.addEventListener('pointerdown', ev => FileDnD.start(ev, this, tr));
|
||
tb.appendChild(tr);
|
||
}
|
||
}
|
||
updateSel() {
|
||
const n = this.selected.size;
|
||
this.q('.fp-selinfo').textContent = n ? 'Выбрано: ' + n : '';
|
||
this.q('.fp-zip').disabled = !n;
|
||
this.q('.fp-delsel').disabled = !n;
|
||
}
|
||
async unzip(e) {
|
||
const r = await this.api('unzip', {path: join(this.cwd, e.name), dest: this.cwd});
|
||
r.ok ? (toast('распаковано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||
}
|
||
|
||
bind() {
|
||
const q = s => this.q(s);
|
||
q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); };
|
||
q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); });
|
||
this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true);
|
||
q('.fp-up').onclick = () => this.go(parent(this.cwd));
|
||
q('.fp-refresh').onclick = () => this.reload();
|
||
q('.fp-dirprops').onclick = () => FV.props(this, this.cwd);
|
||
q('.fp-path').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); this.go((q('.fp-path').value || '').trim() || '/'); }
|
||
});
|
||
this.el.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
|
||
const s = this.getSort(), by = th.dataset.sort;
|
||
// A new column starts the way it is most often wanted: names A to Z,
|
||
// sizes and times biggest and newest first.
|
||
this.setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
|
||
this.render();
|
||
});
|
||
q('.fp-mkdir').onclick = async () => {
|
||
const n = prompt('Имя новой папки:'); if (!n) return;
|
||
const r = await this.api('mkdir', {path: join(this.cwd, n)});
|
||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||
};
|
||
q('.fp-newfile').onclick = async () => {
|
||
const n = prompt('Имя нового файла:'); if (!n) return;
|
||
const r = await this.api('write', {path: join(this.cwd, n), data: ''});
|
||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||
};
|
||
q('.fp-delsel').onclick = async () => {
|
||
if (!confirm('Удалить выбранное (' + this.selected.size + ')?')) return;
|
||
for (const n of this.selected) {
|
||
const e = this.entries.find(x => x.name === n);
|
||
await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)});
|
||
}
|
||
toast('удалено'); this.reload();
|
||
};
|
||
q('.fp-zip').onclick = async () => {
|
||
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||
const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)});
|
||
r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true);
|
||
};
|
||
q('.fp-all').onchange = ev => {
|
||
this.selected.clear();
|
||
if (ev.target.checked) this.entries.forEach(e => this.selected.add(e.name));
|
||
this.el.querySelectorAll('.fp-list .ck').forEach(c => { c.checked = ev.target.checked; });
|
||
this.updateSel();
|
||
};
|
||
q('.fp-busy').onclick = () => { if (this.uploads.length) FV.uploads(this); };
|
||
q('.fp-upl').onchange = ev => { this.upload(ev.target.files); ev.target.value = ''; };
|
||
const drop = q('.fp-drop');
|
||
// Files from the computer dropped on the list go up here.
|
||
const fromOS = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||
['dragenter', 'dragover'].forEach(x => drop.addEventListener(x, e => { if (!fromOS(e)) return; e.preventDefault(); drop.classList.add('drag'); }));
|
||
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
||
drop.addEventListener('drop', e => {
|
||
if (!fromOS(e)) return;
|
||
e.preventDefault();
|
||
drop.classList.remove('drag');
|
||
if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
|
||
});
|
||
}
|
||
|
||
// Uploads: one file at a time, each with its progress. What has gone up
|
||
// shows as a percentage by the spinner; a click on it lists the files.
|
||
renderUploads() {
|
||
const ups = this.uploads;
|
||
const pend = ups.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
|
||
const counted = ups.filter(u => !u.skip);
|
||
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
|
||
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
|
||
const busy = this.q('.fp-busy'), pct = this.q('.fp-uppct');
|
||
busy.classList.toggle('up', ups.length > 0);
|
||
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
|
||
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
||
pct.classList.toggle('on', pend.length > 0);
|
||
FV.uploadsChanged(this);
|
||
}
|
||
async upload(files) {
|
||
clearTimeout(this.upDoneTimer);
|
||
// A new batch starts a fresh list once the last one is over.
|
||
if (!this.upActive) this.uploads.length = 0;
|
||
const dir = this.cwd;
|
||
const batch = [...files].map(f => f.size > UP_CAP
|
||
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
|
||
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
|
||
this.uploads.push(...batch);
|
||
this.renderUploads();
|
||
if (this.upActive) return;
|
||
this.upActive = true;
|
||
this.setBusy(true);
|
||
let failed = 0;
|
||
for (let u; (u = this.uploads.find(x => x.state === 'wait')); ) {
|
||
u.state = 'send'; this.renderUploads();
|
||
const r = await FV.sendFile(this.src, join(u.dir, u.name), u.file, frac => {
|
||
u.frac = frac;
|
||
if (frac >= 1 && u.state === 'send') u.state = 'write';
|
||
this.renderUploads();
|
||
});
|
||
if (r.ok) u.state = 'done';
|
||
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
|
||
this.renderUploads();
|
||
if (u.dir === this.cwd) this.reload();
|
||
}
|
||
this.setBusy(false);
|
||
this.upActive = false;
|
||
failed += this.uploads.filter(u => u.skip).length;
|
||
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
|
||
this.renderUploads();
|
||
// The list stays a while for a look, then the indicator goes quiet.
|
||
this.upDoneTimer = setTimeout(() => { if (!this.upActive) { this.uploads.length = 0; this.renderUploads(); } }, failed ? 30000 : 8000);
|
||
}
|
||
}
|
||
|
||
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: 'ошибка сети'}));
|
||
})();
|