diff options
Diffstat (limited to 'app/web/app.mjs')
| -rw-r--r-- | app/web/app.mjs | 183 |
1 files changed, 0 insertions, 183 deletions
diff --git a/app/web/app.mjs b/app/web/app.mjs deleted file mode 100644 index a6985f0..0000000 --- a/app/web/app.mjs +++ /dev/null @@ -1,183 +0,0 @@ -import { Client } from './client.mjs'; -const $ = id => document.getElementById(id); -const text = new TextDecoder('utf-8', { fatal: true }); -let client = null, current = null, currentPath = '/', busy = false, dirty = false; -let configuration = null; - -function status(message, error = false) { - $('status').textContent = message; - $('status').classList.toggle('error', error); -} -function controls() { - for (const id of ['go', 'reload']) $(id).disabled = busy; - $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty; - $('download').disabled = busy || !current; - $('editor').readOnly = busy; - $('edit-status').textContent = dirty ? 'Unsaved changes.' : 'No unsaved changes.'; - document.querySelector('main').setAttribute('aria-busy', String(busy)); -} -async function action(operation) { - if (busy) return; - busy = true; - controls(); - try { await operation(); } - catch (error) { status(error.message, true); } - finally { busy = false; controls(); } -} -async function connectedClient() { - if (client && !client.closed) return client; - if (!configuration) { - const response = await fetch('/_cloud9/config.json'); - if (!response.ok) throw new Error('Unable to load server settings. Reload to try again.'); - configuration = await response.json(); - } - try { client = await Client.connect('/_cloud9/9p', configuration.user, configuration.tree); } - catch { throw new Error('Unable to reach the file server. Reload to try again.'); } - return client; -} -async function readPath(path) { - const active = await connectedClient(); - try { return await active.readPath(path); } - catch (error) { - // A timed-out/closed transport has lost its fids. Retry reads once on a - // fresh session. Writes are never replayed after an uncertain result. - if (!active.closed) throw error; - return (await connectedClient()).readPath(path); - } -} -function normalizePath(path) { - const parts = []; - for (const name of path.split('/')) { - if (!name || name === '.') continue; - if (name === '..') parts.pop(); else parts.push(name); - } - return '/' + parts.join('/'); -} -function pathURL(path) { - const parts = path.split('/').map(encodeURIComponent); - // Preserve access to an upstream directory named _cloud9 without colliding - // with the literal gateway prefix. Decode each component exactly once. - if (parts[1] === '_cloud9') parts[1] = '%5Fcloud9'; - return parts.join('/'); -} -function locationPath() { - const parts = location.pathname.split('/').map(part => { - let name; - try { name = decodeURIComponent(part); } - catch { throw new Error('The URL contains invalid path encoding.'); } - if (name.includes('/') || name.includes('\0')) throw new Error('The URL contains an invalid path component.'); - return name; - }); - return normalizePath(parts.join('/')); -} -function child(path, name) { return `${path.replace(/\/$/, '')}/${name}`; } -function mode(stat) { - let result = stat.directory ? 'd' : '-'; - for (let bit = 8; bit >= 0; bit--) result += stat.mode & (1 << bit) ? 'rwx'[(8 - bit) % 3] : '-'; - return result; -} -function pathLink(label, path, className = '') { - const link = document.createElement('a'); - link.textContent = label; - link.href = pathURL(path); - link.dataset.path = path; - link.className = className; - return link; -} -function breadcrumbs(path) { - const node = $('breadcrumbs'); - node.replaceChildren(pathLink('root', '/')); - let walked = ''; - for (const name of path.split('/').filter(Boolean)) { - walked += '/' + name; - const separator = document.createElement('span'); - separator.textContent = '/'; separator.className = 'separator'; - node.append(separator, pathLink(name, walked)); - } - node.lastElementChild.setAttribute('aria-current', 'page'); - const parent = path.replace(/\/[^/]+$/, '') || '/'; - $('up').hidden = path === '/'; - $('up').href = pathURL(parent); - $('up').dataset.path = parent; -} -async function navigate(path, historyMode = 'push') { - path = normalizePath(path); - status('Loading…'); - const result = await readPath(path); - current = result; currentPath = path; dirty = false; - $('path').value = path; - const url = pathURL(path); - if (historyMode === 'push' && location.pathname + location.search + location.hash !== url) history.pushState(null, '', url); - document.title = `${path} — cloud9`; - breadcrumbs(path); - $('directory').hidden = !result.stat.directory; - $('file').hidden = result.stat.directory; - if (result.stat.directory) { - const rows = document.createDocumentFragment(); - const sorted = result.entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)); - for (const entry of sorted) { - const row = document.createElement('tr'); - const permissions = document.createElement('td'), name = document.createElement('td'), size = document.createElement('td'); - permissions.className = 'mode'; permissions.textContent = mode(entry); - name.append(pathLink(entry.name + (entry.directory ? '/' : ''), child(path, entry.name), 'entry' + (entry.directory ? ' directory' : ''))); - size.className = 'size'; size.textContent = entry.directory ? '—' : entry.length.toLocaleString(); - row.append(permissions, name, size); rows.append(row); - } - $('entries').replaceChildren(rows); - $('empty').hidden = sorted.length !== 0; - status(`${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`); - } else { - $('filename').textContent = result.stat.name; - $('file-mode').textContent = mode(result.stat); - $('file-size').textContent = `${result.bytes.length.toLocaleString()} bytes`; - let editable = true; - try { - if (result.bytes.includes(0)) throw new Error('Binary'); - $('editor').value = text.decode(result.bytes); - } catch { editable = false; $('editor').value = ''; } - $('editor').hidden = !editable; - $('binary').hidden = editable; - $('edit-actions').hidden = !editable; - status('File loaded.'); - } -} -function mayLeave() { return !dirty || window.confirm('Discard unsaved changes?'); } -document.addEventListener('click', event => { - const link = event.target.closest('a[data-path]'); - if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; - event.preventDefault(); - if (!busy && mayLeave()) action(() => navigate(link.dataset.path)); -}); -$('path-form').onsubmit = event => { - event.preventDefault(); - if (mayLeave()) action(() => navigate($('path').value)); -}; -$('reload').onclick = () => { if (mayLeave()) action(() => navigate(current ? currentPath : locationPath(), 'none')); }; -$('editor').oninput = () => { dirty = true; controls(); }; -$('save').onclick = () => action(async () => { - const bytes = new TextEncoder().encode($('editor').value); - const active = await connectedClient(); - let count; - try { count = await active.writePath(currentPath, bytes); } - catch (error) { - if (active.closed) throw new Error('Save interrupted. The file may be partially written; your edits are still here.'); - throw error; - } - // Keep the editor intact if refreshing after a successful write fails. - dirty = false; - await navigate(currentPath, 'none'); - status(`Saved ${count.toLocaleString()} bytes.`); -}); -$('download').onclick = () => { - const url = URL.createObjectURL(new Blob([current.bytes])); - const link = document.createElement('a'); link.href = url; link.download = current.stat.name; link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); -}; -window.addEventListener('popstate', () => { - if (busy || !mayLeave()) { history.pushState(null, '', pathURL(currentPath)); return; } - action(() => navigate(locationPath(), 'none')); -}); -window.addEventListener('beforeunload', event => { - if (dirty) { event.preventDefault(); event.returnValue = ''; } -}); -action(() => navigate(locationPath(), 'none')); |
