- Terminal link provider: printed paths that are files of the session open in Files (at the line); the file list is preloaded so the first hover works. - Alerts: "In a new session" opens alert-<name> and types the prompt once Claude is up. - devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and guard.local. - State hook logs Bash commands and derives host tags (ssh targets, CT/VM ids); --list carries tags, --peek the last commands; UI shows both. Closes #21 Closes #18 Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
11 KiB
JavaScript
196 lines
11 KiB
JavaScript
// 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 } 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 { 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 } = {}) {
|
|
return [
|
|
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
|
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
|
|
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
|
|
// 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]),
|
|
wrap ? EditorView.lineWrapping : [],
|
|
];
|
|
}
|
|
|
|
// 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 }) {
|
|
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
|
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 }) {
|
|
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 }),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) onChange?.(update.state.doc.toString());
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
return {
|
|
view,
|
|
goto: (line) => gotoLine(view, line),
|
|
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 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 } });
|
|
}
|