Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -221,6 +222,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// context setter
activateContextKeySetter(context, engine);

// div bracket decorations
activateDivBracketDecorations(context);

// commands
const commandManager = new CommandManager();
for (const cmd of commands) {
Expand Down
173 changes: 173 additions & 0 deletions apps/vscode/src/providers/div-brackets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* div-brackets.ts
*
* Copyright (C) 2025 by Posit Software, PBC
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* 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
* 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.
*
*/

import * as vscode from 'vscode';
import { markdownitParser, Token } from 'quarto-core';

/**
* Provides colored decorations for div bracket pairs (:::)
*
* This gives visual feedback similar to bracket pair colorization,
* but works for Quarto's context-sensitive div syntax.
*/
export function activateDivBracketDecorations(context: vscode.ExtensionContext) {
const parser = markdownitParser();

// Cache for parsed tokens
const parseCache = new Map<string, {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add another listener in here so that we delete this cache at onDidCloseTextDocument?

version: number;
divTokens: Token[];
}>();

// 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<Token, number>();
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 => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we debounce this the same way we do the highlighting decorations in background.ts?

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());
}
});
}
2 changes: 1 addition & 1 deletion packages/core/src/markdownit/divs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+?))?$/;
Expand Down
Loading