// The file viewer's editor bundle (devdesk#19): CodeMirror 6 with stream-mode // 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, 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, 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'; import { shell } from '@codemirror/legacy-modes/mode/shell'; import { python } from '@codemirror/legacy-modes/mode/python'; import { javascript, json, typescript } from '@codemirror/legacy-modes/mode/javascript'; import { yaml } from '@codemirror/legacy-modes/mode/yaml'; import { toml } from '@codemirror/legacy-modes/mode/toml'; import { properties } from '@codemirror/legacy-modes/mode/properties'; import { nginx } from '@codemirror/legacy-modes/mode/nginx'; import { diff as diffMode } from '@codemirror/legacy-modes/mode/diff'; import { css } from '@codemirror/legacy-modes/mode/css'; import { xml, html } from '@codemirror/legacy-modes/mode/xml'; import { go } from '@codemirror/legacy-modes/mode/go'; import { rust } from '@codemirror/legacy-modes/mode/rust'; import { standardSQL } from '@codemirror/legacy-modes/mode/sql'; 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 DOMPurify from 'dompurify'; const MODES = { shell, python, javascript, json, typescript, yaml, toml, properties, nginx, diff: diffMode, css, xml, html, go, rust, sql: standardSQL, dockerfile: dockerFile, lua, perl, ruby, }; const BY_EXT = { md: 'markdown', markdown: 'markdown', mdx: 'markdown', sh: 'shell', bash: 'shell', zsh: 'shell', ksh: 'shell', env: 'shell', bats: 'shell', py: 'python', pyi: 'python', js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', mts: 'typescript', json: 'json', jsonc: 'json', webmanifest: 'json', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', cfg: 'properties', conf: 'properties', properties: 'properties', service: 'properties', timer: 'properties', socket: 'properties', mount: 'properties', network: 'properties', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', xml: 'xml', svg: 'xml', plist: 'xml', html: 'html', htm: 'html', php: 'html', j2: 'html', jinja: 'html', go: 'go', rs: 'rust', sql: 'sql', lua: 'lua', pl: 'perl', pm: 'perl', rb: 'ruby', }; // Language for a path: extension, a few well-known names, else none. export function languageFor(path) { const name = path.split('/').pop().toLowerCase(); if (name === 'dockerfile' || name.startsWith('dockerfile.') || name.endsWith('.dockerfile')) return 'dockerfile'; if (name === 'makefile' || name === 'gnumakefile') return 'shell'; if (/(^|\/)nginx\//.test(path) || name === 'nginx.conf') return 'nginx'; if (['.bashrc', '.profile', '.bash_profile', '.zshrc', 'pkgbuild'].includes(name)) return 'shell'; if (name.startsWith('.env')) return 'shell'; const ext = name.includes('.') ? name.split('.').pop() : ''; return BY_EXT[ext] ?? null; } function languageExtension(lang) { if (lang === 'markdown') return markdownLanguage; if (lang && MODES[lang]) return StreamLanguage.define(MODES[lang]); return []; } // Colours come from the page's TDS variables, so dark/light mode just works. const highlight = HighlightStyle.define([ { tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], color: 'var(--accent-orange)' }, { tag: [t.string, t.special(t.string), t.regexp], color: 'var(--accent-green)' }, { tag: [t.comment, t.lineComment, t.blockComment, t.meta], color: 'var(--text-muted)', fontStyle: 'italic' }, { tag: [t.number, t.bool, t.atom, t.null], color: 'var(--accent-purple)' }, { tag: [t.typeName, t.className, t.namespace], color: 'var(--accent-amber)' }, { tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], color: 'var(--accent-cyan)' }, { tag: [t.propertyName, t.attributeName], color: 'var(--accent-cyan)' }, { tag: [t.tagName], color: 'var(--accent-orange)' }, { tag: [t.heading, t.heading1, t.heading2, t.heading3], color: 'var(--accent-cyan)', fontWeight: 'bold' }, { tag: t.strong, fontWeight: 'bold' }, { tag: t.emphasis, fontStyle: 'italic' }, { tag: t.strikethrough, textDecoration: 'line-through' }, { tag: [t.link, t.url], color: 'var(--accent-cyan)', textDecoration: 'underline' }, { tag: [t.monospace], color: 'var(--accent-amber)' }, { tag: [t.quote], color: 'var(--text-secondary)', fontStyle: 'italic' }, { tag: t.inserted, color: 'var(--accent-green)' }, { tag: t.deleted, color: 'var(--accent-red)' }, { tag: t.invalid, color: 'var(--accent-red)' }, ]); const theme = EditorView.theme({ '&': { height: '100%', color: 'var(--text-primary)', backgroundColor: 'var(--bg-terminal)', fontSize: 'var(--dd-editor-font, 13px)' }, '.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.5' }, '.cm-content': { caretColor: 'var(--accent-orange)' }, '.cm-cursor': { borderLeftColor: 'var(--accent-orange)' }, '.cm-gutters': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-dim)', border: 'none', borderRight: '1px solid var(--border-dim)' }, '.cm-activeLine': { backgroundColor: 'var(--accent-cyan-dim)' }, '.cm-activeLineGutter': { backgroundColor: 'var(--accent-cyan-dim)', color: 'var(--accent-cyan)' }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(0, 150, 220, 0.28) !important' }, '.cm-searchMatch': { backgroundColor: 'var(--accent-amber-dim)', outline: '1px solid var(--accent-amber)' }, '.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'var(--accent-orange-dim)' }, '.cm-selectionMatch': { backgroundColor: 'var(--accent-cyan-dim)' }, '.cm-panels': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', borderColor: 'var(--border-color)' }, '.cm-panel input, .cm-panel button': { fontFamily: 'var(--font-mono)', fontSize: '12px' }, '.cm-textfield': { backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' }, '.cm-button': { backgroundImage: 'none', backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' }, '.cm-foldGutter span': { color: 'var(--text-dim)' }, // unified diff '.cm-changedLine': { backgroundColor: 'var(--accent-green-dim) !important' }, '.cm-changedText': { backgroundColor: 'rgba(0, 200, 120, 0.22) !important' }, '.cm-deletedChunk': { backgroundColor: 'var(--accent-red-dim) !important' }, '.cm-deletedChunk .cm-deletedText, .cm-deletedChunk del': { backgroundColor: 'rgba(255, 45, 85, 0.22)', textDecoration: 'none' }, '.cm-insertedLine': { backgroundColor: 'var(--accent-green-dim)' }, '.cm-changeGutter': { width: '3px' }, '.cm-changedLineGutter': { backgroundColor: 'var(--accent-green)' }, '.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' }, }); function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) { return [ lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(), 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([ { 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))); view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) }); view.focus(); } // Read-only view of a file. 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, 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, onCursor }), onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [], EditorView.updateListener.of((update) => { if (update.docChanged) onChange?.(update.state.doc.toString()); }), ], }), }); 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) => { const pos = Math.min(view.state.selection.main.head, text.length); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: pos } }); }, destroy: () => view.destroy(), }; } // Unified diff of the HEAD version against the working copy. export function diff(parent, { original, modified, path, wrap }) { const view = new EditorView({ parent, state: EditorState.create({ doc: modified, extensions: [baseExtensions(path, { wrap }), unifiedMergeView({ original, mergeControls: false, highlightChanges: true, gutter: true })], }), }); return { view, destroy: () => view.destroy() }; } // ---------------------------------------------------------------- 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 += `${escapeHtml(code.slice(from, to))}`; 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 `${inner}\n`; }, code({ text: code, lang }) { const name = (lang || '').split(/\s+/)[0]; return `
${highlightToHtml(code.replace(/\n$/, ''), name)}
\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; }