summaryrefslogtreecommitdiff
path: root/app/web
diff options
context:
space:
mode:
Diffstat (limited to 'app/web')
-rw-r--r--app/web/app.mjs183
-rw-r--r--app/web/client.mjs138
-rw-r--r--app/web/index.html53
-rw-r--r--app/web/style.css87
4 files changed, 0 insertions, 461 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'));
diff --git a/app/web/client.mjs b/app/web/client.mjs
deleted file mode 100644
index 69f44d2..0000000
--- a/app/web/client.mjs
+++ /dev/null
@@ -1,138 +0,0 @@
-// Browser transport and file operations. All 9P serialization lives in WASM.
-const encoder = new TextEncoder();
-const decoder = new TextDecoder();
-export class Client {
- static async connect(url = '/_cloud9/9p', user = 'user', tree = '') {
- const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL('./cloud9.wasm', import.meta.url)), {});
- const wsURL = new URL(url, location.href);
- wsURL.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const socket = new WebSocket(wsURL);
- socket.binaryType = 'arraybuffer';
- const client = new Client(instance.exports, socket);
- try {
- await new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error('Connection timed out')), 10000);
- socket.onopen = () => { clearTimeout(timer); resolve(); };
- socket.onerror = () => { clearTimeout(timer); reject(new Error('Cannot connect to 9P bridge')); };
- });
- await client.ask('version');
- const u = encoder.encode(user), a = encoder.encode(tree);
- client.stage(new Uint8Array([...u, ...a]));
- await client.ask('attach', u.length, a.length);
- return client;
- } catch (error) { client.close(); throw error; }
- }
- constructor(wasm, socket) {
- this.wasm = wasm;
- this.socket = socket;
- this.pending = null;
- this.queue = Promise.resolve();
- this.closed = false;
- wasm.init();
- socket.onmessage = ({ data }) => {
- if (!this.pending) { this.close(); return; }
- try {
- this.stage(new Uint8Array(data));
- const kind = wasm.receive(data.byteLength);
- if (!kind) return;
- const result = { kind, data: this.data(), number: wasm.result_number(), directory: Boolean(wasm.result_directory()) };
- const pending = this.pending;
- this.pending = null;
- clearTimeout(pending.timer);
- if (kind < 0) {
- const error = new Error(decoder.decode(result.data));
- pending.reject(error);
- if (wasm.is_dead()) this.abort(error);
- }
- else pending.resolve(result);
- } catch (error) { this.abort(error); }
- };
- socket.onclose = () => this.abort(new Error('Connection closed'));
- }
- data() { return new Uint8Array(this.wasm.memory.buffer, this.wasm.data_ptr(), this.wasm.data_len()).slice(); }
- stage(bytes) {
- if (bytes.length > this.wasm.input_capacity()) throw new Error('Input exceeds 64 KiB');
- new Uint8Array(this.wasm.memory.buffer, this.wasm.input_ptr(), bytes.length).set(bytes);
- return bytes.length;
- }
- ask(operation, ...args) {
- if (this.closed) return Promise.reject(new Error('Connection closed'));
- if (this.pending) return Promise.reject(new Error('Request already in flight'));
- if (this.wasm[operation](...args) < 0) return Promise.reject(new Error(decoder.decode(this.data())));
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => this.abort(new Error('9P request timed out')), 15000);
- this.pending = { resolve, reject, timer };
- try {
- this.socket.send(new Uint8Array(this.wasm.memory.buffer, this.wasm.output_ptr(), this.wasm.output_len()));
- this.wasm.sent();
- } catch (error) { this.abort(error); }
- });
- }
- abort(error) {
- this.closed = true;
- if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(error); this.pending = null; }
- this.socket.close();
- }
- close() { this.abort(new Error('Disconnected')); }
- serial(operation) {
- const result = this.queue.then(operation);
- this.queue = result.catch(() => {});
- return result;
- }
- async withFile(path, operation) {
- const names = path.split('/').filter(Boolean);
- let live = false;
- try {
- // Walk in spec-sized batches, retaining an unopened root fid.
- for (let index = 0; index < Math.max(1, names.length); index += 16) {
- const batch = names.slice(index, index + 16);
- const len = this.stage(encoder.encode(batch.join('/')));
- const result = await this.ask('walk', index === 0 ? 0 : 1, 1, len);
- live = live || result.number > 0 || batch.length === 0;
- if (result.number !== batch.length) throw new Error('Path does not exist');
- }
- return await operation();
- } finally {
- if (live && !this.closed) await this.ask('clunk', 1);
- }
- }
- readPath(path) {
- return this.serial(() => this.withFile(path, async () => {
- const stat = JSON.parse(decoder.decode((await this.ask('stat', 1)).data));
- const opened = await this.ask('open', 1, 0);
- const count = Math.min(this.wasm.max_read(), opened.number || Infinity);
- let offset = 0;
- const chunks = [], entries = [];
- while (true) {
- const { data } = await this.ask('read', 1, BigInt(offset), count);
- if (!data.length) break;
- offset += data.length;
- if (offset > 8 * 1024 * 1024) throw new Error('Browser view is limited to 8 MiB');
- if (stat.directory) {
- this.stage(data);
- if (this.wasm.decode_directory(data.length) < 0) throw new Error(decoder.decode(this.data()));
- entries.push(...JSON.parse(decoder.decode(this.data())));
- } else chunks.push(data);
- }
- const bytes = new Uint8Array(stat.directory ? 0 : offset);
- let position = 0;
- for (const chunk of chunks) { bytes.set(chunk, position); position += chunk.length; }
- return { stat, entries, bytes };
- }));
- }
- writePath(path, bytes) {
- if (!(bytes instanceof Uint8Array) || bytes.length > 8 * 1024 * 1024) return Promise.reject(new Error('Write is limited to 8 MiB'));
- return this.serial(() => this.withFile(path, async () => {
- const opened = await this.ask('open', 1, 1 | 16); // OWRITE | OTRUNC
- const count = Math.min(this.wasm.max_write(), opened.number || Infinity);
- let offset = 0;
- while (offset < bytes.length) {
- const len = this.stage(bytes.subarray(offset, offset + count));
- const result = await this.ask('write', 1, BigInt(offset), len);
- if (!result.number) throw new Error('Server made no write progress');
- offset += result.number;
- }
- return offset;
- }));
- }
-}
diff --git a/app/web/index.html b/app/web/index.html
deleted file mode 100644
index aeca5d3..0000000
--- a/app/web/index.html
+++ /dev/null
@@ -1,53 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>cloud9 — files</title>
- <link rel="stylesheet" href="/_cloud9/style.css">
- <script type="module" src="/_cloud9/app.mjs"></script>
-</head>
-<body>
- <header>
- <a class="brand" href="/" data-path="/">cloud9</a>
- <div class="description">A web interface to your file server.</div>
- </header>
- <nav class="toolbar" aria-label="File actions">
- <a class="selected" href="/" data-path="/">files</a>
- <button id="reload" type="button">reload</button>
- <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>
- <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>
- <footer>served by cloud9</footer>
-</body>
-</html>
diff --git a/app/web/style.css b/app/web/style.css
deleted file mode 100644
index a8605f5..0000000
--- a/app/web/style.css
+++ /dev/null
@@ -1,87 +0,0 @@
-:root {
- font: 14px/1.45 sans-serif;
- color: #333;
- background: white;
- font-synthesis: none;
-}
-* { box-sizing: border-box; }
-body { margin: 0; padding: 22px 3%; }
-a { color: #07539b; text-decoration: none; }
-a:hover { text-decoration: underline; }
-header { padding: 0 8px 17px; }
-.brand { font-size: 30px; font-weight: bold; color: #222; letter-spacing: -1px; }
-.description { color: #777; margin-top: 1px; }
-.toolbar {
- display: flex;
- align-items: center;
- gap: 2px;
- border-bottom: 3px solid #ccc;
- padding: 0 7px;
-}
-.toolbar > a, .toolbar > button {
- padding: 5px 14px;
- color: #555;
- background: none;
- border: 0;
- font-size: 15px;
-}
-.toolbar .selected { background: #ccc; color: #111; }
-button, input, textarea { font: inherit; color: inherit; }
-button {
- cursor: pointer;
- border: 1px solid #aaa;
- padding: 2px 9px;
- background: #f5f5f5;
- border-radius: 0;
-}
-button:hover:enabled { background: #e8e8e8; }
-button:disabled { color: #999; cursor: default; }
-#path-form { margin-left: auto; display: flex; align-items: center; gap: 6px; padding-bottom: 5px; }
-#path-form label { color: #777; font-size: 12px; }
-#path { width: 250px; border: 1px solid #bbb; padding: 2px 5px; font: 13px/1.5 monospace; }
-main { padding: 0 8px; }
-#breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; padding: 13px 0 5px; font-family: monospace; overflow-wrap: anywhere; }
-#breadcrumbs .separator { color: #999; }
-#breadcrumbs [aria-current] { color: #333; font-weight: bold; }
-.summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 0 13px; font-size: 12px; }
-#status { color: #777; margin: 0; min-height: 18px; }
-#status.error { color: #a22; }
-#up { white-space: nowrap; }
-table { width: 100%; border-collapse: collapse; text-align: left; }
-th { background: #eee; padding: 4px 8px; font-weight: bold; border-bottom: 1px solid #ccc; }
-td { padding: 3px 8px; vertical-align: top; }
-tbody tr:nth-child(even) { background: #f7f7f7; }
-tbody tr:hover { background: #edf3f8; }
-.mode { width: 120px; color: #777; font: 12px/1.7 monospace; white-space: nowrap; }
-.entry { font-family: monospace; overflow-wrap: anywhere; }
-.entry.directory { font-weight: bold; }
-.size { width: 110px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
-td.size { color: #666; font-size: 12px; }
-#empty { color: #777; padding: 4px 8px; }
-.file-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; padding: 6px 8px; background: #eee; border-bottom: 1px solid #ccc; }
-h1 { font: bold 14px monospace; margin: 0; overflow-wrap: anywhere; }
-#file-mode, #file-size { color: #666; font: 12px monospace; }
-#download { margin-left: auto; font-size: 12px; }
-textarea { display: block; width: 100%; min-height: 420px; resize: vertical; border: 1px solid #ddd; border-top: 0; padding: 10px; font: 13px/1.6 monospace; tab-size: 4; white-space: pre; overflow: auto; }
-#binary { color: #777; padding: 16px 8px; }
-.actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; }
-#edit-status { color: #777; }
-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; }
-@media (max-width: 600px) {
- body { padding: 14px 10px; }
- header { padding-left: 3px; }
- .brand { font-size: 26px; }
- .toolbar { flex-wrap: wrap; padding: 0; }
- .toolbar > a, .toolbar > button { padding: 5px 10px; }
- #path-form { order: -1; width: 100%; margin: 0 0 6px; }
- #path { width: auto; min-width: 0; flex: 1; }
- main { padding: 0; }
- th, td { padding-left: 5px; padding-right: 5px; }
- .mode { width: 86px; font-size: 10px; }
- .size { width: 65px; }
- .summary { align-items: start; }
- .file-heading { gap: 7px 12px; }
- textarea { min-height: 350px; }
-}