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

+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;