feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
build-deb / Lint, test, package (push) Successful in 2m29s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped

- Explorer: context menus, multi-select, inline rename/new, drag to move
  or copy, desktop drag-and-drop uploads (folders too), cut/copy/paste,
  duplicate, download, delete to a 7-day trash with Undo. Server side in
  libexec/devdesk-fsops.sh with bats tests. Closes #54
- Markdown preview in a shadow root: headings with anchors and outline,
  highlighted code with copy, callouts, task lists, front matter, links
  that open files, live split view. Editor status bar, multi-cursor,
  closeBrackets, Ctrl+G, tab menus and reordering. Closes #55
- Search in files (Ctrl+Shift+F) via git grep / grep. Closes #56
- Fix: nested $(...) path checks now fail hard (set -e does not reach
  them; an escape created /x in testing); newest folder listing wins;
  sessionAction returns the reply JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
2026-10-22 21:25:43 -04:00
co-authored by Claude Opus 5.5
parent e60f1ad066
commit 65114165e7
15 changed files with 2166 additions and 167 deletions
+26 -1
View File
@@ -246,6 +246,24 @@ export function writeFile(session, path, bytes, base) {
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
}
// Explorer uploads (devdesk#54): any file (up to 25 MB) to PATH in the session's
// folder, folders created as needed. mode "new" refuses to replace; "replace"
// overwrites. Resolves {ok, path, size}; rejects "already exists: …" etc.
export function putFile(session, path, file, mode = 'new', onProgress) {
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => streamIn(['--fs-put', session, path, String(file.size), mode], 'DEVDESK-PUT-READY', file, onProgress));
}
// A file of the session as a Blob, for the browser to save (up to 25 MB).
export async function downloadFile(session, path) {
const { header, base64 } = await runBlob(['--fs-download', session, path], 180000);
if (base64 === null || base64 === undefined) throw new Error('no content');
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return { name: (header.path || path).split('/').pop(), blob: new Blob([bytes]) };
}
// Run a devdesk command that reads base64 from the terminal after printing
// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
@@ -379,7 +397,14 @@ export async function fetchRepo(session) {
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
if (/"ok"\s*:\s*true/.test(output)) return;
// Success: the reply's JSON ({ok: true, …}) for callers that need its fields.
if (/"ok"\s*:\s*true/.test(output)) {
try {
return parseListOutput(output);
} catch {
return { ok: true };
}
}
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
+1319 -138
View File
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -267,14 +267,29 @@ function wireUploads() {
$('drop-session').textContent = state.session || 'a session';
overlay.hidden = false;
});
// Files dragged from the explorer: @path mentions for Claude (devdesk#54).
const fromExplorer = (event) => [...(event.dataTransfer?.types ?? [])].includes('application/x-devdesk-paths');
frame.addEventListener('dragover', (event) => {
if (hasFiles(event)) event.preventDefault();
else if (fromExplorer(event)) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
});
frame.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) overlay.hidden = true;
});
frame.addEventListener('drop', (event) => {
if (!hasFiles(event) && fromExplorer(event)) {
event.preventDefault();
const text = event.dataTransfer.getData('text/plain');
if (text && state.conn?.isOpen) {
term.paste(text);
term.focus();
}
return;
}
if (!hasFiles(event)) return;
event.preventDefault();
depth = 0;
@@ -284,7 +299,8 @@ function wireUploads() {
// Pasting a screenshot / copied file: upload it instead of pasting text.
document.addEventListener('paste', (event) => {
const files = [...(event.clipboardData?.files ?? [])];
if (!files.length) return;
// In the Files pane, pasted files go into the explorer's folder (devdesk#54).
if (!files.length || event.target.closest?.('#files')) return;
event.preventDefault();
event.stopPropagation();
attachFiles(files);
@@ -817,6 +833,7 @@ function buildCommands() {
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: 'search-files', group: 'Files', icon: '⌕', label: 'Search in files', kbd: 'Ctrl+⇧F', keywords: 'grep find text content', run: () => files.openSearch() });
add({
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
run: () => {
@@ -890,6 +907,7 @@ const KEY_HELP = [
['Drag', 'Select text; it is copied when you let go'],
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['Ctrl+⇧ F', 'Search in the session\'s files'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
['Click a file path', 'Open it in Files (at the line, if given)'],
@@ -1063,6 +1081,13 @@ function wireChrome() {
if (state.session) files.toggle();
return;
}
// Ctrl+Shift+F: search in the session's files (devdesk#56).
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyF') {
event.preventDefault();
event.stopPropagation();
if (state.session) files.openSearch();
return;
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
@@ -1115,6 +1140,9 @@ 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;
// A right-click menu, the explorer's name box, or an explorer selection /
// cut to clear take their own Esc first (devdesk#54).
if (event.target.closest?.('.dd-ctx-menu, .dd-tree-input') || files?.wantsEscape(event)) 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) {
+86 -5
View File
@@ -24,13 +24,21 @@ function dismiss(node) {
setTimeout(() => node.remove(), 250);
}
export function toast(message, type = 'info', duration = 4000) {
// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast.
export function toast(message, type = 'info', duration = 4000, actions = []) {
const box = toastContainer();
// At most four on screen: the oldest goes first.
while (box.children.length >= 4) box.firstElementChild.remove();
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
el('span', { class: 'lt-toast-msg' }, message),
...actions.map(({ label, run }) => el('button', {
type: 'button', class: 'lt-toast-action',
onclick: () => {
dismiss(node);
run();
},
}, label)),
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
box.append(node);
@@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) {
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
}
toast.success = (message, duration) => toast(message, 'success', duration);
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
toast.warning = (message, duration) => toast(message, 'warning', duration);
toast.info = (message, duration) => toast(message, 'info', duration);
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
toast.info = (message, duration, actions) => toast(message, 'info', duration, actions);
// ---------------------------------------------------------------- context menu
// A right-click menu at (x, y) (devdesk#54). items: { label, kbd, run, disabled,
// danger } or 'sep'. Arrow keys move, Enter runs, Esc / a click elsewhere /
// scrolling / resizing closes it. Returns the close function.
let closeOpenMenu = null;
export function contextMenu(x, y, items) {
closeOpenMenu?.();
const opener = document.activeElement;
const buttons = [];
const menu = el('div', { class: 'dd-menu dd-ctx-menu', role: 'menu' }, ...items.filter(Boolean).map((item) => {
if (item === 'sep') return el('div', { class: 'dd-menu-sep', role: 'separator' });
const button = el('button', {
type: 'button', role: 'menuitem', class: `dd-menu-item${item.danger ? ' is-danger' : ''}`, disabled: item.disabled ? true : null,
onclick: () => {
close(false);
item.run();
},
}, el('span', { class: 'dd-ctx-label' }, item.label), item.kbd ? el('kbd', { class: 'dd-ctx-kbd' }, item.kbd) : null);
if (!item.disabled) buttons.push(button);
return button;
}));
const close = (refocus = true) => {
if (!menu.isConnected) return;
menu.remove();
closeOpenMenu = null;
document.removeEventListener('pointerdown', outside, true);
window.removeEventListener('blur', onBlur);
window.removeEventListener('resize', onBlur);
document.removeEventListener('scroll', onBlur, true);
if (refocus && opener?.isConnected) opener.focus?.();
};
const outside = (event) => {
if (!menu.contains(event.target)) close(false);
};
const opened = Date.now();
const onBlur = (event) => {
// Scrolling closes it, but not the layout settling right after it opened.
if (event?.type === 'scroll' && (menu.contains(event.target) || Date.now() - opened < 400)) return;
close(false);
};
menu.addEventListener('keydown', (event) => {
const i = buttons.indexOf(document.activeElement);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const next = buttons[(i + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length];
next?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus();
} else if (event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
close();
}
});
menu.addEventListener('contextmenu', (event) => event.preventDefault());
document.body.append(menu);
// Keep it on screen.
const { width, height } = menu.getBoundingClientRect();
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4))}px`;
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4))}px`;
buttons[0]?.focus();
setTimeout(() => {
document.addEventListener('pointerdown', outside, true);
window.addEventListener('blur', onBlur);
window.addEventListener('resize', onBlur);
document.addEventListener('scroll', onBlur, true);
}, 0);
closeOpenMenu = close;
return close;
}
// ---------------------------------------------------------------- dialogs