Files
devdesk/web/src/files.js
T
jaredandClaude Opus 5.5 f82c13612d
build-deb / Lint, test, package (push) Successful in 2m45s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
- Terminal link provider: printed paths that are files of the session open
  in Files (at the line); the file list is preloaded so the first hover works.
- Alerts: "In a new session" opens alert-<name> and types the prompt once
  Claude is up.
- devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for
  destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and
  guard.local.
- State hook logs Bash commands and derives host tags (ssh targets, CT/VM
  ids); --list carries tags, --peek the last commands; UI shows both.

Closes #21
Closes #18
Closes #27

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

1306 lines
54 KiB
JavaScript
Raw Permalink 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, #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 `<!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;
}
// ---------------------------------------------------------------- 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();
}
}