/* 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 `${html}`; } // Relative 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(`${html}`, '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'); } }