feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
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

- 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>
This commit is contained in:
2026-10-08 01:16:37 -04:00
co-authored by Claude Opus 5.5
parent 390ce61f5b
commit 73f8525db2
18 changed files with 1717 additions and 10 deletions
+552
View File
@@ -0,0 +1,552 @@
/* 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');
}
}