- 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>
1306 lines
54 KiB
JavaScript
1306 lines
54 KiB
JavaScript
/* 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();
|
||
}
|
||
}
|