From 7336b4f0334fe44f38d38f24c29754009a899238 Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 3 Nov 2026 15:44:50 -0500 Subject: [PATCH] Monaco in VE --- apps/overview.md | 19 +- apps/vscode-editor/package.json | 1 + apps/vscode/package.json | 1 + apps/vscode/src/providers/editor/codeview.ts | 13 + apps/vscode/src/providers/editor/editor.ts | 2 +- apps/vscode/src/providers/editor/prefs.ts | 5 + apps/vscode/src/providers/editor/theme.ts | 304 +++++++++++++++ package-lock.json | 113 +++++- packages/editor-core/src/services.ts | 18 +- packages/editor-monaco/eslint.config.mjs | 1 + packages/editor-monaco/package.json | 33 ++ .../editor-monaco/src/behaviors/completion.ts | 360 +++++++++++++++++ .../src/behaviors/diagnostics.ts | 125 ++++++ .../editor-monaco/src/behaviors/diagram.ts | 50 +++ packages/editor-monaco/src/behaviors/find.ts | 72 ++++ .../src/behaviors/firstlinemeta.ts | 46 +++ packages/editor-monaco/src/behaviors/index.ts | 95 +++++ .../editor-monaco/src/behaviors/keyboard.ts | 163 ++++++++ .../editor-monaco/src/behaviors/langmode.ts | 45 +++ packages/editor-monaco/src/behaviors/prefs.ts | 61 +++ packages/editor-monaco/src/behaviors/theme.ts | 148 +++++++ .../editor-monaco/src/behaviors/toolbar.ts | 138 +++++++ .../src/behaviors/trackselection.ts | 118 ++++++ .../editor-monaco/src/behaviors/yamloption.ts | 93 +++++ packages/editor-monaco/src/index.ts | 63 +++ packages/editor-monaco/src/languages/index.ts | 67 ++++ packages/editor-monaco/src/monaco.ts | 51 +++ packages/editor-monaco/src/nodeview.ts | 353 +++++++++++++++++ packages/editor-monaco/src/styles.css | 63 +++ packages/editor-monaco/src/textmate.ts | 369 ++++++++++++++++++ packages/editor-monaco/tsconfig.json | 5 + .../editor-server/src/services/codeview.ts | 12 +- packages/editor-types/src/codeview.ts | 65 +++ packages/editor-ui/package.json | 1 + packages/editor-ui/src/context/context.ts | 4 +- packages/editor/src/api/ui-types.ts | 12 +- packages/editor/src/behaviors/select_all.ts | 2 +- .../src/nodes/rmd_chunk/rmd_chunk-image.ts | 2 +- 38 files changed, 3052 insertions(+), 41 deletions(-) create mode 100644 apps/vscode/src/providers/editor/theme.ts create mode 100644 packages/editor-monaco/eslint.config.mjs create mode 100644 packages/editor-monaco/package.json create mode 100644 packages/editor-monaco/src/behaviors/completion.ts create mode 100644 packages/editor-monaco/src/behaviors/diagnostics.ts create mode 100644 packages/editor-monaco/src/behaviors/diagram.ts create mode 100644 packages/editor-monaco/src/behaviors/find.ts create mode 100644 packages/editor-monaco/src/behaviors/firstlinemeta.ts create mode 100644 packages/editor-monaco/src/behaviors/index.ts create mode 100644 packages/editor-monaco/src/behaviors/keyboard.ts create mode 100644 packages/editor-monaco/src/behaviors/langmode.ts create mode 100644 packages/editor-monaco/src/behaviors/prefs.ts create mode 100644 packages/editor-monaco/src/behaviors/theme.ts create mode 100644 packages/editor-monaco/src/behaviors/toolbar.ts create mode 100644 packages/editor-monaco/src/behaviors/trackselection.ts create mode 100644 packages/editor-monaco/src/behaviors/yamloption.ts create mode 100644 packages/editor-monaco/src/index.ts create mode 100644 packages/editor-monaco/src/languages/index.ts create mode 100644 packages/editor-monaco/src/monaco.ts create mode 100644 packages/editor-monaco/src/nodeview.ts create mode 100644 packages/editor-monaco/src/styles.css create mode 100644 packages/editor-monaco/src/textmate.ts create mode 100644 packages/editor-monaco/tsconfig.json diff --git a/apps/overview.md b/apps/overview.md index d03b08e75..2963756ff 100644 --- a/apps/overview.md +++ b/apps/overview.md @@ -44,11 +44,11 @@ - see [package.json](./vscode/package.json) for declaration of commands - see [main.ts](./vscode/src/main.ts) for registration of command -- Look for "behaviors" in ProseMirror, CodeMirror +- Look for "behaviors" in ProseMirror, Monaco (`packages/editor-monaco`; the previous CodeMirror implementation is kept in `packages/editor-codemirror`) - arrow keys, ctrl+z, mouse click, etc. - [commands in Ace](packages/editor/src/optional/ace/ace.ts) - - used instead of CodeMirror for code cells in the Visual Editor in RStudio + - used instead of Monaco for code cells in the Visual Editor in RStudio ## Communication boundaries @@ -129,20 +129,15 @@ which passes through `VisualEditorPovider`, `visualEditorClient`, ### Example: Getting diagnostics for YAML front matter For example in -[diagnostics.ts](../packages/editor-codemirror/src/behaviors/diagnostics.ts) +[diagnostics.ts](../packages/editor-monaco/src/behaviors/diagnostics.ts) ```ts const diagnostics = await getDiagnostics(cellContext, behaviorContext); -if (!diagnostics) return; - -for (const error of diagnostics) { - underline( - cmView, - rowColumnToIndex(code, [error[kStartColumn], error[kStartRow]]), - rowColumnToIndex(code, [error[kEndColumn], error[kEndRow]]), - error.text, - ); +const model = editor.getModel(); +if (id !== requestId || !model || model.isDisposed()) { + return; } +setMarkers(model, diagnostics || []); ``` which passes through diff --git a/apps/vscode-editor/package.json b/apps/vscode-editor/package.json index 1c5a76de2..4f2e080b3 100644 --- a/apps/vscode-editor/package.json +++ b/apps/vscode-editor/package.json @@ -24,6 +24,7 @@ "core-browser": "*", "editor": "*", "editor-codemirror": "*", + "editor-monaco": "*", "editor-core": "*", "editor-types": "*", "editor-ui": "*", diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 093c923ea..b4cf5eec6 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1528,6 +1528,7 @@ "test-positron": "npm run build-test && node ./scripts/run-positron-tests.mjs" }, "dependencies": { + "jsonc-parser": "^3.3.1", "axios": "^1.20.0", "core": "*", "core-node": "*", diff --git a/apps/vscode/src/providers/editor/codeview.ts b/apps/vscode/src/providers/editor/codeview.ts index b639fdd0b..a91f20e2d 100644 --- a/apps/vscode/src/providers/editor/codeview.ts +++ b/apps/vscode/src/providers/editor/codeview.ts @@ -42,6 +42,7 @@ import { embeddedLanguage } from "../../vdoc/languages"; import { virtualDocForCode } from "../../vdoc/vdoc"; import { vdocCompletions } from "../../vdoc/vdoc-completion"; import { MarkdownEngine } from "../../markdown/engine"; +import { activeColorTheme, contributedGrammar, contributedLanguages, editorFont } from "./theme"; /** * In the host, this initializes functions that can be called by the client (the visual editor) for doing code cell things. @@ -117,6 +118,18 @@ export function vscodeCodeViewServer(_engine: MarkdownEngine, document: TextDocu isIncomplete: false }; }, + async codeViewColorTheme(themeId?: string) { + return activeColorTheme(themeId); + }, + async codeViewLanguages() { + return contributedLanguages(); + }, + async codeViewGrammar(scopeName: string) { + return contributedGrammar(scopeName); + }, + async codeViewEditorFont() { + return editorFont(document); + }, async codeViewPreviewDiagram(state: DiagramState, activate: boolean) { commands.executeCommand("quarto.previewDiagram", { state, activate }); } diff --git a/apps/vscode/src/providers/editor/editor.ts b/apps/vscode/src/providers/editor/editor.ts index 0aba15da2..f8cecd30f 100644 --- a/apps/vscode/src/providers/editor/editor.ts +++ b/apps/vscode/src/providers/editor/editor.ts @@ -625,7 +625,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider { - + diff --git a/apps/vscode/src/providers/editor/prefs.ts b/apps/vscode/src/providers/editor/prefs.ts index 374aefbab..3667fc40b 100644 --- a/apps/vscode/src/providers/editor/prefs.ts +++ b/apps/vscode/src/providers/editor/prefs.ts @@ -43,6 +43,11 @@ const kQuartoEditorMarkdownReferences = "quarto.visualEditor.markdownReferences" const kQuartoEditorMarkdownReferenceLinks = "quarto.visualEditor.markdownReferenceLinks"; const kMonitoredConfigurations = [ + "editor.fontFamily", + "editor.fontWeight", + "editor.fontLigatures", + "editor.lineHeight", + "editor.letterSpacing", kEditorAutoClosingBrackets, kEditorRenderWhitespace, kEditorInsertSpaces, diff --git a/apps/vscode/src/providers/editor/theme.ts b/apps/vscode/src/providers/editor/theme.ts new file mode 100644 index 000000000..aba53c060 --- /dev/null +++ b/apps/vscode/src/providers/editor/theme.ts @@ -0,0 +1,304 @@ +/* + * theme.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Resolve the active VS Code color theme and the languages/grammars + * contributed by installed extensions so that the visual editor's code + * cells can be highlighted exactly as the source editor would be. + * + * Reference: https://github.com/quarto-dev/quarto/pull/772 + * Reference: https://github.com/microsoft/vscode/issues/32813#issuecomment-524174937 + */ + +import path from "node:path"; +import { readFileSync } from "node:fs"; + +// (esm entry point: the umd one uses a dynamic require that esbuild can't bundle) +import { parse as parseJsonc } from "jsonc-parser/lib/esm/main.js"; + +import { ColorThemeKind, extensions, TextDocument, window, workspace } from "vscode"; + +import { + CodeViewColorTheme, + CodeViewEditorFont, + CodeViewGrammar, + CodeViewLanguage, + CodeViewTokenColor +} from "editor-types"; + +interface ThemeContribution { + id?: string; + label?: string; + uiTheme?: string; + path: string; +} + +interface GrammarContribution { + language?: string; + scopeName: string; + path: string; +} + +interface LanguageContribution { + id: string; + aliases?: string[]; + extensions?: string[]; + configuration?: string; +} + +interface ThemeJson { + include?: string; + colors?: Record; + tokenColors?: CodeViewTokenColor[] | string; +} + +/** + * Candidate names for the active color theme. When vscode auto-detects the + * OS color scheme the active theme is one of the preferred*ColorTheme + * settings rather than workbench.colorTheme, so we try those first. + */ +export function activeColorThemeNames(): string[] { + const workbench = workspace.getConfiguration("workbench"); + const win = workspace.getConfiguration("window"); + const names: Array = []; + const kind = window.activeColorTheme.kind; + if (win.get("autoDetectHighContrast", true)) { + if (kind === ColorThemeKind.HighContrast) { + names.push(workbench.get("preferredHighContrastColorTheme")); + } else if (kind === ColorThemeKind.HighContrastLight) { + names.push(workbench.get("preferredHighContrastLightColorTheme")); + } + } + if (win.get("autoDetectColorScheme", false)) { + if (kind === ColorThemeKind.Dark) { + names.push(workbench.get("preferredDarkColorTheme")); + } else if (kind === ColorThemeKind.Light) { + names.push(workbench.get("preferredLightColorTheme")); + } + } + names.push(workbench.get("colorTheme")); + return names.filter((name): name is string => !!name); +} + +/** + * Resolve the active color theme to its colors and token color rules (with + * included themes merged in). Returns undefined if the theme can't be found + * or isn't a json theme. `themeId` is the settings id of the theme currently + * applied to the webview (if known). + */ +export function activeColorTheme(themeId?: string): CodeViewColorTheme | undefined { + const activeKind = kindToType(window.activeColorTheme.kind); + // the webview knows the id of the theme it is currently displayed with + // (which during theme preview differs from the workbench.colorTheme setting) + const names = themeId ? [themeId, ...activeColorThemeNames()] : activeColorThemeNames(); + const candidates = names + .map(name => ({ name, contribution: findThemeContribution(name) })) + .filter(candidate => !!candidate.contribution); + if (candidates.length === 0) { + return undefined; + } + + // prefer a theme whose kind matches the active kind + const candidate = candidates.find(candidate => + themeType(candidate.contribution!.theme.uiTheme) === activeKind + ) || candidates[0]; + const { name, contribution } = candidate; + + const themePath = path.join(contribution!.extensionPath, contribution!.theme.path); + if (path.extname(themePath).toLowerCase() !== ".json") { + // .tmTheme (plist) themes aren't supported + return undefined; + } + + const theme = loadThemeJson(themePath, new Set()); + if (!theme) { + return undefined; + } + + return { + name, + type: themeType(contribution!.theme.uiTheme), + colors: theme.colors, + tokenColors: theme.tokenColors + }; +} + +/** + * The source editor's font settings (so code cells can use the same font) + */ +export function editorFont(document: TextDocument): CodeViewEditorFont { + const editor = workspace.getConfiguration("editor", document.uri); + return { + fontFamily: editor.get("fontFamily", "Menlo, Monaco, 'Courier New', monospace"), + fontSize: editor.get("fontSize", 12), + fontWeight: String(editor.get("fontWeight", "normal")), + fontLigatures: editor.get("fontLigatures", false), + lineHeight: editor.get("lineHeight", 0), + letterSpacing: editor.get("letterSpacing", 0) + }; +} + +/** + * Languages contributed by installed extensions (including VS Code's built-in + * language extensions), with the scope of the grammar for each language. + */ +export function contributedLanguages(): CodeViewLanguage[] { + const languages = new Map(); + + // languages (an id can be contributed by multiple extensions; merge them) + for (const extension of extensions.all) { + const contributes = extension.packageJSON?.contributes; + const langs = contributes?.languages as LanguageContribution[] | undefined; + for (const lang of langs || []) { + if (!lang.id) { + continue; + } + const language = languages.get(lang.id) || { id: lang.id, aliases: [], extensions: [] }; + language.aliases = unique([...language.aliases, ...(lang.aliases || [])]); + language.extensions = unique([...language.extensions, ...(lang.extensions || [])]); + if (lang.configuration && language.configuration === undefined) { + const configPath = path.join(extension.extensionPath, lang.configuration); + language.configuration = loadJsonc(configPath); + } + languages.set(lang.id, language); + } + } + + // grammars (first grammar contributed for a language wins, which is also + // what vscode does) + for (const extension of extensions.all) { + const grammars = extension.packageJSON?.contributes?.grammars as GrammarContribution[] | undefined; + for (const grammar of grammars || []) { + if (grammar.language && grammar.scopeName) { + const language = languages.get(grammar.language); + if (language && !language.scopeName) { + language.scopeName = grammar.scopeName; + } + } + } + } + + return Array.from(languages.values()); +} + +/** + * The raw contents of the grammar with the given scope name (undefined if no + * installed extension contributes a grammar for the scope) + */ +export function contributedGrammar(scopeName: string): CodeViewGrammar | undefined { + for (const extension of extensions.all) { + const grammars = extension.packageJSON?.contributes?.grammars as GrammarContribution[] | undefined; + for (const grammar of grammars || []) { + if (grammar.scopeName === scopeName) { + const grammarPath = path.join(extension.extensionPath, grammar.path); + try { + return { + scopeName, + path: grammarPath, + content: readFileSync(grammarPath, "utf8") + }; + } catch { + // fall through to other contributions + } + } + } + } + return undefined; +} + +function findThemeContribution(name: string) { + for (const extension of extensions.all) { + const themes = extension.packageJSON?.contributes?.themes as ThemeContribution[] | undefined; + const theme = themes?.find(theme => themeNameMatches(theme, name)); + if (theme) { + return { extensionPath: extension.extensionPath, theme }; + } + } + return undefined; +} + +// vscode's settings id for a theme is its id (or label if it has no id); +// built-in themes are also addressable with a "Default " prefix +function themeNameMatches(theme: ThemeContribution, name: string) { + const candidates = [theme.id, theme.label].filter((value): value is string => !!value); + return candidates.some(candidate => + candidate === name || + `Default ${candidate}` === name || + candidate === name.replace(/^Default /, "") + ); +} + +function kindToType(kind: ColorThemeKind): CodeViewColorTheme["type"] { + switch (kind) { + case ColorThemeKind.Light: + return "light"; + case ColorThemeKind.HighContrast: + return "hcDark"; + case ColorThemeKind.HighContrastLight: + return "hcLight"; + default: + return "dark"; + } +} + +function themeType(uiTheme?: string): CodeViewColorTheme["type"] { + switch (uiTheme) { + case "vs": + return "light"; + case "vs-dark": + return "dark"; + case "hc-black": + return "hcDark"; + case "hc-light": + return "hcLight"; + default: + return kindToType(window.activeColorTheme.kind); + } +} + +// load a theme and the themes it includes (included themes are applied +// first so the including theme's rules take precedence) +function loadThemeJson(themePath: string, visited: Set) + : { colors: Record, tokenColors: CodeViewTokenColor[] } | undefined { + + if (visited.has(themePath)) { + return undefined; + } + visited.add(themePath); + + const theme = loadJsonc(themePath) as ThemeJson | undefined; + if (!theme) { + return undefined; + } + + let colors: Record = {}; + let tokenColors: CodeViewTokenColor[] = []; + + if (theme.include) { + const included = loadThemeJson(path.join(path.dirname(themePath), theme.include), visited); + if (included) { + colors = included.colors; + tokenColors = included.tokenColors; + } + } + + colors = { ...colors, ...(theme.colors || {}) }; + if (Array.isArray(theme.tokenColors)) { + tokenColors = [...tokenColors, ...theme.tokenColors]; + } + + return { colors, tokenColors }; +} + +function loadJsonc(filePath: string): unknown { + try { + return parseJsonc(readFileSync(filePath, "utf8")); + } catch { + return undefined; + } +} + +function unique(values: string[]) { + return Array.from(new Set(values)); +} diff --git a/package-lock.json b/package-lock.json index 4c080fba0..df4ab913d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "glob": "^13.0.6", "highlight.js": "^11.12.0", "js-yaml": "^5.5.0", + "jsonc-parser": "^3.3.1", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "markdown-it": "^15.0.3", @@ -173,6 +174,7 @@ "editor": "*", "editor-codemirror": "*", "editor-core": "*", + "editor-monaco": "*", "editor-types": "*", "editor-ui": "*", "lodash.debounce": "^4.0.8", @@ -3706,9 +3708,9 @@ "license": "MIT" }, "node_modules/@lezer/common": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", - "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz", + "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==", "license": "MIT" }, "node_modules/@lezer/cpp": { @@ -3723,9 +3725,9 @@ } }, "node_modules/@lezer/css": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz", - "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz", + "integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3754,9 +3756,9 @@ } }, "node_modules/@lezer/java": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.4.tgz", - "integrity": "sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.5.tgz", + "integrity": "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3765,9 +3767,9 @@ } }, "node_modules/@lezer/javascript": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz", - "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==", + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz", + "integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3797,18 +3799,18 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz", + "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/markdown": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", - "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz", + "integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.5.0", @@ -8060,6 +8062,10 @@ "resolved": "packages/editor-core", "link": true }, + "node_modules/editor-monaco": { + "resolved": "packages/editor-monaco", + "link": true + }, "node_modules/editor-server": { "resolved": "packages/editor-server", "link": true @@ -9428,7 +9434,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, "license": "MIT" }, "node_modules/jsondiffpatch": { @@ -10579,6 +10584,37 @@ "node": ">=12" } }, + "node_modules/monaco-editor": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", + "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.4.15", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12971,6 +13007,18 @@ "integrity": "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==", "license": "MIT" }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", @@ -13617,6 +13665,32 @@ "eslint-config-custom": "*" } }, + "packages/editor-monaco": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "core": "*", + "editor": "*", + "editor-core": "*", + "editor-types": "*", + "lodash.debounce": "^4.0.8", + "monaco-editor": "^0.57.0", + "prosemirror-commands": "^1.7.2", + "prosemirror-history": "^1.5.1", + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.6", + "tsconfig": "*", + "vscode-languageserver-types": "^3.18.4", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2" + }, + "devDependencies": { + "@types/lodash.debounce": "^4.0.9", + "eslint": "^10.13.0", + "eslint-config-custom": "*" + } + }, "packages/editor-server": { "version": "0.1.0", "license": "MIT", @@ -13677,6 +13751,7 @@ "editor": "*", "editor-codemirror": "*", "editor-core": "*", + "editor-monaco": "*", "editor-types": "*", "i18next": "^26.5.4", "react": "^19.3.1", diff --git a/packages/editor-core/src/services.ts b/packages/editor-core/src/services.ts index e467c81bf..9cc6ba253 100644 --- a/packages/editor-core/src/services.ts +++ b/packages/editor-core/src/services.ts @@ -39,7 +39,11 @@ import { CodeViewActiveBlockContext, DiagramState, CodeViewCellContext, - kCodeViewGetDiagnostics + kCodeViewGetDiagnostics, + kCodeViewGetColorTheme, + kCodeViewGetLanguages, + kCodeViewGetGrammar, + kCodeViewGetEditorFont } from "editor-types"; @@ -87,6 +91,18 @@ export function editorCodeViewJsonRpcServer(request: JsonRpcRequestTransport): C codeViewPreviewDiagram(state: DiagramState, activate: boolean) { return request(kCodeViewPreviewDiagram, [state, activate]); }, + codeViewColorTheme(themeId?: string) { + return request(kCodeViewGetColorTheme, [themeId]); + }, + codeViewLanguages() { + return request(kCodeViewGetLanguages, []); + }, + codeViewGrammar(scopeName: string) { + return request(kCodeViewGetGrammar, [scopeName]); + }, + codeViewEditorFont() { + return request(kCodeViewGetEditorFont, []); + }, }; } diff --git a/packages/editor-monaco/eslint.config.mjs b/packages/editor-monaco/eslint.config.mjs new file mode 100644 index 000000000..6484834b7 --- /dev/null +++ b/packages/editor-monaco/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "eslint-config-custom"; diff --git a/packages/editor-monaco/package.json b/packages/editor-monaco/package.json new file mode 100644 index 000000000..9d0826482 --- /dev/null +++ b/packages/editor-monaco/package.json @@ -0,0 +1,33 @@ +{ + "name": "editor-monaco", + "version": "0.1.0", + "private": true, + "license": "MIT", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "core": "*", + "editor": "*", + "editor-core": "*", + "editor-types": "*", + "lodash.debounce": "^4.0.8", + "monaco-editor": "^0.57.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2", + "prosemirror-commands": "^1.7.2", + "prosemirror-history": "^1.5.1", + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.6", + "tsconfig": "*", + "vscode-languageserver-types": "^3.18.4" + }, + "devDependencies": { + "@types/lodash.debounce": "^4.0.9", + "eslint": "^10.13.0", + "eslint-config-custom": "*" + }, + "scripts": { + "lint": "eslint src" + } +} diff --git a/packages/editor-monaco/src/behaviors/completion.ts b/packages/editor-monaco/src/behaviors/completion.ts new file mode 100644 index 000000000..27beed5ce --- /dev/null +++ b/packages/editor-monaco/src/behaviors/completion.ts @@ -0,0 +1,360 @@ +/* + * completion.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Completions for code cells (provided by the host via the code view server). + * Monaco completion providers are global (per language), so a single provider + * is registered for all languages and dispatches to the code view that owns + * the model being completed. + */ + +import { + CompletionItem, + CompletionItemKind, + InsertReplaceEdit, + InsertTextFormat, + MarkupContent, + MarkupKind, + TextEdit +} from "vscode-languageserver-types"; + +import { editorLanguage } from "editor-core"; + +import { CodeViewCompletionContext, codeViewCompletionContext } from "editor"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext } from "."; +import { escapeRegExpCharacters } from "core"; + +// code views registered for completion (keyed by model uri) +const codeViews = new Map(); + +// set while an explicit (ctrl+space) completion request is in flight +let explicitRequest = false; + +let providerRegistered = false; + +export function completionBehavior(behaviorContext: BehaviorContext): Behavior { + + // don't provide behavior if we don't have completions + if (!behaviorContext.pmContext.ui.codeview) { + return {}; + } + + registerProvider(); + + let modelUri: string | undefined; + + return { + init(_pmNode, editor) { + const model = editor.getModel(); + if (model) { + modelUri = model.uri.toString(); + codeViews.set(modelUri, behaviorContext); + } + + // explicit completion request (so we can distinguish it from quick suggestions) + editor.addAction({ + id: "quarto.codeview.triggerSuggest", + label: "Trigger Suggest", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space], + run: (ed) => { + explicitRequest = true; + ed.trigger("keyboard", "editor.action.triggerSuggest", {}); + } + }); + }, + cleanup() { + if (modelUri) { + codeViews.delete(modelUri); + } + } + }; +} + +function registerProvider() { + if (providerRegistered) { + return; + } + providerRegistered = true; + + monaco.languages.registerCompletionItemProvider("*", { + triggerCharacters: [".", "$", "@", ":", ">", "/"], + provideCompletionItems: async (model, position, _context, token) => { + + // was this an explicit request? + const explicit = explicitRequest; + explicitRequest = false; + + // find the code view that owns this model + const behaviorContext = codeViews.get(model.uri.toString()); + if (!behaviorContext) { + return null; + } + + // no completions if there is no path + const filepath = behaviorContext.pmContext.ui.context.getDocumentPath(); + if (!filepath) { + return null; + } + + // see if there is a completion context + const cvContext = codeViewCompletionContext(filepath, behaviorContext.view.state, explicit); + if (!cvContext) { + return null; + } + + // check if this is a known editor language + const language = editorLanguage(cvContext.language); + if (!language) { + return null; + } + + // if we don't have quick suggestions enabled and this isn't explicit then bail + if (!behaviorContext.pmContext.ui.prefs.quickSuggestions() && !explicit) { + return null; + } + + // text on the line before the cursor + const textBefore = model.getValueInRange(new monaco.Range( + position.lineNumber, 1, position.lineNumber, position.column + )); + + // if we aren't explicit then filter based on match (letter + wordchar + optional trigger chars) + if (!explicit) { + const trigger = (language.trigger || ["."]); + const regex = new RegExp('(^|[ \t])[\\\\A-Za-z_\\.][\\w_\\(\\)\\[\\]' + escapeRegExpCharacters(trigger.join('')) + ']*$'); + if (!textBefore.match(regex)) { + return null; + } + } + + // get completions + if (token.isCancellationRequested) { + return null; + } + return getCompletions(model, position, textBefore, cvContext, behaviorContext); + } + }); +} + +const compareBySortText = (a: CompletionItem, b: CompletionItem) => { + if (a.sortText && b.sortText) { + return a.sortText.localeCompare(b.sortText); + } else { + return 0; + } +}; + +// compute the column the completion replaces from (the cursor column less the +// length of the text the item's edit replaces) +const itemFromColumn = (item: CompletionItem, cursorColumn: number) => { + return item.textEdit + ? InsertReplaceEdit.is(item.textEdit) + ? cursorColumn - (item.textEdit.insert.end.character - item.textEdit.insert.start.character) + : TextEdit.is(item.textEdit) + ? cursorColumn - (item.textEdit.range.end.character - item.textEdit.range.start.character) + : cursorColumn + : cursorColumn; +}; + +/** + * replaceText for a given CompletionItem is the text that is already in the document + * that that CompletionItem will replace. + * + * Example 1: if you are typing `lib` and get the completion `library`, then this function + * will give `lib`. + * Example 2: if you are typing `os.a` and get the completion `abc`, then this function + * will give `a`. + */ +const getReplaceText = (textBefore: string, item: CompletionItem, cursorColumn: number) => + textBefore.slice(itemFromColumn(item, cursorColumn) - 1); + +const sortTextItemsBoostScore = (textBefore: string, cursorColumn: number, items: CompletionItem[], index: number) => { + const total = items.length; + const item = items[index]; + // compute replaceText + const replaceText = getReplaceText(textBefore, item, cursorColumn); + + // if the replaceText doesn't start with "." then bury items that do + if (!replaceText.startsWith(".") && item.label.startsWith(".")) { + return -99; + } + + // only boost things that have a prefix match + if (item.label.toLowerCase().startsWith(replaceText) || + (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) || + (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) { + return -99 + Math.round(((total - index) / total) * 198); + } else { + return -99; + } +}; + +const defaultBoostScore = (textBefore: string, cursorColumn: number, items: CompletionItem[], index: number) => { + const item = items[index]; + + const replaceText = getReplaceText(textBefore, item, cursorColumn); + + // if you haven't typed into the completions yet (for example after a `.`) then + // score items starting with non-alphabetic characters -1, everything else 0. + if (replaceText.length === 0) return isLetter(item.label[0]) ? 0 : -1; + + // We filter items by replaceText inclusion before scoring, + // so i is guaranteed to be an index into `item.label`... + const i = item.label.toLowerCase().indexOf(replaceText.toLowerCase()); + // and `replaceTextInItemLabel` should be the same as `replaceText` up to upper/lowercase + // differences. + const replaceTextInItemLabel = item.label.slice(i, replaceText.length); + + // mostly counts how many upper/lowercase differences there are + const diff = simpleStringDiff(replaceTextInItemLabel, replaceText); + + // `-i` scores completions better if what you typed is earlier in the completion + // `-diff/10` mostly tie breaks that score by capitalization differences. + return -i - diff / 10; // 10 is a magic number +}; + +async function getCompletions( + model: monaco.editor.ITextModel, + position: monaco.Position, + textBefore: string, + cvContext: CodeViewCompletionContext, + behaviorContext: BehaviorContext +): Promise { + + // get completions + const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext); + if (completions === undefined) return null; + if (completions.items.length == 0) return null; + + const itemsHaveSortText = completions.items?.[0].sortText !== undefined; + + const items = itemsHaveSortText ? + completions.items.sort(compareBySortText) : + completions.items; + + // The token is the contents of the line up to your cursor. + // For example, if you type `os.a` then token will be `os.a`. + // Note: in contrast, when you type `os.a` replaceText will give `a` for a completion like `abc`. + const token = textBefore.match(/\S+$/)?.[0]; + + const cursorColumn = position.column; + + const filteredItems = items.filter(item => { + // no text completions that aren't snippets + if (item.kind === CompletionItemKind.Text && + item.insertTextFormat !== InsertTextFormat.Snippet) return false; + + // only allow non-text edits if we have no token + if (item.textEdit === undefined && token) return false; + + // require at least inclusion + const replaceText = getReplaceText(textBefore, item, cursorColumn).toLowerCase(); + return item.label.toLowerCase().includes(replaceText) || + item.insertText?.toLowerCase().includes(replaceText); + }); + + const boostScore = itemsHaveSortText ? + sortTextItemsBoostScore : + defaultBoostScore; + + // compute boosts and sort by them (highest first); monaco sorts by sortText + // so encode the resulting rank in the sortText + const scored = filteredItems + .map((item, index) => ({ item, boost: boostScore(textBefore, cursorColumn, filteredItems, index), index })) + .sort((a, b) => (b.boost - a.boost) || (a.index - b.index)); + + const suggestions = scored.map(({ item }, rank): monaco.languages.CompletionItem => { + const from = itemFromColumn(item, cursorColumn); + const insertText = item.textEdit?.newText ?? (item.insertText || item.label); + const replaceText = getReplaceText(textBefore, item, cursorColumn); + return { + label: item.label, + kind: lsKindToMonacoKind(item.kind), + detail: item.detail, + documentation: documentationForItem(item), + insertText, + insertTextRules: item.insertTextFormat === InsertTextFormat.Snippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : monaco.languages.CompletionItemInsertTextRule.None, + range: new monaco.Range(position.lineNumber, from, position.lineNumber, cursorColumn), + sortText: String(rank).padStart(5, "0"), + // our own filtering/ranking has already been applied, so make sure + // monaco's filter keeps everything (it matches against filterText) + filterText: replaceText + item.label, + command: item.command?.command === "editor.action.triggerSuggest" + ? { id: "editor.action.triggerSuggest", title: "" } + : undefined + }; + }); + + // make sure the model is still the same (cell could have been deleted) + if (model.isDisposed()) { + return null; + } + + return { suggestions }; +} + +function lsKindToMonacoKind(kind?: CompletionItemKind): monaco.languages.CompletionItemKind { + const kinds = monaco.languages.CompletionItemKind; + switch (kind || CompletionItemKind.Text) { + case CompletionItemKind.Method: return kinds.Method; + case CompletionItemKind.Function: return kinds.Function; + case CompletionItemKind.Constructor: return kinds.Constructor; + case CompletionItemKind.Field: return kinds.Field; + case CompletionItemKind.Variable: return kinds.Variable; + case CompletionItemKind.Class: return kinds.Class; + case CompletionItemKind.Interface: return kinds.Interface; + case CompletionItemKind.Module: return kinds.Module; + case CompletionItemKind.Property: return kinds.Property; + case CompletionItemKind.Unit: return kinds.Unit; + case CompletionItemKind.Value: return kinds.Value; + case CompletionItemKind.Enum: return kinds.Enum; + case CompletionItemKind.Keyword: return kinds.Keyword; + case CompletionItemKind.Snippet: return kinds.Snippet; + case CompletionItemKind.Color: return kinds.Color; + case CompletionItemKind.File: return kinds.File; + case CompletionItemKind.Reference: return kinds.Reference; + case CompletionItemKind.Folder: return kinds.Folder; + case CompletionItemKind.EnumMember: return kinds.EnumMember; + case CompletionItemKind.Constant: return kinds.Constant; + case CompletionItemKind.Struct: return kinds.Struct; + case CompletionItemKind.Event: return kinds.Event; + case CompletionItemKind.Operator: return kinds.Operator; + case CompletionItemKind.TypeParameter: return kinds.TypeParameter; + case CompletionItemKind.Text: + default: + return kinds.Text; + } +} + +function documentationForItem(item: CompletionItem): string | monaco.IMarkdownString | undefined { + if (!item.documentation) { + return undefined; + } + if (MarkupContent.is(item.documentation)) { + if (item.documentation.kind === MarkupKind.Markdown) { + return { value: item.documentation.value }; + } else { + return item.documentation.value; + } + } + return item.documentation; +} + +function simpleStringDiff(str1: string, str2: string) { + let diff = 0; + for (let i = 0; i < Math.min(str1.length, str2.length); i++) { + if (str1[i] !== str2[i]) diff++; + } + return diff; +} + +function isLetter(c: string) { + return c.toLowerCase() != c.toUpperCase(); +} + diff --git a/packages/editor-monaco/src/behaviors/diagnostics.ts b/packages/editor-monaco/src/behaviors/diagnostics.ts new file mode 100644 index 000000000..ab19b5bc8 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/diagnostics.ts @@ -0,0 +1,125 @@ +/* + * diagnostics.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Diagnostics for code cells (currently yaml validation), shown as monaco + * markers (squiggles with the message available on hover). + */ + +import { Position } from "vscode-languageserver-types"; + +import { + CodeViewCellContext, + codeViewCellContext, + kEndColumn, + kEndRow, + kStartColumn, + kStartRow, + LintItem, + stripYamlFrontmatterDelimiters +} from "editor"; +import { lines } from "core"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor } from "."; + +const kMarkerOwner = "quarto"; + +const EMPTY_CODEVIEW_SELECTION = { start: Position.create(0, 0), end: Position.create(0, 0) }; + +export function diagnosticsBehavior(behaviorContext: BehaviorContext): Behavior { + // don't provide behavior if we don't have validation + if (!behaviorContext.pmContext.ui.codeview) { + return {}; + } + + // serialize requests so a slow earlier response can't overwrite a later one + let requestId = 0; + + const updateDiagnostics = async (cellContext: CodeViewCellContext, editor: CodeEditor) => { + const id = ++requestId; + const diagnostics = await getDiagnostics(cellContext, behaviorContext); + const model = editor.getModel(); + if (id !== requestId || !model || model.isDisposed()) { + return; + } + setMarkers(model, diagnostics || []); + }; + + return { + async init(pmNode, editor) { + const language = behaviorContext.options.lang(pmNode, pmNode.textContent); + if (language !== "yaml-frontmatter") return; + + const filepath = behaviorContext.pmContext.ui.context.getDocumentPath(); + if (filepath === null) return; + + const code = lines(pmNode.textContent); + const strippedCodeLines = stripYamlFrontmatterDelimiters(code); + + // here we hand-craft a cellContext because `codeViewCellContext(..)` + // returns undefined inside of init (the selection isn't in the cell yet) + const cellContext = { + filepath, + language: 'yaml', + code: strippedCodeLines, + cellBegin: 0, + cellEnd: code.length - 1, + selection: EMPTY_CODEVIEW_SELECTION + }; + + await updateDiagnostics(cellContext, editor); + }, + + async pmUpdate(_, _updatePmNode, editor) { + const filepath = behaviorContext.pmContext.ui.context.getDocumentPath(); + if (filepath === null) return; + + const cellContext = codeViewCellContext(filepath, behaviorContext.view.state); + if (cellContext === undefined) return; + + await updateDiagnostics(cellContext, editor); + }, + + cleanup() { + requestId++; + } + }; +} + +async function getDiagnostics( + cellContext: CodeViewCellContext, + behaviorContext: BehaviorContext +): Promise { + return await behaviorContext.pmContext.ui.codeview?.codeViewDiagnostics(cellContext); +} + +function setMarkers(model: monaco.editor.ITextModel, diagnostics: LintItem[]) { + const markers = diagnostics.map((item): monaco.editor.IMarkerData => ({ + severity: severityForType(item.type), + message: item.text, + // lint items are 0-based, monaco positions are 1-based + startLineNumber: item[kStartRow] + 1, + startColumn: item[kStartColumn] + 1, + endLineNumber: item[kEndRow] + 1, + endColumn: item[kEndColumn] + 1 + })); + monaco.editor.setModelMarkers(model, kMarkerOwner, markers); +} + +function severityForType(type: string): monaco.MarkerSeverity { + switch (type) { + case "warning": + return monaco.MarkerSeverity.Warning; + case "info": + case "information": + return monaco.MarkerSeverity.Info; + case "hint": + return monaco.MarkerSeverity.Hint; + case "error": + default: + return monaco.MarkerSeverity.Error; + } +} diff --git a/packages/editor-monaco/src/behaviors/diagram.ts b/packages/editor-monaco/src/behaviors/diagram.ts new file mode 100644 index 000000000..c3d54dcad --- /dev/null +++ b/packages/editor-monaco/src/behaviors/diagram.ts @@ -0,0 +1,50 @@ +/* + * diagram.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import debounce from "lodash.debounce"; + +import { codeViewActiveBlockContext } from "editor"; +import { languageDiagramEngine } from "editor-core"; + +import { Behavior, BehaviorContext } from "."; + +export function diagramBehavior(context: BehaviorContext): Behavior { + + const onDiagramCodeChanged = debounce(() => { + previewDiagram(context, false); + }, 500); + + return { + contentChanged(_ev, _editor, pmNode) { + const nodeLang = context.options.lang(pmNode, pmNode.textContent) || ""; + const engine = languageDiagramEngine(nodeLang); + if (engine) { + onDiagramCodeChanged(); + } + }, + cleanup() { + onDiagramCodeChanged.cancel(); + } + }; +} + +export function previewDiagram(context: BehaviorContext, activate: boolean) { + const ctx = codeViewActiveBlockContext(context.view.state); + if (ctx) { + const engine = languageDiagramEngine(ctx.activeLanguage); + if (engine) { + const activeBlock = ctx.blocks.find((block) => block.active); + if (activeBlock) { + context.pmContext.ui.codeview?.codeViewPreviewDiagram({ + engine, + src: activeBlock.code, + }, + activate + ); + } + } + } +} diff --git a/packages/editor-monaco/src/behaviors/find.ts b/packages/editor-monaco/src/behaviors/find.ts new file mode 100644 index 000000000..702ea0258 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/find.ts @@ -0,0 +1,72 @@ +/* + * find.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { scrollCodeViewElementIntoView } from "editor"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor } from "."; + +export function findBehavior(context: BehaviorContext): Behavior { + + const { view, getPos } = context; + + let findDecorations: monaco.editor.IEditorDecorationsCollection | undefined; + + return { + + init(_pmNode, editor) { + findDecorations = editor.createDecorationsCollection(); + }, + + pmUpdate(_prevNode, updateNode, editor) { + + const model = editor.getModel(); + if (!model || !findDecorations) { + return; + } + + // get the find decorations + const findMarkers: monaco.editor.IModelDeltaDecoration[] = []; + const decorations = context.pmContext.find.decorations(); + const pos = getPos(); + if (decorations && pos !== undefined) { + const decos = decorations.find(pos, pos + updateNode.nodeSize - 1); + decos.forEach((deco) => { + if (deco.from !== view.state.selection.from && deco.to !== view.state.selection.to) { + findMarkers.push({ + range: monaco.Range.fromPositions( + model.getPositionAt(deco.from - pos - 1), + model.getPositionAt(deco.to - pos - 1) + ), + options: { inlineClassName: "pm-find-text" } + }); + } else { + // ensure that the selection is visible + scrollLineIntoView(editor, model.getPositionAt(deco.from - pos - 1), context); + } + }); + } + + findDecorations.set(findMarkers); + }, + + cleanup() { + findDecorations?.clear(); + } + }; +} + +// scroll the outer (prosemirror) scroll container so that the given position +// within the editor is visible. scrollCodeViewElementIntoView wants an element +// positioned relative to the code view dom, so we hand it the line's box. +function scrollLineIntoView(editor: CodeEditor, position: monaco.Position, context: BehaviorContext) { + const visible = editor.getScrolledVisiblePosition(position); + if (visible) { + const lineBox = { offsetTop: visible.top, offsetHeight: visible.height } as HTMLElement; + scrollCodeViewElementIntoView(lineBox, context.dom, context.view); + } +} diff --git a/packages/editor-monaco/src/behaviors/firstlinemeta.ts b/packages/editor-monaco/src/behaviors/firstlinemeta.ts new file mode 100644 index 000000000..5bf211e2a --- /dev/null +++ b/packages/editor-monaco/src/behaviors/firstlinemeta.ts @@ -0,0 +1,46 @@ +/* + * firstlinemeta.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * For code views whose first line is metadata (e.g. the `{r}` line of a code + * chunk), render that line in a lighter color. + */ + +import { Behavior, BehaviorContext } from "."; + +import { monaco } from "../monaco"; + +export function firstLineMetaBehavior(context: BehaviorContext): Behavior { + + if (!context.options.firstLineMeta) { + return {}; + } + + let decorations: monaco.editor.IEditorDecorationsCollection | undefined; + + return { + init(_pmNode, editor) { + decorations = editor.createDecorationsCollection([{ + range: new monaco.Range(1, 1, 1, 1), + options: { + isWholeLine: true, + inlineClassName: "pm-monaco-first-line-meta", + // keep the decoration on the first line as it is edited + stickiness: monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore + } + }]); + // the decoration can get pushed off line 1 when text is inserted at the + // very start, so pin it back after every change + editor.onDidChangeModelContent(() => { + decorations?.set([{ + range: new monaco.Range(1, 1, 1, 1), + options: { isWholeLine: true, inlineClassName: "pm-monaco-first-line-meta" } + }]); + }); + }, + cleanup() { + decorations?.clear(); + } + }; +} diff --git a/packages/editor-monaco/src/behaviors/index.ts b/packages/editor-monaco/src/behaviors/index.ts new file mode 100644 index 000000000..ace04c0b8 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/index.ts @@ -0,0 +1,95 @@ +/* + * index.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { Node as ProsemirrorNode } from 'prosemirror-model'; +import { EditorView as PMEditorView } from "prosemirror-view"; + +import { CodeViewOptions, ExtensionContext } from "editor"; + +import { monaco } from "../monaco"; + +import { langModeBehavior } from './langmode'; +import { keyboardBehavior } from './keyboard'; +import { findBehavior } from './find'; +import { trackSelectionBehavior } from './trackselection'; +import { themeBehavior } from './theme'; +import { prefsBehavior } from './prefs'; +import { yamlOptionBehavior } from './yamloption'; +import { toolbarBehavior } from './toolbar'; +import { diagramBehavior } from './diagram'; +import { completionBehavior } from './completion'; +import { diagnosticsBehavior } from './diagnostics'; +import { firstLineMetaBehavior } from './firstlinemeta'; + +export type CodeEditor = monaco.editor.IStandaloneCodeEditor; +export type ContentChangedEvent = monaco.editor.IModelContentChangedEvent; + +export interface Behavior { + // options merged into the editor construction options + options?: monaco.editor.IStandaloneEditorConstructionOptions; + // called once the editor has been created + init?: (pmNode: ProsemirrorNode, editor: CodeEditor) => void; + // called when the monaco model changes (both user and prosemirror initiated) + contentChanged?: (ev: ContentChangedEvent, editor: CodeEditor, pmNode: ProsemirrorNode) => void; + // called when the prosemirror node is updated + pmUpdate?: (prevNode: ProsemirrorNode, updateNode: ProsemirrorNode, editor: CodeEditor) => void; + cleanup?: VoidFunction; +} + +export interface BehaviorContext { + dom: HTMLElement; + view: PMEditorView; + getPos: () => number | undefined; + options: CodeViewOptions; + pmContext: ExtensionContext; + withState: WithState; +} + +export enum State { Updating, Escaping } +export type WithState = (state: State, fn: () => void) => void; + +export function createBehaviors(context: BehaviorContext): Behavior[] { + return [ + langModeBehavior(context), + completionBehavior(context), + findBehavior(context), + themeBehavior(context), + prefsBehavior(context), + trackSelectionBehavior(context), + yamlOptionBehavior(context), + toolbarBehavior(context), + diagramBehavior(context), + diagnosticsBehavior(context), + firstLineMetaBehavior(context), + keyboardBehavior(context) + ]; +} + +export function behaviorOptions(behaviors: Behavior[]): monaco.editor.IStandaloneEditorConstructionOptions { + return behaviors.reduce( + (options, behavior) => ({ ...options, ...(behavior.options || {}) }), {} + ); +} + +export function behaviorInit(behaviors: Behavior[], pmNode: ProsemirrorNode, editor: CodeEditor) { + behaviors.forEach(behavior => behavior.init?.(pmNode, editor)); +} + +export function behaviorContentChanged( + behaviors: Behavior[], ev: ContentChangedEvent, editor: CodeEditor, pmNode: ProsemirrorNode +) { + behaviors.forEach(behavior => behavior.contentChanged?.(ev, editor, pmNode)); +} + +export function behaviorPmUpdate( + behaviors: Behavior[], prevNode: ProsemirrorNode, updateNode: ProsemirrorNode, editor: CodeEditor +) { + behaviors.forEach(behavior => behavior.pmUpdate?.(prevNode, updateNode, editor)); +} + +export function behaviorCleanup(behaviors: Behavior[]) { + behaviors.forEach(behavior => behavior.cleanup?.()); +} diff --git a/packages/editor-monaco/src/behaviors/keyboard.ts b/packages/editor-monaco/src/behaviors/keyboard.ts new file mode 100644 index 000000000..c069550ed --- /dev/null +++ b/packages/editor-monaco/src/behaviors/keyboard.ts @@ -0,0 +1,163 @@ +/* + * keyboard.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { EditorView as PMEditorView } from "prosemirror-view"; +import { undo, redo } from "prosemirror-history"; +import { exitCode, setBlockType } from "prosemirror-commands"; + +import { codeViewActiveBlockContext, handleArrowToAdjacentNode } from "editor"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor, State } from "."; + +const { KeyMod, KeyCode } = monaco; + +// context keys used as preconditions for our keybindings (so that when they +// don't apply monaco's default handling of the key is used) +const kCanEscapeUp = "quartoCodeViewCanEscapeUp"; +const kCanEscapeDown = "quartoCodeViewCanEscapeDown"; +const kCanEscapeLeft = "quartoCodeViewCanEscapeLeft"; +const kCanEscapeRight = "quartoCodeViewCanEscapeRight"; +const kAtStart = "quartoCodeViewAtStart"; + +// don't let monaco consume keys that the host binds (render, run line) +monaco.editor.addKeybindingRules([ + { keybinding: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK, command: null }, + { keybinding: KeyMod.Alt | KeyCode.Enter, command: null }, + { keybinding: KeyMod.CtrlCmd | KeyCode.KeyF, command: null }, +]); + +export function keyboardBehavior(context: BehaviorContext): Behavior { + + const { view, getPos } = context; + + const escape = (dir: 1 | -1, editor: CodeEditor) => { + context.withState(State.Escaping, () => { + const pos = getPos(); + if (pos !== undefined) { + view.focus(); + handleArrowToAdjacentNode(pos, dir, view.state, view.dispatch); + view.focus(); + } + }); + // keep monaco's selection out of the way (prosemirror now owns focus) + editor.setSelection(new monaco.Selection(1, 1, 1, 1)); + }; + + return { + init(_pmNode, editor) { + + const canEscapeUp = editor.createContextKey(kCanEscapeUp, false); + const canEscapeDown = editor.createContextKey(kCanEscapeDown, false); + const canEscapeLeft = editor.createContextKey(kCanEscapeLeft, false); + const canEscapeRight = editor.createContextKey(kCanEscapeRight, false); + const atStart = editor.createContextKey(kAtStart, false); + + const updateContextKeys = () => { + const model = editor.getModel(); + const sel = editor.getSelection(); + if (!model || !sel) { + return; + } + const empty = sel.isEmpty(); + const pos = sel.getPosition(); + const lastLine = model.getLineCount(); + canEscapeUp.set(empty && pos.lineNumber === 1); + canEscapeDown.set(empty && pos.lineNumber === lastLine); + canEscapeLeft.set(empty && pos.lineNumber === 1 && pos.column === 1); + canEscapeRight.set(empty && pos.lineNumber === lastLine && pos.column === model.getLineMaxColumn(lastLine)); + atStart.set(empty && pos.lineNumber === 1 && pos.column === 1); + }; + updateContextKeys(); + editor.onDidChangeCursorSelection(updateContextKeys); + editor.onDidChangeModelContent(updateContextKeys); + + const notPopup = "!suggestWidgetVisible && !parameterHintsVisible"; + + editor.addAction({ + id: "quarto.codeview.escapeUp", + label: "Move to Previous Block", + keybindings: [KeyCode.UpArrow], + precondition: `${kCanEscapeUp} && ${notPopup}`, + run: (ed) => escape(-1, ed as CodeEditor) + }); + editor.addAction({ + id: "quarto.codeview.escapeLeft", + label: "Move to Previous Block", + keybindings: [KeyCode.LeftArrow], + precondition: `${kCanEscapeLeft} && ${notPopup}`, + run: (ed) => escape(-1, ed as CodeEditor) + }); + editor.addAction({ + id: "quarto.codeview.escapeDown", + label: "Move to Next Block", + keybindings: [KeyCode.DownArrow], + precondition: `${kCanEscapeDown} && ${notPopup}`, + run: (ed) => escape(1, ed as CodeEditor) + }); + editor.addAction({ + id: "quarto.codeview.escapeRight", + label: "Move to Next Block", + keybindings: [KeyCode.RightArrow], + precondition: `${kCanEscapeRight} && ${notPopup}`, + run: (ed) => escape(1, ed as CodeEditor) + }); + + // undo/redo are routed to prosemirror (which owns the document history) + editor.addAction({ + id: "quarto.codeview.undo", + label: "Undo", + keybindings: [KeyMod.CtrlCmd | KeyCode.KeyZ], + run: () => { undo(view.state, view.dispatch); } + }); + editor.addAction({ + id: "quarto.codeview.redo", + label: "Redo", + keybindings: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, KeyMod.CtrlCmd | KeyCode.KeyY], + run: () => { redo(view.state, view.dispatch); } + }); + + // backspace at the very start of the block converts it to a paragraph + editor.addAction({ + id: "quarto.codeview.backspaceAtStart", + label: "Convert to Paragraph", + keybindings: [KeyCode.Backspace, KeyMod.CtrlCmd | KeyCode.Backspace], + precondition: `${kAtStart} && ${notPopup}`, + run: () => backspaceAtStart(view) + }); + + // shift-enter executes the cell (or exits the code block if not executable) + editor.addAction({ + id: "quarto.codeview.shiftEnter", + label: "Run Cell", + keybindings: [KeyMod.Shift | KeyCode.Enter], + precondition: notPopup, + run: (ed) => { + const cvContext = codeViewActiveBlockContext(view.state); + if (cvContext && context.pmContext.ui.context.executableLanguges?.().includes(cvContext.activeLanguage)) { + context.pmContext.ui.codeview?.codeViewExecute("cell+advance", cvContext); + } else { + const model = ed.getModel(); + const sel = ed.getSelection(); + if (model && sel && sel.isEmpty() && + model.getOffsetAt(sel.getPosition()) === model.getValueLength()) { + exitCode(view.state, view.dispatch); + view.focus(); + } else { + ed.trigger("keyboard", "type", { text: "\n" }); + } + } + } + }); + } + }; +} + +const backspaceAtStart = (pmView: PMEditorView) => { + setBlockType(pmView.state.schema.nodes.paragraph)(pmView.state, pmView.dispatch); + setTimeout(() => pmView.focus(), 20); +}; diff --git a/packages/editor-monaco/src/behaviors/langmode.ts b/packages/editor-monaco/src/behaviors/langmode.ts new file mode 100644 index 000000000..62f30258a --- /dev/null +++ b/packages/editor-monaco/src/behaviors/langmode.ts @@ -0,0 +1,45 @@ +/* + * langmode.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { Node as ProsemirrorNode } from 'prosemirror-model'; + +import { monaco } from "../monaco"; +import { initTextMate } from "../textmate"; +import { monacoLanguageId } from '../languages'; + +import { Behavior, BehaviorContext, CodeEditor } from '.'; + +export function langModeBehavior(context: BehaviorContext): Behavior { + + let disposed = false; + + const nodeLang = (nd: ProsemirrorNode) => context.options.lang(nd, nd.textContent) || ''; + + const setMode = (lang: string, editor: CodeEditor) => { + // languages are registered once the host has provided them + initTextMate(context.pmContext.ui.codeview).then(() => { + const model = editor.getModel(); + if (!disposed && model) { + monaco.editor.setModelLanguage(model, monacoLanguageId(lang)); + } + }); + }; + + return { + init: (pmNode, editor) => { + setMode(nodeLang(pmNode), editor); + }, + pmUpdate: (prevNode, updateNode, editor) => { + const updateLang = nodeLang(updateNode); + if (nodeLang(prevNode) !== updateLang) { + setMode(updateLang, editor); + } + }, + cleanup: () => { + disposed = true; + } + }; +} diff --git a/packages/editor-monaco/src/behaviors/prefs.ts b/packages/editor-monaco/src/behaviors/prefs.ts new file mode 100644 index 000000000..345ef6c98 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/prefs.ts @@ -0,0 +1,61 @@ +/* + * prefs.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { PrefsChangedEvent } from "editor"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor } from "."; + +export function prefsBehavior(context: BehaviorContext): Behavior { + + const configurePrefs = (editor: CodeEditor) => { + + // alias prefs + const prefs = context.pmContext.ui.prefs; + + // line numbers + const options = context.options; + const showLineNumbers = !!options.lineNumbers && prefs.lineNumbers(); + const formatter = options.lineNumberFormatter; + const lineNumbers: monaco.editor.LineNumbersType = showLineNumbers + ? (formatter ? (lineNo: number) => formatter(lineNo) : "on") + : "off"; + + const autoClosing = prefs.autoClosingBrackets() ? "languageDefined" : "never"; + + editor.updateOptions({ + lineNumbers, + lineNumbersMinChars: showLineNumbers ? 3 : 0, + lineDecorationsWidth: showLineNumbers ? 8 : 0, + selectionHighlight: prefs.highlightSelectedWord(), + renderWhitespace: prefs.showWhitespace() ? "all" : "none", + autoClosingBrackets: autoClosing, + autoClosingQuotes: autoClosing, + cursorBlinking: prefs.blinkingCursor() ? "blink" : "solid", + quickSuggestions: prefs.quickSuggestions() + }); + + editor.getModel()?.updateOptions({ + tabSize: prefs.tabWidth(), + insertSpaces: prefs.spacesForTab() + }); + }; + + let unsubscribe: VoidFunction; + + return { + init(_pmNode, editor) { + configurePrefs(editor); + unsubscribe = context.pmContext.events.subscribe(PrefsChangedEvent, () => { + configurePrefs(editor); + }); + }, + cleanup: () => { + unsubscribe?.(); + } + }; +} diff --git a/packages/editor-monaco/src/behaviors/theme.ts b/packages/editor-monaco/src/behaviors/theme.ts new file mode 100644 index 000000000..f95229cc7 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/theme.ts @@ -0,0 +1,148 @@ +/* + * theme.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Apply the active VS Code color theme (provided by the host) to monaco, and + * apply per-editor font and background settings. + */ + +import { PrefsChangedEvent, ThemeChangedEvent } from "editor"; +import { CodeViewEditorFont } from "editor-types"; + +import { Behavior, BehaviorContext, CodeEditor } from "."; +import { activeThemeColors, applyColorTheme, initTextMate } from "../textmate"; + +// the color theme is shared by all editors, so the theme is fetched/applied +// once per change and then every editor is notified to reconfigure itself +let themeRequest: Promise | undefined; +let themeRequestedAt = 0; +const themeListeners = new Set(); + +// the source editor's font settings (shared by all editors) +let editorFont: CodeViewEditorFont | undefined; +let fontRequest: Promise | undefined; + +function requestEditorFont(context: BehaviorContext, force: boolean): Promise { + if (!fontRequest || force) { + const codeview = context.pmContext.ui.codeview; + fontRequest = (codeview ? codeview.codeViewEditorFont() : Promise.resolve(undefined)) + .then(font => { editorFont = font; }) + .catch(error => console.error("Error reading editor font", error)) + .then(() => themeListeners.forEach(listener => listener())); + } + return fontRequest; +} + +// vscode stamps the webview body with the id of the theme it is currently +// displayed with (updated live, including during theme preview) +function webviewThemeId(): string | undefined { + return document.body.dataset.vscodeThemeId || document.body.dataset.vscodeThemeName || undefined; +} + +function webviewDarkMode(fallback: boolean): boolean { + const kind = document.body.dataset.vscodeThemeKind; + if (kind) { + return kind === "vscode-dark" || kind === "vscode-high-contrast"; + } + return fallback; +} + +function requestColorTheme(context: BehaviorContext, force: boolean): Promise { + // every editor observes the same theme change event, so coalesce their + // forced requests into one + const now = Date.now(); + if (!themeRequest || (force && (now - themeRequestedAt) > 100)) { + themeRequestedAt = now; + const codeview = context.pmContext.ui.codeview; + const darkMode = webviewDarkMode(context.pmContext.theme().darkMode); + themeRequest = initTextMate(codeview) + .then(() => codeview?.codeViewColorTheme(webviewThemeId())) + .then(theme => applyColorTheme(theme, darkMode)) + .catch(error => { + console.error("Error applying color theme", error); + applyColorTheme(undefined, darkMode); + }) + .then(() => themeListeners.forEach(listener => listener())); + } + return themeRequest; +} + +// re-apply the theme whenever vscode changes the theme attributes on the body +let bodyObserver: MutationObserver | undefined; +function observeWebviewTheme(context: BehaviorContext) { + if (!bodyObserver) { + bodyObserver = new MutationObserver(() => { + requestColorTheme(context, true); + }); + bodyObserver.observe(document.body, { + attributes: true, + attributeFilter: ["data-vscode-theme-id", "data-vscode-theme-name", "data-vscode-theme-kind"] + }); + } +} + +export function themeBehavior(context: BehaviorContext): Behavior { + + let unsubscribe: VoidFunction; + + const chunkBackground = !!context.options.classes?.includes("pm-chunk-background-color"); + + const configureEditor = (editor: CodeEditor) => { + + // fonts (the same settings the source editor uses) + if (editorFont) { + editor.updateOptions({ + fontFamily: editorFont.fontFamily, + fontSize: editorFont.fontSize, + fontWeight: editorFont.fontWeight, + fontLigatures: editorFont.fontLigatures, + lineHeight: editorFont.lineHeight, + letterSpacing: editorFont.letterSpacing + }); + } else { + const editorTheme = context.pmContext.theme(); + editor.updateOptions({ + fontFamily: editorTheme.fixedWidthFont, + fontSize: Math.round(editorTheme.fixedWidthFontSizePt * 96 / 72) + }); + } + + // background (monaco's theme is global, so per-editor backgrounds are + // applied via a css variable on our container) + const colors = activeThemeColors(); + const background = chunkBackground + ? (colors["notebook.cellEditorBackground"] || colors["editor.background"]) + : colors["editor.background"]; + if (background) { + context.dom.style.setProperty("--pm-code-background", background); + context.dom.classList.add("pm-monaco-editor-background"); + } else { + context.dom.style.removeProperty("--pm-code-background"); + context.dom.classList.remove("pm-monaco-editor-background"); + } + }; + + return { + init: (_pmNode, editor) => { + const listener = () => configureEditor(editor); + themeListeners.add(listener); + configureEditor(editor); + observeWebviewTheme(context); + requestColorTheme(context, false).then(listener); + requestEditorFont(context, false).then(listener); + const unsubscribeTheme = context.pmContext.events.subscribe(ThemeChangedEvent, () => { + requestColorTheme(context, true); + }); + const unsubscribePrefs = context.pmContext.events.subscribe(PrefsChangedEvent, () => { + requestEditorFont(context, true); + }); + unsubscribe = () => { + unsubscribeTheme(); + unsubscribePrefs(); + themeListeners.delete(listener); + }; + }, + cleanup: () => unsubscribe?.() + }; +} diff --git a/packages/editor-monaco/src/behaviors/toolbar.ts b/packages/editor-monaco/src/behaviors/toolbar.ts new file mode 100644 index 000000000..a9cdbbfe4 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/toolbar.ts @@ -0,0 +1,138 @@ +/* + * toolbar.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { Transaction } from "prosemirror-state"; + +import { languageDiagramEngine } from "editor-core"; + +import { CodeViewActiveBlockContext, codeViewActiveBlockContext, CodeViewExecute, DispatchEvent } from "editor"; + +import { Behavior, BehaviorContext } from "."; +import { previewDiagram } from "./diagram"; +import { asEditorSelection } from "./trackselection"; + +export function toolbarBehavior(context: BehaviorContext): Behavior { + + let unsubscribe: VoidFunction; + let toolbar: HTMLElement | undefined; + + const showToolbar = (show: boolean) => { + if (show && !toolbar) { + toolbar = createToolbarPanel(); + context.dom.appendChild(toolbar); + } else if (!show && toolbar) { + toolbar.remove(); + toolbar = undefined; + } + }; + + function createToolbarPanel() { + + // get context + const cvContext = codeViewActiveBlockContext(context.view.state)!; + + // create toolbar + const panel = document.createElement("div"); + panel.classList.add("pm-monaco-toolbar"); + + // add a button + const addButton = (title: string, onClick: VoidFunction, ...classes: string[]) => { + const button = document.createElement("i"); + button.title = title; + button.classList.add("codicon", ...classes); + button.addEventListener('mousedown', (ev) => { + // don't steal focus from the editor + ev.preventDefault(); + }); + button.addEventListener('click', (ev) => { + onClick(); + ev.preventDefault(); + ev.stopPropagation(); + return false; + }); + panel.appendChild(button); + return button; + }; + + const addExecuteButton = (execute: CodeViewExecute, title: string, ...classes: string[]) => { + addButton(title, () => { + context.pmContext.ui.codeview?.codeViewExecute(execute, cvContext); + }, ...classes); + }; + + // buttons (conditional on context) + const t = context.pmContext.ui.context.translateText; + const diagramEngine = languageDiagramEngine(cvContext.activeLanguage); + if (diagramEngine) { + addButton(t("Preview Diagram"), () => { + previewDiagram(context, true); + }, "codicon-zoom-in", "pm-codeview-run-button"); + } else { + if (runnableCellsAbove(cvContext)) { + addExecuteButton("above", t("Run Cells Above"), "codicon-run-above", "pm-codeview-run-other-button"); + } + if (runnableCellsBelow(cvContext)) { + addExecuteButton("below", t("Run Cells Below"), "codicon-run-below", "pm-codeview-run-other-button"); + } + addExecuteButton("cell", t("Run Cell"), "codicon-play", "pm-codeview-run-button"); + } + + return panel; + } + + return { + init(pmNode, editor) { + unsubscribe = context.pmContext.events.subscribe(DispatchEvent, (tr: Transaction | undefined) => { + // track selection-only changes + if (tr && tr.selectionSet && !tr.docChanged) { + const selection = asEditorSelection(context.view, editor, context.getPos); + if (selection) { + const nodeLang = context.options.lang(pmNode, pmNode.textContent); + const cvContext = codeViewActiveBlockContext(context.view.state); + const show = !!cvContext && !!nodeLang && + // is an executable language + (!!context.pmContext.ui.context.executableLanguges?.().includes(nodeLang) || + // or is a diagram language + !!languageDiagramEngine(nodeLang)); + // rebuild so the above/below buttons reflect the current document + showToolbar(false); + showToolbar(show); + } else { + showToolbar(false); + } + } + }); + }, + cleanup: () => { + unsubscribe?.(); + showToolbar(false); + }, + }; +} + +function runnableCellsAbove(context: CodeViewActiveBlockContext) { + const activeIndex = context.blocks.findIndex(block => block.active); + if (activeIndex !== -1) { + for (let i = 0; i < activeIndex; i++) { + if (context.blocks[i].language === context.activeLanguage) { + return true; + } + } + } + return false; +} + +function runnableCellsBelow(context: CodeViewActiveBlockContext) { + const activeIndex = context.blocks.findIndex(block => block.active); + if (activeIndex !== -1) { + for (let i = (activeIndex + 1); i < context.blocks.length; i++) { + if (context.blocks[i]?.language === context.activeLanguage) { + return true; + } + } + } + return false; +} diff --git a/packages/editor-monaco/src/behaviors/trackselection.ts b/packages/editor-monaco/src/behaviors/trackselection.ts new file mode 100644 index 000000000..179db93c0 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/trackselection.ts @@ -0,0 +1,118 @@ +/* + * trackselection.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import debounce from 'lodash.debounce'; + +import { EditorView as PMEditorView } from "prosemirror-view"; +import { TextSelection, Transaction } from "prosemirror-state"; + +import { DispatchEvent, codeViewCellContext, kCodeViewNextLineTransaction } from "editor"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor, State } from "."; + +// track the selection in prosemirror +export function trackSelectionBehavior(context: BehaviorContext): Behavior { + + let unsubscribe: VoidFunction; + + // 500ms debounced function for code view assist request + const codeViewAssist = debounce(() => { + // get path and context (bail if we can't) + const filepath = context.pmContext.ui.context.getDocumentPath(); + if (!filepath) { + return; + } + const cvContext = codeViewCellContext(filepath, context.view.state); + if (cvContext) { + context.pmContext.ui.codeview?.codeViewAssist(cvContext); + } + }, 500); + + return { + + init(_pmNode, editor) { + unsubscribe = context.pmContext.events.subscribe(DispatchEvent, (tr: Transaction | undefined) => { + if (tr) { + // track selection changes that occur when we don't have focus + if (tr.selectionSet && !tr.docChanged && (tr.selection instanceof TextSelection)) { + const selection = asEditorSelection(context.view, editor, context.getPos) + ?? new monaco.Selection(1, 1, 1, 1); + // don't echo back a selection that originated here (or that we + // already have) or we'd lose the user's selection direction + const current = editor.getSelection(); + if (!current || !current.equalsSelection(selection)) { + context.withState(State.Updating, () => { + editor.setSelection(selection); + }); + } + } else if (tr.getMeta(kCodeViewNextLineTransaction) === true) { + // NOTE: this is a special directive to advance to the next line. as distinct + // from the block above it is not a reporting of a change in the PM selection + // but rather an instruction to move the selection to the next line. as + // such we do not enclose the code in State.Updating, because we want an update + // to the PM selection to occur + const selection = asEditorSelection(context.view, editor, context.getPos); + if (selection) { + const before = editor.getPosition()?.lineNumber; + editor.trigger("quarto", "cursorDown", null); + if (editor.getPosition()?.lineNumber !== before) { + editor.trigger("quarto", "cursorLineStart", null); + } + } + // for other selection changes + } else if (editor.hasTextFocus() && tr.selectionSet && (tr.selection instanceof TextSelection)) { + codeViewAssist(); + } + } + }); + }, + + cleanup: () => { + codeViewAssist.cancel(); + unsubscribe?.(); + } + }; + +} + +/** + * Map the prosemirror selection into a monaco selection for this code view + * (undefined if the prosemirror selection doesn't intersect the code view) + */ +export const asEditorSelection = ( + pmView: PMEditorView, + editor: CodeEditor, + getPos: () => number | undefined +): monaco.Selection | undefined => { + const model = editor.getModel(); + const pos = getPos(); + if (model && pos !== undefined) { + const offset = pos + 1; + const node = pmView.state.doc.nodeAt(pos); + if (node) { + const nodeSize = node.nodeSize; + const selection = pmView.state.selection; + const range = { from: offset, to: offset + nodeSize - 1 }; + const isWithin = (p: number) => p >= range.from && p < range.to; + if (isWithin(selection.from) || isWithin(selection.to)) { + // preserve the selection direction (anchor/head rather than from/to) + const clamp = (p: number) => Math.max(0, Math.min(p - offset, model.getValueLength())); + return monaco.Selection.fromPositions( + model.getPositionAt(clamp(selection.anchor)), + model.getPositionAt(clamp(selection.head)) + ); + } else if (selection.from <= range.from && selection.to >= range.to) { + return monaco.Selection.fromPositions( + model.getPositionAt(0), + model.getPositionAt(model.getValueLength()) + ); + } + } + } + return undefined; +}; diff --git a/packages/editor-monaco/src/behaviors/yamloption.ts b/packages/editor-monaco/src/behaviors/yamloption.ts new file mode 100644 index 000000000..5f51acec7 --- /dev/null +++ b/packages/editor-monaco/src/behaviors/yamloption.ts @@ -0,0 +1,93 @@ +/* + * yamloption.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Continue `#| ` option comment lines when the user presses Enter on one + * (and clear the line when Enter is pressed on an empty `#|`). + */ + +import { Node as ProsemirrorNode } from 'prosemirror-model'; + +import { editorLanguage } from "editor-core"; + +import { monaco } from "../monaco"; + +import { Behavior, BehaviorContext, CodeEditor } from "."; + +const kOnOptionComment = "quartoCodeViewOnOptionComment"; + +export function yamlOptionBehavior(context: BehaviorContext): Behavior { + + // track current option comment prefix (e.g. "#| ") for the node's language + let optionComment: string | undefined; + const updateLanguage = (nd: ProsemirrorNode) => { + const language = context.options.lang(nd, nd.textContent) || ''; + const langComment = editorLanguage(language)?.comment; + optionComment = langComment ? `${langComment}| ` : undefined; + }; + + return { + + init(pmNode, editor) { + updateLanguage(pmNode); + + // context key: is the cursor on an option comment line? + const onOptionComment = editor.createContextKey(kOnOptionComment, false); + const updateContextKey = () => { + const line = currentLine(editor); + onOptionComment.set( + !!optionComment && !!line && + (line.trim() === optionComment.trim() || line.startsWith(optionComment)) + ); + }; + editor.onDidChangeCursorPosition(updateContextKey); + editor.onDidChangeModelContent(updateContextKey); + + editor.addAction({ + id: "quarto.codeview.optionCommentEnter", + label: "Continue Option Comment", + keybindings: [monaco.KeyCode.Enter], + precondition: `${kOnOptionComment} && !suggestWidgetVisible && !inSnippetMode && !parameterHintsVisible`, + run: (ed) => { + const line = currentLine(ed as CodeEditor); + if (!optionComment || line === undefined) { + return; + } + const model = ed.getModel()!; + const lineNumber = ed.getPosition()!.lineNumber; + if (line.trim() === optionComment.trim()) { + // empty option comment: clear it and move to a fresh line + ed.executeEdits("quarto", [{ + range: new monaco.Range(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber)), + text: "\n" + }]); + ed.setPosition({ lineNumber: lineNumber + 1, column: 1 }); + } else { + // continue the option comment on the next line + ed.trigger("keyboard", "type", { text: "\n" }); + const pos = ed.getPosition()!; + ed.executeEdits("quarto", [{ + range: monaco.Range.fromPositions(pos, pos), + text: optionComment + }]); + ed.setPosition({ lineNumber: pos.lineNumber, column: pos.column + optionComment.length }); + } + } + }); + }, + + pmUpdate: (_prevNode, updateNode) => { + updateLanguage(updateNode); + } + }; +} + +function currentLine(editor: CodeEditor): string | undefined { + const model = editor.getModel(); + const pos = editor.getPosition(); + if (model && pos) { + return model.getLineContent(pos.lineNumber); + } + return undefined; +} diff --git a/packages/editor-monaco/src/index.ts b/packages/editor-monaco/src/index.ts new file mode 100644 index 000000000..e95344489 --- /dev/null +++ b/packages/editor-monaco/src/index.ts @@ -0,0 +1,63 @@ +/* + * index.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorView, NodeView } from "prosemirror-view"; + +import { ExtensionFn, CodeViewOptions, BaseKey, codeViewArrowHandler, CodeEditorNodeViews } from "editor"; + +import { monacoNodeView } from "./nodeview"; + +import "./styles.css"; + +export const monacoPluginKey = new PluginKey("monaco"); + +export function monacoExtension( + codeViews: { [key: string]: CodeViewOptions }) +: ExtensionFn { + return (context) => { + + // shared + const codeNodeViews = new CodeEditorNodeViews(); + + // build nodeViews + const nodeTypes = Object.keys(codeViews); + const nodeViews: { + [name: string]: ( + node: ProsemirrorNode, + view: EditorView, + getPos: () => number | undefined + ) => NodeView; + } = {}; + nodeTypes.forEach((name) => { + nodeViews[name] = monacoNodeView(context, codeViews[name], codeNodeViews); + }); + + // return plugin + return { + plugins: () => [ + new Plugin({ + key: monacoPluginKey, + props: { + nodeViews, + handleDOMEvents: { + click: codeNodeViews.handleClick.bind(codeNodeViews), + }, + }, + }), + ], + baseKeys: () => { + return [ + { key: BaseKey.ArrowLeft, command: codeViewArrowHandler('left', nodeTypes) }, + { key: BaseKey.ArrowRight, command: codeViewArrowHandler('right', nodeTypes) }, + { key: BaseKey.ArrowUp, command: codeViewArrowHandler('up', nodeTypes) }, + { key: BaseKey.ArrowDown, command: codeViewArrowHandler('down', nodeTypes) } + ]; + }, + }; + }; +} diff --git a/packages/editor-monaco/src/languages/index.ts b/packages/editor-monaco/src/languages/index.ts new file mode 100644 index 000000000..16e2745c2 --- /dev/null +++ b/packages/editor-monaco/src/languages/index.ts @@ -0,0 +1,67 @@ +/* + * index.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Map quarto code block languages to the language ids registered with + * monaco (which are the ids of the languages the host contributes). + */ + +import { contributedLanguages } from "../textmate"; + +export const kPlainTextLanguage = "plaintext"; + +// quarto / pandoc language names that differ from the vscode language id +const kLanguageAliases: Record = { + "yaml-frontmatter": "yaml", + yml: "yaml", + ojs: "javascript", + js: "javascript", + ts: "typescript", + sh: "shellscript", + bash: "shellscript", + zsh: "shellscript", + py: "python", + jl: "julia", + "c++": "cpp", + md: "markdown", + tex: "latex", + rs: "rust", + rb: "ruby", + ps1: "powershell", + pwsh: "powershell" +}; + +/** + * Resolve a quarto code block language to a monaco language id (plaintext + * if the host doesn't have a grammar for the language) + */ +export function monacoLanguageId(lang: string): string { + const candidate = (kLanguageAliases[lang] ?? lang).toLowerCase(); + if (!candidate) { + return kPlainTextLanguage; + } + + const languages = contributedLanguages().filter(language => !!language.scopeName); + + const byId = languages.find(language => language.id.toLowerCase() === candidate); + if (byId) { + return byId.id; + } + + const byAlias = languages.find(language => + language.aliases.some(alias => alias.toLowerCase() === candidate) + ); + if (byAlias) { + return byAlias.id; + } + + const byExtension = languages.find(language => + language.extensions.some(ext => ext.toLowerCase() === `.${candidate}`) + ); + if (byExtension) { + return byExtension.id; + } + + return kPlainTextLanguage; +} diff --git a/packages/editor-monaco/src/monaco.ts b/packages/editor-monaco/src/monaco.ts new file mode 100644 index 000000000..070eb0805 --- /dev/null +++ b/packages/editor-monaco/src/monaco.ts @@ -0,0 +1,51 @@ +/* + * monaco.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * One-time global setup for the Monaco editor: the editor API, the subset of + * editor features we use, and the web worker used by Monaco's editor services. + */ + +/// + +import * as monaco from "monaco-editor/editor/editor.api"; + +// editor features (each import registers a contribution) +import "monaco-editor/features/codeEditor/register"; +import "monaco-editor/features/suggest/register"; +import "monaco-editor/features/hover/register"; +import "monaco-editor/features/snippet/register"; +import "monaco-editor/features/bracketMatching/register"; +import "monaco-editor/features/indentation/register"; +import "monaco-editor/features/wordOperations/register"; +import "monaco-editor/features/wordPartOperations/register"; +import "monaco-editor/features/linesOperations/register"; +import "monaco-editor/features/clipboard/register"; +import "monaco-editor/features/cursorUndo/register"; +import "monaco-editor/features/smartSelect/register"; +import "monaco-editor/features/wordHighlighter/register"; +import "monaco-editor/features/multicursor/register"; +import "monaco-editor/features/comment/register"; +import "monaco-editor/features/caretOperations/register"; +import "monaco-editor/features/dnd/register"; +import "monaco-editor/features/tokenization/register"; + +// the editor worker, inlined into the bundle as a blob url so that it can be +// created inside a vscode webview (whose resources live on a different origin) +import EditorWorker from "monaco-editor/editor/editor.worker?worker&inline"; + +export { monaco }; + +let initialized = false; + +export function initMonaco() { + if (initialized) { + return; + } + initialized = true; + + self.MonacoEnvironment = { + getWorker: () => new EditorWorker() + }; +} diff --git a/packages/editor-monaco/src/nodeview.ts b/packages/editor-monaco/src/nodeview.ts new file mode 100644 index 000000000..af8258c86 --- /dev/null +++ b/packages/editor-monaco/src/nodeview.ts @@ -0,0 +1,353 @@ +/* + * nodeview.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * ProseMirror NodeView that hosts a Monaco editor for a code node. The + * ProseMirror document is the source of truth: user edits in Monaco are + * forwarded to ProseMirror as transactions, and ProseMirror updates (undo, + * collaboration, find/replace, etc.) are applied back to the Monaco model. + */ + +import { Node } from "prosemirror-model"; +import { EditorView as PMEditorView, NodeView } from "prosemirror-view"; +import { TextSelection } from "prosemirror-state"; + +import { + CodeEditorNodeView, + CodeEditorNodeViews, + CodeViewOptions, + ExtensionContext +} from "editor"; + +import { monaco, initMonaco } from "./monaco"; + +import { + createBehaviors, + behaviorOptions, + behaviorInit, + behaviorPmUpdate, + behaviorContentChanged, + behaviorCleanup, + State, + CodeEditor +} from "./behaviors"; + +const kBaseOptions: monaco.editor.IStandaloneEditorConstructionOptions = { + // layout: the editor grows to fit its content and never scrolls itself + automaticLayout: true, + scrollBeyondLastLine: false, + scrollbar: { + vertical: "hidden", + horizontal: "hidden", + alwaysConsumeMouseWheel: false, + handleMouseWheel: false + }, + wordWrap: "on", + wrappingIndent: "none", + // chrome: strip everything that isn't the text + minimap: { enabled: false }, + lineNumbers: "off", + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + overviewRulerLanes: 0, + overviewRulerBorder: false, + hideCursorInOverviewRuler: true, + renderLineHighlight: "none", + contextmenu: false, + stickyScroll: { enabled: false }, + // popups (suggest, hover) must escape the editor's clipped box + fixedOverflowWidgets: true, + // misc + links: false, + occurrencesHighlight: "off", + selectionHighlight: false, + renderWhitespace: "none", + padding: { top: 4, bottom: 4 }, + unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false } +}; + +export const monacoNodeView: ( + context: ExtensionContext, + codeViewOptions: CodeViewOptions, + nodeViews: CodeEditorNodeViews +) => ( + pmNode: Node, + view: PMEditorView, + getPos: () => number | undefined +) => NodeView = (context, codeViewOptions, nodeViews) => (pmNode, view, getPos) => { + + initMonaco(); + + // track node + let node = pmNode; + + // scoped state and function to allow behaviors to set it + let updating = false; + let escaping = false; + const withState = (state: State, fn: () => void) => { + const setState = (value: boolean) => { + if (state === State.Updating) updating = value; + if (state === State.Escaping) escaping = value; + }; + setState(true); + try { + fn(); + } finally { + setState(false); + } + }; + + // gap cursor pending state (set by the global nodeview click handler) + let gapCursorPending = false; + const setGapCursorPending = (pending: boolean) => { + gapCursorPending = pending; + }; + + // track the last user selection in this code view so we can make it + // sticky for when prosemirror calls setSelection (e.g. on a refocus, + // where by default it passes 0,0) + let lastUserSelection: { anchor: number, head: number } | undefined; + + // setup dom + const dom = document.createElement("div"); + dom.classList.add("pm-code-editor"); + dom.classList.add("pm-monaco-editor"); + dom.classList.add(codeViewOptions.borderColorClass || "pm-block-border-color"); + if (codeViewOptions.classes) { + codeViewOptions.classes.forEach(className => dom.classList.add(className)); + } + const editorDom = document.createElement("div"); + editorDom.classList.add("pm-monaco-editor-container"); + dom.appendChild(editorDom); + + // create behaviors + const behaviors = createBehaviors({ + dom, + view, + getPos, + options: codeViewOptions, + pmContext: context, + withState + }); + + // create model and editor + const model = monaco.editor.createModel(node.textContent); + model.setEOL(monaco.editor.EndOfLineSequence.LF); + const editor: CodeEditor = monaco.editor.create(editorDom, { + ...kBaseOptions, + ...behaviorOptions(behaviors), + model + }); + + // size the editor to its content + const layout = () => { + const height = editor.getContentHeight(); + editorDom.style.height = `${height}px`; + editor.layout({ width: editorDom.clientWidth, height }); + }; + editor.onDidContentSizeChange(layout); + layout(); + + // forward user edits to prosemirror + editor.onDidChangeModelContent((ev) => { + if (!updating) { + valueChanged(modelText(model), node, getPos, view); + forwardSelection(editor, view, getPos); + trackUserSelection(); + } + behaviorContentChanged(behaviors, ev, editor, node); + }); + + // forward user selection changes to prosemirror + const trackUserSelection = () => { + const sel = editor.getSelection(); + if (sel) { + const anchor = model.getOffsetAt(sel.getSelectionStart()); + const head = model.getOffsetAt(sel.getPosition()); + if (anchor !== 0 || head !== 0) { + lastUserSelection = { anchor, head }; + } + } + }; + editor.onDidChangeCursorSelection(() => { + if (!updating) { + forwardSelection(editor, view, getPos); + trackUserSelection(); + } + }); + + // initialize behaviors + behaviorInit(behaviors, node, editor); + + // track node view (this is done both so the outer editor can direct + // commands at the currently active nodeview, as well as for click + // detection between views for gap cursor handling) + const codeNodeView: CodeEditorNodeView = { + isFocused: () => editor.hasTextFocus(), + getPos: () => getPos() ?? 0, + dom, + setGapCursorPending + }; + nodeViews.add(codeNodeView); + + // helper to set the monaco selection from prosemirror offsets + const setEditorSelection = (anchor: number, head: number) => { + const anchorPos = model.getPositionAt(anchor); + const headPos = model.getPositionAt(head); + withState(State.Updating, () => { + editor.setSelection(monaco.Selection.fromPositions(anchorPos, headPos)); + }); + }; + + return { + + dom, + + selectNode() { + editor.focus(); + }, + + stopEvent: () => true, + + setSelection: (anchor, head) => { + + // if prosemirror attempts to set us to 0,0 (which it does on focus) + // just restore our last user selection + if (anchor === 0 && head === 0 && lastUserSelection) { + anchor = lastUserSelection.anchor; + head = lastUserSelection.head; + } + + // reflect the prosemirror selection change into monaco (do this before + // forwarding, otherwise we'd forward monaco's stale selection back to + // prosemirror and clobber the one it just asked for) + setEditorSelection(anchor, head); + + // set focus and forward selection unless our state precludes this (the + // forward only matters if we restored a sticky user selection above) + if (!escaping && !gapCursorPending) { + editor.focus(); + forwardSelection(editor, view, getPos); + } + }, + + update: (updateNode) => { + + // if the node type changed, no update (standard prosemirror boilerplate) + if (updateNode.type.name !== node.type.name) { + return false; + } + + // update node (track prevNode to pass to behaviors) + const prevNode = node; + node = updateNode; + + // apply change from update node + const change = computeChange(modelText(model), node.textContent); + if (change) { + withState(State.Updating, () => { + const range = monaco.Range.fromPositions( + model.getPositionAt(change.from), + model.getPositionAt(change.to) + ); + model.applyEdits([{ range, text: change.text }]); + const cursor = model.getPositionAt(change.from + change.text.length); + editor.setSelection(monaco.Selection.fromPositions(cursor, cursor)); + }); + } + + // trigger update for behaviors + behaviorPmUpdate(behaviors, prevNode, updateNode, editor); + + // success + return true; + }, + + ignoreMutation: () => true, + + destroy: () => { + behaviorCleanup(behaviors); + nodeViews.remove(codeNodeView); + editor.dispose(); + model.dispose(); + }, + }; +}; + +const modelText = (model: monaco.editor.ITextModel) => + model.getValue(monaco.editor.EndOfLinePreference.LF); + +const computeChange = (oldVal: string, newVal: string) => { + if (oldVal === newVal) return null; + let start = 0; + let oldEnd = oldVal.length; + let newEnd = newVal.length; + while ( + start < oldEnd && + oldVal.charCodeAt(start) === newVal.charCodeAt(start) + ) + start += 1; + while ( + oldEnd > start && + newEnd > start && + oldVal.charCodeAt(oldEnd - 1) === newVal.charCodeAt(newEnd - 1) + ) { + oldEnd -= 1; + newEnd -= 1; + } + return { from: start, to: oldEnd, text: newVal.slice(start, newEnd) }; +}; + +const asProseMirrorSelection = ( + pmDoc: Node, + editor: CodeEditor, + getPos: () => number | undefined +) => { + const model = editor.getModel()!; + const sel = editor.getSelection()!; + const offset = (getPos() ?? 0) + 1; + const anchor = model.getOffsetAt(sel.getSelectionStart()) + offset; + const head = model.getOffsetAt(sel.getPosition()) + offset; + return TextSelection.create(pmDoc, anchor, head); +}; + +const forwardSelection = ( + editor: CodeEditor, + pmView: PMEditorView, + getPos: () => number | undefined +) => { + if (!editor.hasTextFocus()) return; + const selection = asProseMirrorSelection(pmView.state.doc, editor, getPos); + if (!selection.eq(pmView.state.selection)) + pmView.dispatch(pmView.state.tr.setSelection(selection)); +}; + +const valueChanged = ( + textUpdate: string, + node: Node, + getPos: () => number | undefined, + view: PMEditorView +) => { + const change = computeChange(node.textContent, textUpdate); + const pos = getPos(); + if (change && pos !== undefined) { + const start = pos + 1; + const pmTr = view.state.tr; + if (change.text) { + pmTr.replaceWith( + start + change.from, + start + change.to, + view.state.schema.text(change.text) + ); + } else { + pmTr.deleteRange( + start + change.from, + start + change.to + ); + } + view.dispatch(pmTr); + } +}; diff --git a/packages/editor-monaco/src/styles.css b/packages/editor-monaco/src/styles.css new file mode 100644 index 000000000..62465c73a --- /dev/null +++ b/packages/editor-monaco/src/styles.css @@ -0,0 +1,63 @@ +/* + * styles.css + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +.pm-monaco-editor { + position: relative; +} + +.pm-monaco-editor .pm-monaco-editor-container { + width: 100%; +} + +/* per-editor background (monaco's theme background is global) */ +.pm-monaco-editor.pm-monaco-editor-background .monaco-editor, +.pm-monaco-editor.pm-monaco-editor-background .monaco-editor .monaco-editor-background, +.pm-monaco-editor.pm-monaco-editor-background .monaco-editor .margin { + background-color: var(--pm-code-background); +} + +/* the first line of a chunk is metadata (e.g. {r}) and is rendered lighter */ +.pm-monaco-editor .pm-monaco-first-line-meta { + opacity: 0.6; +} + +/* no extraneous monaco ui: the find widget is pulled in by the multicursor + feature (cmd+d) and would otherwise appear inside the cell */ +.pm-monaco-editor .monaco-editor .find-widget, +.pm-monaco-editor .monaco-editor .quick-input-widget { + display: none !important; +} + +/* the editor never scrolls itself; the outer document does */ +.pm-monaco-editor .monaco-editor, +.pm-monaco-editor .monaco-editor .overflow-guard { + overflow: visible; +} + +.pm-monaco-editor .pm-monaco-toolbar { + position: absolute; + top: 0; + right: 0; + z-index: 9; + margin-top: 3px; + margin-right: 3px; +} + +.pm-monaco-editor .pm-monaco-toolbar .codicon { + font-size: 14px; + cursor: pointer; + user-select: none; +} + +.pm-codeview-run-button { + margin-left: 9px; + font-weight: 500 !important; +} + +.pm-codeview-run-other-button { + margin-left: 6px; + opacity: 0.7; +} diff --git a/packages/editor-monaco/src/textmate.ts b/packages/editor-monaco/src/textmate.ts new file mode 100644 index 000000000..d58a7cf1c --- /dev/null +++ b/packages/editor-monaco/src/textmate.ts @@ -0,0 +1,369 @@ +/* + * textmate.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * TextMate tokenization and VS Code color themes for Monaco. The grammars, + * languages, and the active color theme all come from the host (the vscode + * extension), so code cells are highlighted exactly as the source editor is. + */ + +/// + +import * as vsctm from "vscode-textmate"; +import * as onig from "vscode-oniguruma"; +import onigWasmUrl from "vscode-oniguruma/release/onig.wasm?url"; + +import { + CodeViewColorTheme, + CodeViewLanguage, + CodeViewServer +} from "editor-types"; + +import { monaco } from "./monaco"; + +const kThemeName = "vscode-active"; + +let registry: vsctm.Registry | undefined; +let languages: CodeViewLanguage[] = []; +let initPromise: Promise | undefined; +let themeColors: Record = {}; + +/** + * One time initialization of the textmate registry and registration of the + * host's languages with monaco. Safe to call repeatedly (returns the same + * promise). Resolves (never rejects) once languages are available. + */ +export function initTextMate(codeview: CodeViewServer | undefined): Promise { + if (!initPromise) { + initPromise = codeview + ? doInit(codeview).catch(error => { + console.error("Error initializing textmate tokenization", error); + }) + : Promise.resolve(); + } + return initPromise; +} + +/** + * Languages contributed by the host (empty until initTextMate resolves) + */ +export function contributedLanguages(): CodeViewLanguage[] { + return languages; +} + +/** + * Workbench colors of the active color theme (empty if no theme applied) + */ +export function activeThemeColors(): Record { + return themeColors; +} + +/** + * Apply a VS Code color theme to monaco (all editors share the theme). + */ +export function applyColorTheme(theme: CodeViewColorTheme | undefined, darkMode: boolean) { + + if (!theme || !registry) { + themeColors = {}; + monaco.editor.setTheme(darkMode ? "vs-dark" : "vs"); + return; + } + + themeColors = theme.colors; + + const base = themeBase(theme.type); + const dark = base !== "vs" && base !== "hc-light"; + const foreground = theme.colors["editor.foreground"] || (dark ? "#D4D4D4" : "#000000"); + const background = theme.colors["editor.background"] || (dark ? "#1E1E1E" : "#FFFFFF"); + + // set the textmate theme (the color map it produces is what token colors + // are encoded against) + registry.setTheme({ + name: theme.name, + settings: [ + { settings: { foreground: normalizeHex(foreground), background: normalizeHex(background) } }, + ...theme.tokenColors.map(rule => ({ + ...rule, + settings: { + ...rule.settings, + foreground: normalizeHex(rule.settings.foreground), + background: normalizeHex(rule.settings.background) + } + })) + ] + }); + // monaco wants null (not undefined) for the unused color id 0 + const colorMap = registry.getColorMap() as Array; + colorMap[0] = null; + + monaco.editor.defineTheme(kThemeName, { + base, + inherit: false, + rules: [], + colors: validColors(theme.colors), + encodedTokensColors: colorMap as string[] + }); + monaco.editor.setTheme(kThemeName); + + // token color ids may have changed, so re-tokenize all models + for (const model of monaco.editor.getModels()) { + const languageId = model.getLanguageId(); + if (languageId !== "plaintext") { + monaco.editor.setModelLanguage(model, "plaintext"); + monaco.editor.setModelLanguage(model, languageId); + } + } +} + +async function doInit(codeview: CodeViewServer) { + + // load oniguruma + await onig.loadWASM(await loadOnigWasm()); + const onigLib = Promise.resolve({ + createOnigScanner: (patterns) => new onig.OnigScanner(patterns), + createOnigString: (str) => new onig.OnigString(str) + }); + + // create registry (grammars are provided by the host) + registry = new vsctm.Registry({ + onigLib, + loadGrammar: async (scopeName) => { + const grammar = await codeview.codeViewGrammar(scopeName); + return grammar ? vsctm.parseRawGrammar(grammar.content, grammar.path) : null; + } + }); + + // register languages + languages = await codeview.codeViewLanguages(); + const registered = new Set(monaco.languages.getLanguages().map(lang => lang.id)); + for (const language of languages) { + if (!language.scopeName || registered.has(language.id)) { + continue; + } + registered.add(language.id); + monaco.languages.register({ + id: language.id, + aliases: language.aliases, + extensions: language.extensions + }); + const scopeName = language.scopeName; + monaco.languages.registerTokensProviderFactory(language.id, { + create: async () => { + const grammar = await registry!.loadGrammar(scopeName); + return grammar ? tokensProvider(grammar) : null; + } + }); + if (language.configuration) { + monaco.languages.setLanguageConfiguration( + language.id, + languageConfiguration(language.configuration as LanguageConfigurationJson) + ); + } + } +} + +// in lib mode vite inlines the wasm as a data url, otherwise it's a real url +async function loadOnigWasm(): Promise { + const marker = ";base64,"; + const idx = onigWasmUrl.indexOf(marker); + if (onigWasmUrl.startsWith("data:") && idx !== -1) { + const binary = atob(onigWasmUrl.slice(idx + marker.length)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } else { + const response = await fetch(onigWasmUrl); + return response.arrayBuffer(); + } +} + +// monaco encoded tokens provider backed by a textmate grammar (the binary +// token format produced by tokenizeLine2 is the one monaco consumes) +class TokenizerState implements monaco.languages.IState { + constructor(public readonly ruleStack: vsctm.StateStack) { } + clone() { + return new TokenizerState(this.ruleStack); + } + equals(other: monaco.languages.IState) { + return other instanceof TokenizerState && other.ruleStack === this.ruleStack; + } +} + +function tokensProvider(grammar: vsctm.IGrammar): monaco.languages.EncodedTokensProvider { + return { + getInitialState: () => new TokenizerState(vsctm.INITIAL), + tokenizeEncoded: (line, state) => { + const result = grammar.tokenizeLine2(line, (state as TokenizerState).ruleStack); + return { + tokens: result.tokens, + endState: new TokenizerState(result.ruleStack) + }; + } + }; +} + +function themeBase(type: CodeViewColorTheme["type"]): monaco.editor.BuiltinTheme { + switch (type) { + case "light": + return "vs"; + case "hcDark": + return "hc-black"; + case "hcLight": + return "hc-light"; + case "dark": + default: + return "vs-dark"; + } +} + +// monaco's token color map only accepts #RRGGBB or #RRGGBBAA +function normalizeHex(color?: string): string | undefined { + if (!color) { + return undefined; + } + const match = color.trim().match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!match) { + return undefined; + } + const hex = match[1]; + if (hex.length === 3 || hex.length === 4) { + return "#" + hex.split("").map(c => c + c).join(""); + } + return "#" + hex; +} + +// monaco only accepts hex colors +function validColors(colors: Record) { + const valid: Record = {}; + for (const [id, color] of Object.entries(colors)) { + if (typeof color === "string" && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color)) { + valid[id] = color; + } + } + return valid; +} + +// convert a vscode language-configuration.json into a monaco LanguageConfiguration + +type RegExpJson = string | { pattern: string; flags?: string }; + +interface LanguageConfigurationJson { + comments?: { lineComment?: string; blockComment?: [string, string] }; + brackets?: Array<[string, string]>; + autoClosingPairs?: Array<[string, string] | { open: string; close: string; notIn?: string[] }>; + surroundingPairs?: Array<[string, string] | { open: string; close: string }>; + wordPattern?: RegExpJson; + indentationRules?: { + increaseIndentPattern?: RegExpJson; + decreaseIndentPattern?: RegExpJson; + indentNextLinePattern?: RegExpJson; + unIndentedLinePattern?: RegExpJson; + }; + folding?: { + offSide?: boolean; + markers?: { start?: RegExpJson; end?: RegExpJson }; + }; + onEnterRules?: Array<{ + beforeText?: RegExpJson; + afterText?: RegExpJson; + previousLineText?: RegExpJson; + action?: { indent?: string; appendText?: string; removeText?: number }; + }>; +} + +function languageConfiguration(json: LanguageConfigurationJson): monaco.languages.LanguageConfiguration { + const config: monaco.languages.LanguageConfiguration = {}; + + if (json.comments) { + config.comments = json.comments; + } + if (json.brackets) { + config.brackets = json.brackets; + } + if (json.autoClosingPairs) { + config.autoClosingPairs = json.autoClosingPairs.map(pair => + Array.isArray(pair) ? { open: pair[0], close: pair[1] } : pair + ); + } + if (json.surroundingPairs) { + config.surroundingPairs = json.surroundingPairs.map(pair => + Array.isArray(pair) ? { open: pair[0], close: pair[1] } : pair + ); + } + const wordPattern = toRegExp(json.wordPattern); + if (wordPattern) { + config.wordPattern = wordPattern; + } + if (json.indentationRules) { + const increase = toRegExp(json.indentationRules.increaseIndentPattern); + const decrease = toRegExp(json.indentationRules.decreaseIndentPattern); + if (increase && decrease) { + config.indentationRules = { + increaseIndentPattern: increase, + decreaseIndentPattern: decrease, + indentNextLinePattern: toRegExp(json.indentationRules.indentNextLinePattern), + unIndentedLinePattern: toRegExp(json.indentationRules.unIndentedLinePattern) + }; + } + } + if (json.folding) { + const start = toRegExp(json.folding.markers?.start); + const end = toRegExp(json.folding.markers?.end); + config.folding = { + offSide: json.folding.offSide, + markers: start && end ? { start, end } : undefined + }; + } + if (json.onEnterRules) { + const rules: monaco.languages.OnEnterRule[] = []; + for (const rule of json.onEnterRules) { + const beforeText = toRegExp(rule.beforeText); + if (!beforeText || !rule.action) { + continue; + } + rules.push({ + beforeText, + afterText: toRegExp(rule.afterText), + previousLineText: toRegExp(rule.previousLineText), + action: { + indentAction: indentAction(rule.action.indent), + appendText: rule.action.appendText, + removeText: rule.action.removeText + } + }); + } + config.onEnterRules = rules; + } + + return config; +} + +function toRegExp(value?: RegExpJson): RegExp | undefined { + if (!value) { + return undefined; + } + try { + return typeof value === "string" + ? new RegExp(value) + : new RegExp(value.pattern, value.flags); + } catch { + return undefined; + } +} + +function indentAction(indent?: string): monaco.languages.IndentAction { + switch (indent) { + case "indent": + return monaco.languages.IndentAction.Indent; + case "outdent": + return monaco.languages.IndentAction.Outdent; + case "indentOutdent": + return monaco.languages.IndentAction.IndentOutdent; + case "none": + default: + return monaco.languages.IndentAction.None; + } +} diff --git a/packages/editor-monaco/tsconfig.json b/packages/editor-monaco/tsconfig.json new file mode 100644 index 000000000..aad794c26 --- /dev/null +++ b/packages/editor-monaco/tsconfig.json @@ -0,0 +1,5 @@ +{ + "exclude": ["node_modules"], + "extends": "tsconfig/base.json", + "include": ["src"] +} diff --git a/packages/editor-server/src/services/codeview.ts b/packages/editor-server/src/services/codeview.ts index 4b69ad01d..091cf775f 100644 --- a/packages/editor-server/src/services/codeview.ts +++ b/packages/editor-server/src/services/codeview.ts @@ -12,7 +12,11 @@ import { kCodeViewExecute, kCodeViewPreviewDiagram, kCodeViewAssist, - kCodeViewGetDiagnostics + kCodeViewGetDiagnostics, + kCodeViewGetColorTheme, + kCodeViewGetLanguages, + kCodeViewGetGrammar, + kCodeViewGetEditorFont } from "editor-types"; @@ -23,7 +27,11 @@ export function codeViewServerMethods(server: CodeViewServer): Record server.codeViewExecute(args[0], args[1]), [kCodeViewGetCompletions]: args => server.codeViewCompletions(args[0]), [kCodeViewGetDiagnostics]: args => server.codeViewDiagnostics(args[0]), - [kCodeViewPreviewDiagram]: args => server.codeViewPreviewDiagram(args[0], args[1]) + [kCodeViewPreviewDiagram]: args => server.codeViewPreviewDiagram(args[0], args[1]), + [kCodeViewGetColorTheme]: args => server.codeViewColorTheme(args[0]), + [kCodeViewGetLanguages]: () => server.codeViewLanguages(), + [kCodeViewGetGrammar]: args => server.codeViewGrammar(args[0]), + [kCodeViewGetEditorFont]: () => server.codeViewEditorFont() }; return methods; } diff --git a/packages/editor-types/src/codeview.ts b/packages/editor-types/src/codeview.ts index 11c40032c..6c8070101 100644 --- a/packages/editor-types/src/codeview.ts +++ b/packages/editor-types/src/codeview.ts @@ -15,6 +15,67 @@ export const kCodeViewPreviewDiagram = 'code_view_preview_diagram'; * for calling [`codeViewDiagnostics` in custom.ts](../../../apps/lsp/src/custom.ts) */ export const kCodeViewGetDiagnostics = 'code_view_get_diagnostics'; +export const kCodeViewGetColorTheme = 'code_view_get_color_theme'; +export const kCodeViewGetLanguages = 'code_view_get_languages'; +export const kCodeViewGetGrammar = 'code_view_get_grammar'; +export const kCodeViewGetEditorFont = 'code_view_get_editor_font'; + +/** + * The font settings of the vscode source editor (editor.fontFamily etc.) + */ +export interface CodeViewEditorFont { + fontFamily: string; + fontSize: number; + fontWeight: string; + fontLigatures: boolean | string; + lineHeight: number; + letterSpacing: number; +} + +/** + * A TextMate token color rule from a VS Code color theme + */ +export interface CodeViewTokenColor { + name?: string; + scope?: string | string[]; + settings: { + foreground?: string; + background?: string; + fontStyle?: string; + }; +} + +/** + * The active VS Code color theme (resolved from the theme's json, including + * any themes it includes). `colors` are workbench color ids (e.g. "editor.background"). + */ +export interface CodeViewColorTheme { + name: string; + type: "light" | "dark" | "hcDark" | "hcLight"; + colors: Record; + tokenColors: CodeViewTokenColor[]; +} + +/** + * A language contributed by a VS Code extension, along with the TextMate + * scope of its grammar (if it has one) and its language configuration + */ +export interface CodeViewLanguage { + id: string; + aliases: string[]; + extensions: string[]; + scopeName?: string; + configuration?: unknown; +} + +/** + * The raw contents of a TextMate grammar (json or plist, see `path`) + */ +export interface CodeViewGrammar { + scopeName: string; + path: string; + content: string; +} export type CodeViewExecute = "selection" | "cell" | "cell+advance" | "above" | "below"; @@ -63,4 +124,8 @@ export interface CodeViewServer { codeViewDiagnostics: (context: CodeViewCellContext) => Promise; codeViewCompletions: (context: CodeViewCompletionContext) => Promise; codeViewPreviewDiagram: (state: DiagramState, activate: boolean) => Promise; + codeViewColorTheme: (themeId?: string) => Promise; + codeViewLanguages: () => Promise; + codeViewGrammar: (scopeName: string) => Promise; + codeViewEditorFont: () => Promise; } diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 270f7e09f..c4c8105aa 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -13,6 +13,7 @@ "core-browser": "*", "editor": "*", "editor-codemirror": "*", + "editor-monaco": "*", "editor-core": "*", "editor-types": "*", "i18next": "^26.5.4", diff --git a/packages/editor-ui/src/context/context.ts b/packages/editor-ui/src/context/context.ts index f8221a3ba..7204f9a6e 100644 --- a/packages/editor-ui/src/context/context.ts +++ b/packages/editor-ui/src/context/context.ts @@ -6,7 +6,7 @@ import { JsonRpcRequestTransport, PromiseQueue } from 'core'; -import { codeMirrorExtension } from "editor-codemirror"; +import { monacoExtension } from "editor-monaco"; import { EditorDialogs, @@ -70,7 +70,7 @@ export function editorContext(providers: EditorProviders) : EditorContext { server: providers.server, ui, extensions: providers.extensions, - codeViewExtension: codeMirrorExtension + codeViewExtension: monacoExtension }; return context; diff --git a/packages/editor/src/api/ui-types.ts b/packages/editor/src/api/ui-types.ts index 03ccb7e5c..3a4a8f478 100644 --- a/packages/editor/src/api/ui-types.ts +++ b/packages/editor/src/api/ui-types.ts @@ -18,8 +18,12 @@ import { CodeViewActiveBlockContext, DiagramState, CodeViewCellContext, - LintItem -} from 'editor-types'; + LintItem, + CodeViewColorTheme, + CodeViewLanguage, + CodeViewGrammar, + CodeViewEditorFont +} from "editor-types"; export * from './spelling'; export { SkinTone } from './emoji'; @@ -127,6 +131,10 @@ export interface EditorUICodeView { codeViewCompletions: (context: CodeViewCompletionContext) => Promise; codeViewDiagnostics: (context: CodeViewCellContext) => Promise; codeViewPreviewDiagram: (state: DiagramState, activate: boolean) => Promise; + codeViewColorTheme: (themeId?: string) => Promise; + codeViewLanguages: () => Promise; + codeViewGrammar: (scopeName: string) => Promise; + codeViewEditorFont: () => Promise; } export const kListSpacingTight = 'tight'; diff --git a/packages/editor/src/behaviors/select_all.ts b/packages/editor/src/behaviors/select_all.ts index d803020ff..41a35e142 100644 --- a/packages/editor/src/behaviors/select_all.ts +++ b/packages/editor/src/behaviors/select_all.ts @@ -34,7 +34,7 @@ export function selectAll(state: EditorState, dispatch?: (tr: Transaction) => vo } dispatch(tr); if (view) { - // we do this to escape from embedded editors e.g. codemirror + // we do this to escape from embedded editors e.g. monaco view.focus(); } } diff --git a/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts b/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts index 3daf8f692..3580d4099 100644 --- a/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts +++ b/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts @@ -84,7 +84,7 @@ function imagePreviewDecorations(state: EditorState, uiContext: EditorUIContext) rmdChunk.pos + rmdChunk.node.nodeSize, (view: EditorView, getPos: () => number | undefined) => { const container = window.document.createElement('div'); - container.style.marginTop = '-1.5em'; // to bridge back to the codemirror block + container.style.marginTop = '-1.5em'; // to bridge back to the code block // which has a margin-block-end of 1em container.classList.add('pm-image-preview'); container.classList.add('pm-block-border-color');