diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 8a4643db2..da2e6ccf0 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -13,6 +13,7 @@ - Running a Stata or C# cell no longer drops leading `#|` lines. These languages write cell options as `*|` and `//|`, so `#|` lines are code, and they are now sent to the kernel just as `quarto render` sends them (). - Updated the Mermaid used to render diagrams in notebook Markdown cells from 9.4 to 11.17, so newer syntax such as `A@{ shape: ... }` works. The diagram preview moves from Mermaid 11.12 to 11.17 too. In both, class and C4 diagrams now use Mermaid's newer renderer, so they look slightly different (). - Fixed a bug where the first character of lines outside code cells (paragraphs, headings, YAML) and of IPython magic lines in Python cells (`%`, `!`) could be highlighted as a comment (). +- In Positron, the Viewer now shows a stop button for Quarto previews. Clicking it stops `quarto preview` and closes the preview. The button goes away when the preview server stops (). ## 1.138.0 (Release on 2026-09-11) diff --git a/apps/vscode/src/host/index.ts b/apps/vscode/src/host/index.ts index 97546dcac..fa6b0c100 100644 --- a/apps/vscode/src/host/index.ts +++ b/apps/vscode/src/host/index.ts @@ -25,6 +25,8 @@ export interface HostWebviewPanel extends vscode.Disposable { reveal(viewColumn?: vscode.ViewColumn, preserveFocus?: boolean): void; readonly onDidChangeViewState: vscode.Event; readonly onDidDispose: vscode.Event; + setStoppable?(stoppable: boolean): void; + readonly onDidRequestStop?: vscode.Event; } export interface HostStatementRangeProvider { diff --git a/apps/vscode/src/host/positron.ts b/apps/vscode/src/host/positron.ts index 89e03991b..cc48c85c4 100644 --- a/apps/vscode/src/host/positron.ts +++ b/apps/vscode/src/host/positron.ts @@ -241,7 +241,21 @@ export function positronExtensionHost(outputChannel?: vscode.LogOutputChannel): } -class PositronWebviewPanel implements HostWebviewPanel { +// TODO: Use positron.PreviewPanel directly once @posit-dev/positron ships +// `stoppable` and `onDidRequestStop`. +type StoppablePreviewPanel = positron.PreviewPanel & { + stoppable: boolean; + readonly onDidRequestStop: vscode.Event; +}; + +/** + * Whether this version of Positron shows a stop button for preview panels. + */ +function isStoppable(panel: positron.PreviewPanel): panel is StoppablePreviewPanel { + return "onDidRequestStop" in panel; +} + +export class PositronWebviewPanel implements HostWebviewPanel { constructor(private readonly panel_: positron.PreviewPanel) { } get webview() { return this.panel_.webview; }; @@ -251,7 +265,13 @@ class PositronWebviewPanel implements HostWebviewPanel { } onDidChangeViewState = this.panel_.onDidChangeViewState; onDidDispose = this.panel_.onDidDispose; + onDidRequestStop = isStoppable(this.panel_) ? this.panel_.onDidRequestStop : undefined; dispose() { this.panel_.dispose(); }; + setStoppable(stoppable: boolean) { + if (isStoppable(this.panel_)) { + this.panel_.stoppable = stoppable; + } + } } class EmbeddedStatementRangeProvider implements HostStatementRangeProvider { diff --git a/apps/vscode/src/providers/preview/preview.ts b/apps/vscode/src/providers/preview/preview.ts index b79feb304..b4617210e 100644 --- a/apps/vscode/src/providers/preview/preview.ts +++ b/apps/vscode/src/providers/preview/preview.ts @@ -281,10 +281,35 @@ class PreviewManager { vscode.window.onDidCloseTerminal(async (closedTerminal) => { if (closedTerminal === this.terminal_) { this.terminal_ = undefined; + this.webviewManager_.setStoppable(false); await this.previewTerminateRequest(); } }) ); + + // Hide the Viewer's stop button when the preview server stops by other + // means (e.g. Ctrl+C in the terminal). Needs shell integration. A + // restart is ignored because killPreview() clears this.terminal_ first. + context.subscriptions.push( + vscode.window.onDidEndTerminalShellExecution((e) => { + if (e.terminal === this.terminal_) { + this.webviewManager_.setStoppable(false); + } + }) + ); + + // The user clicked the Viewer's stop button (Positron only). Stop the + // server but keep the terminal open so its output stays readable, the + // same as Ctrl+C in the terminal. + context.subscriptions.push( + this.webviewManager_.onDidRequestStop(async () => { + this.webviewManager_.setStoppable(false); + await this.previewTerminateRequest(); + this.progressDismiss(); + this.progressCancellationToken_ = undefined; + this.webviewManager_.closeWebview(); + }) + ); } dispose() { @@ -418,7 +443,12 @@ class PreviewManager { return requestUri; } - private async killPreview() { + private async killPreview(options?: { restart?: boolean }) { + // Hide the Viewer's stop button (Positron only), unless a new server is + // about to start; keeping it through restarts avoids a toolbar flicker. + if (!options?.restart) { + this.webviewManager_.setStoppable(false); + } // Clear this.terminal_ before disposing so the onDidCloseTerminal listener // does not send a duplicate termination request. this.terminal_ = undefined; @@ -435,7 +465,7 @@ class PreviewManager { slideIndex?: number ) { // dispose any existing preview terminals - await this.killPreview(); + await this.killPreview({ restart: true }); // cleanup output this.outputSink_.reset(); @@ -500,6 +530,8 @@ class PreviewManager { `Rendering requires Quarto version ${rPkgRequiredVersion} or greater`, { modal: true } ); + // no server will start, so hide the Viewer's stop button + this.webviewManager_.setStoppable(false); return; } } @@ -685,6 +717,9 @@ class PreviewManager { } ); this.webviewManager_.setOnError(this.progressDismiss.bind(this)); + // The server is up; show the Viewer's stop button (Positron only). This + // runs on every start, so the button stays through restarts. + this.webviewManager_.setStoppable(true); } else { this.showOuputFile(); } diff --git a/apps/vscode/src/providers/webview.ts b/apps/vscode/src/providers/webview.ts index 849cb23e3..983bcb2a9 100644 --- a/apps/vscode/src/providers/webview.ts +++ b/apps/vscode/src/providers/webview.ts @@ -67,6 +67,22 @@ export class QuartoWebviewManager, S> { } } + /** + * Shows or hides the stop button for the preview, including on panels + * created later. Only Positron's Viewer has a stop button. + */ + public setStoppable(stoppable: boolean) { + this.stoppable_ = stoppable; + this.activeView_?.webviewPanel().setStoppable?.(stoppable); + } + + /** + * Closes the preview, if one is showing. + */ + public closeWebview() { + this.activeView_?.dispose(); + } + public revealWebview() { if (this.activeView_) { this.activeView_.reveal(); @@ -115,6 +131,7 @@ export class QuartoWebviewManager, S> { } ); + webview.setStoppable?.(this.stoppable_); const quartoWebview = new this.webviewType_(context, state, webview); return quartoWebview; @@ -129,6 +146,9 @@ export class QuartoWebviewManager, S> { view.webviewPanel().onDidChangeViewState(() => { this.onViewStateChanged(); }); + view.webviewPanel().onDidRequestStop?.(() => { + this.onDidRequestStop_.fire(); + }); } public dispose() { @@ -136,6 +156,7 @@ export class QuartoWebviewManager, S> { this.activeView_.dispose(); this.activeView_ = undefined; } + this.onDidRequestStop_.dispose(); let item: Disposable | undefined; while ((item = this.disposables_.pop())) { item.dispose(); @@ -145,6 +166,12 @@ export class QuartoWebviewManager, S> { protected readonly disposables_: Disposable[] = []; private onShow_?: () => void; + private stoppable_ = false; + + private readonly onDidRequestStop_ = new EventEmitter(); + + /** Fired when the user clicks the Viewer's stop button (Positron only). */ + public readonly onDidRequestStop = this.onDidRequestStop_.event; private readonly extensionUri_: Uri; } diff --git a/apps/vscode/src/test/positronWebviewPanel.test.ts b/apps/vscode/src/test/positronWebviewPanel.test.ts new file mode 100644 index 000000000..6269a93ba --- /dev/null +++ b/apps/vscode/src/test/positronWebviewPanel.test.ts @@ -0,0 +1,56 @@ +/* + * positronWebviewPanel.test.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import type * as positron from "positron"; + +import { PositronWebviewPanel } from "../host/positron"; + +/** A Positron preview panel from a version without the Viewer stop button. */ +function olderPanel(): positron.PreviewPanel { + return { + viewType: "quarto.test", + title: "Test", + webview: {} as vscode.Webview, + active: true, + visible: true, + onDidChangeViewState: new vscode.EventEmitter().event, + onDidDispose: new vscode.EventEmitter().event, + reveal: () => { }, + dispose: () => { }, + }; +} + +suite("Positron webview panel stop button", function () { + test("sets stoppable and forwards stop clicks when Positron supports them", function () { + const onDidRequestStop = new vscode.EventEmitter(); + const panel = Object.assign(olderPanel(), { + stoppable: false, + onDidRequestStop: onDidRequestStop.event, + }); + const hostPanel = new PositronWebviewPanel(panel); + let stopRequests = 0; + hostPanel.onDidRequestStop?.(() => stopRequests++); + + hostPanel.setStoppable(true); + onDidRequestStop.fire(); + + assert.deepStrictEqual({ stoppable: panel.stoppable, stopRequests }, { stoppable: true, stopRequests: 1 }); + }); + + test("does nothing on older Positron without the stop button", function () { + const panel = olderPanel(); + const hostPanel = new PositronWebviewPanel(panel); + + hostPanel.setStoppable(true); + + assert.deepStrictEqual( + { hasStoppable: "stoppable" in panel, onDidRequestStop: hostPanel.onDidRequestStop }, + { hasStoppable: false, onDidRequestStop: undefined } + ); + }); +}); diff --git a/apps/vscode/src/test/webviewManager.test.ts b/apps/vscode/src/test/webviewManager.test.ts new file mode 100644 index 000000000..4bde3f34e --- /dev/null +++ b/apps/vscode/src/test/webviewManager.test.ts @@ -0,0 +1,144 @@ +/* + * webviewManager.test.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; + +import { ExtensionHost, HostWebviewPanel } from "../host"; +import { QuartoWebview, QuartoWebviewManager } from "../providers/webview"; + +/** A preview panel that records what the manager does with the stop button. */ +class FakePanel implements HostWebviewPanel { + readonly webview = { + html: "", + cspSource: "", + asWebviewUri: (uri: vscode.Uri) => uri, + } as unknown as vscode.Webview; + readonly visible = true; + stoppable = false; + disposed = false; + + private readonly onDidDispose_ = new vscode.EventEmitter(); + readonly onDidDispose = this.onDidDispose_.event; + readonly onDidChangeViewState = new vscode.EventEmitter().event; + private readonly onDidRequestStop_ = new vscode.EventEmitter(); + readonly onDidRequestStop = this.onDidRequestStop_.event; + + setStoppable(stoppable: boolean) { + this.stoppable = stoppable; + } + + /** Simulates the user clicking the Viewer's stop button. */ + clickStop() { + this.stoppable = false; + this.onDidRequestStop_.fire(); + } + + reveal() { } + + dispose() { + if (!this.disposed) { + this.disposed = true; + this.onDidDispose_.fire(); + } + } +} + +class TestWebview extends QuartoWebview { + protected getHtml(state: string) { + return state; + } +} + +let viewTypeCount = 0; + +/** Creates a manager whose host hands out fake panels. */ +function createManager(createPanel: () => HostWebviewPanel = () => new FakePanel()) { + const panels: HostWebviewPanel[] = []; + const host = { + createPreviewPanel: () => { + const panel = createPanel(); + panels.push(panel); + return panel; + }, + } as unknown as ExtensionHost; + const context = { + subscriptions: [], + extensionUri: vscode.Uri.file("/extension"), + } as unknown as vscode.ExtensionContext; + // Each manager registers a panel serializer, so give each a unique view type. + const manager = new QuartoWebviewManager( + context, + host, + `quarto.test.webviewManager${viewTypeCount++}`, + "Test", + TestWebview + ); + return { manager, panels: panels as FakePanel[] }; +} + +suite("Webview manager stop button", function () { + test("shows the stop button on new panels, including recreated ones", function () { + const { manager, panels } = createManager(); + + manager.setStoppable(true); + manager.showWebview("first"); + panels[0].dispose(); + manager.showWebview("second"); + + assert.deepStrictEqual(panels.map((panel) => panel.stoppable), [true, true]); + manager.dispose(); + }); + + test("forwards a stop click from a recreated panel", function () { + const { manager, panels } = createManager(); + let stopRequests = 0; + manager.onDidRequestStop(() => stopRequests++); + + manager.setStoppable(true); + manager.showWebview("first"); + panels[0].dispose(); + manager.showWebview("second"); + panels[1].clickStop(); + + assert.strictEqual(stopRequests, 1); + manager.dispose(); + }); + + test("works with panels that have no stop button (VS Code)", function () { + const { manager, panels } = createManager(() => { + const panel: HostWebviewPanel = new FakePanel(); + return { + webview: panel.webview, + visible: panel.visible, + reveal: () => panel.reveal(), + onDidChangeViewState: panel.onDidChangeViewState, + onDidDispose: panel.onDidDispose, + dispose: () => panel.dispose(), + }; + }); + + manager.setStoppable(true); + manager.showWebview("first"); + manager.setStoppable(false); + + assert.strictEqual(panels.length, 1); + manager.dispose(); + }); + + test("closeWebview closes the preview", function () { + const { manager, panels } = createManager(); + manager.showWebview("first"); + + manager.closeWebview(); + + assert.deepStrictEqual( + { disposed: panels[0].disposed, hasWebview: manager.hasWebview() }, + { disposed: true, hasWebview: false } + ); + manager.dispose(); + }); +});