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: 2 additions & 2 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@
},
{
"command": "quarto.convertToQmd",
"when": "resourceExtname == .ipynb",
"when": "resourceExtname == .ipynb && !quarto.hasNotebookExporter",
"group": "q_zzConvert"
},
{
Expand Down Expand Up @@ -750,7 +750,7 @@
},
{
"command": "quarto.convertToQmd",
"when": "resourceExtname == .ipynb",
"when": "resourceExtname == .ipynb && !quarto.hasNotebookExporter",
"group": "q_zzConvert"
}
],
Expand Down
67 changes: 67 additions & 0 deletions apps/vscode/src/@types/positron-notebook-export.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/
// DO NOT MANULLY EDIT THIS FILE!
// Copy the relevant version from posit-dev/positron/extensions/positron-notebook-export

import * as vscode from 'vscode';

/**
* A notebook exporter, which can export {@link vscode.NotebookDocument}s to a specific file format.
*/
export interface NotebookExporter {
/**
* A human-readable label for the exporter, shown in the export picker.
*/
readonly label: string;

/**
* The language ID that this exporter supports, e.g. `python`. If not provided,
* the exporter will be available for all languages.
*/
readonly supportedLanguageId?: string;

/**
* The file extension that this exporter exports to, including the prefix `.`, e.g. `.py`.
* Also used to determine the icon in the export picker.
*/
readonly fileExtension: string;

/**
* Export a notebook.
*
* The exporter is responsible for saving the notebook if needed, and showing the
* exported result in the UI.
*
* The recommended pattern is not to save the notebook unless it's necessary to perform
* the export (e.g. if using a CLI that requires a file path), and to show the exported
* result in a new unsaved editor tab if possible.
*
* @param notebook The notebook to export.
* @returns A promise that resolves when the export is complete and the result is visible
* to the user.
*/
export(notebook: vscode.NotebookDocument): Promise<unknown>;
}

/**
* The public API for the Positron Notebook Export extension.
*/
export interface NotebookExportExtension {
/**
* All notebook exporters registered with the extension.
*/
readonly exporters: readonly NotebookExporter[];

/**
* Register a {@link NotebookExporter} with the extension.
*
* Registered exporters are included in the export picker if they support
* the active notebook's language.
*
* @param exporter The notebook exporter to register.
* @returns A disposable which unregisters the notebook exporter.
*/
registerNotebookExporter(exporter: NotebookExporter): vscode.Disposable;
}
43 changes: 43 additions & 0 deletions apps/vscode/src/core/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* context.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 { Uri } from "vscode";
import { commands } from "vscode";

type ContextKeyScalar = null | undefined | boolean | number | string | Uri;

type ContextKeyValue =
| ContextKeyScalar
| Array<ContextKeyScalar>
| Record<string, ContextKeyScalar>;

export class ContextKey<T extends ContextKeyValue = boolean> {
private _value?: T;

constructor(public readonly name: string) { }

public get(): T | undefined {
return this._value;
}

public async set(value: T): Promise<void> {
this._value = value;
await commands.executeCommand('setContext', this.name, this._value);
}

public async reset() {
await commands.executeCommand('setContext', this.name, undefined);
}
}
17 changes: 13 additions & 4 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ import { activateContextKeySetter } from "./providers/context-keys";
import { activateDivBracketDecorations } from "./providers/div-brackets";
import { CommandManager } from "./core/command";
import { createQuartoExtensionApi, QuartoExtensionApi } from "./api";
import { activateNotebookExport, NotebookExportService } from "./providers/notebook-export";

let embeddedDiagnostics: EmbeddedDiagnosticsService | undefined;
let embeddedDiagnosticsService: EmbeddedDiagnosticsService | undefined;
let notebookExportService: NotebookExportService | undefined;

