// IDE Remote: read-only file browser (scope 'all'): a tree and a viewer (no editor:
with line numbers). // Self-mounting in(the shell loads this as a module). Commands: files.list {} -> {files:[{path,size}]} // (the IDE's name; `file.list` is tried when it fails), file.read {path} -> {text, content | image | audio | size}. (function (root, factory) { const api = factory(root); if (typeof module === 'object' && module.exports) module.exports = api; else { (root.IdeRemoteViews = root.IdeRemoteViews || {}).files = { mount: api.mount, pure: api.pure }; if (root.document && root.document.getElementById) api.boot('files', api.mount, ['vendor/ide-md.js']); } })(typeof window !== 'undefined' ? window : globalThis, function (root) { // Texts: the page's table (config/lang.yaml, 14 languages) through IdeRemote.t; the English given here is the fallback (and what tests see). const L = (key, en, vars) => (root.IdeRemote && root.IdeRemote.t ? root.IdeRemote.t(key, vars, en) : String(en).replace(/\{(\w+)\}/g, (m, k) => (vars && k in vars ? vars[k] : m))); const errText = (e) => (root.IdeRemote && root.IdeRemote.errorText ? root.IdeRemote.errorText(e) : (e && e.message) || String(e)); // ---------------------------------------------------------------- pure part /** Flat [{path, size}] (or paths) -> nested {name, path, dir, children[], size}; folders first, then A-Z. */ function buildTree(files) { const top = { name: '', path: '', dir: true, children: [] }; for (const f of files || []) { const path = typeof f === 'string' ? f : f && (f.path || f.name); if (!path) continue; const parts = String(path).split('/').filter(Boolean); let node = top; parts.forEach((name, i) => { const last = i === parts.length - 1; let child = node.children.find((c) => c.name === name && c.dir === !last); if (!child) { child = { name, path: parts.slice(0, i + 1).join('/'), dir: !last, children: [], size: last ? (f && f.size) || 0 : 0 }; node.children.push(child); } node = child; }); } const sort = (n) => { n.children.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)); n.children.forEach(sort); }; sort(top); return top; } /** The viewer's lines: {lines: string[], cut: number} (cut = lines left out beyond `max`). */ function toLines(content, max) { const all = String(content || '').replace(/\r\n?/g, '\n').split('\n'); if (all.length && all[all.length - 1] === '') all.pop(); return { lines: all.slice(0, max), cut: Math.max(0, all.length - max) }; } function humanSize(n) { return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' kB' : n + ' B'; } const pure = { buildTree, toLines, humanSize }; // ---------------------------------------------------------------- DOM part function mount(container) { const R = root.IdeRemote, { el, clear } = root.IdeMd; const open = new Set(['']); let tree = null, current = null, query = ''; const MAX = 4000; const search = el('input.rv-search', { type: 'search', placeholder: L('filter_files', 'Filter files…'), autocomplete: 'off', autocapitalize: 'off', spellcheck: 'false', 'aria-label': L('filter_files_aria', 'Filter files'), oninput: () => { query = search.value.trim().toLowerCase(); paintTree(); } }); const reload = el('button.rv-btn.rv-small', { type: 'button', onclick: () => load() }, L('reload', 'Reload')); const treeBox = el('div.rv-tree', { role: 'tree' }); const treePane = el('div.rv-files-tree', {}, el('div.rv-files-bar', {}, search, reload), treeBox); const title = el('div.rv-viewer-title'); const back = el('button.rv-btn.rv-small.rv-back', { type: 'button', onclick: () => container.classList.remove('rv-viewing') }, '‹ Files'); const body = el('div.rv-viewer-body'); const viewPane = el('div.rv-files-view', {}, el('div.rv-files-bar', {}, back, title), body); clear(container); container.classList.add('rv-files'); container.append(treePane, viewPane); body.append(el('div.rv-empty', {}, L('pick_file', 'Pick a file to read it.'))); async function load() { clear(treeBox); treeBox.append(el('div.rv-empty', {}, L('loading', 'Loading…'))); let r; try { r = await R.invoke('files.list', {}); } catch (e) { try { r = await R.invoke('file.list', {}); } catch (e2) { clear(treeBox); treeBox.append(el('div.rv-empty.rv-error', {}, errText(e2))); return; } } tree = buildTree(Array.isArray(r) ? r : (r && r.files) || []); paintTree(); } function paintTree() { clear(treeBox); if (!tree) return; if (!tree.children.length) { treeBox.append(el('div.rv-empty', {}, L('no_files', 'No files.'))); return; } const walk = (n, depth) => { for (const c of n.children) { if (query) { if (c.dir) walk(c, depth + 1); else if (c.path.toLowerCase().includes(query)) treeBox.append(row(c, 0, c.path)); continue; } treeBox.append(row(c, depth, c.name)); if (c.dir && open.has(c.path)) walk(c, depth + 1); } }; walk(tree, 0); } function row(c, depth, label) { return el('button.rv-row' + (c.dir ? '.rv-dir' : '') + (current === c.path ? '.on' : ''), { type: 'button', role: 'treeitem', style: `padding-left:${12 + depth * 16}px`, 'aria-expanded': c.dir ? String(open.has(c.path)) : undefined, onclick: () => { if (c.dir) { open.has(c.path) ? open.delete(c.path) : open.add(c.path); paintTree(); } else show(c); }, }, el('span.rv-ico', {}, c.dir ? (open.has(c.path) ? '▾' : '▸') : '·'), el('span.rv-name', {}, label)); } async function show(c) { current = c.path; title.textContent = c.path; container.classList.add('rv-viewing'); clear(body); body.append(el('div.rv-empty', {}, L('loading', 'Loading…'))); paintTree(); try { const f = await R.invoke('file.read', { path: c.path }); if (current !== c.path) return; clear(body); if (f && f.image) body.append(el('img.rv-image', { src: f.image, alt: c.path })); else if (f && f.audio) body.append(el('audio', { src: f.audio, controls: true })); else if (f && f.text !== false && typeof f.content === 'string') { const { lines, cut } = toLines(f.content, MAX); const gutter = el('pre.rv-gutter', { 'aria-hidden': 'true' }, lines.map((_, i) => i + 1).join('\n')); body.append(el('div.rv-code', {}, gutter, el('pre.rv-pre', {}, lines.join('\n')))); if (cut) body.append(el('div.rv-note', {}, L('more_lines', '{n} more line(s) not shown.', { n: cut }))); } else body.append(el('div.rv-empty', {}, f && f.size ? L('binary_file_size', 'Binary file ({size}): not shown.', { size: humanSize(f.size) }) : L('binary_file', 'Binary file: not shown.'))); body.scrollTop = 0; } catch (e) { if (current !== c.path) return; clear(body); body.append(el('div.rv-empty.rv-error', {}, errText(e))); } } let loaded = false; const once = () => { if (!loaded && R.state === 'connected') { loaded = true; load(); } }; R.on('ready', () => { loaded = false; if (container.offsetWidth) once(); }); R.on('panel', (id) => { if (id === 'files') once(); }); once(); return { reload: load }; } // The shell loads this file as a module and never calls mount(): the view mounts itself in its , loading its libraries first. function boot(id, mount, assets) { const doc = root.document; const load = (src) => { const cache = (root.__rvAssets = root.__rvAssets || {}); if (cache[src]) return cache[src]; return (cache[src] = new Promise((resolve, reject) => { if (src.endsWith('.css')) { const l = doc.createElement('link'); l.rel = 'stylesheet'; l.href = src; l.onload = resolve; l.onerror = resolve; doc.head.append(l); return; } const s = doc.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('cannot load ' + src)); doc.head.append(s); })); }; const run = async () => { const c = doc.getElementById('panel-' + id); if (!c || c.dataset.rvMounted) return; c.dataset.rvMounted = '1'; try { for (const a of assets) await load(a); mount(c); } catch (e) { c.textContent = errText(e); } }; if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run); else run(); } return { mount, pure, boot, ...pure }; });