diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
| commit | f1b53c1533539aecbf16ad19fd9156deae091f92 (patch) | |
| tree | 38ff00810ef5e0ad271c26e218cf6af4578c71ce /web/static | |
| parent | ba7ec40782ba7020d82a56896a5eb1b52578d6aa (diff) | |
| download | cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.tar.gz cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.zip | |
9web: multiplexer, HTTP view of the tree, live streams, richer page
One upstream 9P connection now serves any number of browser WebSocket
sessions and, with --serve, plain 9P clients over TCP or Unix (a 9pserve-
style frame remux: tags and fids remapped, Tflush forwarded, reconnect on
upstream loss). /fs/<path> maps HTTP onto the tree: GET file or directory
(JSON or HTML), Range, HEAD with 9P headers, PUT (create, truncate, append,
trailing slash makes a directory), DELETE, and ?follow=1 or text/event-stream
turning a blocking read into server-sent events with Tflush on disconnect.
The page gains a lazy tree, stat panel, create, rename, delete, upload and
follow mode. --probe embeds 9proc for self-introspection. New mux-test and
http-fs-test steps; e2e still passes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'web/static')
| -rw-r--r-- | web/static/app.mjs | 183 | ||||
| -rw-r--r-- | web/static/index.html | 70 | ||||
| -rw-r--r-- | web/static/style.css | 22 |
3 files changed, 246 insertions, 29 deletions
diff --git a/web/static/app.mjs b/web/static/app.mjs index a6985f0..429bef7 100644 --- a/web/static/app.mjs +++ b/web/static/app.mjs @@ -9,9 +9,12 @@ function status(message, error = false) { $('status').classList.toggle('error', error); } function controls() { - for (const id of ['go', 'reload']) $(id).disabled = busy; + for (const id of ['go', 'reload', 'newfile', 'newdir', 'upload']) $(id).disabled = busy; $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty; - $('download').disabled = busy || !current; + $('download').disabled = busy || !current || current.stat.directory; + $('follow').disabled = busy || !current || current.stat.directory; + $('rename').disabled = busy || currentPath === '/'; + $('delete').disabled = busy || currentPath === '/'; $('editor').readOnly = busy; $('edit-status').textContent = dirty ? 'Unsaved changes.' : 'No unsaved changes.'; document.querySelector('main').setAttribute('aria-busy', String(busy)); @@ -102,6 +105,7 @@ function breadcrumbs(path) { } async function navigate(path, historyMode = 'push') { path = normalizePath(path); + stopFollow(); status('Loading…'); const result = await readPath(path); current = result; currentPath = path; dirty = false; @@ -140,6 +144,8 @@ async function navigate(path, historyMode = 'push') { $('edit-actions').hidden = !editable; status('File loaded.'); } + updateStat(result); + markTree(path); } function mayLeave() { return !dirty || window.confirm('Discard unsaved changes?'); } document.addEventListener('click', event => { @@ -180,4 +186,175 @@ window.addEventListener('popstate', () => { window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } }); -action(() => navigate(locationPath(), 'none')); + +// ---- HTTP /fs view: tree, stat panel, create/rename/delete/upload, follow ---- +// These operate over the gateway's HTTP mapping of the same 9P tree, so they +// stay independent of the WebSocket editing session above. +function fsURL(path) { return '/fs' + path.split('/').map(encodeURIComponent).join('/'); } +function parentOf(path) { return path.replace(/\/[^/]+$/, '') || '/'; } +function baseName(path) { return path.split('/').filter(Boolean).pop() || ''; } +function dirContext() { return current && current.stat.directory ? currentPath : parentOf(currentPath); } +function sortEntries(a, b) { return Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name); } + +async function fetchDir(path) { + const response = await fetch(fsURL(path) + (path.endsWith('/') ? '' : '/'), { headers: { accept: 'application/json' } }); + if (!response.ok) throw new Error(`list failed: ${response.status}`); + return response.json(); +} + +function renderStat(rows) { + const dl = $('stat'); + dl.replaceChildren(); + for (const [key, value] of rows) { + const dt = document.createElement('dt'); dt.textContent = key; + const dd = document.createElement('dd'); dd.textContent = String(value); + dl.append(dt, dd); + } +} +function updateStat(result) { + const s = result.stat; + const rows = [ + ['name', s.name || baseName(currentPath) || '/'], + ['type', s.directory ? 'directory' : 'file'], + ['mode', mode(s)], + ['length', s.directory ? '—' : (s.length ?? 0).toLocaleString()], + ]; + renderStat(rows); + $('stat-panel').hidden = false; + // Augment with qid and mtime from a HEAD, best-effort. + fetch(fsURL(currentPath), { method: 'HEAD' }).then(response => { + if (!response.ok) return; + const qid = response.headers.get('x-9p-qid'); + const mtime = response.headers.get('x-9p-mtime'); + const extra = []; + if (qid) extra.push(['qid', qid]); + if (mtime && mtime !== '0') extra.push(['mtime', new Date(Number(mtime) * 1000).toISOString()]); + if (extra.length) renderStat(rows.concat(extra)); + }).catch(() => {}); +} + +function treeItem(entry, path) { + const li = document.createElement('li'); + const node = document.createElement('span'); + node.className = 'node' + (entry.dir ? ' dir' : ''); + node.dataset.node = path; + const twist = document.createElement('span'); + twist.className = 'twist'; + twist.textContent = entry.dir ? '▸' : ' '; + node.append(twist, document.createTextNode(entry.name)); + li.append(node); + node.onclick = () => { + if (entry.dir) toggleTree(li, path, twist); + else if (!busy && mayLeave()) action(() => navigate(path)); + }; + return li; +} +async function toggleTree(li, path, twist) { + const open = li.querySelector(':scope > ul'); + if (open) { open.remove(); twist.textContent = '▸'; return; } + twist.textContent = '▾'; + const ul = document.createElement('ul'); + li.append(ul); + try { + for (const entry of (await fetchDir(path)).sort(sortEntries)) ul.append(treeItem(entry, child(path, entry.name))); + } catch (error) { ul.textContent = '(unavailable)'; } +} +async function loadTree() { + const root = $('filetree'); + try { + const entries = (await fetchDir('/')).sort(sortEntries); + root.replaceChildren(...entries.map(entry => treeItem(entry, '/' + entry.name))); + } catch { /* upstream not ready; leave the last tree in place */ } +} +function markTree(path) { + for (const node of $('filetree').querySelectorAll('.node.current')) node.classList.remove('current'); + const match = $('filetree').querySelector(`.node[data-node="${CSS.escape(path)}"]`); + if (match) match.classList.add('current'); +} + +let stream = null; +function stopFollow() { + if (stream) { stream.close(); stream = null; } + $('follow').classList.remove('active'); + $('stream').hidden = true; + $('stream').textContent = ''; +} +function startFollow() { + if (!current || current.stat.directory) return; + $('editor').hidden = true; $('binary').hidden = true; $('edit-actions').hidden = true; + const view = $('stream'); + view.hidden = false; view.textContent = ''; + $('follow').classList.add('active'); + stream = new EventSource(fsURL(currentPath) + '?follow=1'); + stream.onmessage = event => { view.textContent += event.data + '\n'; view.scrollTop = view.scrollHeight; }; + stream.addEventListener('eof', stopFollow); + stream.onerror = () => { status('Stream ended.'); stopFollow(); }; +} +$('follow').onclick = () => { + if ($('follow').classList.contains('active')) { stopFollow(); action(() => navigate(currentPath, 'none')); } + else startFollow(); +}; + +async function mutate(fn, done) { + if (busy) return; + busy = true; controls(); + try { await fn(); await loadTree(); status(done); } + catch (error) { status(error.message, true); } + finally { busy = false; controls(); } +} +function needOk(response, what) { + if (!response.ok) throw new Error(`${what} failed: ${response.status}`); +} +$('newfile').onclick = () => { + const name = window.prompt('New file name:'); + if (!name) return; + const target = child(dirContext(), name); + mutate(async () => { + needOk(await fetch(fsURL(target), { method: 'PUT', body: '' }), 'Create'); + await navigate(target); + }, `Created ${name}.`); +}; +$('newdir').onclick = () => { + const name = window.prompt('New folder name:'); + if (!name) return; + const target = child(dirContext(), name); + mutate(async () => { + needOk(await fetch(fsURL(target) + '/', { method: 'PUT' }), 'Create folder'); + await navigate(dirContext(), 'none'); + }, `Created ${name}/.`); +}; +$('delete').onclick = () => { + if (currentPath === '/' || !window.confirm(`Delete ${currentPath}?`)) return; + const parent = parentOf(currentPath); + mutate(async () => { + needOk(await fetch(fsURL(currentPath), { method: 'DELETE' }), 'Delete'); + await navigate(parent); + }, 'Deleted.'); +}; +$('rename').onclick = () => { + if (!current || current.stat.directory) { status('Rename is available for files.', true); return; } + const name = window.prompt('Rename to:', baseName(currentPath)); + if (!name) return; + const target = child(parentOf(currentPath), name); + const from = currentPath; + mutate(async () => { + // A copy-then-remove rename over HTTP; not atomic. + const bytes = new Uint8Array(await (await fetch(fsURL(from))).arrayBuffer()); + needOk(await fetch(fsURL(target), { method: 'PUT', body: bytes }), 'Rename (write)'); + needOk(await fetch(fsURL(from), { method: 'DELETE' }), 'Rename (remove)'); + await navigate(target); + }, `Renamed to ${name}.`); +}; +$('upload').onclick = () => { if (!busy) $('upload-input').click(); }; +$('upload-input').onchange = () => { + const file = $('upload-input').files[0]; + if (!file) return; + const target = child(dirContext(), file.name); + mutate(async () => { + needOk(await fetch(fsURL(target), { method: 'PUT', body: file }), 'Upload'); + await navigate(target); + }, `Uploaded ${file.name}.`); + $('upload-input').value = ''; +}; + +action(() => navigate(locationPath(), 'none')).then(loadTree); diff --git a/web/static/index.html b/web/static/index.html index aeca5d3..b012b3c 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -15,39 +15,57 @@ <nav class="toolbar" aria-label="File actions"> <a class="selected" href="/" data-path="/">files</a> <button id="reload" type="button">reload</button> + <button id="newfile" type="button">new file</button> + <button id="newdir" type="button">new folder</button> + <button id="rename" type="button">rename</button> + <button id="delete" type="button">delete</button> + <button id="upload" type="button">upload</button> + <input id="upload-input" type="file" hidden> <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 class="layout"> + <aside id="sidebar" aria-label="Tree"> + <div class="tree-head">tree</div> + <ul id="filetree" role="tree"></ul> + </aside> + <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> - <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> + <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="follow" type="button">follow</button> + <button id="download" type="button">download</button> + </div> + <textarea id="editor" aria-label="File contents" spellcheck="false"></textarea> + <pre id="stream" hidden aria-label="Live stream"></pre> + <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> + <section id="stat-panel" aria-label="Stat" hidden> + <h2>stat</h2> + <dl id="stat"></dl> + </section> + </main> + </div> <footer>served by cloud9</footer> </body> </html> diff --git a/web/static/style.css b/web/static/style.css index a8605f5..965406b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -66,6 +66,26 @@ textarea { display: block; width: 100%; min-height: 420px; resize: vertical; bor #binary { color: #777; padding: 16px 8px; } .actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; } #edit-status { color: #777; } +.layout { display: flex; align-items: flex-start; gap: 16px; } +#sidebar { flex: 0 0 220px; border-right: 1px solid #e2e2e2; padding: 8px 10px 0 0; min-width: 0; } +.tree-head { color: #999; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; padding: 4px; } +#filetree, #filetree ul { list-style: none; margin: 0; padding: 0; } +#filetree ul { margin-left: 12px; } +#filetree li { line-height: 1.7; white-space: nowrap; } +#filetree .node { cursor: pointer; font-family: monospace; overflow-wrap: anywhere; } +#filetree .node.dir { font-weight: bold; } +#filetree .node.current { background: #ccc; } +#filetree .twist { display: inline-block; width: 12px; color: #999; } +.layout main { flex: 1 1 auto; min-width: 0; } +#stat-panel { margin-top: 18px; border-top: 1px solid #eee; padding-top: 8px; } +#stat-panel h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: #999; margin: 0 0 6px; } +#stat { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font: 12px monospace; margin: 0; } +#stat dt { color: #888; } +#stat dd { margin: 0; overflow-wrap: anywhere; } +#stream { display: block; width: 100%; min-height: 300px; max-height: 60vh; overflow: auto; border: 1px solid #ddd; border-top: 0; padding: 10px; margin: 0; font: 13px/1.5 monospace; background: #fbfbfb; white-space: pre-wrap; } +#follow { margin-left: auto; font-size: 12px; } +#follow.active { background: #ccc; color: #111; } +#follow + #download { margin-left: 0; } 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; } @@ -84,4 +104,6 @@ a:focus-visible, button:focus-visible { outline: 2px solid #07539b; outline-offs .summary { align-items: start; } .file-heading { gap: 7px 12px; } textarea { min-height: 350px; } + .layout { flex-direction: column; } + #sidebar { flex: none; width: 100%; border-right: 0; border-bottom: 1px solid #e2e2e2; max-height: 180px; overflow: auto; padding: 0 0 6px; } } |
