// 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 (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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 = ''; 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 = '

не удалось показать: ' + esc(r.error || 'ошибка') + '

'; return; } const decoded = decodeText(r.data); if (!decoded) { box.innerHTML = '

неподдерживаемая кодировка: откройте файл в текстовом или hex-редакторе

'; 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 = '

загрузка…

'; 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 = '

не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '

'; return; } $('vmsg').append(' · только просмотр: обновите агента, чтобы править'); } if (size > DOC_CAP) { box.innerHTML = '

файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его

'; return; } box.innerHTML = '

загрузка…

'; 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 = '

не удалось показать: ' + esc(err.message) + '

'; } 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 += ' '; continue; } const b = H.data[i], ch = b !== H.orig[i] ? 'ch' : ''; const at = i === H.cur; hb += '' + hx2(b) + ''; const c = b >= 32 && b < 127 ? String.fromCharCode(b) : '.'; ha += '' + (c === '<' ? '<' : c === '&' ? '&' : c) + ''; } html += '
' + (r * 16).toString(16).toUpperCase().padStart(8, '0') + '' + hb + '' + ha + '
'; } 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 => ''; 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 = ''; 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 ? '' : '

Сейчас ничего не загружается.

'; 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 = '
'; 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 }; })();