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:
+155
-14
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user