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
@@ -352,6 +352,14 @@
|
|||||||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})});
|
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.
|
// ask sends the new words (or an action's result) and shows the answer.
|
||||||
async function ask(text, res) {
|
async function ask(text, res) {
|
||||||
if (text) add('user', text);
|
if (text) add('user', text);
|
||||||
@@ -368,7 +376,10 @@
|
|||||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn()})
|
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.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();
|
const rd = r.body.getReader(), dec = new TextDecoder();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const {done, value} = await rd.read();
|
const {done, value} = await rd.read();
|
||||||
|
|||||||
@@ -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 .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 { 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 { margin-left: 18px; color: var(--muted); }
|
||||||
header.topbar nav a:hover { color: var(--text); }
|
header.topbar nav a:hover { color: var(--text); }
|
||||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
.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 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 .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 .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
.addmenu .chev { margin-left:auto; color:var(--muted); transition:transform .2s ease; }
|
||||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
.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,
|
/* 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
|
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||||
smaller, and there is no splitting. */
|
smaller, and there is no splitting. */
|
||||||
|
|||||||
@@ -85,17 +85,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Writes a file as it is, with no base64: the request body is the file.
|
// 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
|
// The save button turns into a small spinner meanwhile, and takes no
|
||||||
// second click.
|
// second click.
|
||||||
const btn = $('save');
|
const btn = $('save');
|
||||||
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
|
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
|
||||||
btn.classList.add('saving');
|
btn.classList.add('saving');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
cur.setBusy(true);
|
pane.setBusy(true);
|
||||||
try {
|
try {
|
||||||
return await sendFile(cur.src, path, new Blob([body]));
|
return await sendFile(pane.src, path, new Blob([body]));
|
||||||
} finally { cur.setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
|
} 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;
|
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'));
|
if (!$('viewer').open) openModal($('viewer'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let viewSeq = 0;
|
||||||
async function view(e) {
|
async function view(e) {
|
||||||
const path = join(cur.cwd, e.name);
|
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);
|
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);
|
if (DOC.test(e.name)) return viewDoc(path, e.size, pane, seq);
|
||||||
const r = await api('read', {path});
|
openViewer(path);
|
||||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
V.kind = 'loading';
|
||||||
if (r.data.binary) return viewHex(path, r.data.size);
|
// The last file's text goes at once: an empty page while this one comes.
|
||||||
viewText(path, r.data);
|
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);
|
openViewer(path);
|
||||||
V.kind = 'code';
|
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 editable = d.size <= EDIT_LIMIT && !d.truncated;
|
||||||
const c = editor();
|
const c = editor();
|
||||||
showPane('code');
|
|
||||||
const mode = modeFor(path, text);
|
const mode = modeFor(path, text);
|
||||||
c.setOption('mode', mode);
|
c.setOption('mode', mode);
|
||||||
c.setOption('indentWithTabs', mode === 'go' || /(^|\/)makefile$/i.test(path));
|
c.setOption('indentWithTabs', mode === 'go' || /(^|\/)makefile$/i.test(path));
|
||||||
@@ -194,17 +247,22 @@
|
|||||||
V.dirty = false;
|
V.dirty = false;
|
||||||
setButtons({save: editable});
|
setButtons({save: editable});
|
||||||
$('frow2').hidden = $('frow2b').hidden = !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) + ')') +
|
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
|
||||||
' · Ctrl/Cmd+F - поиск и замена';
|
' · Ctrl/Cmd+F - поиск и замена';
|
||||||
$('save').onclick = async () => {
|
$('save').onclick = async () => {
|
||||||
const w = await putFile(path, new TextEncoder().encode(c.getValue()));
|
const seq = viewSeq;
|
||||||
if (w.ok) { V.clean = c.changeGeneration(); V.dirty = false; toast('сохранено'); cur.go(cur.cwd); } else toast(w.error || 'ошибка', true);
|
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);
|
openViewer(path);
|
||||||
V.kind = 'media';
|
V.kind = 'media';
|
||||||
showPane('media');
|
showPane('media');
|
||||||
@@ -214,22 +272,25 @@
|
|||||||
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
|
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
|
||||||
else el.alt = path;
|
else el.alt = path;
|
||||||
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
|
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);
|
box.appendChild(el);
|
||||||
// SVG is text: edited as such, not byte by byte.
|
// SVG is text: edited as such, not byte by byte.
|
||||||
const svg = /\.svg$/i.test(path);
|
const svg = /\.svg$/i.test(path);
|
||||||
setButtons({text: svg, hex: !svg});
|
setButtons({text: svg, hex: !svg});
|
||||||
$('vmsg').textContent = fmtSize(size);
|
$('vmsg').textContent = fmtSize(size);
|
||||||
$('vtext').onclick = async () => {
|
$('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; }
|
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||||
stopMedia();
|
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.
|
// 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);
|
openViewer(path);
|
||||||
V.kind = 'doc';
|
V.kind = 'doc';
|
||||||
showPane('doc');
|
showPane('doc');
|
||||||
@@ -238,15 +299,18 @@
|
|||||||
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
|
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
|
||||||
setButtons({text: isText, hex: !isText});
|
setButtons({text: isText, hex: !isText});
|
||||||
$('vtext').onclick = async () => {
|
$('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; }
|
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');
|
const box = $('vdoc');
|
||||||
$('vmsg').textContent = fmtSize(size);
|
$('vmsg').textContent = fmtSize(size);
|
||||||
if (ext === 'pdf') {
|
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.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
|
||||||
box.firstChild.src = src;
|
box.firstChild.src = src;
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -256,25 +320,27 @@
|
|||||||
}
|
}
|
||||||
if (ext === 'html' || ext === 'htm') {
|
if (ext === 'html' || ext === 'htm') {
|
||||||
// The page as it looks, in a frame that runs none of its scripts.
|
// The page as it looks, in a frame that runs none of its scripts.
|
||||||
const r = await api('read', {path});
|
const r = await pane.src.op('read', {path});
|
||||||
if (V.path !== path || V.kind !== 'doc') return;
|
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
|
||||||
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; 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');
|
const f = document.createElement('iframe');
|
||||||
f.className = 'dv-html';
|
f.className = 'dv-html';
|
||||||
f.setAttribute('sandbox', '');
|
f.setAttribute('sandbox', '');
|
||||||
f.title = 'HTML';
|
f.title = 'HTML';
|
||||||
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
|
f.srcdoc = decoded.text;
|
||||||
box.innerHTML = '';
|
box.innerHTML = '';
|
||||||
box.appendChild(f);
|
box.appendChild(f);
|
||||||
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
||||||
return;
|
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
|
// The agent opens the database and edits it; one that predates that
|
||||||
// gets the file read here, for viewing only.
|
// gets the file read here, for viewing only.
|
||||||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||||
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
|
const r = await DBEdit.open(box, path, (op, args) => pane.src.op(op, args), toast);
|
||||||
if (V.path !== path || V.kind !== 'doc') return;
|
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
|
||||||
if (r && r.ok) return;
|
if (r && r.ok) return;
|
||||||
if (!/неизвестная операция/.test((r && r.error) || '')) {
|
if (!/неизвестная операция/.test((r && r.error) || '')) {
|
||||||
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
|
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
|
||||||
@@ -286,11 +352,11 @@
|
|||||||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||||
let html;
|
let html;
|
||||||
try {
|
try {
|
||||||
cur.setBusy(true);
|
pane.setBusy(true);
|
||||||
const r = await fetch(cur.src.url('download', path));
|
const r = await fetch(pane.src.url('download', path));
|
||||||
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
||||||
const buf = await r.arrayBuffer();
|
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)
|
html = ext === 'docx' ? await DocView.docx(buf)
|
||||||
: ext === 'odt' ? await DocView.odt(buf)
|
: ext === 'odt' ? await DocView.odt(buf)
|
||||||
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
||||||
@@ -300,8 +366,8 @@
|
|||||||
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
||||||
} finally { cur.setBusy(false); }
|
} finally { pane.setBusy(false); }
|
||||||
if (V.path !== path || V.kind !== 'doc') return;
|
if (seq !== viewSeq || V.path !== path || V.kind !== 'doc') return;
|
||||||
box.innerHTML = html;
|
box.innerHTML = html;
|
||||||
DocView.bindTabs(box);
|
DocView.bindTabs(box);
|
||||||
}
|
}
|
||||||
@@ -318,8 +384,13 @@
|
|||||||
// column sets a byte. Changed bytes stay marked until saved.
|
// 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 H = { data: null, orig: null, cur: 0, nib: 0, side: 'hex', changed: 0 };
|
||||||
const ROW = 20;
|
const ROW = 20;
|
||||||
async function viewHex(path, size) {
|
async function viewHex(path, size, pane = cur, seq = ++viewSeq) {
|
||||||
if (size > HEX_CAP) { toast('для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')', true); return; }
|
if (size > HEX_CAP) {
|
||||||
|
const msg = 'для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')';
|
||||||
|
if (seq === viewSeq) $('vmsg').textContent = msg;
|
||||||
|
toast(msg, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
openViewer(path);
|
openViewer(path);
|
||||||
V.kind = 'hex';
|
V.kind = 'hex';
|
||||||
showPane('hex');
|
showPane('hex');
|
||||||
@@ -328,23 +399,23 @@
|
|||||||
$('hxrows').innerHTML = '';
|
$('hxrows').innerHTML = '';
|
||||||
let buf;
|
let buf;
|
||||||
try {
|
try {
|
||||||
cur.setBusy(true);
|
pane.setBusy(true);
|
||||||
const r = await fetch(cur.src.url('download', path));
|
const r = await fetch(pane.src.url('download', path));
|
||||||
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
||||||
buf = new Uint8Array(await r.arrayBuffer());
|
buf = new Uint8Array(await r.arrayBuffer());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$('vmsg').textContent = 'не удалось прочитать: ' + err.message;
|
if (seq === viewSeq) $('vmsg').textContent = 'не удалось прочитать: ' + err.message;
|
||||||
return;
|
return;
|
||||||
} finally { cur.setBusy(false); }
|
} finally { pane.setBusy(false); }
|
||||||
if (V.path !== path || V.kind !== 'hex') return;
|
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;
|
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';
|
$('hxsp').style.height = (Math.ceil(buf.length / 16) * ROW + 8) + 'px';
|
||||||
$('vhexed').scrollTop = 0;
|
$('vhexed').scrollTop = 0;
|
||||||
drawHex(); hexInfo();
|
drawHex(); hexInfo();
|
||||||
$('vhexed').focus();
|
$('vhexed').focus();
|
||||||
$('save').onclick = async () => {
|
$('save').onclick = async () => {
|
||||||
const w = await putFile(path, H.data);
|
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('сохранено'); cur.go(cur.cwd); }
|
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);
|
else toast(w.error || 'ошибка', true);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -554,6 +625,7 @@
|
|||||||
// Closing: Esc first closes the search; unsaved changes are asked about.
|
// Closing: Esc first closes the search; unsaved changes are asked about.
|
||||||
async function closeViewer() {
|
async function closeViewer() {
|
||||||
if (V.dirty && !await UI.confirm('Есть несохранённые изменения. Закрыть без сохранения?', {ok: 'Закрыть', danger: true})) return;
|
if (V.dirty && !await UI.confirm('Есть несохранённые изменения. Закрыть без сохранения?', {ok: 'Закрыть', danger: true})) return;
|
||||||
|
++viewSeq;
|
||||||
stopMedia(); closeFind();
|
stopMedia(); closeFind();
|
||||||
H.data = H.orig = null;
|
H.data = H.orig = null;
|
||||||
V.kind = ''; V.dirty = false;
|
V.kind = ''; V.dirty = false;
|
||||||
|
|||||||
@@ -474,23 +474,23 @@
|
|||||||
return b;
|
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>';
|
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
|
// Both groups keep their contents mounted so they can animate in both directions.
|
||||||
// other machines and an SSH host.
|
const group = (icon, label, kind, mode) => {
|
||||||
const group = (icon, label, kind, sshKind, mode) => {
|
let loaded = false;
|
||||||
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev">›</span>', () => {
|
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
|
||||||
if (g.nextSibling && g.nextSibling.classList && g.nextSibling.classList.contains('mlist')) { g.nextSibling.remove(); placeMenu(anchor); return; }
|
const opening = g.getAttribute('aria-expanded') !== 'true';
|
||||||
addMenu.querySelectorAll('.mlist').forEach(x => x.remove());
|
addMenu.querySelectorAll('.item[aria-expanded="true"]').forEach(b => {
|
||||||
const list = document.createElement('div');
|
b.setAttribute('aria-expanded', 'false');
|
||||||
list.className = 'mlist';
|
b.nextSibling.classList.remove('open');
|
||||||
g.after(list);
|
b.nextSibling.inert = true;
|
||||||
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); });
|
if (!opening) { placeMenu(anchor); return; }
|
||||||
const box = document.createElement('div');
|
g.setAttribute('aria-expanded', 'true');
|
||||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
list.classList.add('open');
|
||||||
list.appendChild(box);
|
list.inert = false;
|
||||||
item(list, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
|
|
||||||
() => { closeMenu(); openSSH(here, mode); });
|
|
||||||
placeMenu(anchor);
|
placeMenu(anchor);
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||||
.then(all => {
|
.then(all => {
|
||||||
agentsCache = all;
|
agentsCache = all;
|
||||||
@@ -501,15 +501,33 @@
|
|||||||
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
|
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
|
||||||
b.disabled = !a.online;
|
b.disabled = !a.online;
|
||||||
});
|
});
|
||||||
placeMenu(anchor);
|
if (list.isConnected) placeMenu(anchor);
|
||||||
})
|
})
|
||||||
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
.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;
|
if (!paneTypes[kind]) g.disabled = true;
|
||||||
return g;
|
return g;
|
||||||
};
|
};
|
||||||
group('>_', 'Терминал', 'term', 'ssh', 'term');
|
group('>_', 'Терминал', 'term', 'term');
|
||||||
group(FILES, 'Файловый менеджер', 'files', 'sshfiles', 'files');
|
group(FILES, 'Файловый менеджер', 'files', 'files');
|
||||||
addMenu.hidden = false;
|
addMenu.hidden = false;
|
||||||
placeMenu(anchor);
|
placeMenu(anchor);
|
||||||
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{define "head"}}<meta charset="utf-8">
|
{{define "head"}}<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
|
||||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<script src="{{asset "pin.js"}}"></script>
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
<script src="{{asset "scroll.js"}}" defer></script>{{end}}
|
<script src="{{asset "scroll.js"}}" defer></script>{{end}}
|
||||||
|
|||||||
Reference in new issue
Block a user