diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 4fc5a05..e08d2a3 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -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) || /= 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 = ''; 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 = '
не удалось показать: ' + esc(r.error || 'ошибка') + '
'; return; } + const decoded = decodeText(r.data); + if (!decoded) { box.innerHTML = 'неподдерживаемая кодировка: откройте файл в текстовом или hex-редакторе
'; 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 = 'загрузка…
'; - 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 = 'не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '
'; @@ -286,11 +352,11 @@ box.innerHTML = 'загрузка…
'; 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 = 'не удалось показать: ' + esc(err.message) + '
'; - } 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; diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 8ff9c10..dbc7704 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -474,23 +474,23 @@ return b; }; const FILES = ''; - // A group: «Терминал ›» or «Файловый менеджер ›», opening to here, the - // other machines and an SSH host. - const group = (icon, label, kind, sshKind, mode) => { - const g = item(addMenu, '' + icon + '' + label + '›', () => { - 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, '+Здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', - () => { closeMenu(); openPane(kind, here); }); - const box = document.createElement('div'); - box.innerHTML = '