Files

1016 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The dialogs of the file panes: the viewer and editor, the hex editor,
// properties, moving and copying, the list of uploads. One of each on the
// page; each works for the pane it was opened from (cur), whose data source
// - a machine, or an SSH host through one - does the work.
(function () {
'use strict';
let cur = null; // the pane the open dialog works for
const api = (op, body) => cur.src.op(op, body);
const EDIT_LIMIT = (window.APP && window.APP.editLimit) || 4 << 20;
const $ = id => document.getElementById(id);
let toastTimer = null;
// Opens a dialog with the focus on the dialog itself, not its first button.
const openModal = d => { d.showModal(); d.focus(); };
const toast = (msg, err) => {
const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body;
if (t.parentNode !== host) host.appendChild(t);
t.textContent = msg;
t.className = 'toast show' + (err ? ' err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.className = 'toast', err ? 3500 : 2200);
};
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// Sends a file to the client in pieces: each a request of its own (a proxy
// in front takes only so much at once), a few in flight together so a good
// link stays busy, and one the link drops is sent again alone. onFrac gets
// how much has gone; the answer is the client's, once it has written it.
const SEND_WORKERS = 4;
async function sendFile(src, path, blob, onFrac) {
let st;
try {
st = await fetch(src.url('upload', path) + '&size=' + blob.size, {method: 'POST'}).then(r => r.json());
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
if (!st.ok) return st;
const {id, chunk} = st.data;
const xurl = '/api/panel/fs/xfer/' + encodeURIComponent(id);
const n = Math.max(1, Math.ceil(blob.size / chunk));
const sent = new Array(n).fill(0);
const report = () => onFrac && onFrac(blob.size ? Math.min(1, sent.reduce((a, b) => a + b, 0) / blob.size) : 1);
let next = 0, failed = null;
const putPiece = (i, part) => new Promise(resolve => {
const x = new XMLHttpRequest();
x.open('PUT', xurl + '/' + i);
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = ev => { sent[i] = ev.loaded; report(); };
x.onload = () => resolve({status: x.status, text: x.responseText});
x.onerror = x.ontimeout = () => resolve({status: 0});
x.timeout = 5 * 60 * 1000;
x.send(part);
});
async function worker() {
while (!failed && next < n) {
const i = next++, part = blob.slice(i * chunk, (i + 1) * chunk);
for (let tries = 0; !failed; ) {
const r = await putPiece(i, part);
if (r.status === 204) { sent[i] = part.size; report(); break; }
if (r.status === 410 || r.status === 400) { failed = (r.text || '').trim() || 'передача прервана'; break; }
sent[i] = 0; report();
if (r.status === 503) continue; // the client is busy: just again
if (++tries >= 30) { failed = 'сеть не держит передачу'; break; }
await new Promise(res => setTimeout(res, Math.min(tries, 10) * 1000));
}
}
}
await Promise.all(Array.from({length: Math.min(SEND_WORKERS, n)}, worker));
if (failed) {
fetch(xurl + '/cancel', {method: 'POST'}).catch(() => {});
return {ok: false, error: failed};
}
for (let tries = 0; ; ) {
try {
const r = await fetch(xurl + '/result', {cache: 'no-store'}).then(r => r.json());
if (r.done) return r.result;
tries = 0;
} catch (e) {
if (++tries >= 30) return {ok: false, error: 'нет ответа о записи файла'};
await new Promise(res => setTimeout(res, 2000));
}
}
}
// Writes a file as it is, with no base64: the request body is the file.
async function putFile(path, body, pane = cur) {
// The save button turns into a small spinner meanwhile, and takes no
// second click.
const btn = $('save');
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
btn.classList.add('saving');
btn.disabled = true;
pane.setBusy(true);
try {
return await sendFile(pane.src, path, new Blob([body]));
} finally { pane.setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
}
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?|db|sqlite3?|db3)$/i;
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
// Highlighting by the file's name, then its extension, then its #! line.
const BY_EXT = {
py:'python', pyw:'python', go:'go', c:'text/x-csrc', h:'text/x-csrc', cc:'text/x-c++src', cpp:'text/x-c++src', cxx:'text/x-c++src',
hpp:'text/x-c++src', hh:'text/x-c++src', java:'text/x-java', kt:'text/x-kotlin', kts:'text/x-kotlin', cs:'text/x-csharp', scala:'text/x-scala',
js:'javascript', mjs:'javascript', cjs:'javascript', jsx:'javascript', ts:'text/typescript', tsx:'text/typescript', json:'application/json',
css:'css', scss:'text/x-scss', less:'text/x-less', html:'htmlmixed', htm:'htmlmixed', vue:'htmlmixed', xml:'xml', svg:'xml', plist:'xml', xsd:'xml',
php:'php', sh:'shell', bash:'shell', zsh:'shell', ksh:'shell', yaml:'yaml', yml:'yaml', md:'markdown', markdown:'markdown', sql:'sql',
toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties', ovpn:'properties',
service:'properties', timer:'properties', socket:'properties', mount:'properties', lua:'lua', rs:'rust', diff:'diff', patch:'diff',
rb:'ruby', pl:'perl', pm:'perl', cmake:'cmake'
};
const BY_NAME = {
'dockerfile':'dockerfile', 'containerfile':'dockerfile', 'makefile':'shell', '.bashrc':'shell', '.bash_profile':'shell', '.profile':'shell',
'.zshrc':'shell', '.zprofile':'shell', '.env':'properties', 'cmakelists.txt':'cmake', 'gemfile':'ruby', 'rakefile':'ruby',
'nginx.conf':'nginx', 'go.mod':'go', 'requirements.txt':'properties'
};
function modeFor(path, text) {
const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase();
if (BY_NAME[name]) return BY_NAME[name];
if (/^dockerfile\./.test(name)) return 'dockerfile';
if (/\/nginx\//.test(path) && /\.conf$/.test(name)) return 'nginx';
const dot = name.lastIndexOf('.');
const ext = dot > 0 ? name.slice(dot + 1) : '';
if (BY_EXT[ext]) return BY_EXT[ext];
const bang = /^#!.*?\b(\w+)\s*$/.exec(text.slice(0, 200).split('\n')[0]);
if (bang) return {sh:'shell', bash:'shell', zsh:'shell', dash:'shell', python:'python', python3:'python', node:'javascript', ruby:'ruby', perl:'perl'}[bang[1]] || null;
return null;
}
const modeName = m => !m ? 'текст' : m.replace(/^(text|application)\/(x-)?/, '').replace('c++src', 'c++').replace('csrc', 'c');
let cm = null;
function editor() {
if (cm) return cm;
cm = CodeMirror($('vcode'), {
theme: 'lpra', lineNumbers: true, matchBrackets: true, indentUnit: 4, tabSize: 4,
extraKeys: {
'Ctrl-F': () => openFind(), 'Cmd-F': () => openFind(),
'Ctrl-H': () => openFind(true), 'Cmd-Alt-F': () => openFind(true), 'Shift-Ctrl-F': () => openFind(true),
'Ctrl-S': () => $('save').click(), 'Cmd-S': () => $('save').click(),
'Ctrl-G': () => findStep(1), 'Cmd-G': () => findStep(1), 'Shift-Ctrl-G': () => findStep(-1), 'Shift-Cmd-G': () => findStep(-1),
'Tab': c => c.somethingSelected() ? c.indentSelection('add') : c.replaceSelection(c.getOption('indentWithTabs') ? '\t' : ' '.repeat(c.getOption('indentUnit')))
}
});
cm.on('changes', () => { V.dirty = !cm.isClean(V.clean); if (F.open) refreshFind(); });
return cm;
}
// What the viewer has open now.
const V = { path: '', kind: '', dirty: false, clean: 0 };
function showPane(which) {
for (const [id, k] of [['vcode', 'code'], ['vmedia', 'media'], ['vhexed', 'hex'], ['vdoc', 'doc']]) $(id).hidden = which !== k;
if (which !== 'doc') $('vdoc').innerHTML = '';
if (which !== 'code') closeFind();
}
function setButtons(o) {
$('vtext').hidden = !o.text; $('vhex').hidden = !o.hex; $('save').hidden = !o.save;
}
function openViewer(path) {
V.path = path; V.dirty = false;
$('vname').textContent = path;
$('vname').parentElement.title = path;
if (!$('viewer').open) openModal($('viewer'));
}
let viewSeq = 0;
async function view(e) {
const pane = cur, seq = ++viewSeq, path = join(pane.cwd, e.name);
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size, pane, seq);
if (DOC.test(e.name)) return viewDoc(path, e.size, pane, seq);
openViewer(path);
V.kind = 'loading';
// The last file's text goes at once: an empty page while this one comes.
const c = editor();
c.setOption('readOnly', true);
c.setValue('');
c.clearHistory();
showPane('code');
setButtons({});
$('vmsg').textContent = 'Чтение файла…';
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { $('vmsg').textContent = 'Не удалось прочитать: ' + (r.error || 'ошибка'); return; }
const decoded = decodeText(r.data);
if (!decoded) return viewHex(path, r.data.size, pane);
viewText(path, r.data, pane, decoded);
}
function decodeText(d) {
const bytes = b64ToBytes(d.content);
let encoding = 'utf-8', bom = false, start = 0;
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
encoding = 'utf-16le'; bom = true; start = 2;
} else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
encoding = 'utf-16be'; bom = true; start = 2;
} else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
bom = true; start = 3;
} else if (d.binary && bytes.length >= 8) {
let evenNul = 0, oddNul = 0, pairs = Math.floor(bytes.length / 2);
for (let i = 0; i < pairs * 2; i++) if (bytes[i] === 0) (i % 2 ? oddNul++ : evenNul++);
if (oddNul / pairs > 0.3 && evenNul / pairs < 0.05) encoding = 'utf-16le';
else if (evenNul / pairs > 0.3 && oddNul / pairs < 0.05) encoding = 'utf-16be';
else return null;
} else if (d.binary) return null;
try {
if (encoding.startsWith('utf-16') && (bytes.length - start) % 2) return null;
const text = new TextDecoder(encoding).decode(bytes.subarray(start));
if (d.binary && (/[^\t\n\r\u0020-\ufffd]/.test(text) || text.includes('\ufffd'))) return null;
return {text, encoding, bom};
} catch (_) { return null; }
}
function encodeText(text, decoded) {
let bytes;
if (decoded.encoding === 'utf-8') bytes = new TextEncoder().encode(text);
else {
bytes = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (decoded.encoding === 'utf-16le') { bytes[i * 2] = c & 255; bytes[i * 2 + 1] = c >> 8; }
else { bytes[i * 2] = c >> 8; bytes[i * 2 + 1] = c & 255; }
}
}
if (!decoded.bom) return bytes;
const mark = decoded.encoding === 'utf-8' ? [0xef, 0xbb, 0xbf]
: decoded.encoding === 'utf-16le' ? [0xff, 0xfe] : [0xfe, 0xff];
return new Uint8Array([...mark, ...bytes]);
}
function viewText(path, d, pane = cur, decoded = decodeText(d)) {
openViewer(path);
V.kind = 'code';
showPane('code');
const text = decoded.text;
const editable = d.size <= EDIT_LIMIT && !d.truncated;
const c = editor();
const mode = modeFor(path, text);
c.setOption('mode', mode);
c.setOption('indentWithTabs', mode === 'go' || /(^|\/)makefile$/i.test(path));
c.setOption('readOnly', !editable);
c.setValue(text);
c.clearHistory();
V.clean = c.changeGeneration();
V.dirty = false;
setButtons({save: editable});
$('frow2').hidden = $('frow2b').hidden = !editable;
const encName = decoded.encoding === 'utf-8' ? 'UTF-8' : decoded.encoding.toUpperCase().replace('UTF-16', 'UTF-16 ');
$('vmsg').textContent = fmtSize(d.size) + (d.size === 0 ? ' · пустой файл' : '') + ' · ' + modeName(mode) + ' · ' + encName +
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
' · Ctrl/Cmd+F - поиск и замена';
$('save').onclick = async () => {
const seq = viewSeq;
const w = await putFile(path, encodeText(c.getValue(), decoded), pane);
if (w.ok) {
if (seq === viewSeq && V.path === path) { V.clean = c.changeGeneration(); V.dirty = false; }
toast('сохранено'); pane.go(pane.cwd);
} else toast(w.error || 'ошибка', true);
};
requestAnimationFrame(() => requestAnimationFrame(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }));
}
function viewMedia(path, size, pane = cur, seq = ++viewSeq) {
openViewer(path);
V.kind = 'media';
showPane('media');
const box = $('vmedia');
box.innerHTML = '';
const el = document.createElement(VID.test(path) ? 'video' : AUD.test(path) ? 'audio' : 'img');
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
else el.alt = path;
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
el.src = pane.src.url('download', path) + '&inline=1';
box.appendChild(el);
// SVG is text: edited as such, not byte by byte.
const svg = /\.svg$/i.test(path);
setButtons({text: svg, hex: !svg});
$('vmsg').textContent = fmtSize(size);
$('vtext').onclick = async () => {
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
stopMedia();
const decoded = decodeText(r.data);
if (decoded) viewText(path, r.data, pane, decoded);
else viewHex(path, size, pane);
};
$('vhex').onclick = () => { stopMedia(); viewHex(path, size, pane); };
}
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
async function viewDoc(path, size, pane = cur, seq = ++viewSeq) {
openViewer(path);
V.kind = 'doc';
showPane('doc');
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
// Text with a preview gets the text editor; the rest, the hex one.
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
setButtons({text: isText, hex: !isText});
$('vtext').onclick = async () => {
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const decoded = decodeText(r.data);
if (decoded) viewText(path, r.data, pane, decoded);
else viewHex(path, size, pane);
};
$('vhex').onclick = () => viewHex(path, size, pane);
const box = $('vdoc');
$('vmsg').textContent = fmtSize(size);
if (ext === 'pdf') {
const src = pane.src.url('download', path) + '&inline=1';
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
box.firstChild.src = src;
const a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'открыть в новой вкладке';
$('vmsg').append(' · ', a);
return;
}
if (ext === 'html' || ext === 'htm') {
// The page as it looks, in a frame that runs none of its scripts.
const r = await pane.src.op('read', {path});
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
const decoded = decodeText(r.data);
if (!decoded) { box.innerHTML = '<p class="dv-note">неподдерживаемая кодировка: откройте файл в текстовом или hex-редакторе</p>'; return; }
const f = document.createElement('iframe');
f.className = 'dv-html';
f.setAttribute('sandbox', '');
f.title = 'HTML';
f.srcdoc = decoded.text;
box.innerHTML = '';
box.appendChild(f);
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return;
}
if (/^(db|sqlite3?|db3)$/.test(ext) && pane.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>';
const r = await DBEdit.open(box, path, (op, args) => pane.src.op(op, args), toast);
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
if (r && r.ok) return;
if (!/неизвестная операция/.test((r && r.error) || '')) {
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
return;
}
$('vmsg').append(' · только просмотр: обновите агента, чтобы править');
}
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html;
try {
pane.setBusy(true);
const r = await fetch(pane.src.url('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
const buf = await r.arrayBuffer();
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
html = ext === 'docx' ? await DocView.docx(buf)
: ext === 'odt' ? await DocView.odt(buf)
: ext === 'xlsx' ? await DocView.xlsx(buf)
: ext === 'ods' ? await DocView.ods(buf)
: ext === 'doc' ? DocView.doc(buf)
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
} catch (err) {
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
} finally { pane.setBusy(false); }
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
box.innerHTML = html;
DocView.bindTabs(box);
}
function stopMedia() {
const el = $('vmedia').firstChild;
if (el && el.pause) el.pause();
if (el) el.removeAttribute('src');
$('vmedia').innerHTML = '';
}
// ---------- Hex editor ----------
// Overwrite in place: a hex digit sets half a byte, a character in the text
// column sets a byte. Changed bytes stay marked until saved.
const H = { data: null, orig: null, cur: 0, nib: 0, side: 'hex', changed: 0 };
const ROW = 20;
async function viewHex(path, size, pane = cur, seq = ++viewSeq) {
if (size > HEX_CAP) {
const msg = 'для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')';
if (seq === viewSeq) $('vmsg').textContent = msg;
toast(msg, true);
return;
}
openViewer(path);
V.kind = 'hex';
showPane('hex');
setButtons({save: true});
$('vmsg').textContent = 'загрузка…';
$('hxrows').innerHTML = '';
let buf;
try {
pane.setBusy(true);
const r = await fetch(pane.src.url('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
buf = new Uint8Array(await r.arrayBuffer());
} catch (err) {
if (seq === viewSeq) $('vmsg').textContent = 'не удалось прочитать: ' + err.message;
return;
} finally { pane.setBusy(false); }
if (seq !== viewSeq || V.path !== path || V.kind !== 'hex') return;
H.data = buf; H.orig = buf.slice(); H.cur = 0; H.nib = 0; H.side = 'hex'; H.changed = 0;
$('hxsp').style.height = (Math.ceil(buf.length / 16) * ROW + 8) + 'px';
$('vhexed').scrollTop = 0;
drawHex(); hexInfo();
$('vhexed').focus();
$('save').onclick = async () => {
const w = await putFile(path, H.data, pane);
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); pane.go(pane.cwd); }
else toast(w.error || 'ошибка', true);
};
}
const hx2 = b => (b < 16 ? '0' : '') + b.toString(16).toUpperCase();
function drawHex() {
const box = $('vhexed'), n = H.data.length;
const first = Math.max(0, Math.floor(box.scrollTop / ROW) - 2);
const last = Math.min(Math.ceil(n / 16), first + Math.ceil(box.clientHeight / ROW) + 5);
let html = '';
for (let r = first; r < last; r++) {
let hb = '', ha = '';
for (let i = r * 16; i < r * 16 + 16; i++) {
if (i >= n) { hb += '<i> </i>'; continue; }
const b = H.data[i], ch = b !== H.orig[i] ? 'ch' : '';
const at = i === H.cur;
hb += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'hex' ? ' cur' : ' cur2') : '') + '">' + hx2(b) + '</i>';
const c = b >= 32 && b < 127 ? String.fromCharCode(b) : '.';
ha += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'txt' ? ' cur' : ' cur2') : '') + '">' + (c === '<' ? '&lt;' : c === '&' ? '&amp;' : c) + '</i>';
}
html += '<div class="hx-r"><span class="hx-o">' + (r * 16).toString(16).toUpperCase().padStart(8, '0') +
'</span><span class="hx-b">' + hb + '</span><span class="hx-a">' + ha + '</span></div>';
}
const rows = $('hxrows');
rows.style.top = (first * ROW + 4) + 'px';
rows.innerHTML = html;
}
function hexInfo() {
$('vmsg').textContent = fmtSize(H.data.length) + ' · смещение 0x' + H.cur.toString(16).toUpperCase() + ' (' + H.cur + ')' +
(H.changed ? ' · изменено байт: ' + H.changed : '') + ' · Tab - между hex и текстом';
}
function hexMove(to) {
if (!H.data.length) return;
H.cur = Math.max(0, Math.min(H.data.length - 1, to)); H.nib = 0;
const box = $('vhexed'), y = Math.floor(H.cur / 16) * ROW;
if (y < box.scrollTop) box.scrollTop = y;
else if (y + ROW > box.scrollTop + box.clientHeight - 8) box.scrollTop = y + ROW - box.clientHeight + 8;
drawHex(); hexInfo();
}
function hexSet(i, b) {
const was = H.data[i] !== H.orig[i];
H.data[i] = b;
H.changed += (H.data[i] !== H.orig[i] ? 1 : 0) - (was ? 1 : 0);
V.dirty = H.changed > 0;
}
$('vhexed').addEventListener('scroll', () => { if (V.kind === 'hex' && H.data) drawHex(); });
$('vhexed').addEventListener('mousedown', ev => {
const t = ev.target.closest('i[data-i]');
if (!t) return;
H.side = t.parentElement.classList.contains('hx-a') ? 'txt' : 'hex';
hexMove(+t.dataset.i);
});
$('vhexed').addEventListener('keydown', ev => {
if (!H.data || !H.data.length) return;
const mod = ev.ctrlKey || ev.metaKey, page = 16 * Math.max(1, Math.floor($('vhexed').clientHeight / ROW) - 1);
const moves = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -16, ArrowDown: 16, PageUp: -page, PageDown: page };
if (mod && (ev.key === 's' || ev.key === 'S' || ev.code === 'KeyS')) { ev.preventDefault(); $('save').click(); return; }
if (mod) return;
if (moves[ev.key] !== undefined) { ev.preventDefault(); hexMove(H.cur + moves[ev.key]); return; }
if (ev.key === 'Home') { ev.preventDefault(); hexMove(H.cur - H.cur % 16); return; }
if (ev.key === 'End') { ev.preventDefault(); hexMove(H.cur - H.cur % 16 + 15); return; }
if (ev.key === 'Tab') { ev.preventDefault(); H.side = H.side === 'hex' ? 'txt' : 'hex'; H.nib = 0; drawHex(); return; }
if (ev.key.length !== 1) return;
if (H.side === 'hex') {
const h = parseInt(ev.key, 16);
if (isNaN(h)) return;
ev.preventDefault();
const b = H.data[H.cur];
if (H.nib === 0) { hexSet(H.cur, (h << 4) | (b & 15)); H.nib = 1; drawHex(); hexInfo(); }
else { hexSet(H.cur, (b & 0xf0) | h); hexMove(H.cur + 1); }
} else {
const code = ev.key.charCodeAt(0);
if (code < 32 || code > 126) return;
ev.preventDefault();
hexSet(H.cur, code); hexMove(H.cur + 1);
}
});
// ---------- Find and replace ----------
const F = { open: false, overlay: null, caseS: false, re: false };
function findRe(global) {
const q = $('fq').value;
if (!q) return null;
try {
return new RegExp(F.re ? q : q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), (global ? 'g' : '') + (F.caseS ? '' : 'i') + 'm');
} catch (e) { return null; }
}
function openFind(replace) {
if (V.kind !== 'code' || !cm) return;
F.open = true;
$('find').hidden = false;
const sel = cm.getSelection();
if (sel && sel.indexOf('\n') < 0) $('fq').value = sel;
const input = replace && !$('frow2').hidden ? $('frep') : $('fq');
input.focus();
input.select();
refreshFind();
cm.refresh();
}
function closeFind() {
if (!F.open) return;
F.open = false;
$('find').hidden = true;
if (cm && F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
if (cm) { cm.refresh(); if (V.kind === 'code') cm.focus(); }
}
// Marks every match and counts them; the one selected shows as "n из всех".
function refreshFind() {
if (F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
const q = $('fq').value, re = findRe(true);
$('fq').classList.toggle('bad', !!q && !re);
if (!re) { $('fcnt').textContent = q ? 'ошибка' : ''; return; }
F.overlay = { token: stream => {
re.lastIndex = stream.pos;
const m = re.exec(stream.string);
if (m && m.index === stream.pos && m[0].length) { stream.pos += m[0].length; return 'searching'; }
if (m && m.index > stream.pos) { stream.pos = m.index; return null; }
if (m) { stream.next(); return null; }
stream.skipToEnd(); return null;
} };
cm.addOverlay(F.overlay);
countFind();
}
function countFind() {
const re = findRe(false);
if (!re) return;
let n = 0, at = 0;
const from = cm.getCursor('from'), to = cm.getCursor('to');
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
while (cur.findNext() && n < 9999) {
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
n++;
if (!at && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) at = n;
}
$('fcnt').textContent = n ? (at ? at + ' из ' + n : n + (n >= 9999 ? '+' : '')) : 'нет';
}
function findStep(dir, fromStart) {
const re = findRe(false);
if (!re || !cm) return false;
let cur = cm.getSearchCursor(re, fromStart ? {line: 0, ch: 0} : dir > 0 ? cm.getCursor('to') : cm.getCursor('from'));
let ok = dir > 0 ? cur.findNext() : cur.findPrevious();
if (!ok) { // go round the end
cur = cm.getSearchCursor(re, dir > 0 ? {line: 0, ch: 0} : {line: cm.lastLine(), ch: cm.getLine(cm.lastLine()).length});
ok = dir > 0 ? cur.findNext() : cur.findPrevious();
}
if (!ok || CodeMirror.cmpPos(cur.from(), cur.to()) === 0) { countFind(); return false; }
cm.setSelection(cur.from(), cur.to());
cm.scrollIntoView({from: cur.from(), to: cur.to()}, 80);
countFind();
return true;
}
// $1.. and $& stand for the groups of a regular expression's match.
function replacement(m) {
const r = $('frep').value;
return F.re && m ? r.replace(/\$(\d+|&)/g, (_, g) => g === '&' ? m[0] : (m[+g] || '')) : r;
}
function replaceOne() {
const re = findRe(false);
if (!re || cm.getOption('readOnly')) return;
const from = cm.getCursor('from'), to = cm.getCursor('to');
const cur = cm.getSearchCursor(re, from);
const m = cur.findNext();
if (m && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) {
cur.replace(replacement(m));
cm.setCursor(cur.to());
}
findStep(1);
}
function replaceAll() {
const re = findRe(false);
if (!re || cm.getOption('readOnly')) return;
let n = 0;
cm.operation(() => {
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
let m;
while ((m = cur.findNext()) && n < 100000) {
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
cur.replace(replacement(m));
n++;
}
});
toast(n ? 'заменено: ' + n : 'совпадений нет', !n);
countFind();
}
$('fq').addEventListener('input', () => { refreshFind(); findStep(1, true); });
$('fq').addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); findStep(ev.shiftKey ? -1 : 1); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
});
$('frep').addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); ev.ctrlKey || ev.metaKey ? replaceAll() : replaceOne(); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
});
$('fcase').onclick = () => { F.caseS = !F.caseS; $('fcase').classList.toggle('on', F.caseS); refreshFind(); };
$('fre').onclick = () => { F.re = !F.re; $('fre').classList.toggle('on', F.re); refreshFind(); };
$('fnext').onclick = () => findStep(1);
$('fprev').onclick = () => findStep(-1);
$('fclose').onclick = closeFind;
$('frepone').onclick = replaceOne;
$('frepall').onclick = replaceAll;
// Ctrl/Cmd+F anywhere in the viewer opens this search, not the browser's.
$('viewer').addEventListener('keydown', ev => {
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'f' || ev.key === 'F' || ev.code === 'KeyF') && V.kind === 'code') {
ev.preventDefault(); openFind();
}
});
// Closing: Esc first closes the search; unsaved changes are asked about.
async function closeViewer() {
if (V.dirty && !await UI.confirm('Есть несохранённые изменения. Закрыть без сохранения?', {ok: 'Закрыть', danger: true})) return;
++viewSeq;
stopMedia(); closeFind();
H.data = H.orig = null;
V.kind = ''; V.dirty = false;
$('viewer').close();
}
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
$('closev').onclick = closeViewer;
// ---------- Properties ----------
// Owner, group and mode of a file or folder; a folder's can go to all that
// is in it too.
const P = { path: '', orig: null };
const modeStr = m => m.toString(8).padStart(m > 0o777 ? 4 : 3, '0');
const permBoxes = () => [...document.querySelectorAll('#propdlg .pp-perm input[data-bit]')];
function showMode(m) {
permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
$('pp-mode').value = modeStr(m);
$('pp-mode').classList.remove('bad');
}
function readMode() {
const v = $('pp-mode').value.trim();
return /^[0-7]{3,4}$/.test(v) ? parseInt(v, 8) : null;
}
permBoxes().forEach(b => b.onchange = () => {
let m = (readMode() ?? P.orig.mode) & 0o7000;
permBoxes().forEach(x => { if (x.checked) m |= +x.dataset.bit; });
showMode(m);
});
$('pp-mode').oninput = () => {
const m = readMode();
$('pp-mode').classList.toggle('bad', m === null);
if (m !== null) permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
};
$('pp-recursive').onchange = () => $('pp-only').disabled = !$('pp-recursive').checked;
async function openProps(path) {
const r = await api('stat', {path});
if (!r.ok) {
toast(/неизвестная операция/.test(r.error || '') ? 'свойства недоступны: обновите клиента' : (r.error || 'ошибка'), true);
return;
}
const d = r.data;
P.path = path; P.orig = d;
$('pp-path').textContent = path;
$('pp-info').textContent = (d.is_dir ? 'папка' : 'файл · ' + fmtSize(d.size)) + ' · изменён ' + fmtTime(d.mtime) +
(d.link ? ' · ссылка на ' + d.link : '');
$('pp-owner').value = d.owner; $('pp-group').value = d.group;
$('pp-users').innerHTML = (d.users || []).map(u => '<option value="' + esc(u) + '">').join('');
$('pp-groups').innerHTML = (d.groups || []).map(g => '<option value="' + esc(g) + '">').join('');
showMode(d.mode);
$('pp-rec').hidden = !d.is_dir;
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
if (!$('propdlg').open) openModal($('propdlg'));
}
$('pp-apply').onclick = async () => {
const o = P.orig, mode = readMode();
if (mode === null) { toast('права - три или четыре восьмеричные цифры, например 755', true); return; }
const rec = o.is_dir && $('pp-recursive').checked;
const owner = $('pp-owner').value.trim(), group = $('pp-group').value.trim();
const args = {path: P.path};
if (mode !== o.mode || rec) args.mode = mode;
if (owner && (owner !== o.owner || rec)) args.owner = owner;
if (group && (group !== o.group || rec)) args.group = group;
if (rec) { args.recursive = true; args.only = $('pp-only').value; }
if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; }
if (rec && !await UI.confirm('Применить к «' + P.path + '» и всему, что внутри?', {ok: 'Применить'})) return;
const btn = $('pp-apply');
btn.disabled = true;
const r = await api('chmod', args);
btn.disabled = false;
if (!r.ok) { toast(/неизвестная операция/.test(r.error || '') ? 'обновите клиента' : (r.error || 'ошибка'), true); return; }
const c = r.data || {};
toast(rec ? 'применено: ' + c.changed + (c.failed ? ', не удалось: ' + c.failed + ' (' + (c.errors || [])[0] + ')' : '') : 'применено', !!c.failed);
openProps(P.path);
cur.go(cur.cwd);
};
$('propdlg-close').onclick = () => $('propdlg').close();
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
async function del(e) {
// The pane asked about, whatever another pane does meanwhile.
const pane = cur;
// A link goes alone: what it leads to stays.
const ok = await UI.confirm(e.link ? 'Удалить ссылку «' + e.name + '»? То, куда она ведёт, останется.'
: e.is_dir ? 'Удалить папку «' + e.name + '» со всем содержимым?' : 'Удалить файл «' + e.name + '»?', {ok: 'Удалить', danger: true});
if (!ok) return;
const r = await pane.src.op(e.is_dir ? 'deltree' : 'delete', {path: join(pane.cwd, e.name)});
r.ok ? (toast('удалено'), pane.go(pane.cwd)) : toast(r.error||'ошибка', true);
}
// ---------- Move or copy ----------
// remote: to another machine (to: its id) or an SSH host (to: 'ssh').
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0, remote: false, to: '', auth: 'password', job: null };
const dirCache = new Map(); // machine + folder -> {at, names} of its subfolders
// The machine the folder is on: this one, or the one it goes to.
const target = () => !M.remote ? cur.agent.id : M.to === 'ssh' ? '' : M.to;
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
async function subdirs(path) {
const on = target();
if (!on) return [];
const key = on + '\n' + path;
const c = dirCache.get(key);
if (c && Date.now() - c.at < 30000) return c.names;
try {
const r = await fetch('/api/panel/agents/' + encodeURIComponent(on) + '/fs/list', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
}).then(r => r.json());
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
dirCache.set(key, {at: Date.now(), names});
return names;
} catch (e) { return []; }
}
const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'});
function setOp(op) {
M.op = op;
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
goLabel();
}
function goLabel() {
$('mv-go').textContent = (M.op === 'move' ? 'Переместить' : 'Копировать') + (M.remote ? ' туда' : '');
}
// ---- to another machine, or by SSH ----
function lastShip() { try { return JSON.parse(localStorage.getItem('lpra.ship.last') || '{}'); } catch (e) { return {}; } }
function setRemote(on) {
M.remote = on;
$('mv-net').classList.toggle('on', on);
$('mv-net').setAttribute('aria-pressed', on);
$('mv-remote').classList.toggle('on', on);
goLabel();
if (on) loadTargets();
$('mv-sug').hidden = true;
}
function setTo(v) {
M.to = v;
$('mv-ssh').hidden = v !== 'ssh';
dirCache.clear();
}
function setAuth(a) {
M.auth = a;
document.querySelectorAll('#mv-auth button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
document.querySelectorAll('.mv-a').forEach(el => { el.hidden = el.dataset.for !== a; });
}
async function loadTargets() {
const sel = $('mv-to'), last = lastShip();
sel.innerHTML = '<option value="" disabled>загрузка…</option>';
let all = [];
try { all = await fetch('/api/panel/agents/presence', {cache: 'no-store'}).then(r => r.json()); } catch (e) { /* SSH only */ }
sel.innerHTML = '';
all.filter(a => a.id !== cur.agent.id).forEach(a => {
const o = document.createElement('option');
o.value = a.id; o.disabled = !a.online;
o.textContent = a.name + ' · ' + a.user + '@' + a.hostname + (a.online ? '' : ' (не в сети)');
sel.appendChild(o);
});
const o = document.createElement('option');
o.value = 'ssh'; o.textContent = 'SSH-хост…';
sel.appendChild(o);
const ok = v => [...sel.options].some(x => x.value === v && !x.disabled);
const first = [...sel.options].find(x => !x.disabled);
sel.value = ok(last.to) ? last.to : first ? first.value : 'ssh';
setTo(sel.value);
if (last.ssh) { $('mv-host').value = last.ssh.host || ''; $('mv-port').value = last.ssh.port || 22; $('mv-user').value = last.ssh.user || ''; setAuth(last.ssh.auth || 'password'); }
}
$('mv-net').onclick = () => { if (!M.job) setRemote(!M.remote); };
$('mv-to').onchange = ev => { setTo(ev.target.value); suggest(); };
document.querySelectorAll('#mv-auth button').forEach(b => b.onclick = () => setAuth(b.dataset.auth));
$('mv-keyload').onclick = () => $('mv-keyfile').click();
$('mv-keyfile').onchange = () => {
const f = $('mv-keyfile').files[0];
if (!f) return;
if (f.size > 32768) { toast('файл великоват для ключа', true); return; }
f.text().then(t => { $('mv-key').value = t.trim() + '\n'; $('mv-keyname').textContent = f.name; });
$('mv-keyfile').value = '';
};
function sshTarget() {
const t = {host: $('mv-host').value.trim(), port: Number($('mv-port').value) || 22, user: $('mv-user').value.trim()};
if (!t.host || !t.user) return {error: 'укажите хост и пользователя'};
if (M.auth === 'password') {
if (!$('mv-pw').value) return {error: 'введите пароль'};
t.password = $('mv-pw').value;
} else if (M.auth === 'key') {
if (!/PRIVATE KEY/.test($('mv-key').value)) return {error: 'вставьте закрытый ключ или загрузите файл'};
t.key = $('mv-key').value;
if ($('mv-pass').value) t.passphrase = $('mv-pass').value;
}
return {t};
}
function busy(on) {
document.querySelector('#mvdlg .mv').classList.toggle('running', on);
$('mv-go').disabled = on;
$('mv-prog').hidden = !on;
if (on) requestAnimationFrame(() => $('mv-prog').scrollIntoView({block: 'nearest', behavior: 'smooth'}));
}
// followJob waits for a transfer job to end, telling onProgress how far
// it has got (the progress and a line about it), and gives its result.
async function followJob(job, onProgress) {
let v = 0;
const seen = [];
for (;;) {
let j;
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
v = j.v || 0;
const p = j.progress || {};
let text = '';
if (p.total > 0 || p.done > 0) {
// Speed over the last few seconds.
seen.push([Date.now(), p.done]);
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
text = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
} else if (p.note) {
text = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
}
onProgress(p, text);
if (j.done) return j.result || {ok: false, error: 'ошибка'};
}
}
// ship sends to the other machine and follows the job to its end.
async function ship(dir, name, conflict, move) {
const body = {src: M.src, name, dir, conflict, move};
const keep = {to: M.to};
if (M.to === 'ssh') {
const r = sshTarget();
if (r.error) { toast(r.error, true); return; }
body.ssh = r.t;
keep.ssh = {host: r.t.host, port: r.t.port, user: r.t.user, auth: M.auth};
} else {
if (!M.to) { toast('выберите машину', true); return; }
body.to = M.to;
keep.ssh = lastShip().ssh;
}
try { localStorage.setItem('lpra.ship.last', JSON.stringify(keep)); } catch (e) { /* not kept */ }
busy(true);
$('mv-bar').parentNode.classList.add('wait');
$('mv-bar').style.width = '0';
$('mv-ptext').textContent = 'подготовка…';
const r = await api('ship', body);
$('mv-pw').value = ''; $('mv-pass').value = '';
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
const job = M.job = r.data.job;
const t0 = Date.now();
const res = await followJob(job, (p, text) => {
if (p.total > 0 || p.done > 0) {
$('mv-bar').parentNode.classList.remove('wait');
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
}
if (text) $('mv-ptext').textContent = text;
});
M.job = null;
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
$('mv-bar').style.width = '100%';
const d = res.data || {}, secs = Math.max(1, Math.round((Date.now() - t0) / 1000));
const what = move ? 'перенесено' : 'отправлено';
toast(what + (d.done != null ? ': ' + d.done : d.files != null ? ': ' + d.files : '') + (d.skipped ? ', оставлено как было: ' + d.skipped : '') + ' · ' + secs + ' с');
setTimeout(() => { busy(false); if ($('mvdlg').open) $('mvdlg').close(); }, 400);
if (move) cur.go(cur.cwd);
}
$('mv-stop').onclick = () => {
if (M.job) fetch('/api/panel/fs/job/' + M.job + '/cancel', {method: 'POST'});
$('mv-ptext').textContent = 'отмена…';
};
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
// Suggestions: the subfolders of what is typed, matching its last part.
async function suggest() {
const v = $('mv-dir').value, seq = ++M.seq;
const i = v.lastIndexOf('/');
const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase();
const names = await subdirs(parent);
if (seq !== M.seq) return;
M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60)
.map(n => (parent === '/' ? '' : parent) + '/' + n + '/');
M.sel = -1;
showSug();
}
function showSug() {
const ul = $('mv-sug');
ul.innerHTML = '';
M.sug.forEach((full, k) => {
const li = document.createElement('li');
const cut = full.slice(0, -1).lastIndexOf('/') + 1;
li.innerHTML = '<span class="muted"></span><span></span>';
li.firstChild.textContent = full.slice(0, cut);
li.lastChild.textContent = full.slice(cut);
if (k === M.sel) li.className = 'sel';
li.onmousedown = ev => { ev.preventDefault(); pick(k); };
ul.appendChild(li);
});
ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir');
const sel = ul.children[M.sel];
if (sel) sel.scrollIntoView({block: 'nearest'});
}
function pick(k) {
$('mv-dir').value = M.sug[k];
suggest();
}
let sugTimer = null;
$('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); });
$('mv-dir').addEventListener('focus', suggest);
$('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100));
$('mv-dir').addEventListener('keydown', ev => {
const open = !$('mv-sug').hidden && M.sug.length;
if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); }
else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); }
else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); }
else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); }
else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; }
});
$('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } });
function move(e) {
M.src = join(cur.cwd, e.name);
$('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';
dirCache.clear();
openModal($('mvdlg'));
$('mv-dir').focus();
const end = $('mv-dir').value.length;
$('mv-dir').setSelectionRange(end, end);
}
$('mv-go').onclick = async () => {
const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/';
const name = $('mv-name').value.trim();
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
const dst = (dir === '/' ? '' : dir) + '/' + name;
const conflict = $('mv-conflict').value, move = M.op === 'move';
if (M.remote) { if (!M.job) ship(dir, name, conflict, move); return; }
const btn = $('mv-go');
btn.disabled = true;
let r = await api('copy', {src: M.src, dst, move, conflict});
// A client that predates copying still moves, when nothing is in the way.
if (!r.ok && /неизвестная операция/.test(r.error || '')) {
r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'};
}
btn.disabled = false;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const skipped = r.data && r.data.skipped;
toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : ''));
$('mvdlg').close();
cur.go(cur.cwd);
};
$('mvdlg-close').onclick = () => $('mvdlg').close();
// The list of a pane's uploads, opened from its progress figures.
let upPane = null;
function renderUploadList() {
const list = $('updlg-list');
if (!$('updlg').open || !upPane) return;
const uploads = upPane.uploads;
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
const UP_ST = { wait: 'в очереди', send: '', write: 'запись на клиенте…', done: 'готово', fail: 'ошибка' };
for (const u of uploads) {
const pct = u.state === 'send' ? Math.floor(100 * u.frac) : u.state === 'wait' ? 0 : 100;
const st = u.state === 'send' ? pct + '%' : u.state === 'fail' ? (u.error || UP_ST.fail) : UP_ST[u.state];
const el = document.createElement('div');
el.className = 'upl-item';
el.innerHTML = '<div class="upl-row"><span class="n"></span><span class="st"></span></div><div class="upl-bar"><i></i></div>';
el.querySelector('.n').textContent = u.name + ' · ' + fmtSize(u.size);
const stEl = el.querySelector('.st');
stEl.textContent = st;
stEl.className = 'st' + (u.state === 'done' ? ' ok' : u.state === 'fail' ? ' bad' : '');
el.querySelector('.upl-bar i').style.transform = 'scaleX(' + pct / 100 + ')';
if (u.state === 'fail') el.querySelector('.upl-bar i').style.background = 'var(--danger)';
list.appendChild(el);
}
}
$('updlg-close').onclick = () => $('updlg').close();
const use = (pane, fn) => (...a) => { cur = pane; return fn(...a); };
window.FileViews = {
view: (pane, e) => use(pane, view)(e),
props: (pane, path) => use(pane, openProps)(path),
move: (pane, e) => use(pane, move)(e),
del: (pane, e) => use(pane, del)(e),
uploads(pane) { upPane = pane; openModal($('updlg')); renderUploadList(); },
uploadsChanged(pane) { if (pane === upPane) renderUploadList(); },
sendFile, followJob, toast, openModal, join, parent, fmtSize, fmtTime, esc, b64ToBytes
};
})();