TR
apps/jumphp-ide-remote / ui / js / views / files.js · commit b81e652
// IDE Remote: read-only file browser (scope 'all'): a tree and a viewer (no editor: <pre> with line numbers).// Self-mounting in <section id="panel-files"> (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 <section>, 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 };});