diff options
Diffstat (limited to 'web/static')
| -rw-r--r-- | web/static/app.mjs | 183 | ||||
| -rw-r--r-- | web/static/client.mjs | 138 | ||||
| -rw-r--r-- | web/static/index.html | 53 | ||||
| -rw-r--r-- | web/static/style.css | 87 |
4 files changed, 461 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')); diff --git a/web/static/client.mjs b/web/static/client.mjs new file mode 100644 index 0000000..69f44d2 --- /dev/null +++ b/web/static/client.mjs @@ -0,0 +1,138 @@ +// Browser transport and file operations. All 9P serialization lives in WASM. +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +export class Client { + static async connect(url = '/_cloud9/9p', user = 'user', tree = '') { + const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL('./cloud9.wasm', import.meta.url)), {}); + const wsURL = new URL(url, location.href); + wsURL.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(wsURL); + socket.binaryType = 'arraybuffer'; + const client = new Client(instance.exports, socket); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Connection timed out')), 10000); + socket.onopen = () => { clearTimeout(timer); resolve(); }; + socket.onerror = () => { clearTimeout(timer); reject(new Error('Cannot connect to 9P bridge')); }; + }); + await client.ask('version'); + const u = encoder.encode(user), a = encoder.encode(tree); + client.stage(new Uint8Array([...u, ...a])); + await client.ask('attach', u.length, a.length); + return client; + } catch (error) { client.close(); throw error; } + } + constructor(wasm, socket) { + this.wasm = wasm; + this.socket = socket; + this.pending = null; + this.queue = Promise.resolve(); + this.closed = false; + wasm.init(); + socket.onmessage = ({ data }) => { + if (!this.pending) { this.close(); return; } + try { + this.stage(new Uint8Array(data)); + const kind = wasm.receive(data.byteLength); + if (!kind) return; + const result = { kind, data: this.data(), number: wasm.result_number(), directory: Boolean(wasm.result_directory()) }; + const pending = this.pending; + this.pending = null; + clearTimeout(pending.timer); + if (kind < 0) { + const error = new Error(decoder.decode(result.data)); + pending.reject(error); + if (wasm.is_dead()) this.abort(error); + } + else pending.resolve(result); + } catch (error) { this.abort(error); } + }; + socket.onclose = () => this.abort(new Error('Connection closed')); + } + data() { return new Uint8Array(this.wasm.memory.buffer, this.wasm.data_ptr(), this.wasm.data_len()).slice(); } + stage(bytes) { + if (bytes.length > this.wasm.input_capacity()) throw new Error('Input exceeds 64 KiB'); + new Uint8Array(this.wasm.memory.buffer, this.wasm.input_ptr(), bytes.length).set(bytes); + return bytes.length; + } + ask(operation, ...args) { + if (this.closed) return Promise.reject(new Error('Connection closed')); + if (this.pending) return Promise.reject(new Error('Request already in flight')); + if (this.wasm[operation](...args) < 0) return Promise.reject(new Error(decoder.decode(this.data()))); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => this.abort(new Error('9P request timed out')), 15000); + this.pending = { resolve, reject, timer }; + try { + this.socket.send(new Uint8Array(this.wasm.memory.buffer, this.wasm.output_ptr(), this.wasm.output_len())); + this.wasm.sent(); + } catch (error) { this.abort(error); } + }); + } + abort(error) { + this.closed = true; + if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(error); this.pending = null; } + this.socket.close(); + } + close() { this.abort(new Error('Disconnected')); } + serial(operation) { + const result = this.queue.then(operation); + this.queue = result.catch(() => {}); + return result; + } + async withFile(path, operation) { + const names = path.split('/').filter(Boolean); + let live = false; + try { + // Walk in spec-sized batches, retaining an unopened root fid. + for (let index = 0; index < Math.max(1, names.length); index += 16) { + const batch = names.slice(index, index + 16); + const len = this.stage(encoder.encode(batch.join('/'))); + const result = await this.ask('walk', index === 0 ? 0 : 1, 1, len); + live = live || result.number > 0 || batch.length === 0; + if (result.number !== batch.length) throw new Error('Path does not exist'); + } + return await operation(); + } finally { + if (live && !this.closed) await this.ask('clunk', 1); + } + } + readPath(path) { + return this.serial(() => this.withFile(path, async () => { + const stat = JSON.parse(decoder.decode((await this.ask('stat', 1)).data)); + const opened = await this.ask('open', 1, 0); + const count = Math.min(this.wasm.max_read(), opened.number || Infinity); + let offset = 0; + const chunks = [], entries = []; + while (true) { + const { data } = await this.ask('read', 1, BigInt(offset), count); + if (!data.length) break; + offset += data.length; + if (offset > 8 * 1024 * 1024) throw new Error('Browser view is limited to 8 MiB'); + if (stat.directory) { + this.stage(data); + if (this.wasm.decode_directory(data.length) < 0) throw new Error(decoder.decode(this.data())); + entries.push(...JSON.parse(decoder.decode(this.data()))); + } else chunks.push(data); + } + const bytes = new Uint8Array(stat.directory ? 0 : offset); + let position = 0; + for (const chunk of chunks) { bytes.set(chunk, position); position += chunk.length; } + return { stat, entries, bytes }; + })); + } + writePath(path, bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length > 8 * 1024 * 1024) return Promise.reject(new Error('Write is limited to 8 MiB')); + return this.serial(() => this.withFile(path, async () => { + const opened = await this.ask('open', 1, 1 | 16); // OWRITE | OTRUNC + const count = Math.min(this.wasm.max_write(), opened.number || Infinity); + let offset = 0; + while (offset < bytes.length) { + const len = this.stage(bytes.subarray(offset, offset + count)); + const result = await this.ask('write', 1, BigInt(offset), len); + if (!result.number) throw new Error('Server made no write progress'); + offset += result.number; + } + return offset; + })); + } +} diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..aeca5d3 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,53 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <title>cloud9 — files</title> + <link rel="stylesheet" href="/_cloud9/style.css"> + <script type="module" src="/_cloud9/app.mjs"></script> +</head> +<body> + <header> + <a class="brand" href="/" data-path="/">cloud9</a> + <div class="description">A web interface to your file server.</div> + </header> + <nav class="toolbar" aria-label="File actions"> + <a class="selected" href="/" data-path="/">files</a> + <button id="reload" type="button">reload</button> + <form id="path-form"> + <label for="path">path</label> + <input id="path" name="path" value="/" aria-label="Path" autocomplete="off" spellcheck="false"> + <button id="go">go</button> + </form> + </nav> + <main> + <nav id="breadcrumbs" aria-label="Breadcrumb"><a href="/" data-path="/">root</a></nav> + <div class="summary"> + <p id="status" role="status" aria-live="polite">Loading files…</p> + <a id="up" href="/" data-path="/" hidden>parent directory</a> + </div> + <section id="directory" aria-label="Directory"> + <table> + <thead><tr><th scope="col">Mode</th><th scope="col">Name</th><th scope="col" class="size">Size</th></tr></thead> + <tbody id="entries"></tbody> + </table> + <p id="empty" hidden>This directory is empty.</p> + </section> + <section id="file" hidden> + <div class="file-heading"> + <h1 id="filename"></h1> + <span id="file-mode"></span><span id="file-size"></span> + <button id="download" type="button">download</button> + </div> + <textarea id="editor" aria-label="File contents" spellcheck="false"></textarea> + <p id="binary" hidden>Binary file. Download to view its contents.</p> + <div id="edit-actions" class="actions"> + <button id="save" type="button">save changes</button> + <span id="edit-status">No unsaved changes.</span> + </div> + </section> + </main> + <footer>served by cloud9</footer> +</body> +</html> diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..a8605f5 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,87 @@ +:root { + font: 14px/1.45 sans-serif; + color: #333; + background: white; + font-synthesis: none; +} +* { box-sizing: border-box; } +body { margin: 0; padding: 22px 3%; } +a { color: #07539b; text-decoration: none; } +a:hover { text-decoration: underline; } +header { padding: 0 8px 17px; } +.brand { font-size: 30px; font-weight: bold; color: #222; letter-spacing: -1px; } +.description { color: #777; margin-top: 1px; } +.toolbar { + display: flex; + align-items: center; + gap: 2px; + border-bottom: 3px solid #ccc; + padding: 0 7px; +} +.toolbar > a, .toolbar > button { + padding: 5px 14px; + color: #555; + background: none; + border: 0; + font-size: 15px; +} +.toolbar .selected { background: #ccc; color: #111; } +button, input, textarea { font: inherit; color: inherit; } +button { + cursor: pointer; + border: 1px solid #aaa; + padding: 2px 9px; + background: #f5f5f5; + border-radius: 0; +} +button:hover:enabled { background: #e8e8e8; } +button:disabled { color: #999; cursor: default; } +#path-form { margin-left: auto; display: flex; align-items: center; gap: 6px; padding-bottom: 5px; } +#path-form label { color: #777; font-size: 12px; } +#path { width: 250px; border: 1px solid #bbb; padding: 2px 5px; font: 13px/1.5 monospace; } +main { padding: 0 8px; } +#breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; padding: 13px 0 5px; font-family: monospace; overflow-wrap: anywhere; } +#breadcrumbs .separator { color: #999; } +#breadcrumbs [aria-current] { color: #333; font-weight: bold; } +.summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 0 13px; font-size: 12px; } +#status { color: #777; margin: 0; min-height: 18px; } +#status.error { color: #a22; } +#up { white-space: nowrap; } +table { width: 100%; border-collapse: collapse; text-align: left; } +th { background: #eee; padding: 4px 8px; font-weight: bold; border-bottom: 1px solid #ccc; } +td { padding: 3px 8px; vertical-align: top; } +tbody tr:nth-child(even) { background: #f7f7f7; } +tbody tr:hover { background: #edf3f8; } +.mode { width: 120px; color: #777; font: 12px/1.7 monospace; white-space: nowrap; } +.entry { font-family: monospace; overflow-wrap: anywhere; } +.entry.directory { font-weight: bold; } +.size { width: 110px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +td.size { color: #666; font-size: 12px; } +#empty { color: #777; padding: 4px 8px; } +.file-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; padding: 6px 8px; background: #eee; border-bottom: 1px solid #ccc; } +h1 { font: bold 14px monospace; margin: 0; overflow-wrap: anywhere; } +#file-mode, #file-size { color: #666; font: 12px monospace; } +#download { margin-left: auto; font-size: 12px; } +textarea { display: block; width: 100%; min-height: 420px; resize: vertical; border: 1px solid #ddd; border-top: 0; padding: 10px; font: 13px/1.6 monospace; tab-size: 4; white-space: pre; overflow: auto; } +#binary { color: #777; padding: 16px 8px; } +.actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; } +#edit-status { color: #777; } +footer { margin: 30px 8px 0; padding-top: 7px; border-top: 1px solid #ddd; text-align: right; color: #999; font-size: 11px; } +a:focus-visible, button:focus-visible { outline: 2px solid #07539b; outline-offset: 2px; } +[hidden] { display: none !important; } +@media (max-width: 600px) { + body { padding: 14px 10px; } + header { padding-left: 3px; } + .brand { font-size: 26px; } + .toolbar { flex-wrap: wrap; padding: 0; } + .toolbar > a, .toolbar > button { padding: 5px 10px; } + #path-form { order: -1; width: 100%; margin: 0 0 6px; } + #path { width: auto; min-width: 0; flex: 1; } + main { padding: 0; } + th, td { padding-left: 5px; padding-right: 5px; } + .mode { width: 86px; font-size: 10px; } + .size { width: 65px; } + .summary { align-items: start; } + .file-heading { gap: 7px 12px; } + textarea { min-height: 350px; } +} |
