From 87fb2f818584a9701e07272a2d72f911149852b4 Mon Sep 17 00:00:00 2001 From: elliot Date: Thu, 7 May 2026 15:45:55 -0400 Subject: [PATCH 1/4] Add custom pair colorization and highlighting for divs --- apps/vscode/src/main.ts | 4 + apps/vscode/src/providers/div-brackets.ts | 173 ++++++++++++++++++++++ packages/core/src/markdownit/divs.ts | 2 +- 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 apps/vscode/src/providers/div-brackets.ts diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index e4eca1d7..c333c3d4 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -47,6 +47,7 @@ import { activateBackgroundHighlighter } from "./providers/background"; import { activateYamlLinks } from "./providers/yaml-links"; import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions"; import { activateContextKeySetter } from "./providers/context-keys"; +import { activateDivBracketDecorations } from "./providers/div-brackets"; import { CommandManager } from "./core/command"; import { createQuartoExtensionApi, QuartoExtensionApi } from "./api"; @@ -221,6 +222,9 @@ export async function activate(context: vscode.ExtensionContext): Promise(); + + // Define decoration types for different nesting levels (rotating colors) + const decorationTypes = [ + vscode.window.createTextEditorDecorationType({ + color: new vscode.ThemeColor('editorBracketHighlight.foreground1'), + }), + vscode.window.createTextEditorDecorationType({ + color: new vscode.ThemeColor('editorBracketHighlight.foreground2'), + }), + vscode.window.createTextEditorDecorationType({ + color: new vscode.ThemeColor('editorBracketHighlight.foreground3'), + }), + ]; + + // Decoration type for matching pairs when cursor is on a bracket + const matchHighlightDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor('editor.wordHighlightBackground'), + border: '1px solid', + borderRadius: '6px', + borderColor: new vscode.ThemeColor('editor.wordHighlightBorder'), + }); + + // Helper to extract ::: range from a line + const getDivMarkerRange = (editor: vscode.TextEditor, line: number): vscode.Range | null => { + const lineText = editor.document.lineAt(line).text; + const match = lineText.match(/^(:::+)/); + return match ? new vscode.Range(line, 0, line, match[1].length) : null; + }; + + function updateDecorations(editor: vscode.TextEditor) { + if (editor.document.languageId !== 'quarto') return; + + const docUri = editor.document.uri.toString(); + const docVersion = editor.document.version; + + // Check cache + let divTokens: Token[]; + const cached = parseCache.get(docUri); + if (cached && cached.version === docVersion) { + divTokens = cached.divTokens; + } else { + // Parse the document + const doc = { + getText: () => editor.document.getText(), + uri: docUri, + version: docVersion, + lineCount: editor.document.lineCount, + }; + + divTokens = parser(doc as any).filter(t => t.type === 'Div'); + parseCache.set(docUri, { version: docVersion, divTokens }); + } + + // Group decorations by nesting level + const decorationsByLevel = decorationTypes.map(() => [] as vscode.Range[]); + const matchHighlights: vscode.Range[] = []; + + // Calculate nesting depth for all divs in a single pass using a stack + const divDepth = new Map(); + const stack: Token[] = []; + for (const divToken of divTokens) { + // Pop divs from stack that have ended before this div starts + while (stack.length > 0 && stack.at(-1)!.range.end.line < divToken.range.start.line) { + stack.pop(); + } + divDepth.set(divToken, stack.length); + stack.push(divToken); + } + + // Apply decorations + for (const divToken of divTokens) { + const openLine = divToken.range.start.line; + const closeLine = divToken.range.end.line; + const depth = divDepth.get(divToken)!; + const colorIndex = depth % decorationTypes.length; + const cursorLine = editor.selection.active.line; + const isCursorOver = cursorLine === openLine || cursorLine === closeLine; + + const openRange = getDivMarkerRange(editor, openLine); + const closeRange = getDivMarkerRange(editor, closeLine); + + const targetList = isCursorOver ? + matchHighlights : + decorationsByLevel[colorIndex]; + if (openRange) targetList.push(openRange); + if (closeRange) targetList.push(closeRange); + } + + decorationTypes.forEach((decorationType, i) => + editor.setDecorations(decorationType, decorationsByLevel[i]) + ); + editor.setDecorations(matchHighlightDecorationType, matchHighlights); + } + + function triggerUpdateDecorations(editor: vscode.TextEditor | undefined) { + + if (editor) { + updateDecorations(editor); + } + } + + // Update decorations when active editor changes + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor) { + triggerUpdateDecorations(editor); + } + }) + ); + + // Update decorations when document changes + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(event => { + const editor = vscode.window.activeTextEditor; + if (editor && event.document === editor.document) { + triggerUpdateDecorations(editor); + } + }) + ); + + // Update decorations when cursor moves + context.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection(event => { + if (event.textEditor === vscode.window.activeTextEditor) { + triggerUpdateDecorations(event.textEditor); + } + }) + ); + + // Update decorations for the active editor now + if (vscode.window.activeTextEditor) { + triggerUpdateDecorations(vscode.window.activeTextEditor); + } + + // Clean up decoration types on deactivation + context.subscriptions.push({ + dispose: () => { + decorationTypes.forEach(type => type.dispose()); + } + }); +} diff --git a/packages/core/src/markdownit/divs.ts b/packages/core/src/markdownit/divs.ts index 3eab79c8..7a3947f5 100644 --- a/packages/core/src/markdownit/divs.ts +++ b/packages/core/src/markdownit/divs.ts @@ -69,7 +69,7 @@ export const divPlugin = (md: MarkdownIt) => { } // Three or more colons followed by a an optional brace with attributes - const divBraceRegex = /^(:::+)\s*(?:(\{[\s\S]+?\}))?$/; + const divBraceRegex = /^(:::+)\s*(?:(\{[\s\S]*?\}))?$/; // Three or more colons followed by a string with no braces const divNoBraceRegex = /^(:::+)\s*(?:([^{}\s]+?))?$/; From 0f925ad03483f9b47b3200ff1b0518025f841f14 Mon Sep 17 00:00:00 2001 From: elliot Date: Thu, 14 May 2026 14:52:20 -0400 Subject: [PATCH 2/4] Add changelog entry --- apps/vscode/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 984feb9b..af73b17e 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,6 +2,9 @@ ## 1.133.0 +- Add custom pair colorization and highlighting for divs in qmds (). + + ## 1.132.0 (Release on 2026-05-05) - Added clickable document links for file paths in `_quarto.yml` files. File paths are now clickable and navigate directly to the referenced file (). From f4b7ac71d23b570b1407e7863726e2be2b036073 Mon Sep 17 00:00:00 2001 From: elliot Date: Thu, 14 May 2026 16:31:11 -0400 Subject: [PATCH 3/4] Change debounce to throttle, now correct --- apps/vscode/src/core/throttle.ts | 41 +++++++++++++++ apps/vscode/src/providers/background.ts | 62 +++++++++++------------ apps/vscode/src/providers/div-brackets.ts | 46 +++++++++++++---- 3 files changed, 108 insertions(+), 41 deletions(-) create mode 100644 apps/vscode/src/core/throttle.ts diff --git a/apps/vscode/src/core/throttle.ts b/apps/vscode/src/core/throttle.ts new file mode 100644 index 00000000..effc84f2 --- /dev/null +++ b/apps/vscode/src/core/throttle.ts @@ -0,0 +1,41 @@ +/* + * throttle.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +/** + * Creates a throttled version of a function. + * First call executes immediately, subsequent calls within the delay are coalesced. + */ +export function createThrottle( + fn: () => any, + getDelay: () => number +): () => any { + let timer: NodeJS.Timeout | undefined; + let pending = false; + + return () => { + if (timer === undefined) { + fn(); + timer = setTimeout(() => { + if (pending) { + fn(); + pending = false; + } + timer = undefined; + }, getDelay()); + } else { + pending = true; + } + }; +} diff --git a/apps/vscode/src/providers/background.ts b/apps/vscode/src/providers/background.ts index 4f2a0358..7b25a45b 100644 --- a/apps/vscode/src/providers/background.ts +++ b/apps/vscode/src/providers/background.ts @@ -1,7 +1,7 @@ /* * background.ts * - * Copyright (C) 2022 by Posit Software, PBC + * Copyright (C) 2026 by Posit Software, PBC * Copyright (c) [2021] [Chris Bain] (https://github.com/baincd/vscode-markdown-color-plus/) * * Unless you have received this program directly from Posit Software pursuant @@ -16,12 +16,12 @@ import * as vscode from "vscode"; -import debounce from "lodash.debounce"; import { isQuartoDoc, kQuartoDocSelector } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; import { isExecutableLanguageBlock } from "quarto-core"; import { vscRange } from "../core/range"; +import { createThrottle } from "../core/throttle"; export function activateBackgroundHighlighter( context: vscode.ExtensionContext, @@ -32,7 +32,7 @@ export function activateBackgroundHighlighter( vscode.workspace.onDidChangeConfiguration( () => { highlightingConfig.sync(); - triggerUpdateAllEditorsDecorations(engine); + updateAllEditorsDecorationsThrottled(engine); }, null, context.subscriptions @@ -45,10 +45,9 @@ export function activateBackgroundHighlighter( if (!isQuartoDoc(doc)) { clearEditorHighlightDecorations(vscode.window.activeTextEditor); } else { - triggerUpdateActiveEditorDecorations( + updateActiveEditorDecorationsThrottled( vscode.window.activeTextEditor, - engine, - highlightingConfig.delayMs() + engine ); } } @@ -59,8 +58,13 @@ export function activateBackgroundHighlighter( // update highlighting when visible text editors change vscode.window.onDidChangeVisibleTextEditors( - (_editors) => { - triggerUpdateAllEditorsDecorations(engine); + (visibleEditors) => { + for (const editor of editorThrottledFunctions.keys()) { + if (!visibleEditors.includes(editor)) { + editorThrottledFunctions.delete(editor); + } + } + updateAllEditorsDecorationsThrottled(engine); }, null, context.subscriptions @@ -73,11 +77,9 @@ export function activateBackgroundHighlighter( return editor.document.uri.toString() === event.document.uri.toString(); }); if (visibleEditor) { - triggerUpdateActiveEditorDecorations( + updateActiveEditorDecorationsThrottled( visibleEditor, engine, - highlightingConfig.delayMs(), - true, event.contentChanges.length === 1 ? event.contentChanges[0].range.start : undefined @@ -97,11 +99,9 @@ export function activateBackgroundHighlighter( token: vscode.CancellationToken ) { if (document === vscode.window.activeTextEditor?.document) { - triggerUpdateActiveEditorDecorations( + updateActiveEditorDecorationsThrottled( vscode.window.activeTextEditor, engine, - highlightingConfig.delayMs(), - true, position, token ); @@ -112,32 +112,32 @@ export function activateBackgroundHighlighter( ); // highlight all editors at activation time - triggerUpdateAllEditorsDecorations(engine); + updateAllEditorsDecorationsThrottled(engine); } -function triggerUpdateActiveEditorDecorations( +// Map of editors to their throttled update functions +const editorThrottledFunctions = new Map void>(); +function updateActiveEditorDecorationsThrottled( editor: vscode.TextEditor, engine: MarkdownEngine, - delay: number, - immediate?: boolean, pos?: vscode.Position, token?: vscode.CancellationToken ) { - debounce( - () => setEditorHighlightDecorations(editor, engine, pos, token), - delay, - { - leading: !!immediate, - } - )(); + let throttled = editorThrottledFunctions.get(editor); + if (!throttled) { + throttled = createThrottle( + () => setEditorHighlightDecorations(editor, engine, pos, token), + () => highlightingConfig.delayMs() + ); + editorThrottledFunctions.set(editor, throttled); + } + throttled(); } -function triggerUpdateAllEditorsDecorations(engine: MarkdownEngine) { - debounce(async () => { - for (const editor of vscode.window.visibleTextEditors) { - await setEditorHighlightDecorations(editor, engine); - } - }, highlightingConfig.delayMs())(); +function updateAllEditorsDecorationsThrottled(engine: MarkdownEngine) { + for (const editor of vscode.window.visibleTextEditors) { + updateActiveEditorDecorationsThrottled(editor, engine); + } } async function setEditorHighlightDecorations( diff --git a/apps/vscode/src/providers/div-brackets.ts b/apps/vscode/src/providers/div-brackets.ts index ac1799cf..3c9a4ebf 100644 --- a/apps/vscode/src/providers/div-brackets.ts +++ b/apps/vscode/src/providers/div-brackets.ts @@ -1,7 +1,7 @@ /* * div-brackets.ts * - * Copyright (C) 2025 by Posit Software, PBC + * Copyright (C) 2026 by Posit Software, PBC * * Unless you have received this program directly from Posit Software pursuant * to the terms of a commercial license agreement with Posit Software, then @@ -15,6 +15,7 @@ import * as vscode from 'vscode'; import { markdownitParser, Token } from 'quarto-core'; +import { createThrottle } from '../core/throttle'; /** * Provides colored decorations for div bracket pairs (:::) @@ -25,12 +26,19 @@ import { markdownitParser, Token } from 'quarto-core'; export function activateDivBracketDecorations(context: vscode.ExtensionContext) { const parser = markdownitParser(); + // Read debounce delay from config + const getDelayMs = () => + vscode.workspace.getConfiguration('quarto').get('cells.background.delay', 250); + // Cache for parsed tokens const parseCache = new Map(); + // Map of editors to their throttled update functions + const editorThrottledFunctions = new Map void>(); + // Define decoration types for different nesting levels (rotating colors) const decorationTypes = [ vscode.window.createTextEditorDecorationType({ @@ -124,18 +132,21 @@ export function activateDivBracketDecorations(context: vscode.ExtensionContext) editor.setDecorations(matchHighlightDecorationType, matchHighlights); } - function triggerUpdateDecorations(editor: vscode.TextEditor | undefined) { - if (editor) { - updateDecorations(editor); + function updateDecorationsThrottled(editor: vscode.TextEditor) { + let throttled = editorThrottledFunctions.get(editor); + if (!throttled) { + throttled = createThrottle(() => updateDecorations(editor), getDelayMs); + editorThrottledFunctions.set(editor, throttled); } + throttled(); } // Update decorations when active editor changes context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor(editor => { if (editor) { - triggerUpdateDecorations(editor); + updateDecorationsThrottled(editor); } }) ); @@ -145,7 +156,7 @@ export function activateDivBracketDecorations(context: vscode.ExtensionContext) vscode.workspace.onDidChangeTextDocument(event => { const editor = vscode.window.activeTextEditor; if (editor && event.document === editor.document) { - triggerUpdateDecorations(editor); + updateDecorationsThrottled(editor); } }) ); @@ -153,15 +164,30 @@ export function activateDivBracketDecorations(context: vscode.ExtensionContext) // Update decorations when cursor moves context.subscriptions.push( vscode.window.onDidChangeTextEditorSelection(event => { - if (event.textEditor === vscode.window.activeTextEditor) { - triggerUpdateDecorations(event.textEditor); + updateDecorationsThrottled(event.textEditor); + }) + ); + + // Clean up cache and throttle state when document is closed + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument(document => { + parseCache.delete(document.uri.toString()); + }) + ); + + context.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors(visibleEditors => { + for (const editor of editorThrottledFunctions.keys()) { + if (!visibleEditors.includes(editor)) { + editorThrottledFunctions.delete(editor); + } } }) ); // Update decorations for the active editor now - if (vscode.window.activeTextEditor) { - triggerUpdateDecorations(vscode.window.activeTextEditor); + for (const editor of vscode.window.visibleTextEditors) { + updateDecorationsThrottled(editor); } // Clean up decoration types on deactivation From 2a8f2136baaa92b747803c99b5762729c7448f27 Mon Sep 17 00:00:00 2001 From: elliot Date: Thu, 14 May 2026 16:50:06 -0400 Subject: [PATCH 4/4] Add todo about another incorrect debounce --- apps/vscode/src/providers/context-keys.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/src/providers/context-keys.ts b/apps/vscode/src/providers/context-keys.ts index f7d588f4..665c5544 100644 --- a/apps/vscode/src/providers/context-keys.ts +++ b/apps/vscode/src/providers/context-keys.ts @@ -65,6 +65,7 @@ export function activateContextKeySetter( vscode.workspace.onDidChangeTextDocument(event => { const activeEditor = vscode.window.activeTextEditor; if (activeEditor) { + // TODO: this debounce is being created and called immediately, which is not correct. debounce( () => { setEditorContextKeys(activeEditor, engine);