Files
devdesk/web/src/files.js
T
jaredandClaude Opus 5.5 73f8525db2
build-deb / Lint, test, package (push) Successful in 2m38s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset
  (libexec/devdesk-files.sh), confined to the session folder.
- The state hook records files Claude edits (Edit/Write/MultiEdit/
  NotebookEdit) for the Changes tab's "C" badge; rename/end carry it.
- Files pane: folders, CodeMirror read-only view, sanitized markdown preview
  in a sandboxed iframe with inlined relative images, images, Changes tab with
  line counts and diff against HEAD, "→ Claude" @path, resizable split.
- The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched
  over ttyd on first use, verified against the page's SHA-256 and cached in
  IndexedDB.
- Fix: the state hook's field split collapsed empty fields.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:16:37 -04:00

553 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global __EDITOR_SHA__ */
// Files pane (devdesk#19): browse the session's folder, read files with syntax
// highlighting, preview markdown and images, and review what changed (git
// status + diff against HEAD, with a badge on files Claude edited). Read-only
// in v1. Everything goes through `devdesk attach --fs-*` over ttyd.
import { runBlob, runCommand } from './connection.js';
import { parseListOutput } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { toast } from './ui.js';
// ---------------------------------------------------------------- editor bundle
const DB = 'devdesk';
const STORE = 'assets';
function idb(mode, run) {
return new Promise((resolve, reject) => {
const open = indexedDB.open(DB, 1);
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const tx = open.result.transaction(STORE, mode);
const request = run(tx.objectStore(STORE));
tx.oncomplete = () => {
open.result.close();
resolve(request?.result);
};
tx.onerror = () => reject(tx.error);
};
});
}
async function sha256(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function decodeBase64(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// The editor (~600 KB) is not in the page: it's fetched over ttyd the first time
// the pane opens, checked against the hash this page was built with, cached in
// IndexedDB under that hash, and run.
let editorLoading = null;
export function loadEditor() {
editorLoading ??= (async () => {
if (window.DevDeskEditor) return window.DevDeskEditor;
let code = null;
try {
code = await idb('readonly', (store) => store.get(__EDITOR_SHA__));
} catch {
// No IndexedDB (private mode): fetch every time.
}
if (!code) {
const { base64 } = await runBlob(['--asset', 'editor.js'], 60000);
if (!base64) throw new Error('the editor bundle is missing on the server');
code = new TextDecoder().decode(decodeBase64(base64));
if (crypto.subtle && (await sha256(code)) !== __EDITOR_SHA__) {
throw new Error('the server has a different DevDesk version: reload the page');
}
idb('readwrite', (store) => {
store.clear();
store.put(code, __EDITOR_SHA__);
}).catch(() => {});
}
const script = document.createElement('script');
script.textContent = code;
document.head.append(script);
script.remove();
if (!window.DevDeskEditor) throw new Error('the editor failed to start');
return window.DevDeskEditor;
})().catch((error) => {
editorLoading = null;
throw error;
});
return editorLoading;
}
// ---------------------------------------------------------------- helpers
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
const join = (dir, name) => (dir ? `${dir}/${name}` : name);
const parent = (path) => path.split('/').slice(0, -1).join('/');
const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path);
const STATUS = {
M: ['M', 'modified', 'amber'], A: ['A', 'added', 'green'], D: ['D', 'deleted', 'red'], R: ['R', 'renamed', 'cyan'],
C: ['C', 'copied', 'cyan'], U: ['U', 'conflict', 'red'], '?': ['N', 'new', 'green'], ' ': ['·', 'edited by Claude', 'cyan'],
};
function statusOf(f) {
const code = f.x === '?' ? '?' : f.x !== ' ' ? f.x : f.y;
return STATUS[code] ?? STATUS.M;
}
// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts,
// no same-origin), styled from the page's current TDS colours.
function previewDocument(html) {
const css = getComputedStyle(document.documentElement);
const v = (name) => css.getPropertyValue(name).trim();
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
:root { color-scheme: ${document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}; }
body { margin: 0; padding: 18px 22px 40px; background: ${v('--bg-terminal')}; color: ${v('--text-primary')};
font: 14px/1.65 ${v('--font-mono') || 'monospace'}; overflow-wrap: anywhere; }
h1, h2, h3, h4 { color: ${v('--accent-cyan')}; line-height: 1.3; margin: 1.4em 0 0.5em; }
h1 { font-size: 1.45em; border-bottom: 1px solid ${v('--border-color')}; padding-bottom: 0.3em; }
h2 { font-size: 1.2em; border-bottom: 1px solid ${v('--border-dim')}; padding-bottom: 0.2em; }
a { color: ${v('--accent-orange')}; }
code { background: ${v('--bg-tertiary')}; padding: 1px 5px; color: ${v('--accent-amber')}; font-family: inherit; }
pre { background: ${v('--bg-secondary')}; border: 1px solid ${v('--border-dim')}; padding: 10px 12px; overflow-x: auto; }
pre code { background: none; padding: 0; color: ${v('--text-primary')}; }
blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 3px solid ${v('--accent-cyan')}; color: ${v('--text-secondary')}; }
table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid ${v('--border-color')}; padding: 4px 10px; }
th { background: ${v('--bg-secondary')}; } hr { border: 0; border-top: 1px solid ${v('--border-color')}; }
img { max-width: 100%; } li { margin: 0.2em 0; } input[type=checkbox] { margin-right: 6px; }
</style></head><body>${html}</body></html>`;
}
// Relative <img> in a markdown file (e.g. docs/screenshot.png) can't load from
// the sandboxed preview: fetch up to 10 of them over ttyd and inline them.
async function inlineImages(html, session, mdPath) {
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const images = [...doc.querySelectorAll('img[src]')]
.filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 10);
if (!images.length) return html;
const base = parent(mdPath);
await Promise.all(images.map(async (img) => {
const parts = [];
for (const part of `${base}/${decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])}`.split('/')) {
if (part === '..') parts.pop();
else if (part && part !== '.') parts.push(part);
}
try {
const { header, base64 } = await runBlob(['--fs-read', session, parts.join('/')]);
if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`);
} catch {
// missing image: leave the broken link
}
}));
return doc.body.innerHTML;
}
// ---------------------------------------------------------------- pane
function readPref(key) {
try {
return localStorage.getItem(`devdesk.files.${key}`);
} catch {
return null;
}
}
function writePref(key, value) {
try {
localStorage.setItem(`devdesk.files.${key}`, value);
} catch {
// Private mode: the preference lasts until the tab closes.
}
}
export class FilesPane {
// hooks: session() -> name, paste(text), onToggle(open), focusTerminal()
constructor(root, hooks) {
this.root = root;
this.hooks = hooks;
this.tab = 'files';
this.dir = '';
this.session = null;
this.listing = null;
this.changes = null;
this.current = null; // { path, mode: 'view' | 'diff' | 'preview', sha }
this.editorView = null;
this.seq = 0;
this.wrap = readPref('wrap') === '1';
this.body = root.querySelector('.dd-files-body');
this.crumbs = root.querySelector('.dd-files-crumbs');
this.tabs = [...root.querySelectorAll('[data-files-tab]')];
this.count = root.querySelector('.dd-files-count');
for (const button of this.tabs) button.addEventListener('click', () => this.showTab(button.dataset.filesTab));
root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh({ force: true }));
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
}
// Esc: back from a file to the list, then close. main.js routes Esc here
// whenever the pane is open and the terminal doesn't have the focus.
escape() {
if (this.current) this.back();
else this.close();
}
get isOpen() {
return !this.root.hidden;
}
open(tab) {
if (tab) this.tab = tab;
this.root.hidden = false;
this.hooks.onToggle(true);
this.syncSession();
this.renderTabs();
if (!this.current) this.refresh();
loadEditor().catch(() => {}); // warm up while the list loads
}
close() {
this.root.hidden = true;
this.hooks.onToggle(false);
this.hooks.focusTerminal();
}
toggle(tab) {
if (this.isOpen && (!tab || tab === this.tab)) this.close();
else this.open(tab);
}
// The pane follows the current session; a different session starts at its root.
syncSession() {
const session = this.hooks.session();
if (session === this.session) return false;
this.session = session;
this.dir = '';
this.listing = null;
this.changes = null;
this.destroyEditor();
this.current = null;
return true;
}
sessionChanged() {
if (this.isOpen && this.syncSession()) this.refresh();
}
showTab(tab) {
this.tab = tab;
this.destroyEditor();
this.current = null;
this.renderTabs();
this.refresh();
}
renderTabs() {
for (const button of this.tabs) button.setAttribute('aria-selected', String(button.dataset.filesTab === this.tab));
}
// After Claude's turn: refresh what's visible; an open file that changed on
// disk gets a reload notice instead of jumping under you.
async claudeTurnEnded() {
if (!this.isOpen) return;
if (!this.current) {
this.refresh();
return;
}
this.loadChanges().catch(() => {});
if (this.current.mode === 'view' || this.current.mode === 'preview') {
try {
const { header } = await runBlob(['--fs-read', this.session, this.current.path]);
if (header.sha && header.sha !== this.current.sha) this.showStale();
} catch {
// deleted or unreadable: leave it
}
}
}
async refresh({ force = false } = {}) {
if (!this.session) {
this.message('Open a session to browse its files.');
return;
}
if (this.current && !force) return;
if (this.current && force) {
this.openFile(this.current.path, this.current.mode);
return;
}
if (this.tab === 'files') await this.loadDir(this.dir);
else await this.renderChanges(true);
if (this.tab === 'files') this.loadChanges().catch(() => {});
}
message(text, tone = '') {
this.crumbs.replaceChildren();
this.body.replaceChildren(el('div', { class: `dd-files-msg${tone ? ` is-${tone}` : ''}` }, text));
}
// ------------------------------------------------------------ files tab
async loadDir(dir) {
const session = this.session;
const seq = ++this.seq; // a newer navigation wins over a slower reply
this.renderCrumbs(dir);
if (!this.listing || this.listing.dir !== dir) this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…'));
try {
const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000));
if (seq !== this.seq || session !== this.session || this.current || this.tab !== 'files') return;
this.listing = listing;
this.dir = listing.dir;
this.renderDir();
} catch (error) {
if (seq !== this.seq) return;
if (dir) {
toast.error(`${dir}: ${error.message}`);
this.dir = '';
this.loadDir('');
} else {
this.message(error.message, 'error');
}
}
}
renderCrumbs(path, { file = false } = {}) {
const parts = path ? path.split('/') : [];
const nodes = [el('button', { type: 'button', class: 'dd-crumb', title: this.listing?.root, onclick: () => this.goDir('') }, this.session)];
parts.forEach((part, i) => {
nodes.push(el('span', { class: 'dd-crumb-sep' }, '/'));
const target = parts.slice(0, i + 1).join('/');
const last = i === parts.length - 1;
nodes.push(last && file
? el('span', { class: 'dd-crumb is-file' }, part)
: el('button', { type: 'button', class: `dd-crumb${last ? ' is-current' : ''}`, onclick: () => this.goDir(target) }, part));
});
this.crumbs.replaceChildren(...nodes);
}
goDir(dir) {
this.destroyEditor();
this.current = null;
this.tab = 'files';
this.renderTabs();
this.loadDir(dir);
}
changeFor(path) {
return this.changes?.files?.find((f) => f.path === path) ?? null;
}
renderDir() {
const { entries, truncated } = this.listing;
this.renderCrumbs(this.dir);
const rows = [];
if (this.dir) {
rows.push(el('li', {}, el('button', { type: 'button', class: 'dd-file is-up', onclick: () => this.goDir(parent(this.dir)) },
el('span', { class: 'dd-file-icon' }, '↰'), el('span', { class: 'dd-file-name' }, '..'))));
}
for (const e of entries) {
const isDir = e.t === 'd' || e.to === 'd';
const path = join(this.dir, e.n);
const change = isDir ? null : this.changeFor(path);
const dirty = isDir && this.changes?.files?.some((f) => f.path?.startsWith(`${path}/`));
rows.push(el('li', {}, el('button', {
type: 'button', class: `dd-file${isDir ? ' is-dir' : ''}${e.t === 'l' ? ' is-link' : ''}`,
title: `${path}${e.t === 'l' ? ' (symlink)' : ''}`,
onclick: () => (isDir ? this.goDir(path) : this.openFile(path)),
},
el('span', { class: 'dd-file-icon', 'aria-hidden': 'true' }, isDir ? '▸' : e.t === 'l' ? '↪' : '·'),
el('span', { class: 'dd-file-name' }, e.n),
change ? el('span', { class: `dd-file-status is-${statusOf(change)[2]}`, title: statusOf(change)[1] }, statusOf(change)[0]) : null,
change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
dirty ? el('span', { class: 'dd-file-status is-amber', title: 'contains changes' }, '•') : null,
el('span', { class: 'dd-file-meta' }, isDir ? '' : size(e.s)),
el('span', { class: 'dd-file-meta dd-file-age' }, ago(e.m)))));
}
if (!entries.length) rows.push(el('li', { class: 'dd-files-msg' }, 'Empty folder.'));
if (truncated) rows.push(el('li', { class: 'dd-files-msg' }, `Only the first ${entries.length} entries are shown.`));
this.body.replaceChildren(el('ul', { class: 'dd-file-list' }, ...rows));
}
// ------------------------------------------------------------ changes tab
async loadChanges() {
const session = this.session;
const changes = parseListOutput(await runCommand(['--git-status', session], 15000));
if (session !== this.session) return null;
this.changes = changes;
const n = changes.files?.length ?? 0;
this.count.textContent = n ? String(n) : '';
if (this.tab === 'files' && !this.current && this.listing) this.renderDir();
return changes;
}
async renderChanges(reload) {
this.crumbs.replaceChildren(el('span', { class: 'dd-crumb is-file' }, this.changes?.git === false ? 'Edited by Claude' : `${this.changes?.branch ?? ''} · uncommitted changes`));
if (reload || !this.changes) {
this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…'));
try {
await this.loadChanges();
} catch (error) {
this.message(error.message, 'error');
return;
}
}
if (this.tab !== 'changes' || this.current) return;
const { files = [], git, branch } = this.changes;
this.crumbs.replaceChildren(el('span', { class: 'dd-crumb is-file' }, git ? `${branch} · ${files.length} changed` : 'Not a git repository · files Claude edited'));
if (!files.length) {
this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files in this folder yet.'));
return;
}
const rows = files.map((f) => {
const [letter, label, tone] = statusOf(f);
const name = f.path ?? f.repoPath;
const openable = f.path !== null && f.path !== undefined;
return el('li', {}, el('button', {
type: 'button', class: 'dd-file', disabled: !openable,
title: `${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`,
onclick: () => this.openFile(f.path, git && f.x !== '?' && f.y !== '?' ? 'diff' : 'view'),
},
el('span', { class: `dd-file-status is-${tone}` }, letter),
el('span', { class: 'dd-file-name' }, name),
f.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
el('span', { class: 'dd-file-meta' },
f.add !== null && f.add !== undefined ? el('span', { class: 'dd-add' }, `+${f.add}`) : null,
f.del !== null && f.del !== undefined ? el('span', { class: 'dd-del' }, ` −${f.del}`) : null)));
});
this.body.replaceChildren(el('ul', { class: 'dd-file-list' }, ...rows));
}
// ------------------------------------------------------------ viewer
destroyEditor() {
this.editorView?.destroy();
this.editorView = null;
}
back() {
this.destroyEditor();
this.current = null;
if (this.tab === 'files') this.loadDir(this.dir);
else this.renderChanges(false);
this.body.focus();
}
showStale() {
const bar = this.body.querySelector('.dd-viewer-stale');
if (bar) bar.hidden = false;
}
async openFile(path, mode) {
const session = this.session;
this.destroyEditor();
const change = this.changeFor(path);
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
this.current = { path, mode: wanted, sha: null };
this.seq += 1;
this.renderCrumbs(path, { file: true });
this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…'));
let blob;
let original = null;
let editor;
try {
const deleted = change && (change.x === 'D' || change.y === 'D');
[blob, original, editor] = await Promise.all([
deleted ? Promise.resolve({ header: { path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', session, path]),
wanted === 'diff' ? runBlob(['--git-show', session, path]) : Promise.resolve(null),
loadEditor().catch((error) => ({ error })),
]);
} catch (error) {
if (session === this.session) this.viewerMessage(path, error.message, 'error');
return;
}
if (session !== this.session || this.current?.path !== path) return;
const { header, base64 } = blob;
this.current.sha = header.sha ?? null;
const text = (b64, utf8 = true) => new TextDecoder(utf8 ? 'utf-8' : 'latin1').decode(decodeBase64(b64 || ''));
const content = el('div', { class: 'dd-viewer-content' });
const actions = [];
const action = (label, run, extra = {}) => actions.push(el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label));
if (wanted !== 'diff' && isMarkdown(path)) {
action(wanted === 'preview' ? 'Source' : 'Preview', () => this.openFile(path, wanted === 'preview' ? 'view' : 'preview'));
}
if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) {
action(wanted === 'diff' ? 'File' : 'Diff', () => this.openFile(path, wanted === 'diff' ? (isMarkdown(path) ? 'preview' : 'view') : 'diff'));
}
if (wanted === 'view' || wanted === 'diff') {
action(this.wrap ? 'No wrap' : 'Wrap', () => {
this.wrap = !this.wrap;
writePref('wrap', this.wrap ? '1' : '0');
this.openFile(path, wanted);
}, { title: 'Wrap long lines' });
}
action('Copy path', () => navigator.clipboard?.writeText(path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused')));
action('→ Claude', () => {
this.hooks.paste(`@${path} `);
toast.info(`Mentioned @${path} in the prompt`);
}, { title: 'Type @path into the current prompt so Claude reads the file' });
if (header.tooLarge) {
content.append(el('div', { class: 'dd-files-msg' }, `This file is ${size(header.size)}; the viewer opens files up to ${size(header.max)}.`));
} else if (header.mime?.startsWith('image/') && base64) {
content.classList.add('is-image');
content.append(el('img', { src: `data:${header.mime};base64,${base64}`, alt: path }));
} else if (header.binary || !header.body) {
content.append(el('div', { class: 'dd-files-msg' }, `Binary file (${size(header.size)}).`));
} else if (editor?.error) {
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
toast.warning(`Plain view: ${editor.error.message}`);
} else if (wanted === 'preview') {
content.classList.add('is-preview');
content.append(el('iframe', {
class: 'dd-md-frame', title: `Preview of ${path}`,
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))),
}));
const frame = content.lastChild;
inlineImages(editor.markdown(text(base64)), session, path).then((html) => {
if (frame.isConnected) frame.srcdoc = previewDocument(html);
});
} else if (wanted === 'diff') {
this.editorView = editor.diff(content, { original: text(original?.base64), modified: text(base64), path, wrap: this.wrap });
} else {
this.editorView = editor.viewer(content, { doc: text(base64, header.utf8), path, wrap: this.wrap });
}
const meta = [
header.size !== undefined && !header.tooLarge ? size(header.size) : '',
header.mtime ? `changed ${ago(header.mtime)}` : '',
header.utf8 === false ? 'not UTF-8 (shown as Latin-1)' : '',
wanted === 'diff' ? 'diff against HEAD' : '',
change ? statusOf(change)[1] : '',
change?.claude ? 'edited by Claude' : '',
].filter(Boolean).join(' · ');
const stale = el('div', { class: 'dd-viewer-stale', hidden: true },
'This file changed on disk. ', el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.openFile(path, wanted) }, 'Reload'));
this.body.replaceChildren(
el('div', { class: 'dd-viewer-bar' },
el('button', { type: 'button', class: 'dd-viewer-btn dd-viewer-back', title: 'Back (Esc)', onclick: () => this.back() }, '←'),
el('span', { class: 'dd-viewer-meta' }, meta),
el('span', { class: 'dd-viewer-actions' }, ...actions)),
stale,
content);
// Keyboard straight into the file: arrows scroll, Ctrl+F searches, Esc goes back.
if (this.editorView) this.editorView.view.focus();
else this.body.focus();
}
viewerMessage(path, text, tone) {
this.body.replaceChildren(
el('div', { class: 'dd-viewer-bar' },
el('button', { type: 'button', class: 'dd-viewer-btn dd-viewer-back', title: 'Back (Esc)', onclick: () => this.back() }, '←'),
el('span', { class: 'dd-viewer-meta' }, path)),
el('div', { class: `dd-files-msg is-${tone}` }, text));
}
// Theme switch: the editor follows CSS variables; the preview iframe needs a re-render.
themeChanged() {
if (this.current?.mode === 'preview') this.openFile(this.current.path, 'preview');
}
}