fix: the editor shows an empty page, not the last file, while a file loads; text in UTF-16 and with BOM; + menu groups open and shut smoothly; the assistant's errors in plain words

This commit is contained in:
redtea committed 2026-10-16 09:28:00 +03:00
1 parent b272aed8fd
commit 352b016384
5 files changed
+178 -70

No files matched your search

+12 -1
View File
@@ -352,6 +352,14 @@
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})});
});
function responseError(status, contentType, body) {
const html = /text\/html/i.test(contentType) || /<!doctype\s+html|<html\b|<body\b/i.test(body);
if (html) return status === 502
? 'Сервис ИИ временно недоступен (502 Bad Gateway). Попробуйте позже.'
: 'Сервис ИИ вернул страницу ошибки (HTTP ' + status + '). Попробуйте позже.';
const detail = body.trim().slice(0, 500).replace(/^\[ошибка:\s*/i, '').replace(/\]$/, '').trim();
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
}
// ask sends the new words (or an action's result) and shows the answer.
async function ask(text, res) {
if (text) add('user', text);
@@ -368,7 +376,10 @@
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn()})
});
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
if (!r.body) throw new Error('ошибка ' + r.status);
const contentType = r.headers.get('content-type') || '';
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
if (/text\/html/i.test(contentType)) throw new Error(responseError(r.status, contentType, ''));
if (!r.body) throw new Error('Ошибка сервиса ИИ (HTTP ' + r.status + ').');
const rd = r.body.getReader(), dec = new TextDecoder();
for (;;) {
const {done, value} = await rd.read();
+8 -2
View File
@@ -32,6 +32,7 @@ header.topbar .brand a.home:hover { color: var(--accent); }
header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header.topbar nav { flex: none; white-space: nowrap; }
}
header.topbar nav { display: flex; align-items: center; }
header.topbar nav a { margin-left: 18px; color: var(--muted); }
header.topbar nav a:hover { color: var(--text); }
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
@@ -415,8 +416,13 @@ body.dnd, body.dnd * { cursor:grabbing !important; }
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
.addmenu .chev { margin-left:auto; color:var(--muted); }
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
.addmenu .chev { margin-left:auto; color:var(--muted); transition:transform .2s ease; }
.addmenu .item[aria-expanded="true"] .chev { transform:rotate(90deg); }
.addmenu .mlist { display:grid; grid-template-rows:0fr; margin-top:0; padding-top:0; border-top:0 solid transparent; opacity:0;
transition:grid-template-rows .22s ease, margin-top .22s ease, padding-top .22s ease, border-width .22s ease, opacity .18s ease; }
.addmenu .mlist.open { grid-template-rows:1fr; margin-top:4px; padding-top:4px; border-top:1px solid var(--border); opacity:1; }
.addmenu .mlist-inner { min-height:0; overflow:hidden; }
.addmenu .mlist-content { min-height:0; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. Tabs are
smaller, and there is no splitting. */
+120 -48
View File
@@ -85,17 +85,17 @@
}
}
// Writes a file as it is, with no base64: the request body is the file.
async function putFile(path, body) {
async function putFile(path, body, pane = cur) {
// The save button turns into a small spinner meanwhile, and takes no
// second click.
const btn = $('save');
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
btn.classList.add('saving');
btn.disabled = true;
cur.setBusy(true);
pane.setBusy(true);
try {
return await sendFile(cur.src, path, new Blob([body]));
} finally { cur.setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
return await sendFile(pane.src, path, new Blob([body]));
} finally { pane.setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
}
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
@@ -167,23 +167,76 @@
if (!$('viewer').open) openModal($('viewer'));
}
let viewSeq = 0;
async function view(e) {
const path = join(cur.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);
const pane = cur, seq = ++viewSeq, path = join(pane.cwd, e.name);
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size, pane, seq);
if (DOC.test(e.name)) return viewDoc(path, e.size, pane, seq);
openViewer(path);
V.kind = 'loading';
// The last file's text goes at once: an empty page while this one comes.
const c = editor();
c.setOption('readOnly', true);
c.setValue('');
c.clearHistory();
showPane('code');
setButtons({});
$('vmsg').textContent = 'Чтение файла…';
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { $('vmsg').textContent = 'Не удалось прочитать: ' + (r.error || 'ошибка'); return; }
const decoded = decodeText(r.data);
if (!decoded) return viewHex(path, r.data.size, pane);
viewText(path, r.data, pane, decoded);
}
function viewText(path, d) {
function decodeText(d) {
const bytes = b64ToBytes(d.content);
let encoding = 'utf-8', bom = false, start = 0;
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
encoding = 'utf-16le'; bom = true; start = 2;
} else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
encoding = 'utf-16be'; bom = true; start = 2;
} else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
bom = true; start = 3;
} else if (d.binary && bytes.length >= 8) {
let evenNul = 0, oddNul = 0, pairs = Math.floor(bytes.length / 2);
for (let i = 0; i < pairs * 2; i++) if (bytes[i] === 0) (i % 2 ? oddNul++ : evenNul++);
if (oddNul / pairs > 0.3 && evenNul / pairs < 0.05) encoding = 'utf-16le';
else if (evenNul / pairs > 0.3 && oddNul / pairs < 0.05) encoding = 'utf-16be';
else return null;
} else if (d.binary) return null;
try {
if (encoding.startsWith('utf-16') && (bytes.length - start) % 2) return null;
const text = new TextDecoder(encoding).decode(bytes.subarray(start));
if (d.binary && (/[^\t\n\r\u0020-\ufffd]/.test(text) || text.includes('\ufffd'))) return null;
return {text, encoding, bom};
} catch (_) { return null; }
}
function encodeText(text, decoded) {
let bytes;
if (decoded.encoding === 'utf-8') bytes = new TextEncoder().encode(text);
else {
bytes = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (decoded.encoding === 'utf-16le') { bytes[i * 2] = c & 255; bytes[i * 2 + 1] = c >> 8; }
else { bytes[i * 2] = c >> 8; bytes[i * 2 + 1] = c & 255; }
}
}
if (!decoded.bom) return bytes;
const mark = decoded.encoding === 'utf-8' ? [0xef, 0xbb, 0xbf]
: decoded.encoding === 'utf-16le' ? [0xff, 0xfe] : [0xfe, 0xff];
return new Uint8Array([...mark, ...bytes]);
}
function viewText(path, d, pane = cur, decoded = decodeText(d)) {
openViewer(path);
V.kind = 'code';
const text = new TextDecoder().decode(b64ToBytes(d.content));
showPane('code');
const text = decoded.text;
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));
@@ -194,17 +247,22 @@
V.dirty = false;
setButtons({save: editable});
$('frow2').hidden = $('frow2b').hidden = !editable;
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
const encName = decoded.encoding === 'utf-8' ? 'UTF-8' : decoded.encoding.toUpperCase().replace('UTF-16', 'UTF-16 ');
$('vmsg').textContent = fmtSize(d.size) + (d.size === 0 ? ' · пустой файл' : '') + ' · ' + modeName(mode) + ' · ' + encName +
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
' · Ctrl/Cmd+F - поиск и замена';
$('save').onclick = async () => {
const w = await putFile(path, new TextEncoder().encode(c.getValue()));
if (w.ok) { V.clean = c.changeGeneration(); V.dirty = false; toast('сохранено'); cur.go(cur.cwd); } else toast(w.error || 'ошибка', true);
const seq = viewSeq;
const w = await putFile(path, encodeText(c.getValue(), decoded), pane);
if (w.ok) {
if (seq === viewSeq && V.path === path) { V.clean = c.changeGeneration(); V.dirty = false; }
toast('сохранено'); pane.go(pane.cwd);
} else toast(w.error || 'ошибка', true);
};
setTimeout(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }, 0);
requestAnimationFrame(() => requestAnimationFrame(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }));
}
function viewMedia(path, size) {
function viewMedia(path, size, pane = cur, seq = ++viewSeq) {
openViewer(path);
V.kind = 'media';
showPane('media');
@@ -214,22 +272,25 @@
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
else el.alt = path;
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
el.src = cur.src.url('download', path) + '&inline=1';
el.src = pane.src.url('download', path) + '&inline=1';
box.appendChild(el);
// SVG is text: edited as such, not byte by byte.
const svg = /\.svg$/i.test(path);
setButtons({text: svg, hex: !svg});
$('vmsg').textContent = fmtSize(size);
$('vtext').onclick = async () => {
const r = await api('read', {path});
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
stopMedia();
viewText(path, r.data);
const decoded = decodeText(r.data);
if (decoded) viewText(path, r.data, pane, decoded);
else viewHex(path, size, pane);
};
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
$('vhex').onclick = () => { stopMedia(); viewHex(path, size, pane); };
}
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
async function viewDoc(path, size) {
async function viewDoc(path, size, pane = cur, seq = ++viewSeq) {
openViewer(path);
V.kind = 'doc';
showPane('doc');
@@ -238,15 +299,18 @@
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
setButtons({text: isText, hex: !isText});
$('vtext').onclick = async () => {
const r = await api('read', {path});
const r = await pane.src.op('read', {path});
if (seq !== viewSeq) return;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
viewText(path, r.data);
const decoded = decodeText(r.data);
if (decoded) viewText(path, r.data, pane, decoded);
else viewHex(path, size, pane);
};
$('vhex').onclick = () => viewHex(path, size);
$('vhex').onclick = () => viewHex(path, size, pane);
const box = $('vdoc');
$('vmsg').textContent = fmtSize(size);
if (ext === 'pdf') {
const src = cur.src.url('download', path) + '&inline=1';
const src = pane.src.url('download', path) + '&inline=1';
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
box.firstChild.src = src;
const a = document.createElement('a');
@@ -256,25 +320,27 @@
}
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;
const r = await pane.src.op('read', {path});
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
const decoded = decodeText(r.data);
if (!decoded) { box.innerHTML = '<p class="dv-note">неподдерживаемая кодировка: откройте файл в текстовом или hex-редакторе</p>'; return; }
const f = document.createElement('iframe');
f.className = 'dv-html';
f.setAttribute('sandbox', '');
f.title = 'HTML';
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
f.srcdoc = decoded.text;
box.innerHTML = '';
box.appendChild(f);
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return;
}
if (/^(db|sqlite3?|db3)$/.test(ext) && cur.src.caps.sqlite) {
if (/^(db|sqlite3?|db3)$/.test(ext) && pane.src.caps.sqlite) {
// The agent opens the database and edits it; one that predates that
// gets the file read here, for viewing only.
box.innerHTML = '<p class="dv-note">загрузка…</p>';
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
if (V.path !== path || V.kind !== 'doc') return;
const r = await DBEdit.open(box, path, (op, args) => pane.src.op(op, args), toast);
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
if (r && r.ok) return;
if (!/неизвестная операция/.test((r && r.error) || '')) {
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
@@ -286,11 +352,11 @@
box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html;
try {
cur.setBusy(true);
const r = await fetch(cur.src.url('download', path));
pane.setBusy(true);
const r = await fetch(pane.src.url('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
const buf = await r.arrayBuffer();
if (V.path !== path || V.kind !== 'doc') return;
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
html = ext === 'docx' ? await DocView.docx(buf)
: ext === 'odt' ? await DocView.odt(buf)
: ext === 'xlsx' ? await DocView.xlsx(buf)
@@ -300,8 +366,8 @@
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
} catch (err) {
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
} finally { cur.setBusy(false); }
if (V.path !== path || V.kind !== 'doc') return;
} finally { pane.setBusy(false); }
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
box.innerHTML = html;
DocView.bindTabs(box);
}
@@ -318,8 +384,13 @@
// 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; }
async function viewHex(path, size, pane = cur, seq = ++viewSeq) {
if (size > HEX_CAP) {
const msg = 'для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')';
if (seq === viewSeq) $('vmsg').textContent = msg;
toast(msg, true);
return;
}
openViewer(path);
V.kind = 'hex';
showPane('hex');
@@ -328,23 +399,23 @@
$('hxrows').innerHTML = '';
let buf;
try {
cur.setBusy(true);
const r = await fetch(cur.src.url('download', path));
pane.setBusy(true);
const r = await fetch(pane.src.url('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
buf = new Uint8Array(await r.arrayBuffer());
} catch (err) {
$('vmsg').textContent = 'не удалось прочитать: ' + err.message;
if (seq === viewSeq) $('vmsg').textContent = 'не удалось прочитать: ' + err.message;
return;
} finally { cur.setBusy(false); }
if (V.path !== path || V.kind !== 'hex') return;
} finally { pane.setBusy(false); }
if (seq !== viewSeq || V.path !== path || V.kind !== 'hex') return;
H.data = buf; H.orig = buf.slice(); H.cur = 0; H.nib = 0; H.side = 'hex'; H.changed = 0;
$('hxsp').style.height = (Math.ceil(buf.length / 16) * ROW + 8) + 'px';
$('vhexed').scrollTop = 0;
drawHex(); hexInfo();
$('vhexed').focus();
$('save').onclick = async () => {
const w = await putFile(path, H.data);
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); cur.go(cur.cwd); }
const w = await putFile(path, H.data, pane);
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); pane.go(pane.cwd); }
else toast(w.error || 'ошибка', true);
};
}
@@ -554,6 +625,7 @@
// Closing: Esc first closes the search; unsaved changes are asked about.
async function closeViewer() {
if (V.dirty && !await UI.confirm('Есть несохранённые изменения. Закрыть без сохранения?', {ok: 'Закрыть', danger: true})) return;
++viewSeq;
stopMedia(); closeFind();
H.data = H.orig = null;
V.kind = ''; V.dirty = false;
+37 -19
View File
@@ -474,23 +474,23 @@
return b;
};
const FILES = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
// A group: «Терминал ›» or «Файловый менеджер ›», opening to here, the
// other machines and an SSH host.
const group = (icon, label, kind, sshKind, mode) => {
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev">›</span>', () => {
if (g.nextSibling && g.nextSibling.classList && g.nextSibling.classList.contains('mlist')) { g.nextSibling.remove(); placeMenu(anchor); return; }
addMenu.querySelectorAll('.mlist').forEach(x => x.remove());
const list = document.createElement('div');
list.className = 'mlist';
g.after(list);
if (here) item(list, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
() => { closeMenu(); openPane(kind, here); });
const box = document.createElement('div');
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
list.appendChild(box);
item(list, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here, mode); });
// Both groups keep their contents mounted so they can animate in both directions.
const group = (icon, label, kind, mode) => {
let loaded = false;
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
const opening = g.getAttribute('aria-expanded') !== 'true';
addMenu.querySelectorAll('.item[aria-expanded="true"]').forEach(b => {
b.setAttribute('aria-expanded', 'false');
b.nextSibling.classList.remove('open');
b.nextSibling.inert = true;
});
if (!opening) { placeMenu(anchor); return; }
g.setAttribute('aria-expanded', 'true');
list.classList.add('open');
list.inert = false;
placeMenu(anchor);
if (loaded) return;
loaded = true;
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
.then(all => {
agentsCache = all;
@@ -501,15 +501,33 @@
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
b.disabled = !a.online;
});
placeMenu(anchor);
if (list.isConnected) placeMenu(anchor);
})
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
});
g.setAttribute('aria-expanded', 'false');
const list = document.createElement('div');
list.className = 'mlist';
list.inert = true;
const inner = document.createElement('div');
inner.className = 'mlist-inner';
const content = document.createElement('div');
content.className = 'mlist-content';
inner.appendChild(content);
list.appendChild(inner);
g.after(list);
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
() => { closeMenu(); openPane(kind, here); });
const box = document.createElement('div');
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
content.appendChild(box);
item(content, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here, mode); });
if (!paneTypes[kind]) g.disabled = true;
return g;
};
group('&gt;_', 'Терминал', 'term', 'ssh', 'term');
group(FILES, 'Файловый менеджер', 'files', 'sshfiles', 'files');
group('&gt;_', 'Терминал', 'term', 'term');
group(FILES, 'Файловый менеджер', 'files', 'files');
addMenu.hidden = false;
placeMenu(anchor);
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);