summaryrefslogtreecommitdiff
path: root/web/static/app.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'web/static/app.mjs')
-rw-r--r--web/static/app.mjs183
1 files changed, 180 insertions, 3 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);