diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-19 23:28:22 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-19 23:28:22 -0300 |
| commit | ba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch) | |
| tree | 282ba00ce5b10d7416aecb9f2f0f0a439340a57d /web/static/app.mjs | |
| parent | b05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff) | |
| download | cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip | |
Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web)
Directories, binaries, build options (-D9ns, -D9proc), step names, module
name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT,
docs and test scripts. Browser assets move to web/static.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'web/static/app.mjs')
| -rw-r--r-- | web/static/app.mjs | 183 |
1 files changed, 183 insertions, 0 deletions
diff --git a/web/static/app.mjs b/web/static/app.mjs new file mode 100644 index 0000000..a6985f0 --- /dev/null +++ b/web/static/app.mjs @@ -0,0 +1,183 @@ +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')); |
