diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index a17e0d8f..6c74d4c6 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed a bug where formatting a code cell stripped leading empty lines. Leading empty lines between option directives and code are now preserved, and two or more leading empty lines are collapsed to one (). - Fixed a bug where IPython magics (`%`, `%%`) and shell escapes (`!`) in Python code cells produced spurious diagnostics from language servers like Pyrefly and Ruff. These lines are now commented out in the virtual document handed to language servers (). - The "Render Document" command is now available in the Positron Notebook Editor (). +- Fixed notebooks failing to render or preview when they were visible but not active, or while the Panel or Sidebar were open (). ## 1.133.0 (Release on 2026-06-03) diff --git a/apps/vscode/src/core/doc.ts b/apps/vscode/src/core/doc.ts index 1ad3cf72..f8459c4a 100644 --- a/apps/vscode/src/core/doc.ts +++ b/apps/vscode/src/core/doc.ts @@ -164,7 +164,7 @@ export function preserveEditorFocus(editor?: QuartoEditor) { editor = editor || (vscode.window.activeTextEditor - ? quartoEditor(vscode.window.activeTextEditor) + ? quartoTextEditor(vscode.window.activeTextEditor) : undefined); if (editor) { if (!isNotebook(editor?.document)) { @@ -217,11 +217,9 @@ export function findQuartoEditor( | vscode.NotebookDocument | undefined; if (notebookDocument) { - const textEditor = vscode.window.visibleTextEditors.find((editor) => { - return editor.document.uri.fsPath.includes(notebookDocument.uri.fsPath); - }); - if (textEditor && filter(textEditor.document)) { - return quartoEditor(textEditor, engine, context, notebookDocument); + const firstCellDocument = notebookDocument.cellAt(0)?.document; + if (firstCellDocument && filter(firstCellDocument)) { + return quartoNotebookEditor(notebookEditor, firstCellDocument); } } } @@ -229,35 +227,53 @@ export function findQuartoEditor( // active text editor const textEditor = vscode.window.activeTextEditor; if (textEditor && filter(textEditor.document)) { - return quartoEditor(textEditor, engine, context); - // check visible text editors - } else if (includeVisible) { - // visible visual editor (sometime it loses track of 'active' so we need to use 'visible') - const visibleVisualEditor = VisualEditorProvider.activeEditor(true); - if (visibleVisualEditor && filter(visibleVisualEditor.document)) { - return visibleVisualEditor; - } + return quartoTextEditor(textEditor, engine, context); + } - // visible text editors - const visibleEditor = vscode.window.visibleTextEditors.find((editor) => - filter(editor.document) - ); - if (visibleEditor) { - return quartoEditor(visibleEditor, engine, context); - } else { - return undefined; - } - } else { - return undefined; + // check visible editors + + // visible visual editor (sometime it loses track of 'active' so we need to use 'visible') + const visibleVisualEditor = VisualEditorProvider.activeEditor(true); + if (visibleVisualEditor && filter(visibleVisualEditor.document)) { + return visibleVisualEditor; } + + // visible notebook editors + const visibleNotebookEditor = vscode.window.visibleNotebookEditors.find((editor) => + filter(editor.notebook.cellAt(0)?.document) + ); + if (visibleNotebookEditor) { + // NOTE: We used to get the text document belonging to the first item in + // `vscode.window.visibleTextEditors` whose URI matched the notebook. + // However, there was a bug where cells would stop appearing in + // `visibleTextEditors` when the Panel or Sidebar were open. Now we + // arbitrarily use the first cell's document. This is ok because, + // for notebooks, the rest of this extension only requires that + // `QuartoEditor.document` belongs to any cell in the notebook. + // A better longer-term solution would be to *not* require a text + // document for notebook `QuartoEditor`s and expose the required + // information (e.g. `document.uri`) in another way. + const firstCellDocument = visibleNotebookEditor.notebook.cellAt(0).document; + return quartoNotebookEditor(visibleNotebookEditor, firstCellDocument); + } + + // visible text editors + const visibleEditor = vscode.window.visibleTextEditors.find((editor) => + filter(editor.document) + ); + if (visibleEditor) { + return quartoTextEditor(visibleEditor, engine, context); + } + + return undefined; } -export function quartoEditor( +function quartoTextEditor( editor: vscode.TextEditor, engine?: MarkdownEngine, context?: QuartoContext, notebook?: NotebookDocument -) { +): QuartoEditor { return { document: editor.document, activate: async () => { @@ -285,6 +301,33 @@ export function quartoEditor( }; } +function quartoNotebookEditor( + editor: vscode.NotebookEditor, + firstCellDocument: vscode.TextDocument, +): QuartoEditor { + return { + document: firstCellDocument, + activate: async () => { + // TODO: This should probably use showNotebookDocument. + // And we could probably also activate() notebook editors + // in many places where we currently skip notebooks. + // We're leaving it as showTextDocument for now to minimize + // the number of changes in this PR focused on #1006. + await vscode.window.showTextDocument( + firstCellDocument, + editor.viewColumn, + false + ); + }, + slideIndex: async () => { + // Throwing is safe, since the only place slideIndex is called today + // skips notebooks. + throw new Error("slideIndex not supported for notebook editors"); + }, + notebook: editor.notebook, + }; +} + async function tryResolveUriToQuartoDoc( resource: vscode.Uri ): Promise { diff --git a/apps/vscode/src/test/examples/hello.ipynb b/apps/vscode/src/test/examples/hello.ipynb new file mode 100644 index 00000000..2514c48e --- /dev/null +++ b/apps/vscode/src/test/examples/hello.ipynb @@ -0,0 +1,29 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e4e2834b", + "metadata": {}, + "source": [ + "# Heading" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1181b7a", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Hello, world!\")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/apps/vscode/src/test/quartoEditor.test.ts b/apps/vscode/src/test/quartoEditor.test.ts new file mode 100644 index 00000000..7898912d --- /dev/null +++ b/apps/vscode/src/test/quartoEditor.test.ts @@ -0,0 +1,230 @@ +/* + * quartoEditor.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 { APPROX_TIME_TO_OPEN_VISUAL_EDITOR, examplesUri, openAndShowExamplesTextDocument, wait } from "./test-utils"; +import { canPreviewDoc, findQuartoEditor, QuartoEditor } from "../core/doc"; +import { MarkdownEngine } from "../markdown/engine"; +import { initQuartoContext } from "quarto-core"; +import { VisualEditorProvider } from "../providers/editor/editor"; + +suite('Quarto Editor', function () { + suite('findQuartoEditor', function () { + teardown(async function () { + // Close all editors after each test to ensure a clean slate for the next test. + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + }); + + test('returns undefined when there is no active or visible .qmd text editor', async function () { + await openAndShowExamplesTextDocument("hello.lua"); + + const quartoEditor = findQuartoEditorWithDefaults(); + + assert.strictEqual( + quartoEditor, + undefined, + "Expected not to find a Quarto editor when there are no .qmd text editors" + ); + }); + + test('finds the active .qmd text editor', async function () { + const { editor: textEditor } = await openAndShowExamplesTextDocument("hello.qmd"); + await openAndShowExamplesTextDocument("simple-divs.qmd", { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }); + + const quartoEditor = findQuartoEditorWithDefaults(); + + + assertQuartoEditorForTextEditor(quartoEditor, textEditor); + }); + + test('finds a visible (inactive) .qmd text editor', async function () { + const { editor: textEditor } = await openAndShowExamplesTextDocument("hello.qmd"); + await openAndShowExamplesTextDocument("hello.lua", { viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }); + + const quartoEditor = findQuartoEditorWithDefaults(); + + assertQuartoEditorForTextEditor(quartoEditor, textEditor); + }); + + // TODO: This test is really hard to write because we currently run extension tests + // against the bundled extension, so our `findQuartoEditor` uses a totally separate + // copy of `VisualEditorProvider` than the extension, so we always get `undefined` + // from `VisualEditorProvider.activeEditor()`. + test.skip('finds the active .qmd visual editor', async function () { + const uri = examplesUri("hello.qmd"); + await vscode.commands.executeCommand("quarto.test_setkVisualModeConfirmedTrue"); + await vscode.commands.executeCommand( + "vscode.openWith", + uri, + VisualEditorProvider.viewType + ); + + await vscode.commands.executeCommand( + "vscode.openWith", + examplesUri("simple-divs.qmd"), + VisualEditorProvider.viewType, + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true } + ); + + // TODO: Could we hook into an event instead? Currently really hard because of the + // issue noted above. + await wait(APPROX_TIME_TO_OPEN_VISUAL_EDITOR); + + const quartoEditor = findQuartoEditorWithDefaults(); + + assertQuartoEditorForVisualEditor(quartoEditor, uri, vscode.ViewColumn.One); + }); + + test.skip('finds a visible (inactive) .qmd visual editor', async function () { + const uri = examplesUri("hello.qmd"); + await vscode.commands.executeCommand("quarto.test_setkVisualModeConfirmedTrue"); + await vscode.commands.executeCommand( + "vscode.openWith", + uri, + VisualEditorProvider.viewType + ); + + await openAndShowExamplesTextDocument("hello.lua", { viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }); + + // TODO: Could we hook into an event instead? Currently really hard because of the + // issue noted above. + await wait(APPROX_TIME_TO_OPEN_VISUAL_EDITOR); + + const quartoEditor = findQuartoEditorWithDefaults(); + + assertQuartoEditorForVisualEditor(quartoEditor, uri, vscode.ViewColumn.One); + }); + + test('finds the active .ipynb notebook editor', async function () { + this.timeout(30_000); // Opening the first notebook can be slow, especially on CI + + console.log('open notebook 1'); + const notebook = await vscode.workspace.openNotebookDocument(examplesUri("convert-ipynb-to-qmd.ipynb")); + console.log('show notebook 1'); + const notebookEditor = await vscode.window.showNotebookDocument(notebook); + console.log('open notebook 2'); + const notebook2 = await vscode.workspace.openNotebookDocument(examplesUri("hello.ipynb")); + console.log('show notebook 2'); + await vscode.window.showNotebookDocument(notebook2, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }); + + console.log('find quarto editor'); + const quartoEditor = findQuartoEditorWithDefaults(); + + console.log('assert quarto editor'); + assertQuartoEditorForNotebookEditor(quartoEditor, notebookEditor); + }); + + test('finds a visible (inactive) .ipynb notebook editor', async function () { + const notebook = await vscode.workspace.openNotebookDocument(examplesUri("convert-ipynb-to-qmd.ipynb")); + const notebookEditor = await vscode.window.showNotebookDocument(notebook); + await openAndShowExamplesTextDocument("hello.lua", { viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }); + + const quartoEditor = findQuartoEditorWithDefaults(); + + assertQuartoEditorForNotebookEditor(quartoEditor, notebookEditor); + }); + }); +}); + +/** Convenience helper wrapping `findQuartoEditor` with defaults. */ +function findQuartoEditorWithDefaults() { + return findQuartoEditor( + new MarkdownEngine(), + initQuartoContext(), + canPreviewDoc + ); +} + +function assertQuartoEditorForTextEditor( + quartoEditor: QuartoEditor | undefined, + textEditor: vscode.TextEditor, +) { + assert.ok( + quartoEditor, + "Expected to find a Quarto editor for the active text editor" + ); + assert.strictEqual( + quartoEditor.document, + textEditor.document, + "Expected the found editor's document to be the active document" + ); + assert.strictEqual( + quartoEditor.notebook, + undefined, + "Expected notebook to be undefined for a text editor" + ); + assert.strictEqual( + quartoEditor.textEditor, + textEditor, + "Expected the found editor's text editor to be the active text editor" + ); + assert.strictEqual( + quartoEditor.viewColumn, + textEditor.viewColumn, + "Expected the found editor's view column to match the active text editor's view column" + ); +} + +function assertQuartoEditorForVisualEditor( + quartoEditor: QuartoEditor | undefined, + expectedUri: vscode.Uri, + expectedViewColumn: vscode.ViewColumn, +) { + assert.ok( + quartoEditor, + "Expected to find a Quarto editor for the active visual editor" + ); + assert.strictEqual( + quartoEditor.document.uri.toString(), + expectedUri.toString(), + "Expected the found editor's document to be the active visual editor's document" + ); + assert.strictEqual( + quartoEditor.textEditor, + undefined, + "Expected text editor to be undefined for a visual editor" + ); + assert.strictEqual( + quartoEditor.notebook, + undefined, + "Expected notebook to be undefined for a visual editor" + ); + assert.strictEqual( + quartoEditor.viewColumn, + expectedViewColumn, + "Expected the found editor's view column to be the active visual editor's view column" + ); +} + +function assertQuartoEditorForNotebookEditor( + quartoEditor: QuartoEditor | undefined, + notebookEditor: vscode.NotebookEditor, +) { + assert.ok( + quartoEditor, + "Expected to find a Quarto editor for the active notebook editor" + ); + + assert.ok( + notebookEditor.notebook.getCells().map(c => c.document).includes(quartoEditor.document), + "Expected the found editor's document to be a cell in the active notebook editor" + ); + assert.strictEqual( + quartoEditor.notebook, + notebookEditor.notebook, + "Expected the found editor's notebook to be the active notebook" + ); +} diff --git a/apps/vscode/src/test/test-utils.ts b/apps/vscode/src/test/test-utils.ts index 0fd78e20..3fc0918c 100644 --- a/apps/vscode/src/test/test-utils.ts +++ b/apps/vscode/src/test/test-utils.ts @@ -27,17 +27,26 @@ export function wait(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -export async function openAndShowExamplesTextDocument(fileName: string) { - return openAndShowUri(examplesUri(fileName)); +export async function openAndShowExamplesTextDocument( + fileName: string, + showOptions?: vscode.TextDocumentShowOptions +) { + return openAndShowUri(examplesUri(fileName), showOptions); } -export async function openAndShowExamplesOutTextDocument(fileName: string) { - return openAndShowUri(examplesOutUri(fileName)); +export async function openAndShowExamplesOutTextDocument( + fileName: string, + showOptions?: vscode.TextDocumentShowOptions +) { + return openAndShowUri(examplesOutUri(fileName), showOptions); } -export async function openAndShowUri(uri: vscode.Uri) { +export async function openAndShowUri( + uri: vscode.Uri, + showOptions?: vscode.TextDocumentShowOptions +) { const doc = await vscode.workspace.openTextDocument(uri); - const editor = await vscode.window.showTextDocument(doc); + const editor = await vscode.window.showTextDocument(doc, showOptions); return { doc, editor }; } @@ -73,7 +82,7 @@ export async function openUniqueExampleDocument(fileName: string) { }; } -const APPROX_TIME_TO_OPEN_VISUAL_EDITOR = 1700; +export const APPROX_TIME_TO_OPEN_VISUAL_EDITOR = 1700; export async function roundtrip(doc: vscode.TextDocument) { const before = doc.getText();