1043 lines
55 KiB
HTML
1043 lines
55 KiB
HTML
<!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; }
|
||
/* Sortable headers: a click sorts by the column, a second one turns it round. */
|
||
table.fm th.srt { cursor:pointer; user-select:none; white-space:nowrap; }
|
||
table.fm th.srt:hover { color:var(--text); }
|
||
table.fm th.srt i { font-style:normal; display:inline-block; width:12px; margin-left:4px; color:var(--accent); font-size:10px; }
|
||
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; }
|
||
#save { display:inline-flex; align-items:center; gap:8px; }
|
||
#save.saving { opacity:.85; cursor:progress; }
|
||
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
|
||
border-radius:50%; animation:spin .7s linear infinite; }
|
||
#save[hidden] { display:none; }
|
||
/* 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, the
|
||
buttons of each row after its field. */
|
||
.find { display:grid; grid-template-columns:minmax(0, 1fr) 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; justify-self:end; display:flex; gap:6px; }
|
||
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px;
|
||
display:inline-flex; align-items:center; justify-content:center; }
|
||
.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 #frow2, .find #frow2b { grid-row:2; }
|
||
/* Documents and tables, drawn in the panel's colours. */
|
||
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
|
||
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
|
||
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
|
||
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
|
||
.dv-doc p { margin:0 0 .7em; }
|
||
.dv-doc h1, .dv-doc h2, .dv-doc h3, .dv-doc h4, .dv-doc h5, .dv-doc h6 { margin:1.2em 0 .5em; line-height:1.3; }
|
||
.dv-doc h1 { font-size:26px; } .dv-doc h2 { font-size:21px; } .dv-doc h3 { font-size:18px; } .dv-doc h4, .dv-doc h5, .dv-doc h6 { font-size:16px; }
|
||
.dv-doc ul { margin:0 0 .7em; padding-left:1.4em; }
|
||
.dv-doc li p { margin:0; }
|
||
.dv-doc .dv-link { color:var(--accent); }
|
||
.dv-table { border-collapse:collapse; margin:.4em 0 1em; width:auto; }
|
||
.dv-table td { border:1px solid var(--border); padding:6px 10px; vertical-align:top; font-size:14px; }
|
||
.dv-table td p { margin:0; }
|
||
.dv-scroll { overflow:auto; }
|
||
.dv-grid { max-height:100%; }
|
||
.dv-sheet { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
|
||
.dv-sheet th, .dv-sheet td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:4px 10px;
|
||
white-space:nowrap; max-width:360px; overflow:hidden; text-overflow:ellipsis; text-align:left; }
|
||
.dv-sheet thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--muted); font-weight:500; text-align:center; }
|
||
.dv-sheet tbody th { position:sticky; left:0; background:var(--panel); color:var(--muted); font-weight:500; text-align:right; }
|
||
.dv-sheet thead th:first-child { left:0; z-index:2; }
|
||
.dv-sheet td.n { text-align:right; }
|
||
.dv-tabs { position:sticky; top:0; z-index:3; display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--border); }
|
||
.dv-tabs button { height:28px; padding:0 12px; font-size:13px; background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||
.dv-tabs button.on { border-color:var(--accent); color:var(--text); background:#12202f; }
|
||
.dv-note { color:var(--muted); font-size:13px; padding:12px 16px; margin:0; }
|
||
/* 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 { z-index:50; 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 class="srt" data-sort="name">Имя<i></i></th><th class="sz c-sz srt" data-sort="size">Размер<i></i></th>
|
||
<th class="sz c-mt srt" data-sort="mtime">Изменён<i></i></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 close" 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)" aria-label="Предыдущее">
|
||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
|
||
</button>
|
||
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
|
||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
|
||
</button>
|
||
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
|
||
<svg width="13" height="13" 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>
|
||
<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="vdoc" id="vdoc" 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 close" 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 src="/static/docview.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);
|
||
// An open dialog sits in the page's top layer, above any z-index: the toast
|
||
// goes inside it then, so "сохранено" or an error shows over the editor.
|
||
let toastTimer = null;
|
||
const toast = (msg, err) => {
|
||
const t = $('toast'), host = document.querySelector('dialog[open]') || document.body;
|
||
if (t.parentNode !== host) host.appendChild(t);
|
||
t.textContent = msg;
|
||
t.className = 'toast show' + (err ? ' err' : '');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.className = 'toast', err ? 3500 : 2200);
|
||
};
|
||
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
|
||
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
|
||
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
|
||
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
|
||
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
|
||
|
||
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();
|
||
}
|
||
|
||
// Sorting: by name, size or time, folders always ahead of files. Kept in
|
||
// this browser for each folder of each machine.
|
||
const SORT_DEF = {by: 'name', dir: 1};
|
||
const sortKey = () => 'lpra.sort:' + agentID + ':' + cwd;
|
||
function getSort() {
|
||
try { const v = JSON.parse(localStorage.getItem(sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) {}
|
||
return SORT_DEF;
|
||
}
|
||
function setSort(v) {
|
||
try {
|
||
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(sortKey());
|
||
else localStorage.setItem(sortKey(), JSON.stringify(v));
|
||
} catch (e) {}
|
||
}
|
||
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||
function sorted() {
|
||
const s = getSort();
|
||
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
|
||
return entries.slice().sort((a, b) => {
|
||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||
const d = key ? key(a) - key(b) : 0;
|
||
return (d || byName(a, b)) * s.dir;
|
||
});
|
||
}
|
||
function showSort() {
|
||
const s = getSort();
|
||
document.querySelectorAll('table.fm th.srt').forEach(th => {
|
||
th.querySelector('i').textContent = th.dataset.sort === s.by ? (s.dir > 0 ? '▲' : '▼') : '';
|
||
});
|
||
}
|
||
document.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
|
||
const s = getSort(), by = th.dataset.sort;
|
||
// A new column starts the way it is most often wanted: names A to Z,
|
||
// sizes and times biggest and newest first.
|
||
setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
|
||
render();
|
||
});
|
||
|
||
function render() {
|
||
showSort();
|
||
const tb = $('list'); tb.innerHTML = '';
|
||
updateSel();
|
||
for (const e of sorted()) {
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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) {
|
||
// 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;
|
||
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); 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',
|
||
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) $('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);
|
||
if (DOC.test(e.name)) return viewDoc(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;
|
||
$('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);
|
||
// 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 api('read', {path});
|
||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||
stopMedia();
|
||
viewText(path, r.data);
|
||
};
|
||
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
|
||
}
|
||
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
|
||
async function viewDoc(path, size) {
|
||
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 api('read', {path});
|
||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||
viewText(path, r.data);
|
||
};
|
||
$('vhex').onclick = () => viewHex(path, size);
|
||
const box = $('vdoc');
|
||
$('vmsg').textContent = fmtSize(size);
|
||
if (ext === 'pdf') {
|
||
const src = fsURL('download', path) + '&inline=1';
|
||
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
|
||
box.firstChild.src = src;
|
||
const a = document.createElement('a');
|
||
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'открыть в новой вкладке';
|
||
$('vmsg').append(' · ', a);
|
||
return;
|
||
}
|
||
if (ext === 'html' || ext === 'htm') {
|
||
// The page as it looks, in a frame that runs none of its scripts.
|
||
const r = await api('read', {path});
|
||
if (V.path !== path || V.kind !== 'doc') return;
|
||
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||
const f = document.createElement('iframe');
|
||
f.className = 'dv-html';
|
||
f.setAttribute('sandbox', '');
|
||
f.title = 'HTML';
|
||
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
|
||
box.innerHTML = '';
|
||
box.appendChild(f);
|
||
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
||
return;
|
||
}
|
||
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
|
||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||
let html;
|
||
try {
|
||
setBusy(true);
|
||
const r = await fetch(fsURL('download', path));
|
||
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
||
const buf = await r.arrayBuffer();
|
||
if (V.path !== path || V.kind !== 'doc') return;
|
||
html = ext === 'docx' ? await DocView.docx(buf)
|
||
: ext === 'odt' ? await DocView.odt(buf)
|
||
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
||
: ext === 'ods' ? await DocView.ods(buf)
|
||
: ext === 'doc' ? DocView.doc(buf)
|
||
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
|
||
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
||
} catch (err) {
|
||
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
||
} finally { setBusy(false); }
|
||
if (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) {
|
||
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 === '<' ? '<' : c === '&' ? '&' : 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>
|