feat: files up to 512 MB as streams, a code editor with highlighting and find/replace, media preview, hex editing; touch selection in the terminal (0.0.7)

This commit is contained in:
redtea committed 2026-10-14 15:52:38 +03:00
1 parent 3e46116a9b
commit e1f5595afc
14 files changed
+1178 -94

No files matched your search

+497 -42
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/codemirror/codemirror.css">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */
@@ -56,16 +57,79 @@
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
textarea#edit { font-size:13px; }
}
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); }
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
.dlg-b { padding:16px 18px; }
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
border:1px solid var(--border); border-radius:8px; padding:12px;
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
#viewer { max-width:1100px; }
#viewer .dlg-h { flex-wrap:wrap; }
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
.vmsg { width:100%; order:9; font-size:12px; }
.vbody { padding:0; }
#vcode .CodeMirror { height:70vh; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
.vmedia { display:flex; align-items:center; justify-content:center; min-height:200px; max-height:75vh; padding:16px;
background:#0c0f13 repeating-conic-gradient(#151a21 0 25%, #0c0f13 0 50%) 0 0/20px 20px; border-radius:0 0 12px 12px; }
.vmedia img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; }
.vmedia audio { width:min(100%, 520px); }
/* Find and replace, over the editor. */
.find { display:flex; flex-direction:column; gap:6px; padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
.find .fr { display:flex; gap:6px; align-items:center; }
.find input { flex:1; min-width:0; height:30px; padding:4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; }
.find input.bad { border-color:var(--danger); }
.find button { height:30px; padding:0 10px; font-size:13px; flex:none; }
.find button.tg { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:ui-monospace,Menlo,monospace; }
.find button.tg.on { border-color:var(--accent); color:var(--text); background:#12202f; }
.find .cnt { flex:none; min-width:64px; text-align:center; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; }
/* Hex editor: offset, 16 bytes, the same as text. Only the rows in sight are drawn. */
.hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13;
font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; }
.hexed .hx-rows { position:absolute; left:0; right:0; padding:0 12px; }
.hx-r { display:flex; gap:18px; height:20px; white-space:pre; }
.hx-o { color:var(--muted); }
.hx-b i, .hx-a i { font-style:normal; cursor:text; }
.hx-b i { margin-right:6px; }
.hx-b i:nth-child(8) { margin-right:14px; }
.hx-a { color:#9aa4b2; }
.hx-r i.ch { color:#ffcb6b; }
.hx-r i.cur { background:var(--accent); color:#fff; border-radius:2px; }
.hx-r i.cur2 { outline:1px solid var(--accent); border-radius:2px; }
/* Editor colours, in the panel's palette. */
.cm-s-lpra.CodeMirror { background:#0c0f13; color:#d7dce4; }
.cm-s-lpra .CodeMirror-gutters { background:#0c0f13; border-right:1px solid var(--border); }
.cm-s-lpra .CodeMirror-linenumber { color:#4b5563; }
.cm-s-lpra .CodeMirror-cursor { border-left:2px solid #e6e9ef; }
.cm-s-lpra .CodeMirror-selected, .cm-s-lpra.CodeMirror-focused .CodeMirror-selected { background:rgba(79,157,255,.28); }
.cm-s-lpra .CodeMirror-line::selection, .cm-s-lpra .CodeMirror-line > span::selection { background:rgba(79,157,255,.28); }
.cm-s-lpra span.cm-keyword { color:#c792ea; }
.cm-s-lpra span.cm-def { color:#82aaff; }
.cm-s-lpra span.cm-variable-2, .cm-s-lpra span.cm-variable-3, .cm-s-lpra span.cm-type { color:#ffcb6b; }
.cm-s-lpra span.cm-builtin { color:#ffcb6b; }
.cm-s-lpra span.cm-string, .cm-s-lpra span.cm-string-2 { color:#c3e88d; }
.cm-s-lpra span.cm-number, .cm-s-lpra span.cm-atom { color:#f78c6c; }
.cm-s-lpra span.cm-comment { color:#6b7686; font-style:italic; }
.cm-s-lpra span.cm-meta, .cm-s-lpra span.cm-qualifier { color:#89ddff; }
.cm-s-lpra span.cm-operator { color:#89ddff; }
.cm-s-lpra span.cm-property { color:#82aaff; }
.cm-s-lpra span.cm-tag { color:#f07178; }
.cm-s-lpra span.cm-attribute { color:#ffcb6b; }
.cm-s-lpra span.cm-header { color:#82aaff; font-weight:600; }
.cm-s-lpra span.cm-link { color:#89ddff; text-decoration:underline; }
.cm-s-lpra span.cm-error { color:#ff5c5c; }
.cm-s-lpra span.cm-positive { color:#37c871; }
.cm-s-lpra span.cm-negative { color:#ff5c5c; }
.cm-s-lpra .CodeMirror-matchingbracket { color:#fff !important; outline:1px solid #4f9dff; }
.cm-s-lpra .cm-searching { background:rgba(255,203,107,.32); border-radius:2px; }
@media (max-width: 640px) {
#vcode .CodeMirror, .hexed { height:calc(100vh - 220px); }
.hexed { font-size:11px; }
.hx-r { gap:10px; }
.hx-b i { margin-right:3px; }
.hx-b i:nth-child(8) { margin-right:8px; }
}
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
@@ -116,13 +180,33 @@
<dialog id="viewer">
<div class="dlg-h">
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
<button class="btn small" id="save" style="display:none">Сохранить</button>
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
<button class="btn small" id="save" hidden>Сохранить</button>
<button class="btn ghost small" id="closev">Закрыть</button>
<div class="vmsg muted" id="vmsg"></div>
</div>
<div class="dlg-b">
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
<textarea id="edit" spellcheck="false"></textarea>
<div class="vbody">
<div class="find" id="find" hidden>
<div class="fr">
<input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off">
<span class="cnt" id="fcnt"></span>
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)">↑</button>
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)">↓</button>
<button type="button" class="btn ghost" id="fclose" title="Закрыть (Esc)">×</button>
</div>
<div class="fr" id="frow2">
<input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off">
<button type="button" class="btn ghost" id="frepone">Заменить</button>
<button type="button" class="btn ghost" id="frepall">Все</button>
</div>
</div>
<div id="vcode" hidden></div>
<div class="vmedia" id="vmedia" hidden></div>
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
</div>
</dialog>
@@ -136,6 +220,7 @@
<div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script>
<script>
(function () {
const agentID = {{.Agent.ID}};
@@ -154,6 +239,7 @@
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_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>';
let cwd = '/';
let entries = [];
@@ -162,7 +248,6 @@
const $ = id => document.getElementById(id);
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',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 bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
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'; };
@@ -198,7 +283,7 @@
'<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" data-act="unzip">unzip</button>':'') +
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" 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>' +
@@ -220,30 +305,398 @@
$('zipsel').disabled = !n; $('delsel').disabled = !n;
}
async function view(e) {
const r = await api('read', {path: join(cwd, e.name)});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const d = r.data;
$('vname').textContent = e.name;
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
if (d.binary) {
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
msg.textContent = 'Размер: '+fmtSize(d.size);
} else {
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size <= EDIT_LIMIT && !d.truncated;
ta.readOnly = !editable;
save.style.display = editable ? '' : 'none';
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
save.onclick = async () => {
const bytes = new TextEncoder().encode(ta.value);
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
};
}
$('viewer').showModal();
// ---------- File viewer ----------
const fsURL = (what, path) => '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + what + '?path=' + encodeURIComponent(path);
// Writes a file as it is, with no base64: the request body is the file.
async function putFile(path, body) {
setBusy(true);
try {
const r = await fetch(fsURL('upload', path), {method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body});
return (r.headers.get('Content-Type') || '').startsWith('application/json') ? await r.json() : {ok: false, error: 'HTTP ' + r.status};
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
finally { setBusy(false); }
}
$('closev').onclick = () => $('viewer').close();
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
// 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',
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']]) $(id).hidden = which !== k;
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) $('viewer').showModal();
}
async function view(e) {
const path = join(cwd, e.name);
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size);
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
if (r.data.binary) return viewHex(path, r.data.size);
viewText(path, r.data);
}
function viewText(path, d) {
openViewer(path);
V.kind = 'code';
const text = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size <= EDIT_LIMIT && !d.truncated;
const c = editor();
showPane('code');
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({hex: true, save: editable});
$('frow2').hidden = !editable;
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
' · Ctrl/Cmd+F - поиск и замена';
$('save').onclick = async () => {
const w = await putFile(path, new TextEncoder().encode(c.getValue()));
if (w.ok) { V.clean = c.changeGeneration(); V.dirty = false; toast('сохранено'); load(cwd); } else toast(w.error || 'ошибка', true);
};
$('vhex').onclick = () => { if (!V.dirty || confirm('Несохранённые изменения пропадут. Открыть бинарно?')) viewHex(path, d.size); };
setTimeout(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }, 0);
}
function viewMedia(path, size) {
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 = fsURL('download', path) + '&inline=1';
box.appendChild(el);
setButtons({text: /\.svg$/i.test(path), hex: true});
$('vmsg').textContent = fmtSize(size);
$('vtext').onclick = async () => {
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
stopMedia();
viewText(path, r.data);
};
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
}
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) {
if (size > HEX_CAP) { toast('для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')', true); return; }
openViewer(path);
V.kind = 'hex';
showPane('hex');
setButtons({save: true});
$('vmsg').textContent = 'загрузка…';
$('hxrows').innerHTML = '';
let buf;
try {
setBusy(true);
const r = await fetch(fsURL('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
buf = new Uint8Array(await r.arrayBuffer());
} catch (err) {
$('vmsg').textContent = 'не удалось прочитать: ' + err.message;
return;
} finally { setBusy(false); }
if (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);
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); load(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.
function closeViewer() {
if (V.dirty && !confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return;
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;
async function del(e) {
if (e.is_dir) {
@@ -341,16 +794,19 @@
$('updlg-close').onclick = () => $('updlg').close();
// XHR rather than fetch: it reports how much of the request has gone out.
function writeWithProgress(path, bytes, onFrac) {
// The body is the file itself, which the panel passes on to the client as
// it comes, so the bar follows the whole way.
const UP_CAP = 512 * 1024 * 1024;
function writeWithProgress(path, file, onFrac) {
return new Promise(resolve => {
const x = new XMLHttpRequest();
x.open('POST', '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/write');
x.setRequestHeader('Content-Type', 'application/json');
x.open('POST', fsURL('upload', path));
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = ev => { if (ev.lengthComputable) onFrac(ev.loaded / ev.total); };
x.upload.onload = () => onFrac(1);
x.onload = () => { try { resolve(JSON.parse(x.responseText)); } catch (e) { resolve({ok: false, error: 'HTTP ' + x.status}); } };
x.onerror = () => resolve({ok: false, error: 'ошибка сети'});
x.send(JSON.stringify({path, data: bytesToB64(bytes)}));
x.send(file);
});
}
@@ -359,8 +815,8 @@
// A new batch starts a fresh list once the last one is over.
if (!upActive) uploads.length = 0;
const dir = cwd;
const batch = [...files].map(f => f.size > 32*1024*1024
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 32 МБ', skip: true}
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: ''});
uploads.push(...batch);
renderUploads();
@@ -372,8 +828,7 @@
const refused = () => uploads.filter(u => u.skip).length;
for (let u; (u = uploads.find(x => x.state === 'wait')); ) {
u.state = 'send'; renderUploads();
const buf = new Uint8Array(await u.file.arrayBuffer());
const r = await writeWithProgress(join(u.dir, u.name), buf, frac => {
const r = await writeWithProgress(join(u.dir, u.name), u.file, frac => {
u.frac = frac;
if (frac >= 1 && u.state === 'send') u.state = 'write';
renderUploads();
+117
View File
@@ -27,6 +27,12 @@
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
/* Copy and paste on touch screens: a long press selects, this menu acts. */
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:4px; padding:4px;
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.selmenu[hidden] { display:none; }
.selmenu button { height:36px; padding:0 12px; font-size:14px; touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
</style>
</head>
<body>
@@ -44,6 +50,12 @@
<div class="wrap" style="max-width:none">
<div id="terminal"></div>
</div>
<div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy">Копировать</button>
<button type="button" class="btn ghost" data-a="paste">Вставить</button>
<button type="button" class="btn ghost" data-a="all">Всё</button>
<button type="button" class="btn ghost" data-a="close" aria-label="Закрыть">×</button>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
@@ -272,6 +284,111 @@
});
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
// --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word
// under the finger and dragging on from there stretches the selection; a
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
if (touch) {
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
let timer = null, start = null, anchor = null, selecting = false;
function cellAt(x, y) {
const r = box.querySelector('.xterm-screen').getBoundingClientRect();
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
return { col: col, row: row + term.buffer.active.viewportY };
}
function selectWord(c) {
const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch);
let a = c.col, b = c.col;
if (word(text[c.col])) {
while (a > 0 && word(text[a - 1])) a--;
while (b < text.length - 1 && word(text[b + 1])) b++;
}
term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
}
function stretch(c) {
const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
}
function showMenu() {
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 8) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
menu.hidden = false;
}
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { clearTimeout(timer); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu();
clearTimeout(timer);
timer = setTimeout(function () {
selecting = true;
selectWord(cellAt(start.x, start.y));
if (navigator.vibrate) navigator.vibrate(15);
}, 450);
}, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) {
const t = e.touches[0];
if (selecting) {
e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation();
stretch(cellAt(t.clientX, t.clientY));
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll
}
}, { passive: false, capture: true });
box.addEventListener('touchend', function (e) {
clearTimeout(timer);
if (!selecting) return;
selecting = false;
e.preventDefault(); // no tap: the keyboard stays as it was
e.stopPropagation();
showMenu();
}, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) {
const b = e.target.closest('button');
if (!b) return;
const a = b.dataset.a;
if (a === 'copy') {
const text = term.getSelection();
hideMenu();
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') {
hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') {
term.selectAll();
showMenu();
} else {
hideMenu();
}
});
}
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the