/**
* Entry point for the entire extension! This initializes the LSP, quartoContext, extension host, and more...
Expand Down Expand Up @@ -123,8 +125,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
activateDenoConfig(context, engine);

// embedded diagnostics
embeddedDiagnostics = activateEmbeddedDiagnostics(engine, outputChannel);
context.subscriptions.push(embeddedDiagnostics);
embeddedDiagnosticsService = activateEmbeddedDiagnostics(engine, outputChannel);
context.subscriptions.push(embeddedDiagnosticsService);

// lsp
const lspClient = await activateLsp(context, quartoContext, engine, outputChannel);
Expand Down Expand Up @@ -238,6 +240,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// div bracket decorations
activateDivBracketDecorations(context);

// notebook export (conditionally in Positron)
notebookExportService = activateNotebookExport(quartoContext, outputChannel);
if (notebookExportService) {
context.subscriptions.push(notebookExportService);
}

// commands
const commandManager = new CommandManager();
for (const cmd of commands) {
Expand Down Expand Up @@ -296,6 +304,7 @@ function registerQuartoPathConfigListener(context: vscode.ExtensionContext, outp
}

export async function deactivate() {
await embeddedDiagnostics?.deactivate();
await embeddedDiagnosticsService?.deactivate();
await notebookExportService?.deactivate();
return deactivateLsp();
}
2 changes: 1 addition & 1 deletion apps/vscode/src/providers/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function activateConvert(
* @param targetExt Target extension for the converted file (e.g. ".ipynb", ".qmd")
* @returns Promise that resolves when conversion is complete and the converted file is opened.
*/
async function convertDocument(
export async function convertDocument(
quartoContext: QuartoContext,
outputChannel: LogOutputChannel,
sourceUri: Uri,
Expand Down
157 changes: 157 additions & 0 deletions apps/vscode/src/providers/notebook-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* notebook-export.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 { QuartoContext } from "quarto-core";
import { LogOutputChannel } from "vscode";
import { extensions } from "vscode";
import { NotebookExporter, NotebookExportExtension } from "../@types/positron-notebook-export";
import { promptForQuartoInstallation } from "../core/quarto";
import { convertDocument } from "./convert";
import { Extension } from "vscode";
import { NotebookDocument } from "vscode";
import { ContextKey } from "../core/context";
import { Disposable, DisposableStore } from "core";
import { Disposable as VscodeDisposable } from "vscode";

/**
* ID of the Positron Notebook Export extension, which provides an API for exporting notebooks to other formats.
*/
const notebookExportExtensionId = 'positron.notebook-export';

/**
* Context key that is true when the Quarto notebook exporter is registered.
*/
const hasNotebookExporterKey = 'quarto.hasNotebookExporter';

/**
* Label for the Quarto notebook exporter, exported for tests.
*/
export const notebookExporterLabel = 'Quarto Markdown';

/**
* Activate the notebook export feature.
* @returns The notebook export service, or `undefined` if it it could not be activated.
*/
export function activateNotebookExport(
quartoContext: QuartoContext,
outputChannel: LogOutputChannel
): NotebookExportService | undefined {
const exportExt = getNotebookExportExtension();
if (!exportExt) {
outputChannel.debug(
`No ${notebookExportExtensionId} extension, ` +
'not activating notebook export'
);
return undefined;
}

const notebookExportService = new NotebookExportService(exportExt, quartoContext, outputChannel);
return notebookExportService;
}

/**
* Get the Positron Notebook Export extension, if it is available.
*/
export function getNotebookExportExtension(): Extension<NotebookExportExtension> | undefined {
return extensions.getExtension<NotebookExportExtension>(notebookExportExtensionId);
}

export class NotebookExportService extends Disposable {
_activatePromise: Thenable<void>;

private readonly _hasNotebookExporter = new ContextKey(hasNotebookExporterKey);

constructor(
exportExt: Extension<NotebookExportExtension>,
private readonly _quartoContext: QuartoContext,
private readonly _outputChannel: LogOutputChannel,
) {
super();

this._outputChannel.debug('Activating notebook export...');
this._activatePromise = exportExt.activate().then((exportApi) => {
this._register(this._registerNotebookExporter(exportApi));
this._outputChannel.debug('Activated notebook export!');
}, (err) => {
this._outputChannel.error(`Failed to activate ${notebookExportExtensionId} extension: ${err}`);
}).then(undefined, (err) => {
this._outputChannel.error(`Failed to activate notebook exporter: ${err}`);
});
}

/** Awaitable cleanup for use during extension deactivation. */
async deactivate(): Promise<void> {
try {
await this._activatePromise;
} catch {
// Ignore activation errors, they're handled elsewhere.
}
}

private _registerNotebookExporter(exportApi: NotebookExportExtension): VscodeDisposable {
const disposables = new DisposableStore();

// Unregister the exporter when this feature is disposed.
disposables.add(
exportApi.registerNotebookExporter(
new QuartoNotebookExporter(this._quartoContext, this._outputChannel)
)
);

// Enable the context key used to disable convert commands;
// exporters are preferred when available.
this._hasNotebookExporter.set(true)
.catch(err => this._outputChannel.error(
`Failed to set context key ${this._hasNotebookExporter.name}: ${err}`
));

// Reset the context key when this feature is disposed.
disposables.add({
dispose: () => this._hasNotebookExporter.reset()
// Log at debug, since this should be harmless.
.catch(err => this._outputChannel.debug(
`Failed to reset context key ${this._hasNotebookExporter.name}: ${err}`
))
});

return disposables;
}
}

class QuartoNotebookExporter implements NotebookExporter {
label = notebookExporterLabel;
fileExtension = '.qmd';

constructor(
private readonly quartoContext: QuartoContext,
private readonly outputChannel: LogOutputChannel
) { }

async export(notebook: NotebookDocument): Promise<void> {
if (!this.quartoContext.available) {
// Ensure that Quarto is installed.
// `quarto convert` was available from the pre-release, no need to check min version.
await promptForQuartoInstallation("before exporting notebooks", true);
return;
}

await convertDocument(
this.quartoContext,
this.outputChannel,
notebook.uri,
".qmd"
);
}
}
Loading
Loading