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
+155 -14
View File
@@ -2,11 +2,16 @@
// languages, a unified diff view, and markdown rendering. Built separately to
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
// the first time the Files pane opens, so the page itself stays small.
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
import {
EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars,
rectangularSelection, crosshairCursor, dropCursor,
} from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches, gotoLine as gotoLineDialog } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { highlightTree, classHighlighter } from '@lezer/highlight';
import { markdownLanguage } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { tags as t } from '@lezer/highlight';
@@ -27,7 +32,7 @@ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { perl } from '@codemirror/legacy-modes/mode/perl';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { marked } from 'marked';
import { Marked } from 'marked';
import DOMPurify from 'dompurify';
const MODES = {
@@ -121,20 +126,58 @@ const theme = EditorView.theme({
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
});
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
return [
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
drawSelection(), dropCursor(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
// VS Code habits (devdesk#55): Alt+click adds a cursor, Alt+drag selects a
// column, brackets and quotes close themselves, Ctrl+G goes to a line.
EditorState.allowMultipleSelections.of(true), rectangularSelection(), crosshairCursor(),
readOnly ? [] : [closeBrackets(), indentOnInput()],
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
onCursor ? EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged || update.focusChanged) onCursor(cursorInfo(update.state));
}) : [],
// Read-only document in an editable view: the caret, selection, arrow keys
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
EditorState.readOnly.of(readOnly),
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
keymap.of([
{ key: 'Mod-g', run: gotoLineDialog, preventDefault: true },
...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap,
]),
wrap ? EditorView.lineWrapping : [],
];
}
// Where the cursor is, for the status bar: line, column, selections, selected
// characters; plus the document's line endings and indentation.
function cursorInfo(state) {
const main = state.selection.main;
const line = state.doc.lineAt(main.head);
let selected = 0;
for (const r of state.selection.ranges) selected += r.to - r.from;
return { line: line.number, col: main.head - line.from + 1, cursors: state.selection.ranges.length, selected, lines: state.doc.lines };
}
// Indentation a file uses, guessed from its first indented lines: "Tabs" or "Spaces: N".
export function detectIndent(text) {
let tabs = 0;
const widths = {};
for (const line of text.split('\n', 2000)) {
if (line.startsWith('\t')) tabs += 1;
else {
const m = /^( +)\S/.exec(line);
if (m) widths[m[1].length] = (widths[m[1].length] ?? 0) + 1;
}
}
const spaced = Object.values(widths).reduce((a, b) => a + b, 0);
if (tabs > spaced) return 'Tabs';
if (!spaced) return null;
const unit = [2, 4, 8, 3].find((w) => Object.keys(widths).every((k) => Number(k) % w === 0)) ?? 4;
return `Spaces: ${unit}`;
}
// Put the cursor on a line (1-based) and scroll it to the middle.
function gotoLine(view, line) {
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
@@ -143,21 +186,22 @@ function gotoLine(view, line) {
}
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
export function viewer(parent, { doc, path, wrap, onCursor }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) });
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
export function editor(parent, { doc, path, wrap, onChange, onSave }) {
export function editor(parent, { doc, path, wrap, onChange, onSave, onCursor, onScroll }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
baseExtensions(path, { readOnly: false, wrap }),
baseExtensions(path, { readOnly: false, wrap, onCursor }),
onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [],
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
@@ -167,6 +211,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
return {
view,
goto: (line) => gotoLine(view, line),
info: () => cursorInfo(view.state),
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
@@ -189,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) {
return { view, destroy: () => view.destroy() };
}
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
export function markdown(text) {
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
// ---------------------------------------------------------------- markdown (devdesk#55)
// Fence names and aliases -> a language for highlighting.
const FENCE = {
sh: 'shell', bash: 'shell', zsh: 'shell', shell: 'shell', console: 'shell', shellsession: 'shell', env: 'shell', dotenv: 'shell',
py: 'python', python: 'python', js: 'javascript', javascript: 'javascript', jsx: 'javascript', mjs: 'javascript', node: 'javascript',
ts: 'typescript', typescript: 'typescript', tsx: 'typescript', json: 'json', jsonc: 'json', json5: 'json',
yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', conf: 'properties', cfg: 'properties', properties: 'properties', systemd: 'properties',
nginx: 'nginx', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', html: 'html', xml: 'xml', svg: 'xml',
go: 'go', golang: 'go', rust: 'rust', rs: 'rust', sql: 'sql', dockerfile: 'dockerfile', docker: 'dockerfile',
lua: 'lua', perl: 'perl', pl: 'perl', ruby: 'ruby', rb: 'ruby', md: 'markdown', markdown: 'markdown',
};
const parsers = new Map();
function parserFor(lang) {
const name = FENCE[(lang || '').toLowerCase()];
if (!name) return null;
if (!parsers.has(name)) parsers.set(name, name === 'markdown' ? markdownLanguage.parser : StreamLanguage.define(MODES[name]).parser);
return parsers.get(name);
}
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
// Code as HTML with tok-* classes (CodeMirror's own parsers, no extra library).
export function highlightToHtml(code, lang) {
const parser = parserFor(lang);
if (!parser || code.length > 200000) return escapeHtml(code);
let out = '';
let pos = 0;
try {
highlightTree(parser.parse(code), classHighlighter, (from, to, classes) => {
if (from > pos) out += escapeHtml(code.slice(pos, from));
out += `<span class="${classes}">${escapeHtml(code.slice(from, to))}</span>`;
pos = to;
});
} catch {
return escapeHtml(code);
}
return out + escapeHtml(code.slice(pos));
}
// GitHub's heading ids: lowercase, punctuation dropped, spaces to dashes, and
// "-1", "-2" for repeats.
function slugger() {
const seen = new Map();
return (text) => {
const base = text.toLowerCase().trim().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-') || 'section';
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return n ? `${base}-${n}` : base;
};
}
// Leading YAML front matter (--- … ---) as simple key/value pairs.
function frontMatter(text) {
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!m) return { body: text, meta: null };
const meta = [];
for (const line of m[1].split(/\r?\n/)) {
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
if (kv) meta.push([kv[1], kv[2].replace(/^["']|["']$/g, '')]);
else if (meta.length && /^\s+\S/.test(line)) meta[meta.length - 1][1] += ` ${line.trim()}`;
}
return { body: text.slice(m[0].length), meta };
}
// Markdown to sanitized HTML for the preview (devdesk#55): GitHub-flavoured,
// heading ids, highlighted code blocks; returns { html, headings, meta }. No
// scripts, styles, forms, frames or inline style survive DOMPurify.
export function renderMarkdown(text) {
const { body, meta } = frontMatter(text);
const slug = slugger();
const headings = [];
const md = new Marked({
gfm: true,
breaks: false,
renderer: {
heading({ tokens, depth, text: raw }) {
const inner = this.parser.parseInline(tokens);
const id = slug(raw);
headings.push({ depth, id, text: inner.replace(/<[^>]*>/g, '') });
return `<h${depth} id="${id}"><a class="dd-anchor" href="#${id}" aria-hidden="true">#</a>${inner}</h${depth}>\n`;
},
code({ text: code, lang }) {
const name = (lang || '').split(/\s+/)[0];
return `<pre class="dd-code" data-lang="${escapeHtml(name)}"><code>${highlightToHtml(code.replace(/\n$/, ''), name)}</code></pre>\n`;
},
},
});
const html = DOMPurify.sanitize(md.parse(body), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'form', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'textarea', 'select', 'button'],
FORBID_ATTR: ['style'],
});
return { html, headings, meta };
}
// Kept for older callers: just the HTML.
export function markdown(text) {
return renderMarkdown(text).html;
}
+10 -1
View File
@@ -109,8 +109,17 @@
<div class="dd-explorer-views" role="tablist">
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
<button type="button" role="tab" data-files-view="search" aria-selected="false" title="Search in files (Ctrl+Shift+F)">Search</button>
<span class="dd-explorer-actions">
<button type="button" class="dd-icon-btn" data-explorer-act="new-file" title="New file" aria-label="New file">🗋</button>
<button type="button" class="dd-icon-btn" data-explorer-act="new-folder" title="New folder" aria-label="New folder">🗀</button>
<button type="button" class="dd-icon-btn" data-explorer-act="upload" title="Upload files (or drop them on a folder)" aria-label="Upload files">⇪</button>
<button type="button" class="dd-icon-btn" data-explorer-act="collapse" title="Collapse all folders" aria-label="Collapse all folders">⊟</button>
</span>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
<div class="dd-explorer-body" role="tree" aria-label="Files" aria-multiselectable="true"></div>
<div class="dd-upload-bar" hidden><span class="dd-upload-text"></span><span class="dd-upload-meter"><span class="dd-upload-fill"></span></span></div>
<input type="file" class="dd-upload-input" multiple hidden>
</div>
<div class="dd-editor-area"></div>
</div>
+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
+76 -2
View File
@@ -799,7 +799,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
.dd-explorer-views button {
.dd-explorer-views button[role="tab"] {
flex: 1;
padding: 6px 4px 5px;
border: 0;
@@ -812,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
text-transform: uppercase;
cursor: pointer;
}
.dd-explorer-views button:hover { color: var(--text-primary); }
.dd-explorer-views button[role="tab"]:hover { color: var(--text-primary); }
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
@@ -1070,3 +1070,77 @@ body.dd-touch .dd-footer-usage { display: none; }
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-repo-change-stat { flex: none; color: var(--text-muted); }
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }
/* Toast actions (Undo) and right-click menus (devdesk#54) */
.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; }
.lt-toast-action:hover { background: var(--accent-cyan-dim); }
.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; }
.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; }
.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); }
.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; }
.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); }
/* Explorer operations (devdesk#54) */
.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); }
.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; }
.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; }
.dd-tree-row.is-selected { background: var(--accent-cyan-dim); }
.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); }
.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); }
.dd-tree-row.is-cut { opacity: 0.45; }
.dd-tree-row.is-drop { background: var(--accent-orange-dim); }
.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); }
.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); }
.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); }
.dd-tree-input {
flex: 1;
min-width: 0;
height: 20px;
padding: 0 4px;
border: 1px solid var(--accent-cyan);
background: var(--bg-input, var(--bg-primary));
color: var(--text-primary);
font: inherit;
outline: none;
}
.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); }
.dd-upload-bar[hidden] { display: none; }
.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-upload-meter { height: 3px; background: var(--bg-tertiary); }
.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; }
.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; }
/* Markdown preview and split, editor status bar (devdesk#55) */
.dd-viewer-content.is-preview { overflow: hidden; }
.dd-md-host { flex: 1; min-height: 0; outline: none; }
.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); }
.dd-split-editor .cm-editor { height: 100%; }
.dd-split-preview { min-width: 0; }
.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; }
.dd-status-pos { color: var(--text-secondary); }
.dd-status-sel { color: var(--accent-cyan); }
.dd-status-spacer { flex: 1; }
/* Search in files (devdesk#56) */
.dd-search { display: flex; flex-direction: column; min-height: 100%; }
.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); }
.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; }
.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; }
.dd-search-flag:hover { color: var(--text-primary); }
.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; }
.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; }
.dd-search-file:hover { background: var(--bg-tertiary); }
.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; }
.dd-search-file[aria-expanded="false"]::before { content: '▸'; }
.dd-search-file .dd-tree-name { flex: none; }
.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; }
.dd-search-lines[hidden] { display: none; }
.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; }
.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); }
.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); }
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }