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', 'newfile', 'newdir', 'upload']) $(id).disabled = busy; $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty; $('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)); } 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); stopFollow(); 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.'); } updateStat(result); markTree(path); } 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 = ''; } }); // ---- 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);