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:
1 parent
b272aed8fd
commit
352b016384
5 files changed
+178
-70
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user