feat: file manager as a workspace pane, side by side with terminals; zip and delete of the selection as icons

This commit is contained in:
redtea committed 2026-10-15 21:24:10 +03:00
1 parent 012c0af7f9
commit f5d6bd30e8
8 files changed
+1782 -1689

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
// 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() {}
}
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 ico fp-up" 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="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 fp-refresh" type="button">Обновить</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};
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);
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});
})();