feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
- 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:
+26
-1
@@ -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
File diff suppressed because it is too large
Load Diff
+29
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user