/* global __EDITOR_SHA__ */ // Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the // git changes list) beside an editor area with tabs. Read the session's files // with syntax highlighting, preview markdown and images, diff against HEAD, // see what Claude edited, and jump to any file by name. Read-only for now. // Everything goes through `devdesk attach --fs-*` over ttyd. import { runBlob, runCommand, writeFile } from './connection.js'; import { parseListOutput } from './protocol.js'; import { el } from './dom.js'; import { ago } from './sessions.js'; import { CommandPalette, openDialog, 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 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; } // ---------------------------------------------------------------- prefs function readJson(key, fallback) { try { const value = JSON.parse(localStorage.getItem(`devdesk.files.${key}`)); return value ?? fallback; } catch { return fallback; } } function writeJson(key, value) { try { localStorage.setItem(`devdesk.files.${key}`, JSON.stringify(value)); } catch { // Private mode: the state lasts until the tab closes. } } // Unsaved edits survive a reload, a session switch or a crash (devdesk#20): // each dirty buffer is mirrored to localStorage with the sha it was based on. const draftKey = (session, path) => `devdesk.draft.${session}:${path}`; function readDraft(session, path) { try { return JSON.parse(localStorage.getItem(draftKey(session, path))); } catch { return null; } } function writeDraft(session, path, draft) { try { if (draft) localStorage.setItem(draftKey(session, path), JSON.stringify(draft)); else localStorage.removeItem(draftKey(session, path)); } catch { // Quota or private mode: the edit just isn't backed up. } } // ---------------------------------------------------------------- icons // A two-character badge per file type (VS Code's icon idea, in the terminal style). const ICONS = [ [/\.(md|markdown|mdx)$/i, 'M↓', 'cyan'], [/\.(js|mjs|cjs|jsx)$/i, 'JS', 'amber'], [/\.(ts|tsx|mts)$/i, 'TS', 'blue'], [/\.(json|jsonc|webmanifest)$/i, '{}', 'amber'], [/\.(py|pyi)$/i, 'PY', 'blue'], [/\.(sh|bash|zsh|bats|env)$|^\.env/i, '$_', 'green'], [/\.(ya?ml)$/i, 'YM', 'purple'], [/\.(toml|ini|cfg|conf|properties)$/i, '⚙', 'muted'], [/\.(service|timer|socket|mount)$/i, 'SD', 'muted'], [/\.(html?|xml|svg|j2|jinja|php)$/i, '<>', 'orange'], [/\.(css|scss)$/i, '#', 'blue'], [/\.(png|jpe?g|gif|webp|ico|bmp)$/i, '▣', 'purple'], [/\.(go)$/i, 'GO', 'cyan'], [/\.(rs)$/i, 'RS', 'orange'], [/\.(sql)$/i, 'DB', 'amber'], [/\.(lock)$|-lock\.json$/i, '≡', 'muted'], [/^(dockerfile|.*\.dockerfile)$/i, 'DK', 'cyan'], [/^(makefile|gnumakefile)$/i, 'MK', 'orange'], [/^(license|copying)(\..*)?$/i, '©', 'muted'], [/^\.git/i, '±', 'muted'], [/\.(diff|patch)$/i, '±', 'green'], [/\.(txt|log)$/i, '≡', 'muted'], ]; function iconFor(name) { for (const [re, text, tone] of ICONS) if (re.test(name)) return [text, tone]; return ['·', 'muted']; } const base = (path) => path.split('/').pop(); // Fuzzy "go to file": every query character in order; consecutive runs, the // file name, and starts of segments score higher; shorter paths break ties. export function fuzzyScore(path, query) { if (!query) return 1; const p = path.toLowerCase(); const q = query.toLowerCase().replace(/\s+/g, ''); const nameStart = p.lastIndexOf('/') + 1; let score = 0; let last = -1; let run = 0; for (const ch of q) { const i = p.indexOf(ch, last + 1); if (i < 0) return null; run = i === last + 1 ? run + 1 : 0; score += 1 + run * 3 + (i >= nameStart ? 4 : 0) + (i === 0 || p[i - 1] === '/' || p[i - 1] === '.' || p[i - 1] === '-' || p[i - 1] === '_' ? 3 : 0); last = i; } if (p.slice(nameStart).includes(q)) score += 20; return score - path.length / 100; } // ---------------------------------------------------------------- pane export class FilesPane { // hooks: session() -> name, paste(text), onToggle(open), focusTerminal() constructor(root, hooks) { this.root = root; this.hooks = hooks; this.session = null; this.listings = new Map(); // dir -> listing (per session; cleared on switch) this.loadingDirs = new Set(); this.changes = null; this.tabs = []; // { path, mode, pinned } this.active = null; this.views = new Map(); // tab key -> { node, editor, header, base64, stale } this.view = 'explorer'; this.expanded = new Set(); this.focusIndex = 0; this.wrap = readJson('wrap', false); this.fileList = null; // go-to-file cache { at, files } this.explorer = root.querySelector('.dd-explorer-body'); this.tabStrip = root.querySelector('.dd-tabs'); this.editorArea = root.querySelector('.dd-editor-area'); this.count = root.querySelector('.dd-files-count'); this.viewButtons = [...root.querySelectorAll('[data-files-view]')]; for (const button of this.viewButtons) button.addEventListener('click', () => this.setView(button.dataset.filesView)); root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh()); root.querySelector('.dd-files-close').addEventListener('click', () => this.close()); root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open()); root.querySelector('.dd-files-new').addEventListener('click', () => this.newFile()); root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown())); this.explorer.addEventListener('keydown', (event) => this.treeKey(event)); root.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyP') { event.preventDefault(); this.quickOpen.open(); } if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyS') { event.preventDefault(); if (this.active) this.save(this.active); } }); this.wireExplorerResize(); // Leaving or reloading the page: write pending drafts now, not in 400 ms. window.addEventListener('pagehide', () => this.flushDrafts()); window.addEventListener('beforeunload', () => this.flushDrafts()); // Narrow pane (phone, or dragged small): the explorer slides over the editor. new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root); this.quickOpen = new CommandPalette(() => this.loadFileList(), { id: 'dd-goto', placeholder: 'Go to file… (type part of a name)', search: (files, query) => (query ? files.map((c) => ({ c, s: fuzzyScore(c.id, query) })).filter((x) => x.s !== null).sort((a, b) => b.s - a.s).slice(0, 60).map((x) => x.c) : [...this.recent().map((p) => files.find((c) => c.id === p)).filter(Boolean), ...files.slice(0, 40)].slice(0, 40)), }); } get isOpen() { return !this.root.hidden; } // Drag the explorer's right edge; the width is remembered per browser. wireExplorerResize() { const handle = el('div', { class: 'dd-explorer-resizer', role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize the explorer' }); this.root.querySelector('.dd-explorer').after(handle); const apply = (px) => { const width = Math.round(Math.max(160, Math.min(this.root.clientWidth * 0.6, px))); this.root.style.setProperty('--dd-explorer-w', `${width}px`); return width; }; const saved = readJson('explorerWidth', 0); if (saved) apply(saved); handle.addEventListener('pointerdown', (event) => { event.preventDefault(); handle.setPointerCapture(event.pointerId); document.body.classList.add('dd-resizing'); const left = this.root.querySelector('.dd-explorer').getBoundingClientRect().left; const move = (e) => apply(e.clientX - left); const up = () => { document.body.classList.remove('dd-resizing'); handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); writeJson('explorerWidth', this.root.querySelector('.dd-explorer').getBoundingClientRect().width); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up); }); } // ------------------------------------------------------------ open / close / session open(view) { this.root.hidden = false; this.hooks.onToggle(true); this.syncSession(); if (view) this.setView(view === 'files' ? 'explorer' : view); else this.render(); this.revalidate(); loadEditor().catch(() => {}); // warm up } close() { this.root.hidden = true; this.hooks.onToggle(false); this.hooks.focusTerminal(); } toggle(view) { if (this.isOpen && (!view || (view === 'changes') === (this.view === 'changes'))) this.close(); else this.open(view); } escape() { if (this.root.classList.contains('is-narrow') && this.explorerShown() && this.active) this.setExplorer(false); else this.close(); } stateKey() { return `state.${this.session}`; } saveState() { if (!this.session) return; writeJson(this.stateKey(), { expanded: [...this.expanded], view: this.view, active: this.active, tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode }) => ({ path, mode })), }); } syncSession() { const session = this.hooks.session(); if (session === this.session) return false; this.flushDrafts(); for (const v of this.views.values()) v.editor?.destroy(); this.views.clear(); this.listings.clear(); this.changes = null; this.fileList = null; this.session = session; const saved = session ? readJson(this.stateKey(), {}) : {}; this.expanded = new Set(saved.expanded ?? []); this.view = saved.view ?? 'explorer'; this.tabs = (saved.tabs ?? []).map((t) => ({ ...t, key: `${t.mode}:${t.path}`, pinned: true })); this.active = this.tabs.some((t) => t.key === saved.active) ? saved.active : (this.tabs[0]?.key ?? null); return true; } sessionChanged() { if (this.isOpen && this.syncSession()) { this.render(); this.revalidate(); } } // ------------------------------------------------------------ data async listDir(dir) { if (this.loadingDirs.has(dir)) return; const session = this.session; this.loadingDirs.add(dir); try { const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000)); if (session !== this.session) return; this.listings.set(dir, listing); } catch (error) { if (session !== this.session) return; if (dir) { this.expanded.delete(dir); toast.error(`${dir}: ${error.message}`); } else { this.listings.set('', { error: error.message, entries: [] }); } } finally { this.loadingDirs.delete(dir); } if (session === this.session) this.renderExplorer(); } async loadChanges() { const session = this.session; try { const changes = parseListOutput(await runCommand(['--git-status', session], 15000)); if (session !== this.session) return; this.changes = changes; } catch { return; } const n = this.changes.files?.length ?? 0; this.count.textContent = n ? String(n) : ''; this.renderExplorer(); this.renderTabs(); if (!this.active) this.renderEditor(); } async loadFileList() { if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items; const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000)); this.fileListRoot = list.root; const items = list.files.map((path) => { const name = base(path); const [icon] = iconFor(name); return { id: path, label: name, hint: path.includes('/') ? path.slice(0, path.length - name.length - 1) : '', icon, group: 'Files', run: () => this.openFile(path, { pin: true }) }; }); this.fileList = { at: Date.now(), session: this.session, items }; if (list.truncated) toast.info(`Go to file searches the first ${list.files.length} files`); return items; } // Refresh what's visible: the tree's open folders, git status, the active file. revalidate() { if (!this.session) return; this.listDir(''); for (const dir of this.expanded) this.listDir(dir); this.loadChanges(); } refresh() { this.fileList = null; this.revalidate(); if (this.active) this.reloadTab(this.active, { quiet: false }); } async claudeTurnEnded() { // New files may exist now: refresh the list terminal links are checked against. this.fileList = null; this.knownFiles(); if (!this.isOpen) { // Next time it opens it revalidates anyway; open tabs re-check on activation. for (const v of this.views.values()) v.stale = true; return; } this.fileList = null; this.revalidate(); for (const [key, v] of this.views) if (key !== this.active) v.stale = true; if (this.active) this.reloadTab(this.active, { quiet: true }); } changeFor(path) { return this.changes?.files?.find((f) => f.path === path) ?? null; } recent() { return readJson(`recent.${this.session}`, []); } pushRecent(path) { writeJson(`recent.${this.session}`, [path, ...this.recent().filter((p) => p !== path)].slice(0, 8)); } // ------------------------------------------------------------ layout explorerShown() { return !this.root.classList.contains('explorer-hidden'); } setExplorer(show) { this.root.classList.toggle('explorer-hidden', !show); if (show) this.focusTree(); } setView(view) { this.view = view; this.saveState(); this.setExplorer(true); this.render(); if (view === 'changes') this.loadChanges(); } render() { for (const b of this.viewButtons) b.setAttribute('aria-selected', String(b.dataset.filesView === this.view)); this.renderExplorer(); this.renderTabs(); this.renderEditor(); } // ------------------------------------------------------------ explorer // Visible rows: the root listing plus every expanded folder's children. rows() { const out = []; const walk = (dir, depth) => { const listing = this.listings.get(dir); if (!listing) { out.push({ kind: 'loading', depth, dir }); return; } for (const e of listing.entries) { const path = dir ? `${dir}/${e.n}` : e.n; const isDir = e.t === 'd' || e.to === 'd'; out.push({ kind: isDir ? 'dir' : 'file', depth, path, entry: e }); if (isDir && this.expanded.has(path)) walk(path, depth + 1); } if (listing.truncated) out.push({ kind: 'note', depth, text: `first ${listing.entries.length} entries only` }); }; walk('', 0); return out; } renderExplorer() { if (!this.session) { this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Open a session to browse its files.')); return; } if (this.view === 'changes') { this.renderChangesList(); return; } const root = this.listings.get(''); if (root?.error) { this.explorer.replaceChildren(el('div', { class: 'dd-files-msg is-error' }, root.error)); return; } const activePath = this.tabs.find((t) => t.key === this.active)?.path; const changed = new Map((this.changes?.files ?? []).filter((f) => f.path).map((f) => [f.path, f])); this.treeRows = this.rows(); this.focusIndex = Math.min(this.focusIndex, Math.max(0, this.treeRows.length - 1)); const nodes = this.treeRows.map((row, i) => { const indent = { style: `padding-left:${8 + row.depth * 14}px` }; if (row.kind === 'loading') return el('div', { class: 'dd-tree-row is-note', ...indent }, 'Loading…'); if (row.kind === 'note') return el('div', { class: 'dd-tree-row is-note', ...indent }, row.text); const { entry, path } = row; const isDir = row.kind === 'dir'; const open = isDir && this.expanded.has(path); const change = isDir ? null : changed.get(path); const dirty = isDir && [...changed.keys()].some((p) => p.startsWith(`${path}/`)); const [icon, tone] = isDir ? [open ? '▾' : '▸', 'dir'] : iconFor(entry.n); const status = change ? statusOf(change) : null; return el('div', { class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`, role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1), tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i), title: `${path}${entry.t === 'l' ? ' (symlink)' : ''}${status ? ` · ${status[1]}` : ''}${change?.claude ? ' · edited by Claude' : ''}`, ...indent, onclick: () => { this.focusIndex = i; if (isDir) this.toggleDir(path); else this.openFile(path); }, ondblclick: () => { if (!isDir) this.pin(path); }, }, el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon), el('span', { class: 'dd-tree-name' }, entry.n), change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, status ? el('span', { class: `dd-file-status is-${status[2]}` }, status[0]) : null, dirty ? el('span', { class: 'dd-dirty-dot', title: 'contains changes' }) : null); }); if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder.')); const hadFocus = this.explorer.contains(document.activeElement); this.explorer.setAttribute('role', 'tree'); this.explorer.replaceChildren(...nodes); if (hadFocus) this.focusTree(); } toggleDir(path, open = !this.expanded.has(path)) { if (open) { this.expanded.add(path); if (!this.listings.has(path)) this.listDir(path); else this.listDir(path); // revalidate in the background, show the cached one now } else { this.expanded.delete(path); } this.saveState(); this.renderExplorer(); } focusTree() { const row = this.explorer.querySelector(`[data-index="${this.focusIndex}"]`) ?? this.explorer.querySelector('[data-index]'); row?.focus(); } // ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open. treeKey(event) { if (this.view !== 'explorer' || !this.treeRows?.length) return; const row = this.treeRows[this.focusIndex]; const move = (i) => { this.focusIndex = Math.max(0, Math.min(this.treeRows.length - 1, i)); for (const node of this.explorer.querySelectorAll('[data-index]')) node.tabIndex = Number(node.dataset.index) === this.focusIndex ? 0 : -1; this.focusTree(); }; switch (event.key) { case 'ArrowDown': move(this.focusIndex + 1); break; case 'ArrowUp': move(this.focusIndex - 1); break; case 'Home': move(0); break; case 'End': move(this.treeRows.length - 1); break; case 'ArrowRight': if (row?.kind === 'dir') { if (!this.expanded.has(row.path)) this.toggleDir(row.path, true); else move(this.focusIndex + 1); } break; case 'ArrowLeft': if (row?.kind === 'dir' && this.expanded.has(row.path)) this.toggleDir(row.path, false); else if (row?.path?.includes('/')) { const up = row.path.split('/').slice(0, -1).join('/'); move(this.treeRows.findIndex((r) => r.path === up)); } break; case 'Enter': case ' ': if (row?.kind === 'dir') this.toggleDir(row.path); else if (row?.kind === 'file') this.openFile(row.path, { pin: event.key === 'Enter' }); break; default: return; } event.preventDefault(); } // Expand the folders above a file and scroll it into view. reveal(path) { const parts = path.split('/'); let changed = false; for (let i = 1; i < parts.length; i += 1) { const dir = parts.slice(0, i).join('/'); if (!this.expanded.has(dir)) { this.expanded.add(dir); changed = true; if (!this.listings.has(dir)) this.listDir(dir); } } if (changed) this.saveState(); if (this.view !== 'explorer') this.setView('explorer'); else this.renderExplorer(); this.explorer.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' }); } renderChangesList() { if (!this.changes) { this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…')); return; } const { files = [], git, branch } = this.changes; const head = el('div', { class: 'dd-changes-head' }, git ? `${branch} · ${files.length} uncommitted` : 'Not a git repository · files Claude edited'); if (!files.length) { this.explorer.replaceChildren(head, el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files here yet.')); return; } const activePath = this.tabs.find((t) => t.key === this.active)?.path; const rows = files.map((f) => { const [letter, label, tone] = statusOf(f); const path = f.path ?? f.repoPath; const name = base(path); const dir = path.slice(0, path.length - name.length).replace(/\/$/, ''); const openable = f.path !== null && f.path !== undefined; const canDiff = git && f.x !== '?' && f.y !== '?'; return el('div', { class: `dd-tree-row dd-change-row git-${tone}${path === activePath ? ' is-active' : ''}${openable ? '' : ' is-disabled'}`, tabindex: '0', role: 'button', title: `${path} · ${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`, onclick: () => openable && this.openFile(f.path, { mode: canDiff ? 'diff' : undefined }), onkeydown: (event) => { if (event.key === 'Enter' && openable) this.openFile(f.path, { mode: canDiff ? 'diff' : undefined, pin: true }); }, }, el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]), el('span', { class: 'dd-tree-name' }, name, dir ? el('span', { class: 'dd-change-dir' }, ` ${dir}`) : null), f.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, 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, el('span', { class: `dd-file-status is-${tone}` }, letter)); }); this.explorer.setAttribute('role', 'list'); this.explorer.replaceChildren(head, ...rows); } // ------------------------------------------------------------ tabs // Single click opens a preview tab (italic) that the next click replaces; // double click, Enter or go-to-file keeps it. openFile(path, { mode, pin = false, line } = {}) { // A line number means source: markdown opens as text, not as a preview. const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view'); const key = `${wanted}:${path}`; let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode); if (!tab) { const preview = this.tabs.findIndex((t) => !t.pinned); tab = { key, path, mode: wanted, pinned: pin }; if (preview >= 0 && !pin) { this.dropView(this.tabs[preview].key); this.tabs[preview] = tab; } else { this.tabs.push(tab); while (this.tabs.length > 12) { const victim = this.tabs.find((t) => t.key !== key && !this.views.get(t.key)?.dirty); if (!victim) break; this.closeTab(victim.key, { render: false, force: true }); } } } else if (pin) { tab.pinned = true; } this.pushRecent(path); if (line) { const loaded = this.views.get(tab.key)?.editor; if (loaded?.goto) setTimeout(() => loaded.goto(line), 0); else tab.line = line; } this.activate(tab.key); if (this.root.classList.contains('is-narrow')) this.setExplorer(false); } pin(path) { const tab = this.tabs.find((t) => t.path === path); if (tab) { tab.pinned = true; this.saveState(); this.renderTabs(); } } activate(key) { this.active = key; this.saveState(); this.renderTabs(); this.renderEditor(); this.renderExplorer(); } flushDrafts() { for (const [key, view] of this.views) { if (!view.editable || !view.draftTimer) continue; clearTimeout(view.draftTimer); view.draftTimer = 0; const tab = this.tabs.find((t) => t.key === key); if (tab) writeDraft(this.session, tab.path, view.dirty ? { text: view.editor.text(), base: view.base, at: Date.now() } : null); } } hasDirty() { return [...this.views.values()].some((v) => v.dirty); } async closeTab(key, { render = true, force = false } = {}) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); if (!force && view?.dirty && tab) { const choice = await openDialog({ title: `Save changes to ${base(tab.path)}?`, size: 'sm', body: el('p', { class: 'dd-dialog-text' }, `${tab.path} has unsaved changes.`), actions: [{ label: 'Cancel', value: null }, { label: 'Don\'t save', value: 'discard' }, { label: 'Save', primary: true }], }); if (!choice) return; if (choice === 'discard') writeDraft(this.session, tab.path, null); else if (!(await this.save(key))) return; } const i = this.tabs.findIndex((t) => t.key === key); if (i < 0) return; this.tabs.splice(i, 1); this.dropView(key); if (this.active === key) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.key ?? null; this.saveState(); if (render) { this.renderTabs(); this.renderEditor(); this.renderExplorer(); } } dropView(key) { clearTimeout(this.views.get(key)?.draftTimer); this.views.get(key)?.editor?.destroy(); this.views.get(key)?.node.remove(); this.views.delete(key); } renderTabs() { const nodes = this.tabs.map((tab) => { const name = base(tab.path); const change = this.changeFor(tab.path); const status = change ? statusOf(change) : null; return el('div', { class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}${this.views.get(tab.key)?.dirty ? ' is-dirty' : ''}`, role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1', title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`, onclick: () => this.activate(tab.key), ondblclick: () => this.pin(tab.path), onauxclick: (event) => { if (event.button === 1) this.closeTab(tab.key); }, onkeydown: (event) => { if (event.key === 'Enter') this.activate(tab.key); }, }, el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]), el('span', { class: 'dd-tab-name' }, name), tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null, el('button', { type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: this.views.get(tab.key)?.dirty ? 'Unsaved changes: close…' : 'Close', onclick: (event) => { event.stopPropagation(); this.closeTab(tab.key); }, }, this.views.get(tab.key)?.dirty ? '●' : '×')); }); this.tabStrip.replaceChildren(...nodes); this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } // ------------------------------------------------------------ editor renderEditor() { for (const [key, v] of this.views) v.node.hidden = key !== this.active; let empty = this.editorArea.querySelector('.dd-editor-empty'); if (!this.active) { if (!empty) { empty = el('div', { class: 'dd-editor-empty' }); this.editorArea.append(empty); } empty.hidden = false; this.renderEmpty(empty); return; } if (empty) empty.hidden = true; const view = this.views.get(this.active); if (!view) this.loadTab(this.active); else if (view.stale) this.reloadTab(this.active, { quiet: true }); } renderEmpty(node) { const recent = this.recent(); const n = this.changes?.files?.length ?? 0; node.replaceChildren(...[ el('div', { class: 'dd-empty-title' }, this.session ? `No file open in ${this.session}` : 'No session'), el('div', { class: 'dd-empty-actions' }, el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.quickOpen.open() }, 'Go to file… Ctrl+P'), n ? el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.setView('changes') }, `Review ${n} change${n === 1 ? '' : 's'}`) : null), recent.length ? el('div', { class: 'dd-empty-recent' }, el('div', { class: 'dd-empty-label' }, 'Recent'), ...recent.map((path) => el('button', { type: 'button', class: 'dd-recent', title: path, onclick: () => this.openFile(path, { pin: true }) }, el('span', { class: `dd-ficon tone-${iconFor(base(path))[1]}` }, iconFor(base(path))[0]), el('span', { class: 'dd-recent-name' }, base(path)), el('span', { class: 'dd-change-dir' }, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')))) : null, el('div', { class: 'dd-empty-hint' }, 'Click a file for a preview tab, double-click to keep it open. Esc returns to the terminal.'), ].filter(Boolean)); } async fetchTab(tab) { const change = this.changeFor(tab.path); const deleted = change && (change.x === 'D' || change.y === 'D'); const [blob, original] = await Promise.all([ deleted ? Promise.resolve({ header: { path: tab.path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', this.session, tab.path]), tab.mode === 'diff' ? runBlob(['--git-show', this.session, tab.path]) : Promise.resolve(null), ]); return { blob, original }; } async loadTab(key) { const tab = this.tabs.find((t) => t.key === key); if (!tab) return; const node = el('div', { class: 'dd-tab-view' }, el('div', { class: 'dd-files-msg' }, 'Loading…')); this.editorArea.append(node); const view = { node, editor: null, header: null, stale: false }; this.views.set(key, view); node.hidden = key !== this.active; const session = this.session; try { const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]); if (session !== this.session || this.views.get(key) !== view) return; this.fill(view, tab, blob, original, editor); } catch (error) { if (this.views.get(key) !== view) return; node.replaceChildren(this.bar(tab, null), el('div', { class: 'dd-files-msg is-error' }, error.message)); } } // Claude changed files: re-read; if the content differs, swap it in place // and keep the scroll position (read-only, so nothing of yours is lost). async reloadTab(key, { quiet }) { const tab = this.tabs.find((t) => t.key === key); const view = this.views.get(key); if (!tab || !view) { if (tab) this.loadTab(key); return; } view.stale = false; try { const session = this.session; const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]); if (session !== this.session || this.views.get(key) !== view) return; if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return; if (view.dirty) { if (blob.header.sha !== view.base) this.changedOnDisk(key); return; } const scroller = view.node.querySelector('.cm-scroller'); const top = scroller?.scrollTop ?? 0; view.editor?.destroy(); this.fill(view, tab, blob, original, editor); const next = view.node.querySelector('.cm-scroller'); if (next) next.scrollTop = top; if (quiet && view.header?.sha !== blob.header.sha) view.node.querySelector('.dd-viewer-meta')?.classList.add('is-flash'); } catch { // gone or unreadable: keep what's shown } } bar(tab, header, view) { const change = this.changeFor(tab.path); const meta = header ? [ size(header.size ?? 0), header.mtime ? `changed ${ago(header.mtime)}` : '', header.utf8 === false ? 'Latin-1' : '', tab.mode === 'diff' ? 'diff against HEAD' : '', change ? statusOf(change)[1] : '', change?.claude ? 'edited by Claude' : '', ].filter(Boolean).join(' · ') : ''; const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label); const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true }); const actions = []; if (view?.editable) { actions.push(button('Save', () => this.save(tab.key), { class: 'dd-viewer-btn is-primary dd-dirty-only', title: 'Save (Ctrl+S)' })); actions.push(button('Revert', () => this.revert(tab.key), { class: 'dd-viewer-btn dd-dirty-only', title: 'Discard your changes' })); } if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview'))); if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) { actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff'))); } if (tab.mode !== 'preview') { actions.push(button(this.wrap ? 'No wrap' : 'Wrap', () => { this.wrap = !this.wrap; writeJson('wrap', this.wrap); for (const key of [...this.views.keys()]) this.dropView(key); this.renderEditor(); }, { title: 'Wrap long lines' })); } actions.push(button('Reveal', () => this.reveal(tab.path), { title: 'Show in the explorer' })); actions.push(button('Copy', () => navigator.clipboard?.writeText(tab.path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused')), { title: 'Copy the path' })); actions.push(button('@Claude', () => { this.hooks.paste(`@${tab.path} `); toast.info(`Mentioned @${tab.path} in the prompt`); }, { title: 'Type @path into the current prompt so Claude reads the file' })); return el('div', { class: 'dd-viewer-bar' }, el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') }, el('span', { class: 'dd-viewer-path' }, tab.path), meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null, view?.editable ? el('span', { class: 'dd-unsaved dd-dirty-only' }, ' · unsaved') : null), el('span', { class: 'dd-viewer-actions' }, ...actions)); } fill(view, tab, blob, original, editor) { const { header, base64 } = blob; view.header = header; const text = (b64, utf8 = true) => new TextDecoder(utf8 ? 'utf-8' : 'latin1').decode(decodeBase64(b64 || '')); const content = el('div', { class: 'dd-viewer-content' }); 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: tab.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))); } else if (tab.mode === 'preview') { content.classList.add('is-preview'); // Unsaved edits in the Source tab show in the preview too. const source = this.views.get(`view:${tab.path}`); const md = source?.dirty ? source.editor.text() : text(base64); const frame = el('iframe', { class: 'dd-md-frame', title: `Preview of ${tab.path}`, sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(md)), }); content.append(frame); inlineImages(editor.markdown(md), this.session, tab.path).then((html) => { if (frame.isConnected) frame.srcdoc = previewDocument(html); }); } else if (tab.mode === 'diff') { view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap }); } else if (header.utf8 === false) { // Not UTF-8: shown as Latin-1, read-only (saving would change the bytes). view.editor = editor.viewer(content, { doc: text(base64, false), path: tab.path, wrap: this.wrap }); } else { const disk = text(base64); const draft = readDraft(this.session, tab.path); let doc = disk; let banner = null; if (draft && draft.text !== disk) { if (draft.base === header.sha) { doc = draft.text; toast.info(`Restored your unsaved changes to ${tab.path}`); } else { banner = draft; } } view.editable = true; view.saved = disk; view.base = header.sha; view.editor = editor.editor(content, { doc, path: tab.path, wrap: this.wrap, onChange: (now) => this.edited(tab.key, now), onSave: () => this.save(tab.key), }); view.dirty = doc !== disk; view.node.classList.toggle('is-dirty', view.dirty); view.node.replaceChildren(this.bar(tab, header, view), content); if (banner) this.oldDraft(tab.key, banner); if (view.dirty) this.renderTabs(); this.gotoPending(tab, view); return; } view.node.replaceChildren(this.bar(tab, header, view), content); this.gotoPending(tab, view); } gotoPending(tab, view) { if (!tab.line || !view.editor?.goto) return; const line = tab.line; delete tab.line; setTimeout(() => view.editor.goto(line), 0); } // ------------------------------------------------------------ paths in the terminal (devdesk#21) // The session's file list (cached by go-to-file). Loaded ahead of time (on // connect and after Claude's turns): xterm asks for a line's links once and // caches the answer, so it has to be ready before the first hover. knownFiles() { if (this.session !== this.hooks.session()) this.syncSession(); if (this.fileList?.session === this.session) { if (!this.fileSet || this.fileSet.list !== this.fileList.items) { this.fileSet = { list: this.fileList.items, set: new Set(this.fileList.items.map((x) => x.id)) }; } return this.fileSet.set; } if (!this.loadingList && this.session) { this.loadingList = true; this.loadFileList().catch(() => {}).finally(() => { this.loadingList = false; }); } return null; } // A path as printed in the terminal -> the session-relative path, if it's a // file of this session (absolute paths inside the folder count too). resolvePrinted(text) { const known = this.knownFiles(); if (!known) return null; let path = text.replace(/^\.\//, ''); const root = this.fileListRoot; if (path.startsWith('/') && root && path.startsWith(`${root}/`)) path = path.slice(root.length + 1); return known.has(path) ? path : null; } // ------------------------------------------------------------ editing (devdesk#20) edited(key, now) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); if (!view || !tab) return; const dirty = now !== view.saved; if (dirty && !tab.pinned) { tab.pinned = true; // editing keeps a preview tab this.saveState(); } clearTimeout(view.draftTimer); view.draftTimer = setTimeout(() => writeDraft(this.session, tab.path, dirty ? { text: now, base: view.base, at: Date.now() } : null), 400); if (dirty !== view.dirty) { view.dirty = dirty; view.node.classList.toggle('is-dirty', dirty); this.renderTabs(); } } // Resolves true when saved. A conflict (the file changed on disk since it was // opened) asks before overwriting. async save(key, { base: forced } = {}) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); if (!view?.editable || !tab || view.saving) return false; if (!view.dirty && !forced) return true; const now = view.editor.text(); view.saving = true; try { const result = await writeFile(this.session, tab.path, new TextEncoder().encode(now), forced ?? view.base ?? 'new'); if (result.conflict) return this.conflict(key, result.sha); if (!result.ok) throw new Error('not saved'); view.saved = now; view.base = result.sha; view.header = { ...view.header, sha: result.sha, size: result.size, mtime: result.mtime }; clearTimeout(view.draftTimer); writeDraft(this.session, tab.path, null); this.edited(key, view.editor.text()); view.node.querySelector('.dd-viewer-banner')?.remove(); toast.success(`Saved ${tab.path}`); this.loadChanges(); this.fileList = null; return true; } catch (error) { toast.error(`Could not save ${tab.path}: ${error.message}`); return false; } finally { view.saving = false; } } revert(key) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); if (!view?.editable || !tab) return; clearTimeout(view.draftTimer); writeDraft(this.session, tab.path, null); view.editor.setText(view.saved); view.node.querySelector('.dd-viewer-banner')?.remove(); } async diskText(path) { const { header, base64 } = await runBlob(['--fs-read', this.session, path]); return { sha: header.sha, text: new TextDecoder().decode(decodeBase64(base64 || '')) }; } async conflict(key, diskSha) { const tab = this.tabs.find((t) => t.key === key); const choice = await openDialog({ title: `${base(tab.path)} changed on disk`, size: 'sm', body: el('p', { class: 'dd-dialog-text' }, `Someone (probably Claude) changed ${tab.path} after you opened it. Saving now would overwrite that.`), actions: [{ label: 'Cancel', value: null }, { label: 'Compare', value: 'compare' }, { label: 'Overwrite', primary: true, danger: true }], danger: true, }); if (choice === 'compare') return this.compare(key); if (choice) return this.save(key, { base: diskSha }); return false; } // Disk version vs yours, side by side in a unified diff; pick one. async compare(key) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); if (!view || !tab) return false; let disk; let editor; try { [disk, editor] = await Promise.all([this.diskText(tab.path), loadEditor()]); } catch (error) { toast.error(error.message); return false; } const holder = el('div', { class: 'dd-compare' }); let diffView = null; const choice = await openDialog({ title: `${tab.path}: disk (red) vs yours (green)`, className: 'is-wide', body: holder, onOpen: () => { diffView = editor.diff(holder, { original: disk.text, modified: view.editor.text(), path: tab.path, wrap: this.wrap }); return holder.querySelector('.cm-content'); }, actions: [{ label: 'Close', value: null }, { label: 'Take the disk version', value: 'disk' }, { label: 'Save mine', primary: true }], }); diffView?.destroy(); if (choice === 'disk') { view.saved = disk.text; view.base = disk.sha; view.header = { ...view.header, sha: disk.sha }; view.editor.setText(disk.text); writeDraft(this.session, tab.path, null); view.node.querySelector('.dd-viewer-banner')?.remove(); return false; } if (choice) return this.save(key, { base: disk.sha }); return false; } banner(key, text, actions) { const view = this.views.get(key); if (!view) return; view.node.querySelector('.dd-viewer-banner')?.remove(); const node = el('div', { class: 'dd-viewer-banner', role: 'alert' }, el('span', {}, text), ...actions.map(([label, run]) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run }, label))); view.node.querySelector('.dd-viewer-bar')?.after(node); } // Claude changed a file you're editing: nothing is replaced until you choose. changedOnDisk(key) { this.banner(key, 'This file changed on disk while you were editing it.', [ ['Compare', () => this.compare(key)], ['Reload (discard mine)', () => { this.revert(key); this.dropView(key); this.renderEditor(); }], ['Keep mine', () => this.views.get(key)?.node.querySelector('.dd-viewer-banner')?.remove()], ]); } // Unsaved changes from an earlier visit, but the file changed since. oldDraft(key, draft) { const view = this.views.get(key); const tab = this.tabs.find((t) => t.key === key); const age = ago(Math.floor(draft.at / 1000)); this.banner(key, `You have unsaved changes from ${age === 'now' ? 'a moment ago' : `${age} ago`}, but the file changed on disk since.`, [ ['Compare', async () => { view.editor.setText(draft.text); await this.compare(key); }], ['Restore mine', () => { view.editor.setText(draft.text); view.node.querySelector('.dd-viewer-banner')?.remove(); }], ['Discard', () => { writeDraft(this.session, tab.path, null); view.node.querySelector('.dd-viewer-banner')?.remove(); }], ]); } // New file in the folder you're looking at (folders are created as needed). async newFile() { if (!this.session) return; const activePath = this.tabs.find((t) => t.key === this.active)?.path; const row = this.treeRows?.[this.focusIndex]; const folder = row?.kind === 'dir' ? row.path : parent(row?.path ?? activePath ?? ''); const input = el('input', { class: 'lt-input', value: folder ? `${folder}/` : '', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-newfile-hint' }); const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-newfile-hint' }, `Relative to ${this.listings.get('')?.root ?? 'the session folder'}. Missing folders are created.`); const path = await openDialog({ title: 'New file', size: 'sm', body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'Path'), input), hint), actions: [{ label: 'Cancel' }, { label: 'Create', primary: true }], onOpen: () => { input.setSelectionRange(input.value.length, input.value.length); return input; }, submit: () => { const value = input.value.trim().replace(/^\/+/, ''); if (!value || value.endsWith('/') || value.split('/').some((part) => part === '..' || part === '.')) { hint.textContent = 'Type a file name (no .. or trailing /).'; hint.classList.add('is-error'); return null; } return value; }, }); if (!path) return; try { const result = await writeFile(this.session, path, new Uint8Array(0), 'new'); if (!result.ok) throw new Error('not created'); // Show it: re-list every folder above the new file and open them. const parts = result.path.split('/').slice(0, -1); for (let i = 0; i <= parts.length; i += 1) { const dir = parts.slice(0, i).join('/'); if (dir) this.expanded.add(dir); this.listDir(dir); } this.saveState(); this.fileList = null; this.openFile(result.path, { mode: 'view', pin: true }); toast.success(`Created ${result.path}`); } catch (error) { toast.error(`Could not create ${path}: ${error.message}`); } } // Theme switch: the editor follows CSS variables; previews need a re-render. themeChanged() { for (const tab of this.tabs) { if (tab.mode === 'preview') this.dropView(tab.key); } if (this.isOpen) this.renderEditor(); } }