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'));