feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
build-deb / Lint, test, package (push) Successful in 2m38s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped

- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset
  (libexec/devdesk-files.sh), confined to the session folder.
- The state hook records files Claude edits (Edit/Write/MultiEdit/
  NotebookEdit) for the Changes tab's "C" badge; rename/end carry it.
- Files pane: folders, CodeMirror read-only view, sanitized markdown preview
  in a sandboxed iframe with inlined relative images, images, Changes tab with
  line counts and diff against HEAD, "→ Claude" @path, resizable split.
- The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched
  over ttyd on first use, verified against the page's SHA-256 and cached in
  IndexedDB.
- Fix: the state hook's field split collapsed empty fields.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:16:37 -04:00
co-authored by Claude Opus 5.5
parent 390ce61f5b
commit 73f8525db2
18 changed files with 1717 additions and 10 deletions
+83 -1
View File
@@ -12,6 +12,7 @@ import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -75,6 +76,7 @@ let fit;
let sessionsPanel;
let palette;
let inbox;
let files;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
@@ -330,6 +332,7 @@ function connect(session, { reconnect = false } = {}) {
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
files?.sessionChanged();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
@@ -465,6 +468,7 @@ function applyTitle() {
sessionsPanel.setCurrent(parsed.session);
onSessionData();
refreshRepo();
files?.sessionChanged();
}
paneTitleRaw = parsed.pane;
renderPaneTitle();
@@ -536,6 +540,7 @@ function setFontSize(size) {
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
@@ -607,6 +612,10 @@ function buildCommands() {
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
}
if (state.session) {
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
}
add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
@@ -666,6 +675,7 @@ const KEY_HELP = [
]],
['DevDesk', [
['Ctrl+⇧ P', 'Command palette'],
['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
@@ -681,6 +691,41 @@ function showHelp() {
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
}
// Drag the files pane's left edge (or use ←/→ on it). The terminal keeps at
// least ~60 columns; the width is remembered per browser.
function wireResizer() {
const main = document.querySelector('.dd-main');
const handle = $('files-resizer');
const apply = (px) => {
const max = main.clientWidth - 520;
const width = Math.round(Math.max(340, Math.min(max, px)));
main.style.setProperty('--dd-files-w', `${width}px`);
return width;
};
const saved = Number(store.get('filesWidth', 0));
if (saved) apply(saved);
handle.addEventListener('pointerdown', (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
ui.body.classList.add('dd-resizing');
const move = (e) => apply(main.getBoundingClientRect().right - e.clientX);
const up = () => {
ui.body.classList.remove('dd-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
store.set('filesWidth', $('files').getBoundingClientRect().width);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
handle.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const width = apply($('files').getBoundingClientRect().width + (event.key === 'ArrowLeft' ? 40 : -40));
store.set('filesWidth', width);
});
}
async function toggleFullscreen() {
if (document.fullscreenElement) {
await document.exitFullscreen();
@@ -704,6 +749,7 @@ function renderNotifyButton() {
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
ui.backdrop.hidden = !(open && isNarrow());
@@ -765,10 +811,34 @@ function wireChrome() {
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
$('palette-menu').addEventListener('click', () => {
closeMenus();
palette.open();
});
files = new FilesPane($('files'), {
session: () => state.session,
paste: (text) => {
term.paste(text);
},
focusTerminal: () => term.focus(),
onToggle: (open) => {
ui.body.classList.toggle('dd-files-open', open);
$('files-toggle').setAttribute('aria-expanded', String(open));
if (open && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
},
});
$('files-toggle').addEventListener('click', () => files.toggle());
wireResizer();
palette = new CommandPalette(buildCommands);
$('cmd-toggle').addEventListener('click', () => palette.open());
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyE') {
event.preventDefault();
event.stopPropagation();
if (state.session) files.toggle();
return;
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
@@ -819,6 +889,14 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || dialogOpen()) return;
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
// own search panel has the focus.
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
event.preventDefault();
event.stopPropagation();
files.escape();
return;
}
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
@@ -901,7 +979,10 @@ async function boot() {
theme: getTheme(),
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
onThemeChange((theme) => {
term.options.theme = THEMES[theme];
files?.themeChanged();
});
$('theme-toggle').addEventListener('click', toggleTheme);
term.onData((data) => state.conn?.sendInput(data));
term.onBinary((data) => state.conn?.sendBinary(data));
@@ -914,6 +995,7 @@ async function boot() {
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
refreshSoon(); // Claude finished a turn: states and context just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
setTimeout(() => files?.claudeTurnEnded(), 1200); // …or edited files
});
let fitFrame = 0;