Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<https://github.com/quarto-dev/quarto/pull/1183>).
- 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 (<https://github.com/quarto-dev/quarto/pull/1189>).
- 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 (<https://github.com/quarto-dev/quarto/issues/985>).
- 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 (<https://github.com/quarto-dev/quarto/pull/1193>).

## 1.138.0 (Release on 2026-09-11)

Expand Down
2 changes: 2 additions & 0 deletions apps/vscode/src/host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface HostWebviewPanel extends vscode.Disposable {
reveal(viewColumn?: vscode.ViewColumn, preserveFocus?: boolean): void;
readonly onDidChangeViewState: vscode.Event<unknown>;
readonly onDidDispose: vscode.Event<void>;
setStoppable?(stoppable: boolean): void;
readonly onDidRequestStop?: vscode.Event<void>;
}

export interface HostStatementRangeProvider {
Expand Down
22 changes: 21 additions & 1 deletion apps/vscode/src/host/positron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
};

/**
* 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; };
Expand All @@ -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 {
Expand Down
39 changes: 37 additions & 2 deletions apps/vscode/src/providers/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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();
}
Expand Down
27 changes: 27 additions & 0 deletions apps/vscode/src/providers/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ export class QuartoWebviewManager<T extends QuartoWebview<S>, 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();
Expand Down Expand Up @@ -115,6 +131,7 @@ export class QuartoWebviewManager<T extends QuartoWebview<S>, S> {
}
);

webview.setStoppable?.(this.stoppable_);
const quartoWebview = new this.webviewType_(context, state, webview);

return quartoWebview;
Expand All @@ -129,13 +146,17 @@ export class QuartoWebviewManager<T extends QuartoWebview<S>, S> {
view.webviewPanel().onDidChangeViewState(() => {
this.onViewStateChanged();
});
view.webviewPanel().onDidRequestStop?.(() => {
this.onDidRequestStop_.fire();
});
}

public dispose() {
if (this.activeView_) {
this.activeView_.dispose();
this.activeView_ = undefined;
}
this.onDidRequestStop_.dispose();
let item: Disposable | undefined;
while ((item = this.disposables_.pop())) {
item.dispose();
Expand All @@ -145,6 +166,12 @@ export class QuartoWebviewManager<T extends QuartoWebview<S>, S> {
protected readonly disposables_: Disposable[] = [];

private onShow_?: () => void;
private stoppable_ = false;

private readonly onDidRequestStop_ = new EventEmitter<void>();

/** Fired when the user clicks the Viewer's stop button (Positron only). */
public readonly onDidRequestStop = this.onDidRequestStop_.event;
private readonly extensionUri_: Uri;
}

Expand Down
56 changes: 56 additions & 0 deletions apps/vscode/src/test/positronWebviewPanel.test.ts
Original file line number Diff line number Diff line change
@@ -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<positron.PreviewPanelOnDidChangeViewStateEvent>().event,
onDidDispose: new vscode.EventEmitter<void>().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<void>();
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 }
);
});
});
144 changes: 144 additions & 0 deletions apps/vscode/src/test/webviewManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
readonly onDidDispose = this.onDidDispose_.event;
readonly onDidChangeViewState = new vscode.EventEmitter<unknown>().event;
private readonly onDidRequestStop_ = new vscode.EventEmitter<void>();
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<string> {
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();
});
});
Loading