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