diff --git a/apps/lsp/src/config.ts b/apps/lsp/src/config.ts index cd8941fa2..4c870b81f 100644 --- a/apps/lsp/src/config.ts +++ b/apps/lsp/src/config.ts @@ -47,6 +47,7 @@ export interface Settings { }; readonly symbols: { readonly exportToWorkspace: 'default' | 'all' | 'none'; + readonly showCodeCellsInOutline: boolean; }; }; readonly markdown: { @@ -96,7 +97,8 @@ function defaultSettings(): Settings { extensions: [] }, symbols: { - exportToWorkspace: 'all' + exportToWorkspace: 'all', + showCodeCellsInOutline: true } }, markdown: { @@ -181,7 +183,8 @@ export class ConfigurationManager extends Disposable { extensions: quarto?.mathjax?.extensions ?? this._settings.quarto.mathjax.extensions }, symbols: { - exportToWorkspace: quarto?.symbols?.exportToWorkspace ?? this._settings.quarto.symbols.exportToWorkspace + exportToWorkspace: quarto?.symbols?.exportToWorkspace ?? this._settings.quarto.symbols.exportToWorkspace, + showCodeCellsInOutline: quarto?.symbols?.showCodeCellsInOutline ?? this._settings.quarto.symbols.showCodeCellsInOutline } } }; @@ -258,6 +261,9 @@ export function lsConfiguration(configManager: ConfigurationManager): LsConfigur }, get exportSymbolsToWorkspace(): 'default' | 'all' | 'none' { return configManager.getSettings().quarto.symbols.exportToWorkspace; + }, + get showCodeCellsInOutline(): boolean { + return configManager.getSettings().quarto.symbols.showCodeCellsInOutline; } }; } diff --git a/apps/lsp/src/index.ts b/apps/lsp/src/index.ts index 11a4b6a71..2095b63cd 100644 --- a/apps/lsp/src/index.ts +++ b/apps/lsp/src/index.ts @@ -18,8 +18,10 @@ import path from "path"; import { ClientCapabilities, Definition, + Disposable, DocumentLink, DocumentSymbol, + DocumentSymbolRequest, FoldingRange, InitializeParams, ProposedFeatures, @@ -74,6 +76,13 @@ let initializationOptions: LspInitializationOptions | undefined; // Markdown language service let mdLs: IMdLanguageService | undefined; +// Resolved once `mdLs` has been created in `onInitialized`. Request handlers +// that depend on `mdLs` should `await` this so that requests arriving during +// the async portion of startup do not race and return empty results that the +// client then caches (e.g. an empty document outline after a window reload). +let resolveMdLsReady!: () => void; +const mdLsReady = new Promise(resolve => { resolveMdLsReady = resolve; }); + connection.onInitialize((params: InitializeParams) => { // Set log level from initialization options if provided so that we use the // expected level as soon as possible @@ -131,6 +140,11 @@ connection.onInitialize((params: InitializeParams) => { connection.onDocumentSymbol(async (params, token): Promise => { logger.logRequest('documentSymbol'); + await mdLsReady; + if (token.isCancellationRequested) { + return []; + } + const document = documents.get(params.textDocument.uri); if (!document) { return []; @@ -141,6 +155,11 @@ connection.onInitialize((params: InitializeParams) => { connection.onFoldingRanges(async (params, token): Promise => { logger.logRequest('foldingRanges'); + await mdLsReady; + if (token.isCancellationRequested) { + return []; + } + const document = documents.get(params.textDocument.uri); if (!document) { return []; @@ -198,7 +217,6 @@ connection.onInitialize((params: InitializeParams) => { hoverProvider: true, definitionProvider: true, documentLinkProvider: { resolveProvider: true }, - documentSymbolProvider: true, foldingRangeProvider: true, referencesProvider: true, selectionRangeProvider: true, @@ -291,6 +309,29 @@ connection.onInitialized(async () => { // register custom methods registerCustomMethods(quarto, lspConnection, documents); + + // dynamically register the document symbol provider now that `mdLs` exists + // so the client only learns about the capability once we can actually serve + // it (avoids the cold-start race where the client requests symbols, caches + // an empty response, and never re-queries on its own). Re-register on every + // config change so the client re-queries symbols with the new shape; the + // VS Code extension restores outline expansion state after the re-query. + let documentSymbolRegistration: Disposable | undefined; + const registerDocumentSymbolProvider = async () => { + documentSymbolRegistration?.dispose(); + documentSymbolRegistration = await connection.client.register( + DocumentSymbolRequest.type, + { documentSelector: null } + ); + }; + await registerDocumentSymbolProvider(); + configManager.onDidChangeConfiguration(() => { + registerDocumentSymbolProvider(); + }); + + // signal that `mdLs` is now ready to serve requests: + // handlers like document symbols, folding ranges, etc will now proceed + resolveMdLsReady(); }); diff --git a/apps/lsp/src/service/config.ts b/apps/lsp/src/service/config.ts index 14404470a..0f2ddbcd9 100644 --- a/apps/lsp/src/service/config.ts +++ b/apps/lsp/src/service/config.ts @@ -80,6 +80,7 @@ export interface LsConfiguration { readonly mathjaxScale: number; readonly mathjaxExtensions: readonly MathjaxSupportedExtension[]; readonly exportSymbolsToWorkspace: 'default' | 'all' | 'none'; + readonly showCodeCellsInOutline: boolean; } export const defaultMarkdownFileExtension = 'qmd'; @@ -111,7 +112,8 @@ const defaultConfig: LsConfiguration = { colorTheme: 'light', mathjaxScale: 1, mathjaxExtensions: [], - exportSymbolsToWorkspace: 'all' + exportSymbolsToWorkspace: 'all', + showCodeCellsInOutline: true }; export function defaultLsConfiguration(): LsConfiguration { diff --git a/apps/lsp/src/service/index.ts b/apps/lsp/src/service/index.ts index 1c6b40033..6544891c9 100644 --- a/apps/lsp/src/service/index.ts +++ b/apps/lsp/src/service/index.ts @@ -208,7 +208,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL const definitionsProvider = new MdDefinitionProvider(config, init.workspace, tocProvider, linkCache); const diagnosticOnSaveComputer = new DiagnosticOnSaveComputer(init.quarto); const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger); - const docSymbolProvider = new MdDocumentSymbolProvider(tocProvider, linkProvider, logger); + const docSymbolProvider = new MdDocumentSymbolProvider(config, tocProvider, linkProvider, logger); const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, init.config, docSymbolProvider); const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider); diff --git a/apps/lsp/src/service/providers/document-symbols.ts b/apps/lsp/src/service/providers/document-symbols.ts index 678a8a1a5..91b120d49 100644 --- a/apps/lsp/src/service/providers/document-symbols.ts +++ b/apps/lsp/src/service/providers/document-symbols.ts @@ -16,9 +16,10 @@ import { CancellationToken } from 'vscode-languageserver'; import * as lsp from 'vscode-languageserver-types'; import { isBefore, makeRange, Document } from 'quarto-core'; -import { ILogger, LogLevel } from '../logging'; +import { ILogger } from '../logging'; import { MdTableOfContentsProvider, TableOfContents, TocEntry, TocEntryType } from '../toc'; import { MdLinkDefinition, MdLinkKind, MdLinkProvider } from './document-links'; +import { LsConfiguration } from '../config'; interface MarkdownSymbol { readonly level: number; @@ -36,12 +37,15 @@ export class MdDocumentSymbolProvider { readonly #tocProvider: MdTableOfContentsProvider; readonly #linkProvider: MdLinkProvider; readonly #logger: ILogger; + readonly #config: LsConfiguration; constructor( + config: LsConfiguration, tocProvider: MdTableOfContentsProvider, linkProvider: MdLinkProvider, logger: ILogger, ) { + this.#config = config; this.#tocProvider = tocProvider; this.#linkProvider = linkProvider; this.#logger = logger; @@ -75,7 +79,21 @@ export class MdDocumentSymbolProvider { range: makeRange(0, 0, document.lineCount + 1, 0), }; const additionalSymbols = [...linkSymbols]; - this.#buildTocSymbolTree(root, toc.entries.filter(entry => entry.type !== TocEntryType.Title), additionalSymbols); + + // Filter out TOC entries based on configuration + const filteredEntries = toc.entries.filter(entry => { + // Always exclude title entries + if (entry.type === TocEntryType.Title) { + return false; + } + // Exclude all code cells if the setting is disabled + if (entry.type === TocEntryType.CodeCell && !this.#config.showCodeCellsInOutline) { + return false; + } + return true; + }); + + this.#buildTocSymbolTree(root, filteredEntries, additionalSymbols); // Put remaining link definitions into top level document instead of last header root.children.push(...additionalSymbols); return root.children; diff --git a/apps/overview.md b/apps/overview.md index c7490d1d3..b4f243e0b 100644 --- a/apps/overview.md +++ b/apps/overview.md @@ -89,6 +89,27 @@ out the extension there are a couple of places where your logs can end up: that says EXTENSION HOST. - `Quarto` output console for [[LSP]] code +### LSP Log Levels + +The `quarto.server.logLevel` setting controls **LSP server** logs (from `apps/lsp/`): +- `"trace"` - Most verbose, includes all requests/notifications +- `"debug"` - Debug information +- `"info"` - Informational messages +- `"warn"` - Warnings only (default) +- `"error"` - Errors only +- `"off"` - No logging + +When debugging the LSP, you may need to set `"quarto.server.logLevel": "info"` or `"trace"` in your user settings to see detailed LSP logs in the Quarto output channel. + +Available logging methods in the LSP (in `apps/lsp/`): +- `logger.logTrace()` - Only appears at trace level +- `logger.logDebug()` - Appears at debug level and below +- `logger.logInfo()` - Appears at info level and below +- `logger.logWarn()` - Appears at warn level and below (use for important debug messages during development) +- `logger.logError()` - Always appears unless logging is off + +Note: Extension host code (in `apps/vscode/src/`) uses `outputChannel.info()`, `outputChannel.warn()`, etc. for logging (e.g., "Activated Quarto extension."). These logs appear in the same Quarto output channel but are not controlled by the `quarto.server.logLevel` setting. + ## Examples of Controlling the Visual Editor from the server-side of the extension ### Example: Setting cursor position diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 689142f3b..acb558a09 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## 1.133.0 +## 1.133.0 (Unreleased) +- Added setting and command to show/hide cells in outline (). - Added 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 (). diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 596dbe845..c5606fd35 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -292,6 +292,11 @@ "title": "Clear Cache...", "category": "Quarto" }, + { + "command": "quarto.toggleCodeCellsInOutline", + "title": "Toggle Code Cells in Outline", + "category": "Quarto" + }, { "command": "quarto.convertToIpynb", "title": "Convert to .ipynb", @@ -1364,6 +1369,11 @@ ], "default": "default", "description": "Whether Markdown elements like section headers are included in workspace symbol search." + }, + "quarto.symbols.showCodeCellsInOutline": { + "type": "boolean", + "default": true, + "description": "Show code cells in the document outline." } } }, diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index c333c3d4f..00acd4d9e 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -40,6 +40,7 @@ import { activateDenoConfig } from "./providers/deno-config"; import { textFormattingCommands } from "./providers/text-format"; import { newDocumentCommands } from "./providers/newdoc"; import { insertCommands } from "./providers/insert"; +import { registerOutlineConfigListener, symbolsCommands } from "./providers/symbols"; import { activateDiagram } from "./providers/diagram/diagram"; import { activateCodeFormatting } from "./providers/format"; import { activateOptionEnterProvider } from "./providers/option"; @@ -121,6 +122,9 @@ export async function activate(context: vscode.ExtensionContext): Promise("symbols.showCodeCellsInOutline", true); + const newValue = !currentValue; + + // The LSP re-registers its document symbol provider on config change, which triggers VS Code to re-query the outline. + // The VS Code extension restores outline expansion state after the re-query (see `registerOutlineConfigListener`). + await config.update("symbols.showCodeCellsInOutline", newValue, vscode.ConfigurationTarget.Global); + + vscode.window.showInformationMessage( + `Code cells in outline will now be ${newValue ? "shown" : "hidden"}.` + ); + } +} + +export function symbolsCommands(): Command[] { + return [new ToggleCodeCellsInOutlineCommand()]; +} + + + +const expandOutline = async (uri: vscode.Uri) => { + // make sure document can provide symbols (for the outline) before expanding the outline + await vscode.commands.executeCommand("vscode.executeDocumentSymbolProvider", uri); + await vscode.commands.executeCommand("outline.expand"); +}; +/** + * Executes `listener(editor)` ONCE, the next time the user switches their active text editor to a qmd. + */ +const onNextChangeActiveTextEditorToQmd = (listener: (editor: vscode.TextEditor) => any) => { + const listenForNextChangeToQmdDisposable = + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor?.document.languageId === "quarto") { + // once we switch to a quarto file once, stop listening + listenForNextChangeToQmdDisposable.dispose(); + listener(editor); + } + }); +}; + +/** + * Restore outline expansion state after settings that affect symbol output change. + * + * The LSP re-registers its document symbol provider whenever the relevant + * settings change, which forces VS Code to re-query and refresh the outline. + * That re-query rebuilds the tree from scratch, so VS Code's heuristic for + * symbols with newly-appearing children defaults them to collapsed (e.g. + * toggling on code cells leaves their parent headers collapsed). + * + * We expand the outline once a Quarto editor is active: immediately if the + * user already has one focused (e.g. they ran the toggle command), or on the + * next switch back if the setting was changed from the Settings UI. + */ +export function registerOutlineConfigListener(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("quarto.symbols.showCodeCellsInOutline")) { + if (vscode.window.activeTextEditor?.document.languageId === "quarto") { + expandOutline(vscode.window.activeTextEditor.document.uri); + } else { + onNextChangeActiveTextEditorToQmd((editor) => { + expandOutline(editor.document.uri); + }); + } + } + }) + ); +} diff --git a/apps/vscode/src/test/symbols.test.ts b/apps/vscode/src/test/symbols.test.ts index cc079b049..f5ee1834b 100644 --- a/apps/vscode/src/test/symbols.test.ts +++ b/apps/vscode/src/test/symbols.test.ts @@ -1,5 +1,7 @@ +import * as path from "path"; import * as vscode from "vscode"; import * as assert from "assert"; +import { WORKSPACE_PATH } from "./test-utils"; suite("Workspace Symbols", function () { teardown(async function () { @@ -53,3 +55,98 @@ suite("Workspace Symbols", function () { assert.ok(!symbols.find((s) => s.name === "Regular-Project Header 2")); }); }); + +suite("Document Symbols", function () { + const basicsUri = vscode.Uri.file(path.join(WORKSPACE_PATH, "format", "basics.qmd")); + + teardown(async function () { + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", undefined); + }); + + test("includes code cells when showCodeCellsInOutline is true", async function () { + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", true); + + // give the LSP time to re-register its symbol provider after the config change + await new Promise(r => setTimeout(r, 500)); + + await vscode.workspace.openTextDocument(basicsUri); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + basicsUri + ); + + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("(code cell)"), + `expected a code cell in symbols, got: ${names.join(", ")}` + ); + }); + + test("excludes code cells when showCodeCellsInOutline is false", async function () { + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", false); + + await new Promise(r => setTimeout(r, 500)); + + await vscode.workspace.openTextDocument(basicsUri); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + basicsUri + ); + + const names = flattenSymbolNames(symbols); + assert.ok( + !names.includes("(code cell)"), + `expected no code cells in symbols, got: ${names.join(", ")}` + ); + }); + + test("toggling showCodeCellsInOutline live updates symbols", async function () { + await vscode.workspace.openTextDocument(basicsUri); + + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", true); + await new Promise(r => setTimeout(r, 500)); + + let symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + basicsUri + ); + assert.ok( + flattenSymbolNames(symbols).includes("(code cell)"), + "expected code cells to appear after setting true" + ); + + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", false); + await new Promise(r => setTimeout(r, 500)); + + symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + basicsUri + ); + assert.ok( + !flattenSymbolNames(symbols).includes("(code cell)"), + "expected code cells to disappear after setting false" + ); + }); +}); + +function flattenSymbolNames(symbols: vscode.DocumentSymbol[]): string[] { + const result: string[] = []; + const walk = (syms: vscode.DocumentSymbol[]) => { + for (const sym of syms) { + result.push(sym.name); + if (sym.children?.length) walk(sym.children); + } + }; + walk(symbols); + return result; +}