summaryrefslogtreecommitdiff
path: root/web/static
diff options
context:
space:
mode:
Diffstat (limited to 'web/static')
-rw-r--r--web/static/app.mjs183
-rw-r--r--web/static/index.html70
-rw-r--r--web/static/style.css22
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; }
}