Files
lpra/internal/server/web/templates/files.html
T

880 lines
46 KiB
HTML
Raw 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.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<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. */
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
display:inline-flex; align-items:center; justify-content:center;
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
}
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
border-radius:50%; animation:spin .7s linear infinite; display:none; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Busy indicator; while files upload it also shows how far they got, and a
click on it lists them. */
.busy { display:inline-flex; align-items:center; gap:6px; background:none; border:0; padding:0 4px;
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
.busy:hover { background:none; }
.busy.up { cursor:pointer; color:var(--text); }
.busy.up:hover { color:var(--accent); }
#updlg { max-width:480px; }
.upl-item { padding:8px 0; border-bottom:1px solid var(--border); }
.upl-item:last-child { border-bottom:0; }
.upl-row { display:flex; gap:12px; justify-content:space-between; font-size:14px; }
.upl-row .n { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.upl-row .st { flex:none; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
.upl-row .st.ok { color:var(--ok); }
.upl-row .st.bad { color:var(--danger); }
.upl-bar { height:4px; margin-top:6px; border-radius:2px; background:#0c0f13; overflow:hidden; }
.upl-bar i { display:block; height:100%; background:var(--accent); transform-origin:left; transition:transform .2s; }
#drop { border:1px dashed var(--border); border-radius:10px; }
#drop.drag { border-color:var(--accent); background:#12202f; }
table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); }
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
table.fm .c-sz { width:120px; }
table.fm .c-mt { width:160px; }
table.fm .c-act { width:160px; }
.fm .nm { overflow-wrap:anywhere; }
/* Phones: no date column, and the row buttons only as wide as they are. */
@media (max-width: 640px) {
table.fm .c-mt, table.fm td.mt { display:none; }
table.fm .c-sz { width:auto; white-space:nowrap; }
table.fm .c-act { width:1%; }
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
}
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; }
/* 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: both fields the same width, their
buttons in a column of their own, the close button beside both rows. */
.find { display:grid; grid-template-columns:minmax(0, 1fr) auto auto; gap:6px 8px; align-items:center;
padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
.find .fin { position:relative; grid-column:1; }
.find input { width:100%; height:32px; padding:4px 76px 4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; }
.find #frep { padding-right:10px; }
.find input.bad { border-color:var(--danger); }
.find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none;
color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; }
.find .fbtns { grid-column:2; display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:6px; }
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px; }
.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 .fx { grid-column:3; grid-row:1 / span 2; align-self:center; width:32px; height:32px; padding:0;
display:inline-flex; align-items:center; justify-content:center;
background:transparent; border:1px solid rgba(255,92,92,.55); color:var(--danger); }
.find .fx:hover { background:#3a1d1d; border-color:var(--danger); }
.find.ro .fx { grid-row:1; }
.find #frow2, .find #frow2b { grid-row:2; }
/* 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; }
.toast.show { opacity:1; }
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap" style="max-width:1100px">
<div class="fm-bar">
<button class="btn ghost small" id="up">↑ Вверх</button>
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
<button class="btn ghost small" id="refresh">Обновить</button>
</div>
<div class="fm-bar">
<button class="btn small" id="mkdir">+ Папка</button>
<button class="btn small" id="newfile">+ Файл</button>
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
</div>
<div class="card" id="drop" style="padding:0">
<table class="fm">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="all"></th>
<th>Имя</th><th class="sz c-sz">Размер</th>
<th class="sz c-mt">Изменён</th><th class="c-act"></th>
</tr></thead>
<tbody id="list"></tbody>
</table>
</div>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div>
<dialog id="viewer">
<div class="dlg-h">
<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="vbody">
<div class="find" id="find" hidden>
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
<div class="fbtns">
<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>
</div>
<button type="button" class="fx" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
<div class="fbtns" id="frow2b">
<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>
<dialog id="updlg">
<div class="dlg-h">
<strong style="flex:1">Загрузка файлов</strong>
<button class="btn ghost small" id="updlg-close">Закрыть</button>
</div>
<div class="dlg-b" id="updlg-list"></div>
</dialog>
<div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script>
<script>
(function () {
const agentID = {{.Agent.ID}};
let busyN = 0;
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
const api = (op, body) => {
setBusy(true);
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {})
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
};
const EDIT_LIMIT = {{.EditLimit}};
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_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 = [];
const selected = new Set();
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 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() : '';
async function load(path) {
const r = await api('list', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd;
render();
}
// Editable path: Enter navigates, or reports a missing directory.
async function navTo(p) {
p = (p || '').trim() || '/';
const r = await api('list', {path: p});
if (!r.ok) { toast(r.error || 'нет такого каталога', true); $('path').value = cwd; return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd; render();
}
function render() {
const tb = $('list'); tb.innerHTML = '';
updateSel();
for (const e of entries) {
const tr = document.createElement('tr');
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' +
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" 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>' +
'</div></td>';
const nm = tr.querySelector('.nm');
nm.onclick = () => e.is_dir ? load(join(cwd, e.name)) : view(e);
tr.querySelector('.ck').onchange = ev => { const n=e.name; ev.target.checked?selected.add(n):selected.delete(n); updateSel(); };
tr.querySelector('[data-act="del"]').onclick = () => del(e);
tr.querySelector('[data-act="ren"]').onclick = () => move(e);
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
tb.appendChild(tr);
}
}
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function updateSel() {
const n = selected.size;
$('selinfo').textContent = n ? ('выбрано: '+n) : '';
$('zipsel').disabled = !n; $('delsel').disabled = !n;
}
// ---------- 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); }
}
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({save: editable});
$('frow2').hidden = $('frow2b').hidden = !editable;
$('find').classList.toggle('ro', !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);
};
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) {
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
const r = await api('deltree', {path: join(cwd, e.name)});
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
} else {
if (!confirm('Удалить файл "'+e.name+'"?')) return;
const r = await api('delete', {path: join(cwd, e.name)});
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
}
}
async function move(e) {
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
if (!dst) return;
const r = await api('move', {src: join(cwd, e.name), dst});
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
}
async function unzip(e) {
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
r.ok ? (toast('распаковано'), load(cwd)) : toast(r.error||'ошибка', true);
}
$('mkdir').onclick = async () => {
const n = prompt('Имя новой папки:'); if (!n) return;
const r = await api('mkdir', {path: join(cwd, n)});
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('newfile').onclick = async () => {
const n = prompt('Имя нового файла:'); if (!n) return;
const r = await api('write', {path: join(cwd, n), data: ''});
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('delsel').onclick = async () => {
if (!confirm('Удалить выбранное ('+selected.size+')?')) return;
for (const n of selected) {
const e = entries.find(x => x.name === n);
await api(e && e.is_dir ? 'deltree' : 'delete', {path: join(cwd, n)});
}
toast('удалено'); load(cwd);
};
$('zipsel').onclick = async () => {
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
const paths = [...selected].map(n => join(cwd, n));
const r = await api('zip', {paths, dest: join(cwd, name)});
r.ok ? (toast('архив создан'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('all').onchange = ev => {
selected.clear();
if (ev.target.checked) entries.forEach(e => selected.add(e.name));
document.querySelectorAll('.ck').forEach(c => c.checked = ev.target.checked);
updateSel();
};
$('up').onclick = () => load(parent(cwd));
$('refresh').onclick = () => load(cwd);
$('path').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
});
// uploads: one file at a time, each with its progress. What has gone up
// shows as a percentage by the spinner; a click on it lists the files.
const uploads = [];
let upActive = false, upDoneTimer = null;
const UP_ST = { wait: 'в очереди', send: '', write: 'запись на клиенте…', done: 'готово', fail: 'ошибка' };
function renderUploads() {
const pend = uploads.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
const counted = uploads.filter(u => !u.skip);
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
const busy = $('busy');
busy.classList.toggle('up', uploads.length > 0);
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : '';
const list = $('updlg-list');
if (!$('updlg').open) return;
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
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);
}
}
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
$('updlg-close').onclick = () => $('updlg').close();
// XHR rather than fetch: it reports how much of the request has gone out.
// 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', 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(file);
});
}
async function uploadFiles(files) {
clearTimeout(upDoneTimer);
// 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 > 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();
if (upActive) return;
upActive = true;
setBusy(true);
let failed = 0;
// Files refused up front count as failed too.
const refused = () => uploads.filter(u => u.skip).length;
for (let u; (u = uploads.find(x => x.state === 'wait')); ) {
u.state = 'send'; renderUploads();
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();
});
if (r.ok) u.state = 'done';
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
renderUploads();
if (u.dir === cwd) load(cwd);
}
setBusy(false);
upActive = false;
failed += refused();
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
renderUploads();
// The list stays a while for a look, then the indicator goes quiet.
upDoneTimer = setTimeout(() => { if (!upActive) { uploads.length = 0; renderUploads(); } }, failed ? 30000 : 8000);
}
$('upl').onchange = ev => { uploadFiles(ev.target.files); ev.target.value = ''; };
const drop = $('drop');
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
// Start in the home directory of the machine's user; an agent too old to
// know "~" fails that listing, so fall back to the root.
(async () => {
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
if (!r.ok) { load('/'); return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd;
render();
})();
})();
</script>
</body>
</html>