From 72a9f0ca5bf0b3991d993e25acf556b4c02b48d6 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 10 Jun 2026 15:49:26 +0200 Subject: [PATCH] quarto notebook exporter --- apps/vscode/CHANGELOG.md | 1 + apps/vscode/package.json | 4 +- .../src/@types/positron-notebook-export.d.ts | 67 ++++++++ apps/vscode/src/core/context.ts | 43 +++++ apps/vscode/src/main.ts | 17 +- apps/vscode/src/providers/convert.ts | 2 +- apps/vscode/src/providers/notebook-export.ts | 157 ++++++++++++++++++ apps/vscode/src/test/notebook-export.test.ts | 77 +++++++++ 8 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 apps/vscode/src/@types/positron-notebook-export.d.ts create mode 100644 apps/vscode/src/core/context.ts create mode 100644 apps/vscode/src/providers/notebook-export.ts create mode 100644 apps/vscode/src/test/notebook-export.test.ts diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 0cdfaf7a..5dd5ea7c 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.134.0 (Unreleased) - The "Preview" and "Preview Format..." commands now show in the Positron Notebook Editor overflow menu (). +- In Positron, Jupyter Notebooks (`.ipynb`) are now exported via the new unified "Export" command, rather than the "Quarto: Convert to .qmd" command (). ## 1.133.0 (Release on 2026-06-03) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index dc478136..bcd715d5 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -696,7 +696,7 @@ }, { "command": "quarto.convertToQmd", - "when": "resourceExtname == .ipynb", + "when": "resourceExtname == .ipynb && !quarto.hasNotebookExporter", "group": "q_zzConvert" }, { @@ -750,7 +750,7 @@ }, { "command": "quarto.convertToQmd", - "when": "resourceExtname == .ipynb", + "when": "resourceExtname == .ipynb && !quarto.hasNotebookExporter", "group": "q_zzConvert" } ], diff --git a/apps/vscode/src/@types/positron-notebook-export.d.ts b/apps/vscode/src/@types/positron-notebook-export.d.ts new file mode 100644 index 00000000..a7dd1379 --- /dev/null +++ b/apps/vscode/src/@types/positron-notebook-export.d.ts @@ -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; +} + +/** + * 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; +} diff --git a/apps/vscode/src/core/context.ts b/apps/vscode/src/core/context.ts new file mode 100644 index 00000000..4f553082 --- /dev/null +++ b/apps/vscode/src/core/context.ts @@ -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 + | Record; + +export class ContextKey { + private _value?: T; + + constructor(public readonly name: string) { } + + public get(): T | undefined { + return this._value; + } + + public async set(value: T): Promise { + this._value = value; + await commands.executeCommand('setContext', this.name, this._value); + } + + public async reset() { + await commands.executeCommand('setContext', this.name, undefined); + } +} diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 5d7a158d..0e2e1254 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -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... @@ -123,8 +125,8 @@ export async function activate(context: vscode.ExtensionContext): Promise | undefined { + return extensions.getExtension(notebookExportExtensionId); +} + +export class NotebookExportService extends Disposable { + _activatePromise: Thenable; + + private readonly _hasNotebookExporter = new ContextKey(hasNotebookExporterKey); + + constructor( + exportExt: Extension, + 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 { + 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 { + 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" + ); + } +} diff --git a/apps/vscode/src/test/notebook-export.test.ts b/apps/vscode/src/test/notebook-export.test.ts new file mode 100644 index 00000000..7f568d43 --- /dev/null +++ b/apps/vscode/src/test/notebook-export.test.ts @@ -0,0 +1,77 @@ +/* + * notebook-export.test.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 assert from "assert"; +import * as vscode from "vscode"; +import { examplesOutUri, WORKSPACE_PATH } from "./test-utils"; +import { extension } from "./extension"; +import { QuickPickItem } from "vscode"; +import { getNotebookExportExtension, notebookExporterLabel } from "../providers/notebook-export"; + +// Skipped until we can run extension tests against Positron. +suite.skip("Notebook export", function () { + suiteSetup(async function () { + const notebookExportExtension = getNotebookExportExtension(); + if (!notebookExportExtension) { + // The notebook export extension is not available (we're in VS Code), + // skip this suite. + this.skip(); + } + + // Wait for this extension and the notebook export extension to activate. + await extension().activate(); + await notebookExportExtension.activate(); + + await vscode.workspace.fs.delete(examplesOutUri(), { recursive: true }); + await vscode.workspace.fs.copy( + vscode.Uri.file(WORKSPACE_PATH), + examplesOutUri() + ); + }); + + let originalShowQuickPick: typeof vscode.window.showQuickPick; + setup(function () { + originalShowQuickPick = vscode.window.showQuickPick; + }); + + teardown(async function () { + vscode.window.showQuickPick = originalShowQuickPick; + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + }); + + test("exports a .ipynb notebook to .qmd", async function () { + const uri = examplesOutUri('convert-ipynb-to-qmd.ipynb'); + const notebook = await vscode.workspace.openNotebookDocument(uri); + await vscode.window.showNotebookDocument(notebook); + + (vscode.window as any).showQuickPick = async (items: readonly QuickPickItem[]) => { + // Return the Quarto exporter item, as if it were selected by the user. + const item = (await items).find(item => item.label === notebookExporterLabel); + return item; + }; + + await vscode.commands.executeCommand('notebook.export', notebook.uri); + + const exported = vscode.window.activeTextEditor?.document; + assert.ok(exported, 'Expected an active text editor after exporting'); + + // The exporter uses the same mechanism already tested in `convert.test.ts`, + // so it should be sufficient to check that the converted file is opened. + assert.ok( + exported.uri.toString() === examplesOutUri('convert-ipynb-to-qmd.qmd').toString(), + 'Expected the exported document to be opened' + ); + }); +});