Skip to content
Merged
10 changes: 8 additions & 2 deletions apps/lsp/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface Settings {
};
readonly symbols: {
readonly exportToWorkspace: 'default' | 'all' | 'none';
readonly showCodeCellsInOutline: boolean;
};
};
readonly markdown: {
Expand Down Expand Up @@ -96,7 +97,8 @@ function defaultSettings(): Settings {
extensions: []
},
symbols: {
exportToWorkspace: 'all'
exportToWorkspace: 'all',
showCodeCellsInOutline: true
}
},
markdown: {
Expand Down Expand Up @@ -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
}
}
};
Expand Down Expand Up @@ -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;
}
};
}
Expand Down
43 changes: 42 additions & 1 deletion apps/lsp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import path from "path";
import {
ClientCapabilities,
Definition,
Disposable,
DocumentLink,
DocumentSymbol,
DocumentSymbolRequest,
FoldingRange,
InitializeParams,
ProposedFeatures,
Expand Down Expand Up @@ -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<void>(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
Expand Down Expand Up @@ -131,6 +140,11 @@ connection.onInitialize((params: InitializeParams) => {
connection.onDocumentSymbol(async (params, token): Promise<DocumentSymbol[]> => {
logger.logRequest('documentSymbol');

await mdLsReady;
if (token.isCancellationRequested) {
return [];
}

const document = documents.get(params.textDocument.uri);
if (!document) {
return [];
Expand All @@ -141,6 +155,11 @@ connection.onInitialize((params: InitializeParams) => {
connection.onFoldingRanges(async (params, token): Promise<FoldingRange[]> => {
logger.logRequest('foldingRanges');

await mdLsReady;
if (token.isCancellationRequested) {
return [];
}

const document = documents.get(params.textDocument.uri);
if (!document) {
return [];
Expand Down Expand Up @@ -198,7 +217,6 @@ connection.onInitialize((params: InitializeParams) => {
hoverProvider: true,
definitionProvider: true,
documentLinkProvider: { resolveProvider: true },
documentSymbolProvider: true,
foldingRangeProvider: true,
referencesProvider: true,
selectionRangeProvider: true,
Expand Down Expand Up @@ -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();
});


Expand Down
4 changes: 3 additions & 1 deletion apps/lsp/src/service/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -111,7 +112,8 @@ const defaultConfig: LsConfiguration = {
colorTheme: 'light',
mathjaxScale: 1,
mathjaxExtensions: [],
exportSymbolsToWorkspace: 'all'
exportSymbolsToWorkspace: 'all',
showCodeCellsInOutline: true
};

export function defaultLsConfiguration(): LsConfiguration {
Expand Down
2 changes: 1 addition & 1 deletion apps/lsp/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
22 changes: 20 additions & 2 deletions apps/lsp/src/service/providers/document-symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions apps/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Changelog

## 1.133.0
## 1.133.0 (Unreleased)

- Added setting and command to show/hide cells in outline (<https://github.com/quarto-dev/quarto/pull/974>).
- Added custom pair colorization and highlighting for divs in qmds (<https://github.com/quarto-dev/quarto/pull/973>).


## 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 (<https://github.com/quarto-dev/quarto/pull/906>).
Expand Down
10 changes: 10 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,6 +122,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// lsp
const lspClient = await activateLsp(context, quartoContext, engine, outputChannel);

// restore outline expansion after the LSP re-registers symbols on config change
registerOutlineConfigListener(context);

// provide visual editor
const editorCommands = activateEditor(context, host, quartoContext, lspClient, engine);
commands.push(...editorCommands);
Expand Down Expand Up @@ -158,6 +162,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto

commands.push(...insertCommands(engine));

commands.push(...symbolsCommands());

commands.push(...activateDiagram(context, host, engine));

commands.push(...activateCodeFormatting(engine));
Expand Down
89 changes: 89 additions & 0 deletions apps/vscode/src/providers/symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* symbols.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.
*
*/

import * as vscode from "vscode";
import { Command } from "../core/command";

class ToggleCodeCellsInOutlineCommand implements Command {
public readonly id = "quarto.toggleCodeCellsInOutline";

public async execute() {
const config = vscode.workspace.getConfiguration("quarto");
const currentValue = config.get<boolean>("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);
});
}
}
})
);
}
Loading
Loading