summaryrefslogtreecommitdiff
path: root/app/web/app.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'app/web/app.mjs')
-rw-r--r--app/web/app.mjs183
1 files changed, 0 insertions, 183 deletions
diff --git a/app/web/app.mjs b/app/web/app.mjs
deleted file mode 100644
index a6985f0..0000000
--- a/app/web/app.mjs
+++ /dev/null
@@ -1,183 +0,0 @@
-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'));