From 240a81d86244160a275539c3c0e71adf70bbcbf6 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 26 Jul 2024 16:23:27 -0700 Subject: [PATCH] Overhaul R HTML widgets and HTML support in Viewer (#4151) This change holistically addresses a large number of issues around viewing R HTML widgets, and HTML in general, in the Viewer and Plots panes. Specifically, it addresses the following issues: https://github.com/posit-dev/positron/issues/2023 https://github.com/posit-dev/positron/issues/2145 https://github.com/posit-dev/positron/issues/2559 https://github.com/posit-dev/positron/issues/2888 https://github.com/posit-dev/positron/issues/3018 https://github.com/posit-dev/positron/issues/3678 https://github.com/posit-dev/positron/issues/3756 https://github.com/posit-dev/positron/issues/3783 https://github.com/posit-dev/positron/issues/3914 https://github.com/posit-dev/positron/issues/3968 https://github.com/posit-dev/positron/issues/4057 Prior to this change, R HTML widgets were emitted from Ark as notebook outputs in format compatible with the [VS Code Notebook Renderer API](https://code.visualstudio.com/api/extension-guides/notebook#notebook-renderer). This approach was chosen because we need to be able to render these kinds of outputs in any case (other kernels emit them), and for minimal friction with the existing infrastructure. However, R HTML widgets aren't really a good match for Notebook Renderer API, and the abstraction was very leaky. In particular, the API is really designed to be used by widgets directly -- for example, plotly has an output renderer -- not as a layer on top of another widget framework. It also can't be used to just render HTML output directly, which is what a lot of R code does. The approach taken here largely supersedes the notebook output approach (for console sessions). It extends our existing help proxy with a new file proxy. R HTML content is rendered to a temporary path on disk, and then we create a proxy that serves content from the path on disk. ```mermaid graph TD proxy[Positron Proxy] --HTML --> pos[Positron] html[(HTML)] -- read by--> proxy pos -- create proxy --> proxy R(R runtime) -- generates --> html R -- show_html_file UI comm --> pos ``` Step by step: 1. An HTML widget is emitted in R. 2. Ark's custom HTML widget printer is invoked. 3. Ark renders the widget to a temporary directory, using `html_print`. 4. Ark emits a `show_html_file` event over the UI comm to Positron with the path to the rendered widget. 5. Positron receives the `show_html_file` event. 6. Positron asks the Positron Proxy extension to create a server URL for the file path. 7. The Positron Proxy extension uses an Express server to create a route that serves the static content at a random URL. 8. The Positron Proxy extension sends the URL back to Positron. 9. Positron opens the URL in the Viewer or Plots pane, as appropriate. ### New Features In addition to fixing some bugs around HTML rendering, we have a few new features enabled by this approach: #### Popout Interactive Content Because we now have a real URL for HTML content, it can be opened in web browsers. Plots that are based on HTML content now have a popout affordance: ![image](https://github.com/user-attachments/assets/67fda7d4-ddb0-46f3-b182-37bb698f76cf) And so does HTML content rendered into the Viewer pane: ![image](https://github.com/user-attachments/assets/5271401d-822d-4f0a-b7ab-e9ebc94821f6) #### For the Plots Pane haters Don't like HTML going to your Plots pane? You can turn it off, so all HTML content goes to the Viewer pane. image *Why would you want to live this way, though?* #### RStudio Viewer API The `rstudio::viewer()` API is implemented with this change, so R packages that depend on it to show content will work correctly. #### Open in Viewer context menu You can now open HTML files directly in the Viewer pane using a context menu action in the Explorer pane: image ### QA Notes Here's a bunch of test cases. These are all things that didn't work before this PR. #### Mapview Opens up a nice map in the Viewer pane. ```r library(mapview) m <- mapview() m ``` ### ggiraph An interactive 2D plot. Should open up in the Plots pane. ```r library(ggplot2) library(ggiraph) g <- ggplot(mpg, aes(x = displ, y = cty)) + geom_point_interactive( aes(tooltip = model, data_id = model), size = 3, hover_nearest = TRUE ) girafe(ggobj = g) ``` #### Threejs An interactive 3D plot. Should open up in the Plots pane. ```r library(threejs) z <- seq(-10, 10, 0.01) x <- cos(z) y <- sin(z) scatterplot3js(x,y,z, color=rainbow(length(z))) ``` #### Model Summary This is one of those packages that calls `rstudioapi::viewer` directly. ```r library(palmerpenguins) library(fixest) library(modelsummary) m1 = feols(body_mass_g ~ bill_depth_mm + bill_length_mm | species, data = penguins) modelsummary(m1) ``` #### React Table Also should open in the Viewer. ``` library(reactable) mtcars |> reactable::reactable() ``` #### Reprex A common R package used to create reproducible examples. Should open in the Viewer. ```r reprex::reprex({ x <- rnorm(100) plot(x, sin(x)) }) ``` ### Impacted Areas This change touches code outside R HTML widgets, so these code paths should also be spot checked for issues. - Previewing URLs in the Viewer (e.g. Streamlit, Shiny content) - Previewing extension-provided content in the Viewer (e.g. Quarto) - Notebook outputs in the Plots pane (e.g. IPyWidgets) --- extensions/positron-proxy/package.json | 106 +++++---- extensions/positron-proxy/package.nls.json | 5 + extensions/positron-proxy/src/extension.ts | 21 +- extensions/positron-proxy/src/htmlProxy.ts | 96 ++++++++ .../positron-proxy/src/positronProxy.ts | 29 ++- extensions/positron-proxy/src/util.ts | 24 ++ extensions/positron-proxy/tsconfig.json | 1 + .../positron/positron_ipykernel/ui_comm.py | 27 +++ extensions/positron-r/package.json | 2 +- positron/comms/ui-frontend-openrpc.json | 35 +++ src/positron-dts/positron.d.ts | 11 + .../positron/mainThreadPreviewPanel.ts | 11 +- .../positron/extHost.positron.api.impl.ts | 3 + .../positron/extHost.positron.protocol.ts | 5 + .../common/positron/extHostPreviewPanels.ts | 21 ++ .../browser/components/actionBars.tsx | 26 ++- .../components/webviewPlotInstance.tsx | 2 +- .../components/webviewPlotThumbnail.tsx | 2 +- .../positronPlots/browser/htmlPlotClient.ts | 37 ++++ .../browser/notebookOutputPlotClient.ts | 53 +++++ .../browser/positronPlots.contribution.ts | 3 +- .../browser/positronPlotsActions.ts | 30 +++ .../browser/positronPlotsService.ts | 126 +++++++++-- .../browser/webviewPlotClient.ts | 38 +--- .../positronPlots/browser/widgetPlotClient.ts | 6 +- .../browser/components/actionBars.css | 4 + .../browser/components/actionBars.ts | 24 ++ .../browser/components/htmlActionBars.tsx | 103 +++++++++ .../browser/components/previewContainer.tsx | 10 +- .../{actionBars.tsx => urlActionBars.tsx} | 31 +-- .../browser/positronPreview.contribution.ts | 18 ++ .../browser/positronPreview.tsx | 15 +- .../browser/positronPreviewActions.ts | 2 +- .../browser/positronPreviewServiceImpl.ts | 209 +++++++++++++++--- .../browser/positronPreviewSevice.ts | 39 +++- .../positronPreview/browser/previewHtml.ts | 42 ++++ .../browser/previewOverlayWebview.ts | 104 +++++++++ .../positronPreview/browser/previewUrl.ts | 69 +----- .../positronPreview/browser/previewWebview.ts | 6 +- .../positronPreviewServiceImpl.ts | 13 +- ...previewUrl.ts => previewOverlayWebview.ts} | 6 +- .../contrib/webview/browser/overlayWebview.ts | 5 + .../webview/browser/pre/webview-events.js | 5 + .../contrib/webview/browser/webview.ts | 1 + .../contrib/webview/browser/webviewElement.ts | 5 + .../webview/browser/webviewMessages.d.ts | 3 + .../common/languageRuntimeUiClient.ts | 91 +++++++- .../languageRuntime/common/positronUiComm.ts | 39 +++- .../positronPlots/common/positronPlots.ts | 5 + .../runtimeSession/common/runtimeSession.ts | 13 +- 50 files changed, 1320 insertions(+), 262 deletions(-) create mode 100644 extensions/positron-proxy/package.nls.json create mode 100644 extensions/positron-proxy/src/htmlProxy.ts create mode 100644 extensions/positron-proxy/src/util.ts create mode 100644 src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts create mode 100644 src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts create mode 100644 src/vs/workbench/contrib/positronPreview/browser/components/actionBars.ts create mode 100644 src/vs/workbench/contrib/positronPreview/browser/components/htmlActionBars.tsx rename src/vs/workbench/contrib/positronPreview/browser/components/{actionBars.tsx => urlActionBars.tsx} (80%) create mode 100644 src/vs/workbench/contrib/positronPreview/browser/previewHtml.ts create mode 100644 src/vs/workbench/contrib/positronPreview/browser/previewOverlayWebview.ts rename src/vs/workbench/contrib/positronPreview/electron-sandbox/{previewUrl.ts => previewOverlayWebview.ts} (79%) diff --git a/extensions/positron-proxy/package.json b/extensions/positron-proxy/package.json index 64d6b2acd5a..e3fe19217ac 100644 --- a/extensions/positron-proxy/package.json +++ b/extensions/positron-proxy/package.json @@ -1,50 +1,74 @@ { - "name": "positron-proxy", - "displayName": "Positron Proxy", - "description": "Positron Proxy", - "version": "1.0.0", - "publisher": "vscode", - "engines": { - "vscode": "^1.65.0" - }, - "categories": [ + "name": "positron-proxy", + "displayName": "%displayName%", + "description": "%description%", + "version": "1.0.0", + "publisher": "vscode", + "engines": { + "vscode": "^1.65.0" + }, + "categories": [ "Other" - ], - "activationEvents": [ + ], + "activationEvents": [ "onCommand:positronProxy.startHelpProxyServer", "onCommand:positronProxy.setHelpProxyServerStyles", - "onStartupFinished" - ], - "main": "./out/extension.js", - "contributes": { - }, - "scripts": { - "vscode:prepublish": "yarn run compile", - "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "lint": "eslint src --ext ts" - }, + "onCommand:positronProxy.startHtmlProxyServer", + "onStartupFinished" + ], + "main": "./out/extension.js", + "contributes": { + "menus": { + "explorer/context": [ + { + "when": "resourceLangId == html", + "command": "positronProxy.showHtmlPreview", + "group": "navigation" + } + ], + "editor/title": [ + { + "when": "resourceLangId == html", + "command": "positronProxy.showHtmlPreview", + "group": "navigation" + } + ] + }, + "commands": [ + { + "command": "positronProxy.showHtmlPreview", + "title": "%command.positronProxy.showHtmlPreview.title%", + "icon": "$(eye)" + } + ] + }, + "scripts": { + "vscode:prepublish": "yarn run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, "dependencies": { "express": "^4.19.2", "http-proxy-middleware": "^2.0.6" }, - "devDependencies": { - "@types/express": "^4.17.17", - "@types/glob": "^7.2.0", - "@types/mocha": "^9.1.0", - "@types/node": "14.x", - "@typescript-eslint/eslint-plugin": "^5.12.1", - "@typescript-eslint/parser": "^5.12.1", - "@vscode/test-electron": "^2.1.2", - "eslint": "^8.9.0", - "glob": "^7.2.0", - "mocha": "^9.2.1", - "ts-node": "^10.9.1", - "typescript": "^4.5.5", - "vsce": "^2.11.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/posit-dev/positron" - } + "devDependencies": { + "@types/express": "^4.17.17", + "@types/glob": "^7.2.0", + "@types/mocha": "^9.1.0", + "@types/node": "14.x", + "@typescript-eslint/eslint-plugin": "^5.12.1", + "@typescript-eslint/parser": "^5.12.1", + "@vscode/test-electron": "^2.1.2", + "eslint": "^8.9.0", + "glob": "^7.2.0", + "mocha": "^9.2.1", + "ts-node": "^10.9.1", + "typescript": "^4.5.5", + "vsce": "^2.11.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/posit-dev/positron" + } } diff --git a/extensions/positron-proxy/package.nls.json b/extensions/positron-proxy/package.nls.json new file mode 100644 index 00000000000..f434bcabec0 --- /dev/null +++ b/extensions/positron-proxy/package.nls.json @@ -0,0 +1,5 @@ +{ + "displayName": "Positron Proxy", + "description": "HTTP proxying for Positron", + "command.positronProxy.showHtmlPreview.title": "Open in Viewer" +} diff --git a/extensions/positron-proxy/src/extension.ts b/extensions/positron-proxy/src/extension.ts index 0925a1f1385..ab19545c2b0 100644 --- a/extensions/positron-proxy/src/extension.ts +++ b/extensions/positron-proxy/src/extension.ts @@ -1,10 +1,12 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import * as positron from 'positron'; import { PositronProxy } from './positronProxy'; +import path from 'path'; /** * ProxyServerStyles type. @@ -27,6 +29,14 @@ export function activate(context: vscode.ExtensionContext) { ) ); + // Register the positronProxy.startHtmlProxyServer command and add its disposable. + context.subscriptions.push( + vscode.commands.registerCommand( + 'positronProxy.startHtmlProxyServer', + async (targetPath: string) => await positronProxy.startHtmlProxyServer(targetPath) + ) + ); + // Register the positronProxy.stopHelpProxyServer command and add its disposable. context.subscriptions.push( vscode.commands.registerCommand( @@ -43,6 +53,15 @@ export function activate(context: vscode.ExtensionContext) { ) ); + // Register the positronProxy.showHtmlPreview command and add its disposable. + context.subscriptions.push( + vscode.commands.registerCommand( + 'positronProxy.showHtmlPreview', + (path: vscode.Uri) => { + positron.window.previewHtml(path.toString()); + }) + ); + // Add the PositronProxy object disposable. context.subscriptions.push(positronProxy); } diff --git a/extensions/positron-proxy/src/htmlProxy.ts b/extensions/positron-proxy/src/htmlProxy.ts new file mode 100644 index 00000000000..5faa19168fe --- /dev/null +++ b/extensions/positron-proxy/src/htmlProxy.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import express from 'express'; +import path = require('path'); +import fs = require('fs'); + +import { Disposable, Uri } from 'vscode'; +import { PromiseHandles } from './util'; +import { isAddressInfo } from './positronProxy'; + +/** + * HtmlProxyServer class. + * + * HtmlProxyServer wraps an Express server that serves static HTML content. All + * the content is served by the same server, but each piece of content is served + * from a unique path. + */ +export class HtmlProxyServer implements Disposable { + private readonly _app = express(); + private readonly _server; + + private readonly _paths = new Map(); + private readonly _ready: PromiseHandles = new PromiseHandles(); + + /** + * Construct a new HtmlProxyServer; creates the server and listens on a + * random port on localhost. + */ + constructor() { + this._server = this._app.listen(0, 'localhost', () => { + this._ready.resolve(); + }); + } + + /** + * Creates a unique URL that serves the content at the specified path. + * + * @param targetPath The path to the content to serve. May be specified as a + * file URI or a plain path, and may be a file or a directory. When a file + * is specified, the parent directory is served and the filename is appended + * to the URL. + * @returns A URL that serves the content at the specified path. + */ + public async createHtmlProxy(targetPath: string): Promise { + // Wait for the server to be ready. + await this._ready.promise; + + // The targetPath may be specified as a file URI or a file path. If it's + // a file URI, convert it to a file path first. + try { + const uri = Uri.parse(targetPath); + if (uri.scheme === 'file') { + targetPath = uri.fsPath; + } + } catch { + // Ignore parse failures; expected when the target path is not a + // URI. + } + + // Ensure the target path exists. + if (!fs.existsSync(targetPath)) { + throw new Error(`Path does not exist: ${targetPath}`); + } + + // Generate a random 8-character hex string to use as the path, and + // ensure it's unique. + let serverPath = ''; + do { + serverPath = Math.random().toString(16).substring(2, 10); + } while (this._paths.has(serverPath)); + + // Is the target path a file, or a directory? If it's a file, we'll + // serve the parent directory and then amend the filename to the URL. + let filename = ''; + const isFile = fs.statSync(targetPath).isFile(); + if (isFile) { + filename = path.basename(targetPath); + targetPath = path.dirname(targetPath); + } + + // Create a new path entry. + this._app.use(`/${serverPath}`, express.static(targetPath)); + const address = this._server.address(); + if (!isAddressInfo(address)) { + throw new Error(`Server address is not available; cannot serve ${targetPath}`); + } + return `http://${address.address}:${address.port}/${serverPath}/${filename}`; + } + + dispose() { + this._server.close(); + } +} diff --git a/extensions/positron-proxy/src/positronProxy.ts b/extensions/positron-proxy/src/positronProxy.ts index 19cfe9c828a..5aaa6802ca4 100644 --- a/extensions/positron-proxy/src/positronProxy.ts +++ b/extensions/positron-proxy/src/positronProxy.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -10,6 +10,7 @@ import { AddressInfo, Server } from 'net'; import { ProxyServerStyles } from './extension'; import { Disposable, ExtensionContext } from 'vscode'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; +import { HtmlProxyServer } from './htmlProxy'; /** * Constants. @@ -55,7 +56,7 @@ type ContentRewriter = ( * @param addressInfo The value. * @returns true if the value is aAddressInfo AddressInfo; otherwise, false. */ -const isAddressInfo = (addressInfo: string | AddressInfo | null): addressInfo is AddressInfo => +export const isAddressInfo = (addressInfo: string | AddressInfo | null): addressInfo is AddressInfo => (addressInfo as AddressInfo).address !== undefined && (addressInfo as AddressInfo).family !== undefined && (addressInfo as AddressInfo).port !== undefined; @@ -68,13 +69,11 @@ export class ProxyServer implements Disposable { * Constructor. * @param serverOrigin The server origin. * @param targetOrigin The target origin. - * @param type The type. (Right now, only help is supported.) * @param server The server. */ constructor( readonly serverOrigin: string, readonly targetOrigin: string, - private readonly type: 'help', private readonly server: Server, ) { } @@ -123,6 +122,12 @@ export class PositronProxy implements Disposable { */ private _proxyServers = new Map(); + /** + * The HTML proxy server. There's only ever one of these; it serves all raw + * HTML content. + */ + private _htmlProxyServer?: HtmlProxyServer; + //#endregion Private Properties //#region Constructor & Dispose @@ -161,6 +166,9 @@ export class PositronProxy implements Disposable { this._proxyServers.forEach(proxyServer => { proxyServer.dispose(); }); + if (this._htmlProxyServer) { + this._htmlProxyServer.dispose(); + } } //#endregion Constructor & Dispose @@ -240,6 +248,18 @@ export class PositronProxy implements Disposable { return false; } + /** + * Starts a proxy server to server local HTML content. + * @param targetPath The target path + * @returns The server URL. + */ + async startHtmlProxyServer(targetPath: string) { + if (!this._htmlProxyServer) { + this._htmlProxyServer = new HtmlProxyServer(); + } + return this._htmlProxyServer.createHtmlProxy(targetPath); + } + /** * Sets the help proxy server styles. * @param styles The help proxy server styles. @@ -290,7 +310,6 @@ export class PositronProxy implements Disposable { this._proxyServers.set(targetOrigin, new ProxyServer( serverOrigin, targetOrigin, - 'help', server )); diff --git a/extensions/positron-proxy/src/util.ts b/extensions/positron-proxy/src/util.ts new file mode 100644 index 00000000000..cbf94901709 --- /dev/null +++ b/extensions/positron-proxy/src/util.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + + +/** + * PromiseHandles is a class that represents a promise that can be resolved or + * rejected externally. + */ +export class PromiseHandles { + resolve!: (value: T | Promise) => void; + + reject!: (error: unknown) => void; + + promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} diff --git a/extensions/positron-proxy/tsconfig.json b/extensions/positron-proxy/tsconfig.json index aaaaaabb860..b800d6f29e7 100644 --- a/extensions/positron-proxy/tsconfig.json +++ b/extensions/positron-proxy/tsconfig.json @@ -25,5 +25,6 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", + "../../src/positron-dts/positron.d.ts", ] } diff --git a/extensions/positron-python/python_files/positron/positron_ipykernel/ui_comm.py b/extensions/positron-python/python_files/positron/positron_ipykernel/ui_comm.py index a406741c174..dda73094a4e 100644 --- a/extensions/positron-python/python_files/positron/positron_ipykernel/ui_comm.py +++ b/extensions/positron-python/python_files/positron/positron_ipykernel/ui_comm.py @@ -219,6 +219,9 @@ class UiFrontendEvent(str, enum.Enum): # Show a URL in Positron's Viewer pane ShowUrl = "show_url" + # Show an HTML file in Positron + ShowHtmlFile = "show_html_file" + class BusyParams(BaseModel): """ @@ -422,6 +425,28 @@ class ShowUrlParams(BaseModel): ) +class ShowHtmlFileParams(BaseModel): + """ + Show an HTML file in Positron + """ + + path: StrictStr = Field( + description="The fully qualified filesystem path to the HTML file to display", + ) + + title: StrictStr = Field( + description="A title to be displayed in the viewer. May be empty, and can be superseded by the title in the HTML file.", + ) + + is_plot: StrictBool = Field( + description="Whether the HTML file is a plot-like object", + ) + + height: StrictInt = Field( + description="The desired height of the HTML viewer, in pixels. The special value 0 indicates that no particular height is desired, and -1 indicates that the viewer should be as tall as possible.", + ) + + EditorContext.update_forward_refs() TextDocument.update_forward_refs() @@ -465,3 +490,5 @@ class ShowUrlParams(BaseModel): ModifyEditorSelectionsParams.update_forward_refs() ShowUrlParams.update_forward_refs() + +ShowHtmlFileParams.update_forward_refs() diff --git a/extensions/positron-r/package.json b/extensions/positron-r/package.json index 0d5753f30b0..fd34acde7f0 100644 --- a/extensions/positron-r/package.json +++ b/extensions/positron-r/package.json @@ -606,7 +606,7 @@ }, "positron": { "binaryDependencies": { - "ark": "0.1.118" + "ark": "0.1.119" }, "minimumRVersion": "4.2.0", "minimumRenvVersion": "1.0.7" diff --git a/positron/comms/ui-frontend-openrpc.json b/positron/comms/ui-frontend-openrpc.json index 427015d2100..eb748374af7 100644 --- a/positron/comms/ui-frontend-openrpc.json +++ b/positron/comms/ui-frontend-openrpc.json @@ -389,6 +389,41 @@ } } ] + }, + { + "name": "show_html_file", + "summary": "Show an HTML file in Positron", + "description": "Causes the HTML file to be shown in Positron.", + "params": [ + { + "name": "path", + "description": "The fully qualified filesystem path to the HTML file to display", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "description": "A title to be displayed in the viewer. May be empty, and can be superseded by the title in the HTML file.", + "schema": { + "type": "string" + } + }, + { + "name": "is_plot", + "description": "Whether the HTML file is a plot-like object", + "schema": { + "type": "boolean" + } + }, + { + "name": "height", + "description": "The desired height of the HTML viewer, in pixels. The special value 0 indicates that no particular height is desired, and -1 indicates that the viewer should be as tall as possible.", + "schema": { + "type": "integer" + } + } + ] } ], "components": { diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index 838b1d10853..805f6859d85 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -1069,6 +1069,17 @@ declare module 'positron' { */ export function previewUrl(url: vscode.Uri): PreviewPanel; + /** + * Create and show a new preview panel for an HTML file. This is a + * convenience method that creates a new webview panel and sets its + * content to that of the given file. + * + * @param path The fully qualified path to the HTML file to preview + * + * @return New preview panel. + */ + export function previewHtml(path: string): PreviewPanel; + /** * Create a log output channel from raw data. * diff --git a/src/vs/workbench/api/browser/positron/mainThreadPreviewPanel.ts b/src/vs/workbench/api/browser/positron/mainThreadPreviewPanel.ts index 9e1567ec4b1..3a9c4bfcfff 100644 --- a/src/vs/workbench/api/browser/positron/mainThreadPreviewPanel.ts +++ b/src/vs/workbench/api/browser/positron/mainThreadPreviewPanel.ts @@ -120,12 +120,17 @@ export class MainThreadPreviewPanel extends Disposable implements extHostProtoco const extension = reviveWebviewExtension(extensionData); const targetUri = URI.revive(uri); - const origin = this.webviewOriginStore.getOrigin('positron.previewUrl', extension.id); - const preview = this._positronPreviewService.openUri(handle, origin, extension, targetUri); + const preview = this._positronPreviewService.openUri(handle, extension, targetUri); this.attachPreview(handle, preview); } + async $previewHtml(extensionData: WebviewExtensionDescription, handle: string, path: string): Promise { + const extension = reviveWebviewExtension(extensionData); + const preview = await this._positronPreviewService.openHtml(handle, extension, path); + this.attachPreview(handle, preview); + } + private attachPreview(handle: string, preview: PreviewWebview) { // Store this preview in the map @@ -185,7 +190,7 @@ export class MainThreadPreviewPanel extends Disposable implements extHostProtoco public addWebview(handle: extHostProtocol.PreviewHandle, preview: PreviewWebview): void { this._previews.add(handle, preview); - this._mainThreadWebviews.addWebview(handle, preview.webview, + this._mainThreadWebviews.addWebview(handle, preview.webview.webview, { // This is the standard for extensions built for VS Code // 1.57.0 and above (see `shouldSerializeBuffersForPostMessage`). diff --git a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts index 76fd8018b5d..9128ad2052a 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts @@ -134,6 +134,9 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce previewUrl(url: vscode.Uri) { return extHostPreviewPanels.previewUrl(extension, url); }, + previewHtml(path: string) { + return extHostPreviewPanels.previewHtml(extension, path); + }, createRawLogOutputChannel(name: string): vscode.OutputChannel { return extHostOutputService.createRawLogOutputChannel(name, extension); }, diff --git a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts index b43b4944f52..5f7e760bfeb 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts @@ -150,6 +150,11 @@ export interface MainThreadPreviewPanelShape extends IDisposable { handle: PreviewHandle, uri: URI ): void; + $previewHtml( + extension: WebviewExtensionDescription, + handle: PreviewHandle, + path: string + ): void; $disposePreview(handle: PreviewHandle): void; $reveal(handle: PreviewHandle, preserveFocus: boolean): void; $setTitle(handle: PreviewHandle, value: string): void; diff --git a/src/vs/workbench/api/common/positron/extHostPreviewPanels.ts b/src/vs/workbench/api/common/positron/extHostPreviewPanels.ts index df162aba045..d878931be57 100644 --- a/src/vs/workbench/api/common/positron/extHostPreviewPanels.ts +++ b/src/vs/workbench/api/common/positron/extHostPreviewPanels.ts @@ -228,6 +228,27 @@ export class ExtHostPreviewPanels implements extHostProtocol.ExtHostPreviewPanel return panel; } + public previewHtml( + extension: IExtensionDescription, + htmlpath: string, + ): positron.PreviewPanel { + + const handle = ExtHostPreviewPanels.newHandle(); + const viewType = 'positron.previewHtml'; + const options: positron.PreviewOptions = { + enableForms: true, + enableScripts: true, + localResourceRoots: [], + }; + this._proxy.$previewHtml(toExtensionData(extension), handle, htmlpath); + const webview = this.webviews.$createNewWebview(handle, options, extension); + const path = require('path'); + const title = path.basename(htmlpath); + const panel = this.createNewPreviewPanel(handle, viewType, title, webview as ExtHostWebview, true); + + return panel; + } + public $onDidChangePreviewPanelViewStates(newStates: extHostProtocol.PreviewPanelViewStateData): void { // Note: This logic is largely copied from // `$onDidChangeWebviewPanelViewStates`, and is written to handle diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx index 4c60eb50a19..e17d3341a6b 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx @@ -25,8 +25,9 @@ import { ZoomPlotMenuButton } from 'vs/workbench/contrib/positronPlots/browser/c import { PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; import { StaticPlotClient } from 'vs/workbench/services/positronPlots/common/staticPlotClient'; import { INotificationService } from 'vs/platform/notification/common/notification'; -import { PlotsClearAction, PlotsCopyAction, PlotsNextAction, PlotsPreviousAction, PlotsSaveAction } from 'vs/workbench/contrib/positronPlots/browser/positronPlotsActions'; +import { PlotsClearAction, PlotsCopyAction, PlotsNextAction, PlotsPopoutAction, PlotsPreviousAction, PlotsSaveAction } from 'vs/workbench/contrib/positronPlots/browser/positronPlotsActions'; import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { HtmlPlotClient } from 'vs/workbench/contrib/positronPlots/browser/htmlPlotClient'; // Constants. const kPaddingLeft = 14; @@ -77,10 +78,11 @@ export const ActionBars = (props: PropsWithChildren) => { || selectedPlot instanceof StaticPlotClient); const enableCopyPlot = hasPlots && - (positronPlotsContext.positronPlotInstances[positronPlotsContext.selectedInstanceIndex] - instanceof StaticPlotClient - || positronPlotsContext.positronPlotInstances[positronPlotsContext.selectedInstanceIndex] - instanceof PlotClientInstance); + (selectedPlot instanceof StaticPlotClient + || selectedPlot instanceof PlotClientInstance); + + const enablePopoutPlot = hasPlots && + selectedPlot instanceof HtmlPlotClient; useEffect(() => { // Empty for now. @@ -118,6 +120,10 @@ export const ActionBars = (props: PropsWithChildren) => { props.commandService.executeCommand(PlotsCopyAction.ID); }; + const popoutPlotHandler = () => { + props.commandService.executeCommand(PlotsPopoutAction.ID); + }; + // Render. return ( @@ -129,7 +135,7 @@ export const ActionBars = (props: PropsWithChildren) => { - {(enableSizingPolicy || enableSavingPlots || enableZoomPlot) && } + {(enableSizingPolicy || enableSavingPlots || enableZoomPlot || enablePopoutPlot) && } {enableSavingPlots && } {enableCopyPlot && ) => { plotsService={positronPlotsContext.positronPlotsService} /> } + {enablePopoutPlot && + + } diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotInstance.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotInstance.tsx index a2531a964a3..38df87c18e4 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotInstance.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotInstance.tsx @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotThumbnail.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotThumbnail.tsx index f214cf2b528..743bd63ea62 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotThumbnail.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/webviewPlotThumbnail.tsx @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts new file mode 100644 index 00000000000..372f81a7665 --- /dev/null +++ b/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WebviewPlotClient } from 'vs/workbench/contrib/positronPlots/browser/webviewPlotClient'; +import { PreviewHtml } from 'vs/workbench/contrib/positronPreview/browser/previewHtml'; + +/** + * A Positron plot instance that contains content from an HTML file. + */ +export class HtmlPlotClient extends WebviewPlotClient { + + private static _nextId = 0; + + /** + * Creates a new HtmlPlotClient, which wraps an HTML preview webview in an + * object that can be displayed in the Plots pane. + * + * @param html The webview to wrap. + */ + constructor(public readonly html: PreviewHtml) { + // Create the metadata for the plot. + super({ + id: `plot-${HtmlPlotClient._nextId++}`, + parent_id: '', + created: Date.now(), + session_id: html.sessionId, + code: '', + }, html.webview.webview); + + // Render the thumbnail when the webview loads. + this._register(this.html.webview.onDidLoad(e => { + this.nudgeRenderThumbnail(); + })); + } +} diff --git a/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts new file mode 100644 index 00000000000..6dbbc8002a5 --- /dev/null +++ b/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INotebookOutputWebview } from 'vs/workbench/contrib/positronOutputWebview/browser/notebookOutputWebviewService'; +import { WebviewPlotClient } from 'vs/workbench/contrib/positronPlots/browser/webviewPlotClient'; +import { ILanguageRuntimeMessageOutput } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; + +/** + * A Positron plot instance created from notebook output rendered into a + * webview. + */ +export class NotebookOutputPlotClient extends WebviewPlotClient { + + /** + * Creates a new NotebookOutputPlotClient, which wraps a notebook output + * webview in an object that can be displayed in the Plots pane. + * + * @param output The notebook output webview to wrap. + * @param message The output message from which the webview was created. + * @param code The code that generated the webview (if known) + */ + constructor(public readonly output: INotebookOutputWebview, + message: ILanguageRuntimeMessageOutput, + code?: string) { + + // Create the metadata for the plot. + super({ + id: message.id, + parent_id: message.parent_id, + created: Date.parse(message.when), + session_id: output.sessionId, + code: code ? code : '', + }, output.webview); + + // Wait for the webview to finish rendering. When it does, nudge the + // timer that renders the thumbnail. + this._register(this.output.onDidRender(e => { + this.nudgeRenderThumbnail(); + })); + } + + /** + * Claims the underlying webview. + * + * @param claimant The object taking ownership. + */ + public override claim(claimant: any) { + super.claim(claimant); + this.output.render?.(); + } +} diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlots.contribution.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlots.contribution.ts index 159010930a6..510ab644175 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlots.contribution.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlots.contribution.ts @@ -18,7 +18,7 @@ import { IPositronPlotsService, POSITRON_PLOTS_VIEW_ID } from 'vs/workbench/serv import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Extensions as ViewContainerExtensions, IViewsRegistry } from 'vs/workbench/common/views'; import { registerAction2 } from 'vs/platform/actions/common/actions'; -import { PlotsClearAction, PlotsCopyAction, PlotsNextAction, PlotsPreviousAction, PlotsRefreshAction, PlotsSaveAction } from 'vs/workbench/contrib/positronPlots/browser/positronPlotsActions'; +import { PlotsClearAction, PlotsCopyAction, PlotsNextAction, PlotsPopoutAction, PlotsPreviousAction, PlotsRefreshAction, PlotsSaveAction } from 'vs/workbench/contrib/positronPlots/browser/positronPlotsActions'; import { POSITRON_SESSION_CONTAINER } from 'vs/workbench/contrib/positronSession/browser/positronSessionContainer'; // Register the Positron plots service. @@ -68,6 +68,7 @@ class PositronPlotsContribution extends Disposable implements IWorkbenchContribu registerAction2(PlotsNextAction); registerAction2(PlotsPreviousAction); registerAction2(PlotsClearAction); + registerAction2(PlotsPopoutAction); } } diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsActions.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsActions.ts index f7c7042c703..4b35c293d01 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsActions.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsActions.ts @@ -174,3 +174,33 @@ export class PlotsClearAction extends Action2 { } } } + +/** + * Action to pop the selected plot out into a new window. + */ +export class PlotsPopoutAction extends Action2 { + static ID = 'workbench.action.positronPlots.popout'; + + constructor() { + super({ + id: PlotsPopoutAction.ID, + title: localize2('positronPlots.popoutPlot', 'Open Plot in New Window'), + category, + f1: true, + }); + } + + /** + * Runs the action and opens the selected plot in a new window. + * + * @param accessor The service accessor. + */ + async run(accessor: ServicesAccessor) { + const plotsService = accessor.get(IPositronPlotsService); + if (plotsService.selectedPlotId) { + plotsService.openPlotInNewWindow(); + } else { + accessor.get(INotificationService).info(localize('positronPlots.noPlotSelected', 'No plot selected.')); + } + } +} diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts index 8929e0393bd..d57e51bcb5b 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts @@ -21,7 +21,6 @@ import { PlotSizingPolicyFill } from 'vs/workbench/services/positronPlots/common import { PlotSizingPolicyLandscape } from 'vs/workbench/services/positronPlots/common/sizingPolicyLandscape'; import { PlotSizingPolicyPortrait } from 'vs/workbench/services/positronPlots/common/sizingPolicyPortrait'; import { PlotSizingPolicyCustom } from 'vs/workbench/services/positronPlots/common/sizingPolicyCustom'; -import { WebviewPlotClient } from 'vs/workbench/contrib/positronPlots/browser/webviewPlotClient'; import { IPositronNotebookOutputWebviewService } from 'vs/workbench/contrib/positronOutputWebview/browser/notebookOutputWebviewService'; import { IPositronIPyWidgetsService } from 'vs/workbench/services/positronIPyWidgets/common/positronIPyWidgetsService'; import { Schemas } from 'vs/base/common/network'; @@ -32,6 +31,15 @@ import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/la import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { localize } from 'vs/nls'; +import { UiFrontendEvent } from 'vs/workbench/services/languageRuntime/common/positronUiComm'; +import { IShowHtmlUriEvent } from 'vs/workbench/services/languageRuntime/common/languageRuntimeUiClient'; +import { WebviewExtensionDescription } from 'vs/workbench/contrib/webview/browser/webview'; +import { IPositronPreviewService } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; +import { NotebookOutputPlotClient } from 'vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient'; +import { HtmlPlotClient } from 'vs/workbench/contrib/positronPlots/browser/htmlPlotClient'; +import { PreviewHtml } from 'vs/workbench/contrib/positronPreview/browser/previewHtml'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; /** The maximum number of recent executions to store. */ const MaxRecentExecutions = 10; @@ -102,14 +110,17 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe @IRuntimeSessionService private _runtimeSessionService: IRuntimeSessionService, @IStorageService private _storageService: IStorageService, @IViewsService private _viewsService: IViewsService, + @IOpenerService private _openerService: IOpenerService, @IPositronNotebookOutputWebviewService private _notebookOutputWebviewService: IPositronNotebookOutputWebviewService, @IPositronIPyWidgetsService private _positronIPyWidgetsService: IPositronIPyWidgetsService, + @IPositronPreviewService private _positronPreviewService: IPositronPreviewService, @IFileService private readonly _fileService: IFileService, @IFileDialogService private readonly _fileDialogService: IFileDialogService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IClipboardService private _clipboardService: IClipboardService, - @IDialogService private readonly _dialogService: IDialogService) { + @IDialogService private readonly _dialogService: IDialogService, + @IExtensionService private readonly _extensionService: IExtensionService) { super(); // Register for language runtime service startups @@ -117,6 +128,17 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe this.attachRuntime(runtime); })); + // Register for UI comm events + this._register(this._runtimeSessionService.onDidReceiveRuntimeEvent(event => { + // If we have a new HTML file to show, turn it into a webview plot. + if (event.event.name === UiFrontendEvent.ShowHtmlFile) { + const data = event.event.data as IShowHtmlUriEvent; + if (data.event.is_plot) { + this.createWebviewPlot(event.session_id, data); + } + } + })); + // Listen for plots being selected and update the selected plot ID this._register(this._onDidSelectPlot.event((id) => { this._selectedPlotId = id; @@ -125,10 +147,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe // Listen for plot clients being created by the IPyWidget service and register them with the plots service // so they can be displayed in the plots pane. this._register(this._positronIPyWidgetsService.onDidCreatePlot((plotClient) => { - this._plots.unshift(plotClient); - this._onDidEmitPlot.fire(plotClient); - this._onDidSelectPlot.fire(plotClient.id); - this._register(plotClient); + this.registerNewPlotClient(plotClient); })); // When the storage service is about to save state, store the current history policy @@ -164,6 +183,34 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe } }); + // When the extension service is about to stop, remove any HTML plots + // from the plots list. These plots are backed by a proxy that runs in + // the extension host, so may become invalid when the extension host is + // stopped. + this._register(this._extensionService.onWillStop((e) => { + // Nothing to do if there are no plots + if (this._plots.length === 0) { + return; + } + let removedSelectedPlot = false; + this._plots.forEach((plot, index) => { + if (plot instanceof HtmlPlotClient) { + plot.dispose(); + if (this._selectedPlotId === plot.id) { + removedSelectedPlot = true; + } + this._plots.splice(index, 1); + } + }); + + this._onDidReplacePlots.fire(this._plots); + + // If we removed the selected plot, select the first plot in the list + if (removedSelectedPlot && this._plots.length > 0) { + this.selectPlot(this._plots[0].id); + } + })); + // Create the default sizing policy this._selectedSizingPolicy = new PlotSizingPolicyAuto(); this._sizingPolicies.push(this._selectedSizingPolicy); @@ -210,6 +257,25 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe } } + openPlotInNewWindow(): void { + + if (!this._selectedPlotId) { + throw new Error('Cannot open plot in new window: no plot selected'); + } + + const selectedPlot = this._plots.find(plot => plot.id === this._selectedPlotId); + if (!selectedPlot) { + throw new Error(`Cannot open plot in new window: plot ${this._selectedPlotId} not found`); + } + + if (selectedPlot instanceof HtmlPlotClient) { + this._openerService.open(selectedPlot.html.uri, + { openExternal: true, fromUserGesture: true }); + } else { + throw new Error(`Cannot open plot in new window: plot ${this._selectedPlotId} is not an HTML plot`); + } + } + /** * Gets the currently known sizing policies. */ @@ -467,7 +533,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe this._viewsService.openView(POSITRON_PLOTS_VIEW_ID, false); } else if (message.kind === RuntimeOutputKind.PlotWidget) { // Create a new webview plot client instance and register it with the service. - await this.registerWebviewPlot(session, message, code); + await this.registerNotebookOutputPlot(session, message, code); // Raise the Plots pane so the plot is visible. this._viewsService.openView(POSITRON_PLOTS_VIEW_ID, false); @@ -543,11 +609,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe sessionId: string, message: ILanguageRuntimeMessageOutput, code?: string) { - const client = new StaticPlotClient(sessionId, message, code); - this._plots.unshift(client); - this._onDidEmitPlot.fire(client); - this._onDidSelectPlot.fire(client.id); - this._register(client); + this.registerNewPlotClient(new StaticPlotClient(sessionId, message, code)); } /** @@ -557,7 +619,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe * @param message The message containing the source for the webview. * @param code The code that generated the plot, if available. */ - private async registerWebviewPlot( + private async registerNotebookOutputPlot( runtime: ILanguageRuntimeSession, message: ILanguageRuntimeMessageOutput, code?: string) { @@ -566,11 +628,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe const webview = await this._notebookOutputWebviewService.createNotebookOutputWebview( runtime, message); if (webview) { - const client = new WebviewPlotClient(webview, message, code); - this._plots.unshift(client); - this._onDidEmitPlot.fire(client); - this._onDidSelectPlot.fire(client.id); - this._register(client); + this.registerNewPlotClient(new NotebookOutputPlotClient(webview, message, code)); } } @@ -806,6 +864,38 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe plot.metadata.id === plotId); } + private createWebviewPlot(sessionId: string, event: IShowHtmlUriEvent) { + // Look up the extension ID + const session = this._runtimeSessionService.getSession(sessionId); + const extension = session!.runtimeMetadata.extensionId; + const webviewExtension: WebviewExtensionDescription = { + id: extension + }; + + // Create the webview. + const webview = this._positronPreviewService.createHtmlWebview(sessionId, + webviewExtension, event) as PreviewHtml; + + // Register the new plot client + this.registerNewPlotClient(new HtmlPlotClient(webview)); + + // Raise the Plots pane so the plot is visible. + this._viewsService.openView(POSITRON_PLOTS_VIEW_ID, false); + } + + /** + * Registser a new plot client with the service, select it, and fire the + * appropriate events. + * + * @param client The plot client to register + */ + private registerNewPlotClient(client: IPositronPlotClient) { + this._plots.unshift(client); + this._onDidEmitPlot.fire(client); + this._onDidSelectPlot.fire(client.id); + this._register(client); + } + /** * Placeholder for service initialization. */ diff --git a/src/vs/workbench/contrib/positronPlots/browser/webviewPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/webviewPlotClient.ts index 182f5e1ef45..8a49577b831 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/webviewPlotClient.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/webviewPlotClient.ts @@ -7,9 +7,8 @@ import * as DOM from 'vs/base/browser/dom'; import { VSBuffer, encodeBase64 } from 'vs/base/common/buffer'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; -import { INotebookOutputWebview } from 'vs/workbench/contrib/positronOutputWebview/browser/notebookOutputWebviewService'; +import { IOverlayWebview } from 'vs/workbench/contrib/webview/browser/webview'; import { IPositronPlotMetadata } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; -import { ILanguageRuntimeMessageOutput } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; import { IPositronPlotClient } from 'vs/workbench/services/positronPlots/common/positronPlots'; /** @@ -17,7 +16,6 @@ import { IPositronPlotClient } from 'vs/workbench/services/positronPlots/common/ */ export class WebviewPlotClient extends Disposable implements IPositronPlotClient { - public readonly metadata: IPositronPlotMetadata; private _thumbnail: VSBuffer | undefined; @@ -30,33 +28,18 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient private _element: HTMLElement | undefined; /** - * Creates a new WebviewPlotClient, which wraps a notebook output webview in + * Creates a new WebPlotClient, which wraps a notebook output webview in * an object that can be displayed in the Plots pane. * * @param webview The webview to wrap. * @param message The output message from which the webview was created. * @param code The code that generated the webview (if known) */ - constructor(public readonly webview: INotebookOutputWebview, - message: ILanguageRuntimeMessageOutput, - code?: string) { + constructor( + public readonly metadata: IPositronPlotMetadata, + public readonly webview: IOverlayWebview) { super(); - // Create the metadata for the plot. - this.metadata = { - id: message.id, - parent_id: message.parent_id, - created: Date.parse(message.when), - session_id: webview.sessionId, - code: code ? code : '', - }; - - // Wait for the webview to finish rendering. When it does, nudge the - // timer that renders the thumbnail. - this._register(this.webview.onDidRender(e => { - this.nudgeRenderThumbnail(); - })); - this._onDidRenderThumbnail = new Emitter(); this.onDidRenderThumbnail = this._onDidRenderThumbnail.event; } @@ -83,8 +66,7 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient * @param claimant The object taking ownership. */ public claim(claimant: any) { - this.webview.webview.claim(claimant, DOM.getWindow(this._element), undefined); - this.webview.render?.(); + this.webview.claim(claimant, DOM.getWindow(this._element), undefined); this._claimed = true; } @@ -95,7 +77,7 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient */ public layoutWebviewOverElement(ele: HTMLElement) { this._element = ele; - this.webview.webview.layoutWebviewOverElement(ele); + this.webview.layoutWebviewOverElement(ele); } /** @@ -104,7 +86,7 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient * @param claimant The object releasing ownership. */ public release(claimant: any) { - this.webview.webview.release(claimant); + this.webview.release(claimant); this._claimed = false; // We can't render a thumbnail while the webview isn't showing, so cancel the @@ -117,7 +99,7 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient * Electron APIs in desktop mode) as PNG. */ private renderThumbnail() { - this.webview.webview.captureContentsAsPng().then(data => { + this.webview.captureContentsAsPng().then(data => { if (data) { this._thumbnail = data; this._onDidRenderThumbnail.fire(this.asDataUri(data)); @@ -128,7 +110,7 @@ export class WebviewPlotClient extends Disposable implements IPositronPlotClient /** * Nudge the render timer; debounces requests to render the plot thumbnail. */ - private nudgeRenderThumbnail() { + protected nudgeRenderThumbnail() { // Cancel any pending render this.cancelPendingRender(); diff --git a/src/vs/workbench/contrib/positronPlots/browser/widgetPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/widgetPlotClient.ts index 3f89bc0967c..c0710a73499 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/widgetPlotClient.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/widgetPlotClient.ts @@ -1,17 +1,17 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ import { INotebookOutputWebview } from 'vs/workbench/contrib/positronOutputWebview/browser/notebookOutputWebviewService'; -import { WebviewPlotClient } from 'vs/workbench/contrib/positronPlots/browser/webviewPlotClient'; +import { NotebookOutputPlotClient } from 'vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient'; import { IPyWidgetClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeIPyWidgetClient'; import { ILanguageRuntimeMessageOutput } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; /** * A Positron plot instance that is backed by a webview. */ -export class WidgetPlotClient extends WebviewPlotClient { +export class WidgetPlotClient extends NotebookOutputPlotClient { /** * Creates a new WebviewPlotClient, which wraps a notebook output webview in diff --git a/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.css b/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.css index faede01f706..dbf3533f6b5 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.css +++ b/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.css @@ -33,3 +33,7 @@ bar to fill the center of the toolbar. padding-bottom: 2px; flex-grow: 1; } + +.preview-action-bar .preview-title { + flex-grow: 1; +} diff --git a/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.ts b/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.ts new file mode 100644 index 00000000000..9e3e08c5ea7 --- /dev/null +++ b/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IPositronPreviewService } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; +import { PositronSessionsServices } from 'vs/workbench/contrib/positronRuntimeSessions/browser/positronRuntimeSessionsState'; +import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; + + +export const kPaddingLeft = 8; +export const kPaddingRight = 8; + +/** + * PreviewActionBarsProps interface. + */ +export interface PreviewActionBarsProps extends PositronSessionsServices { + readonly layoutService: IWorkbenchLayoutService; + readonly notificationService: INotificationService; + readonly openerService: IOpenerService; + readonly positronPreviewService: IPositronPreviewService; +} diff --git a/src/vs/workbench/contrib/positronPreview/browser/components/htmlActionBars.tsx b/src/vs/workbench/contrib/positronPreview/browser/components/htmlActionBars.tsx new file mode 100644 index 00000000000..96af303eaef --- /dev/null +++ b/src/vs/workbench/contrib/positronPreview/browser/components/htmlActionBars.tsx @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./actionBars'; +import * as React from 'react'; +import { PropsWithChildren, useEffect, useState } from 'react'; // eslint-disable-line no-duplicate-imports +import { localize } from 'vs/nls'; +import { PositronActionBar } from 'vs/platform/positronActionBar/browser/positronActionBar'; +import { PositronActionBarContextProvider } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; +import { kPaddingLeft, kPaddingRight, PreviewActionBarsProps } from 'vs/workbench/contrib/positronPreview/browser/components/actionBars'; +import { PreviewHtml } from 'vs/workbench/contrib/positronPreview/browser/previewHtml'; +import { ActionBarRegion } from 'vs/platform/positronActionBar/browser/components/actionBarRegion'; +import { ActionBarButton } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; +import { ActionBarSeparator } from 'vs/platform/positronActionBar/browser/components/actionBarSeparator'; +import { DisposableStore } from 'vs/base/common/lifecycle'; + +const reload = localize('positron.preview.html.reload', "Reload the content"); +const clear = localize('positron.preview.html.clear', "Clear the content"); +const openInBrowser = localize('positron.preview.html.openInBrowser', "Open the content in the default browser"); + +/** + * HtmlActionBarsProps interface. + */ +export interface HtmlActionBarsProps extends PreviewActionBarsProps { + + // The active preview. + readonly preview: PreviewHtml; +} + +export const HtmlActionBars = (props: PropsWithChildren) => { + + const [title, setTitle] = useState(props.preview.html.title); + + // Handler for the reload button. + const reloadHandler = () => { + props.preview.webview.postMessage({ + channel: 'execCommand', + data: 'reload-window' + }); + }; + + // Handler for the clear button. + const clearHandler = () => { + props.positronPreviewService.clearAllPreviews(); + }; + + // Handler for the open in browser button. + const openInBrowserHandler = () => { + props.openerService.open(props.preview.uri, + { openExternal: true, fromUserGesture: true }); + }; + + // Main use effect. + useEffect(() => { + // Create the disposable store for cleanup. + const disposableStore = new DisposableStore(); + disposableStore.add(props.preview.webview.onDidLoad((title) => { + if (title) { + setTitle(title); + } + })); + return () => disposableStore.dispose(); + }, [props.preview.webview]); + + // Render. + return ( + +
+ + + + + + {title} + + + + + + + + +
+
+ ); +}; diff --git a/src/vs/workbench/contrib/positronPreview/browser/components/previewContainer.tsx b/src/vs/workbench/contrib/positronPreview/browser/components/previewContainer.tsx index 120934f856d..a17cea0305d 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/components/previewContainer.tsx +++ b/src/vs/workbench/contrib/positronPreview/browser/components/previewContainer.tsx @@ -57,15 +57,15 @@ export const PreviewContainer = (props: PreviewContainerProps) => { if (props.visible) { if (webviewRef.current) { const window = DOM.getWindow(webviewRef.current); - webview.claim(this, window, undefined); - webview.layoutWebviewOverElement(webviewRef.current); + webview.webview.claim(this, window, undefined); + webview.webview.layoutWebviewOverElement(webviewRef.current); return () => { - webview?.release(this); + webview?.webview.release(this); }; } } else { // If the preview is not visible, release the webview. - webview.release(this); + webview.webview.release(this); } } return () => { }; @@ -77,7 +77,7 @@ export const PreviewContainer = (props: PreviewContainerProps) => { // every time the container is resized or moved. useEffect(() => { if (props.preview && webviewRef.current && props.visible) { - props.preview.webview.layoutWebviewOverElement(webviewRef.current); + props.preview.webview.webview.layoutWebviewOverElement(webviewRef.current); } }); diff --git a/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.tsx b/src/vs/workbench/contrib/positronPreview/browser/components/urlActionBars.tsx similarity index 80% rename from src/vs/workbench/contrib/positronPreview/browser/components/actionBars.tsx rename to src/vs/workbench/contrib/positronPreview/browser/components/urlActionBars.tsx index f8cb362306d..634f3028bf9 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/components/actionBars.tsx +++ b/src/vs/workbench/contrib/positronPreview/browser/components/urlActionBars.tsx @@ -6,45 +6,24 @@ import 'vs/css!./actionBars'; import * as React from 'react'; import { PropsWithChildren, useEffect, } from 'react'; // eslint-disable-line no-duplicate-imports -import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { PositronActionBar } from 'vs/platform/positronActionBar/browser/positronActionBar'; -import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { PositronActionBarContextProvider } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; -import { PositronSessionsServices } from 'vs/workbench/contrib/positronRuntimeSessions/browser/positronRuntimeSessionsState'; import { ActionBarRegion } from 'vs/platform/positronActionBar/browser/components/actionBarRegion'; import { ActionBarButton } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; import { localize } from 'vs/nls'; import { PreviewUrl, QUERY_NONCE_PARAMETER } from 'vs/workbench/contrib/positronPreview/browser/previewUrl'; -import { IPositronPreviewService } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; import { ActionBarSeparator } from 'vs/platform/positronActionBar/browser/components/actionBarSeparator'; -import { IOpenerService } from 'vs/platform/opener/common/opener'; import { URI } from 'vs/base/common/uri'; -import { INotificationService } from 'vs/platform/notification/common/notification'; import { DisposableStore } from 'vs/base/common/lifecycle'; +import { kPaddingLeft, kPaddingRight, PreviewActionBarsProps } from 'vs/workbench/contrib/positronPreview/browser/components/actionBars'; // Constants. -const kPaddingLeft = 8; -const kPaddingRight = 8; const kUrlBarInputName = 'url-bar'; /** - * ActionBarsProps interface. + * UrlActionBarsProps interface. */ -export interface ActionBarsProps extends PositronSessionsServices { - // Services. - readonly commandService: ICommandService; - readonly configurationService: IConfigurationService; - readonly contextKeyService: IContextKeyService; - readonly contextMenuService: IContextMenuService; - readonly keybindingService: IKeybindingService; - readonly layoutService: IWorkbenchLayoutService; - readonly notificationService: INotificationService; - readonly openerService: IOpenerService; - readonly positronPreviewService: IPositronPreviewService; +export interface UrlActionBarsProps extends PreviewActionBarsProps { // The active preview. readonly preview: PreviewUrl; @@ -59,11 +38,11 @@ const openInBrowser = localize('positron.preview.openInBrowser', "Open the curre const currentUrl = localize('positron.preview.currentUrl', "The current URL"); /** - * ActionBars component. + * UrlActionBars component. * @param props An ActionBarsProps that contains the component properties. * @returns The rendered component. */ -export const ActionBars = (props: PropsWithChildren) => { +export const UrlActionBars = (props: PropsWithChildren) => { // Save the current URL. const currentUri = props.preview.currentUri; diff --git a/src/vs/workbench/contrib/positronPreview/browser/positronPreview.contribution.ts b/src/vs/workbench/contrib/positronPreview/browser/positronPreview.contribution.ts index 2a37f263b58..3de51e015b1 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/positronPreview.contribution.ts +++ b/src/vs/workbench/contrib/positronPreview/browser/positronPreview.contribution.ts @@ -19,10 +19,13 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWo import { ViewContainer, IViewContainersRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, IViewsRegistry } from 'vs/workbench/common/views'; import { registerAction2 } from 'vs/platform/actions/common/actions'; import { PositronOpenUrlInViewerAction } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewActions'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope, } from 'vs/platform/configuration/common/configurationRegistry'; // The Positron preview view icon. const positronPreviewViewIcon = registerIcon('positron-preview-view-icon', Codicon.positronPreviewView, nls.localize('positronPreviewViewIcon', 'View icon of the Positron preview view.')); +export const POSITRON_PREVIEW_PLOTS_IN_VIEWER = 'positron.viewer.interactivePlotsInViewer'; + // Register the Positron preview container. const VIEW_CONTAINER: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ id: POSITRON_PREVIEW_VIEW_ID, @@ -71,4 +74,19 @@ class PositronPreviewContribution extends Disposable implements IWorkbenchContri } } +// Register configuration options for the preview service +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'positron', + properties: { + [POSITRON_PREVIEW_PLOTS_IN_VIEWER]: { + scope: ConfigurationScope.MACHINE, + type: 'boolean', + default: false, + description: nls.localize('positron.viewer.interactivePlotsInViewer', "When enabled, interactive HTML plots are shown in the Viewer pane rather than in the Plots pane.") + }, + } +}); + + Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(PositronPreviewContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/positronPreview/browser/positronPreview.tsx b/src/vs/workbench/contrib/positronPreview/browser/positronPreview.tsx index 8405fda7e70..81bc7e3c169 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/positronPreview.tsx +++ b/src/vs/workbench/contrib/positronPreview/browser/positronPreview.tsx @@ -19,12 +19,14 @@ import { PositronPreviewContextProvider } from 'vs/workbench/contrib/positronPre import { IPositronPreviewService } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; import { PreviewWebview } from 'vs/workbench/contrib/positronPreview/browser/previewWebview'; import { PositronPreviewViewPane } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewView'; -import { ActionBars } from 'vs/workbench/contrib/positronPreview/browser/components/actionBars'; +import { UrlActionBars } from 'vs/workbench/contrib/positronPreview/browser/components/urlActionBars'; import { IRuntimeSessionService } from 'vs/workbench/services/runtimeSession/common/runtimeSessionService'; import { PreviewUrl } from 'vs/workbench/contrib/positronPreview/browser/previewUrl'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { PreviewHtml } from 'vs/workbench/contrib/positronPreview/browser/previewHtml'; +import { HtmlActionBars } from 'vs/workbench/contrib/positronPreview/browser/components/htmlActionBars'; /** * PositronPreviewProps interface. @@ -94,16 +96,21 @@ export const PositronPreview = (props: PropsWithChildren) return () => disposableStore.dispose(); }, [props.positronPreviewService, props.reactComponentContainer]); - const showToolbar = activePreview && activePreview instanceof PreviewUrl; + const urlToolbar = activePreview && activePreview instanceof PreviewUrl; + const htmlToolbar = activePreview && activePreview instanceof PreviewHtml; + const showToolbar = urlToolbar || htmlToolbar; // Render. return ( - {showToolbar && + {urlToolbar && // Render the action bars. We supply the preview ID as a key // here to ensure the action bars are keyed to the preview; // otherwise the URL bar can get out of sync with the preview // since it's an uncontrolled component. - + + } + {htmlToolbar && + } ; constructor( + @ICommandService private readonly _commandService: ICommandService, @IWebviewService private readonly _webviewService: IWebviewService, @IViewsService private readonly _viewsService: IViewsService, @IRuntimeSessionService private readonly _runtimeSessionService: IRuntimeSessionService, @ILogService private readonly _logService: ILogService, @IOpenerService private readonly _openerService: IOpenerService, @IPositronNotebookOutputWebviewService private readonly _notebookOutputWebviewService: IPositronNotebookOutputWebviewService, + @IExtensionService private readonly _extensionService: IExtensionService ) { super(); this.onDidCreatePreviewWebview = this._onDidCreatePreviewWebviewEmitter.event; @@ -52,11 +60,13 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi this._runtimeSessionService.activeSessions.forEach(runtime => { this.attachRuntime(runtime); }); - this._runtimeSessionService.onWillStartSession(e => { + this._register(this._runtimeSessionService.onWillStartSession(e => { this.attachRuntime(e.session); - }); - this._runtimeSessionService.onDidReceiveRuntimeEvent(e => { - if (e.event.name === UiFrontendEvent.ShowUrl) { + })); + this._register(this._runtimeSessionService.onDidReceiveRuntimeEvent(e => { + if (e.event.name === UiFrontendEvent.ShowUrl || + e.event.name === UiFrontendEvent.ShowHtmlFile + ) { // We need to figure out which extension is responsible for this // URL. First, look up the session. const session = this._runtimeSessionService.getSession(e.session_id); @@ -69,9 +79,34 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi return; } - this.handleShowUrlEvent(session, e.event.data as ShowUrlEvent); + if (e.event.name === UiFrontendEvent.ShowHtmlFile) { + const data = e.event.data as IShowHtmlUriEvent; + if (!data.event.is_plot) { + this.handleShowHtmlFileEvent(session, data); + } + } else { + this.handleShowUrlEvent(session, e.event.data as ShowUrlEvent); + } } - }); + })); + + // When the extension host is about to stop, dispose all previews that + // use HTML proxies, since these proxies live in the extension host. + this._register(this._extensionService.onWillStop((e) => { + for (const preview of this._items.values()) { + if (preview instanceof PreviewHtml) { + preview.webview.dispose(); + this._items.delete(preview.previewId); + } + } + })); + } + + createHtmlWebview(sessionId: string, + extension: WebviewExtensionDescription | undefined, + event: IShowHtmlUriEvent): PreviewHtml { + const preview = this.createPreviewHtml(sessionId, `previewHtml.${PositronPreviewService._previewIdCounter++}`, extension, event.uri, event.event); + return preview as PreviewHtml; } get previewWebviews(): PreviewWebview[] { @@ -146,7 +181,8 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi preserveFocus?: boolean | undefined): PreviewWebview { const webview = this._webviewService.createWebviewOverlay(webviewInitInfo); - const preview = new PreviewWebview(viewType, previewId, title, webview); + const overlay = this.createOverlayWebview(webview); + const preview = new PreviewWebview(viewType, previewId, title, overlay); this._items.set(previewId, preview); this.openPreviewWebview(preview, preserveFocus); @@ -154,10 +190,22 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi return preview; } - openUri(previewId: string, origin: string, extension: WebviewExtensionDescription, uri: URI): PreviewWebview { + /** + * Create an overlay webview to host preview content. + * + * @param viewType The view type of the preview + * @param uri The URI to show in the webview + * @param extension Optional information about the extension that is + * creating the preview + * @returns + */ + private createWebview( + viewType: string, + uri: URI, + extension: WebviewExtensionDescription | undefined): PreviewOverlayWebview { const webviewInitInfo: WebviewInitInfo = { origin, - providedViewType: 'positron.previewUrl', + providedViewType: viewType, title: '', options: { enableFindWidget: true, @@ -174,34 +222,118 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi }; const webview = this._webviewService.createWebviewOverlay(webviewInitInfo); - const preview = this.createPreviewUrl(previewId, webview, uri); + const overlay = this.createOverlayWebview(webview); + return overlay; + } + + /** + * Create a URL preview. + */ + private createPreviewUrl( + previewId: string, + extension: WebviewExtensionDescription | undefined, uri: URI): PreviewUrl { + const overlay = this.createWebview(POSITRON_PREVIEW_URL_VIEW_TYPE, uri, extension); + return new PreviewUrl(previewId, overlay, uri); + } - // Remove any other preview URLs from the item list; they can be expensive + /** + * Create a preview for an HTML file (being proxied at a URI). + */ + private createPreviewHtml( + sessionId: string, + previewId: string, + extension: WebviewExtensionDescription | undefined, + uri: URI, + event: ShowHtmlFileEvent): PreviewHtml { + const overlay = this.createWebview(POSITRON_PREVIEW_HTML_VIEW_TYPE, uri, extension); + return new PreviewHtml(sessionId, previewId, overlay, uri, event); + } + + /** + * Open a URI in the preview pane. + */ + public openUri( + previewId: string, + extension: WebviewExtensionDescription, + uri: URI): PreviewWebview { + const preview = this.createPreviewUrl(previewId, extension, uri); + this.makeActivePreview(preview); + return preview; + } + + /** + * Makes a preview the active preview, removing other previews and opening + * the new preview. + * + * @param preview The preview to make active + */ + private makeActivePreview(preview: PreviewWebview) { + // Remove any other previews from the item list; they can be expensive // to keep around. - this._items.forEach((value, key) => { - if (value instanceof PreviewUrl) { - value.dispose(); - this._items.delete(key); - } + this._items.forEach((value) => { + value.dispose(); }); - this._items.set(previewId, preview); + this._items.clear(); + this._items.set(preview.previewId, preview); // Open the preview this.openPreviewWebview(preview); + } + + /** + * Opens an HTML file in the preview pane. + * + * @param previewId The unique ID or handle of the preview. + * @param extension The extension that is opening the URL. + * @param htmlpath The path to the HTML file. + */ + public async openHtml( + previewId: string, + extension: WebviewExtensionDescription, + htmlpath: string): Promise { + + // Use the Positron Proxy extension to create a URL for the HTML file. + const url = await this._commandService.executeCommand( + 'positronProxy.startHtmlProxyServer', + htmlpath + ); + + if (!url) { + throw new Error(`Failed to start HTML file proxy server for ${htmlpath}`); + } + + // Parse the URL and resolve it if necessary. The resolution step is + // necessary when URI is hosted on a remote server. + let uri = URI.parse(url); + try { + const resolvedUri = await this._openerService.resolveExternalUri(uri); + uri = resolvedUri.resolved; + } catch { + // Noop; use the original URI + } + + // Create a ShowFileEvent for the HTML file. + const evt: ShowHtmlFileEvent = { + height: 0, + title: basename(htmlpath), + is_plot: false, + path: htmlpath, + }; + + // Create the preview + const preview = this.createPreviewHtml('', previewId, extension, uri, evt); + // Make the preview active and return it + this.makeActivePreview(preview); return preview; } /** - * Creates a URL preview instance. - * - * @param previewId The preview ID - * @param webview The overlay webview instance - * @param uri The URI to open in the preview - * @returns A PreviewUrl instance + * Create an overlay webview. */ - protected createPreviewUrl(previewId: string, webview: IOverlayWebview, uri: URI): PreviewUrl { - return new PreviewUrl(previewId, webview, uri); + protected createOverlayWebview( + webview: IOverlayWebview): PreviewOverlayWebview { + return new PreviewOverlayWebview(webview); } /** @@ -264,10 +396,11 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi const webview = await this._notebookOutputWebviewService.createNotebookOutputWebview(session, e); if (webview) { + const overlay = this.createOverlayWebview(webview.webview); const preview = new PreviewWebview( 'notebookRenderer', e.id, session.metadata.sessionName, - webview.webview); + overlay); this._items.set(e.id, preview); this.openPreviewWebview(preview, false); } @@ -277,6 +410,24 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi this._register(session.onDidReceiveRuntimeMessageResult(handleDidReceiveRuntimeMessageOutput)); } + /** + * Handles a ShowHtmlFile event. + */ + private handleShowHtmlFileEvent(session: ILanguageRuntimeSession, event: IShowHtmlUriEvent) { + // Create a unique ID for this preview. + const previewId = `previewHtml.${PositronPreviewService._previewIdCounter++}`; + + const extension = session.runtimeMetadata.extensionId; + const webviewExtension: WebviewExtensionDescription = { + id: extension + }; + + // Create the preview + const preview = this.createPreviewHtml(session.sessionId, previewId, webviewExtension, event.uri, event.event); + + this.makeActivePreview(preview); + } + /** * Handles a ShowUrl event from a runtime session. * @@ -318,7 +469,7 @@ export class PositronPreviewService extends Disposable implements IPositronPrevi const previewId = `previewUrl.${PositronPreviewService._previewIdCounter++}`; // Open the requested URI. - this.openUri(previewId, POSITRON_PREVIEW_URL_VIEW_TYPE, webviewExtension, uri); + this.openUri(previewId, webviewExtension, uri); } /** diff --git a/src/vs/workbench/contrib/positronPreview/browser/positronPreviewSevice.ts b/src/vs/workbench/contrib/positronPreview/browser/positronPreviewSevice.ts index 68ec7cb4324..ad7080e7872 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/positronPreviewSevice.ts +++ b/src/vs/workbench/contrib/positronPreview/browser/positronPreviewSevice.ts @@ -6,8 +6,10 @@ import { Event } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { PreviewHtml } from 'vs/workbench/contrib/positronPreview/browser/previewHtml'; import { PreviewWebview } from 'vs/workbench/contrib/positronPreview/browser/previewWebview'; import { WebviewExtensionDescription, WebviewInitInfo } from 'vs/workbench/contrib/webview/browser/webview'; +import { IShowHtmlUriEvent } from 'vs/workbench/services/languageRuntime/common/languageRuntimeUiClient'; export const POSITRON_PREVIEW_VIEW_ID = 'workbench.panel.positronPreview'; @@ -18,6 +20,11 @@ export const POSITRON_PREVIEW_SERVICE_ID = 'positronPreviewService'; */ export const POSITRON_PREVIEW_URL_VIEW_TYPE = 'positron.previewUrl'; +/** + * The unique viewType that identifies Positron HTML previews. + */ +export const POSITRON_PREVIEW_HTML_VIEW_TYPE = 'positron.previewHtml'; + export const IPositronPreviewService = createDecorator(POSITRON_PREVIEW_SERVICE_ID); /** @@ -44,15 +51,41 @@ export interface IPositronPreviewService { * Opens a URI in the preview pane. * * @param previewId The unique ID or handle of the preview. - * @param origin The origin of the URL. * @param extension The extension that is opening the URL. * @param uri The URI to open in the preview. */ - openUri(previewId: string, - origin: string, + openUri( + previewId: string, extension: WebviewExtensionDescription | undefined, uri: URI): PreviewWebview; + /** + * Opens an HTML file in the preview pane. + * + * @param previewId The unique ID or handle of the preview. + * @param extension The extension that is opening the URL. + * @param path The path to the HTML file. + */ + openHtml( + previewId: string, + extension: WebviewExtensionDescription | undefined, + path: string): Promise; + + /** + * Opens an HTML file from a runtime message in the preview pane. This + * method just creates and returns the preview; it doesn't show it in the + * pane. Used by the Plots service to create a webview for an interactive + * plot. + * + * @param sessionId The session ID of the preview. + * @param extension The extension that is opening the URL. + * @param uri The URI to open in the preview. + */ + createHtmlWebview( + sessionId: string, + extension: WebviewExtensionDescription | undefined, + event: IShowHtmlUriEvent): PreviewHtml; + /** * An event that is fired when a new preview panel webview is created. */ diff --git a/src/vs/workbench/contrib/positronPreview/browser/previewHtml.ts b/src/vs/workbench/contrib/positronPreview/browser/previewHtml.ts new file mode 100644 index 00000000000..eabde919f65 --- /dev/null +++ b/src/vs/workbench/contrib/positronPreview/browser/previewHtml.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from 'vs/base/common/uri'; +import { POSITRON_PREVIEW_HTML_VIEW_TYPE } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; +import { PreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/browser/previewOverlayWebview'; +import { PreviewWebview } from 'vs/workbench/contrib/positronPreview/browser/previewWebview'; +import { ShowHtmlFileEvent } from 'vs/workbench/services/languageRuntime/common/positronUiComm'; + +export const QUERY_NONCE_PARAMETER = '_positronRender'; + +/** + * PreviewHtml is a class that represents a Positron `PreviewWebview` that + * contains a preview of HTML content. + */ +export class PreviewHtml extends PreviewWebview { + + /** + * Construct a new PreviewHtml. + * + * @param sessionId The session ID of the preview + * @param previewId A unique ID for the preview + * @param webview The underlying webview instance that hosts the preview's content + * @param uri The URI to open in the preview + */ + constructor( + readonly sessionId: string, + previewId: string, + webview: PreviewOverlayWebview, + readonly uri: URI, + readonly html: ShowHtmlFileEvent + ) { + super(POSITRON_PREVIEW_HTML_VIEW_TYPE, previewId, + POSITRON_PREVIEW_HTML_VIEW_TYPE, + webview); + + // Perform the initial navigation. + this.webview.loadUri(uri); + } +} diff --git a/src/vs/workbench/contrib/positronPreview/browser/previewOverlayWebview.ts b/src/vs/workbench/contrib/positronPreview/browser/previewOverlayWebview.ts new file mode 100644 index 00000000000..59ebaa3a12c --- /dev/null +++ b/src/vs/workbench/contrib/positronPreview/browser/previewOverlayWebview.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { IOverlayWebview } from 'vs/workbench/contrib/webview/browser/webview'; + +export class PreviewOverlayWebview extends Disposable { + + public onDidNavigate = this.webview.onDidNavigate; + public onDidDispose = this.webview.onDidDispose; + public onDidLoad = this.webview.onDidLoad; + + constructor(public readonly webview: IOverlayWebview) { + super(); + this._register(webview); + } + + public setTitle(value: string): void { + this.webview.setTitle(value); + } + + public postMessage(message: any, transfer?: readonly ArrayBuffer[]): Promise { + return this.webview.postMessage(message, transfer); + } + + /** + * Loads a URI in the internal webview. + * + * This is overridden in the Electron implementation to use the webview's + * `loadUri` method, which has native support for loading URIs. + * + * @param uri The URI to load + */ + public loadUri(uri: URI): void { + this.webview.setHtml(` + + + + + + + + + `); + } +} diff --git a/src/vs/workbench/contrib/positronPreview/browser/previewUrl.ts b/src/vs/workbench/contrib/positronPreview/browser/previewUrl.ts index cb08a60a220..71b7f890aa0 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/previewUrl.ts +++ b/src/vs/workbench/contrib/positronPreview/browser/previewUrl.ts @@ -6,8 +6,8 @@ import { Emitter } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; import { POSITRON_PREVIEW_URL_VIEW_TYPE } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewSevice'; +import { PreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/browser/previewOverlayWebview'; import { PreviewWebview } from 'vs/workbench/contrib/positronPreview/browser/previewWebview'; -import { IOverlayWebview } from 'vs/workbench/contrib/webview/browser/webview'; export const QUERY_NONCE_PARAMETER = '_positronRender'; @@ -31,7 +31,7 @@ export class PreviewUrl extends PreviewWebview { */ constructor( previewId: string, - webview: IOverlayWebview, + webview: PreviewOverlayWebview, private _uri: URI ) { super(POSITRON_PREVIEW_URL_VIEW_TYPE, previewId, @@ -63,7 +63,7 @@ export class PreviewUrl extends PreviewWebview { query: uri.query ? uri.query + '&' + nonce : nonce }); - this.loadUri(iframeUri); + this.webview.loadUri(iframeUri); } public _onDidNavigate = this._register(new Emitter()); @@ -72,67 +72,4 @@ export class PreviewUrl extends PreviewWebview { get currentUri(): URI { return this._uri; } - - /** - * Loads a URI in the internal webview. - * - * This is overridden in the Electron implementation to use the webview's - * `loadUri` method, which has native support for loading URIs. - * - * @param uri The URI to load - */ - protected loadUri(uri: URI): void { - this.webview.setHtml(` - - - - - - - - - `); - } } diff --git a/src/vs/workbench/contrib/positronPreview/browser/previewWebview.ts b/src/vs/workbench/contrib/positronPreview/browser/previewWebview.ts index 7c6e044405f..f8aa0e2b7fb 100644 --- a/src/vs/workbench/contrib/positronPreview/browser/previewWebview.ts +++ b/src/vs/workbench/contrib/positronPreview/browser/previewWebview.ts @@ -1,11 +1,11 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vs/base/common/lifecycle'; -import { IOverlayWebview } from 'vs/workbench/contrib/webview/browser/webview'; import { Emitter, Event } from 'vs/base/common/event'; +import { PreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/browser/previewOverlayWebview'; /** * This class represents a Positron preview webview as actually loaded into the @@ -38,7 +38,7 @@ export class PreviewWebview extends Disposable { readonly viewType: string, readonly previewId: string, readonly name: string, - readonly webview: IOverlayWebview + readonly webview: PreviewOverlayWebview ) { super(); diff --git a/src/vs/workbench/contrib/positronPreview/electron-sandbox/positronPreviewServiceImpl.ts b/src/vs/workbench/contrib/positronPreview/electron-sandbox/positronPreviewServiceImpl.ts index 84ec3600ca2..c5d47832720 100644 --- a/src/vs/workbench/contrib/positronPreview/electron-sandbox/positronPreviewServiceImpl.ts +++ b/src/vs/workbench/contrib/positronPreview/electron-sandbox/positronPreviewServiceImpl.ts @@ -3,10 +3,9 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from 'vs/base/common/uri'; import { PositronPreviewService } from 'vs/workbench/contrib/positronPreview/browser/positronPreviewServiceImpl'; -import { PreviewUrl } from 'vs/workbench/contrib/positronPreview/browser/previewUrl'; -import { ElectronPreviewUrl } from 'vs/workbench/contrib/positronPreview/electron-sandbox/previewUrl'; +import { PreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/browser/previewOverlayWebview'; +import { ElectronPreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/electron-sandbox/previewOverlayWebview'; import { IOverlayWebview } from 'vs/workbench/contrib/webview/browser/webview'; /** @@ -16,11 +15,9 @@ export class ElectronPositronPreviewService extends PositronPreviewService { /** * Electron override for creating preview URL objects; returns the Electron variant. */ - protected override createPreviewUrl( - previewId: string, - webview: IOverlayWebview, - uri: URI): PreviewUrl { - return new ElectronPreviewUrl(previewId, webview, uri); + protected override createOverlayWebview( + webview: IOverlayWebview): PreviewOverlayWebview { + return new ElectronPreviewOverlayWebview(webview); } /** diff --git a/src/vs/workbench/contrib/positronPreview/electron-sandbox/previewUrl.ts b/src/vs/workbench/contrib/positronPreview/electron-sandbox/previewOverlayWebview.ts similarity index 79% rename from src/vs/workbench/contrib/positronPreview/electron-sandbox/previewUrl.ts rename to src/vs/workbench/contrib/positronPreview/electron-sandbox/previewOverlayWebview.ts index 843c6d815db..cb49d3b6f39 100644 --- a/src/vs/workbench/contrib/positronPreview/electron-sandbox/previewUrl.ts +++ b/src/vs/workbench/contrib/positronPreview/electron-sandbox/previewOverlayWebview.ts @@ -4,19 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from 'vs/base/common/uri'; -import { PreviewUrl } from 'vs/workbench/contrib/positronPreview/browser/previewUrl'; +import { PreviewOverlayWebview } from 'vs/workbench/contrib/positronPreview/browser/previewOverlayWebview'; /** * Electron version of the Positron preview URL object. */ -export class ElectronPreviewUrl extends PreviewUrl { +export class ElectronPreviewOverlayWebview extends PreviewOverlayWebview { /** * Loads a URI in the preview's underlying webview. * * @param uri The URI to open in the preview */ - protected override loadUri(uri: URI): void { + public override loadUri(uri: URI): void { // Load the URI in the webview. We can set the URI directly in Electron // mode instead of building an HTML string with an iframe. // diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 439db4952d5..b2d696e871a 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -288,6 +288,9 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._webviewEvents.add(webview.onDidNavigate(x => { this._onDidNavigate.fire(x); })); + this._webviewEvents.add(webview.onDidLoad(x => { + this._onDidLoad.fire(x); + })); // --- End Positron --- if (this._isFirstLoad) { @@ -326,6 +329,8 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { } private _onDidNavigate = this._register(new Emitter()); public onDidNavigate = this._onDidNavigate.event; + private _onDidLoad = this._register(new Emitter()); + public onDidLoad = this._onDidLoad.event; // --- End Positron --- public get initialScrollProgress(): number { return this._initialScrollProgress; } diff --git a/src/vs/workbench/contrib/webview/browser/pre/webview-events.js b/src/vs/workbench/contrib/webview/browser/pre/webview-events.js index 05d7397efba..ac75a0ad9b5 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/webview-events.js +++ b/src/vs/workbench/contrib/webview/browser/pre/webview-events.js @@ -359,4 +359,9 @@ window.addEventListener('load', () => { openLinkInHost(link); } } + + // Notify the host that the webview has loaded its content + hostMessaging.postMessage('did-load-window', { + title: document.title, + }); }); diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index a43a86a3742..62f8a4c5245 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -279,6 +279,7 @@ export interface IWebview extends IDisposable { captureContentsAsPng(): Promise; executeJavaScript(frameId: WebviewFrameId, code: string): Promise; onDidNavigate: Event; + onDidLoad: Event; // --- End Positron runFindAction(previous: boolean): void; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 230cafaf15c..b823d568215 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -355,6 +355,9 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD this._onDidNavigate.fire(URI.parse(evt.url)); } })); + this._register(this.on('did-load-window', (data) => { + this._onDidLoad.fire(data.title); + })); // --- End Positron --- } @@ -974,6 +977,8 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD protected readonly _onDidNavigate = this._register(new Emitter()); public readonly onDidNavigate = this._onDidNavigate.event; + protected readonly _onDidLoad = this._register(new Emitter()); + public readonly onDidLoad = this._onDidLoad.event; // --- End Positron --- /** diff --git a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts index e8d58d50f1b..39433a0e649 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts @@ -29,6 +29,9 @@ export type FromWebviewMessage = { 'did-blur': void; 'did-load': void; 'did-find': { didFind: boolean }; + // --- Start Positron --- + 'did-load-window': { title: string }; + // --- End Positron --- 'do-update-state': string; 'do-reload': void; 'load-resource': { id: number; path: string; query: string; scheme: string; authority: string; ifNoneMatch?: string }; diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimeUiClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimeUiClient.ts index e1df929d810..8f303331c91 100644 --- a/src/vs/workbench/services/languageRuntime/common/languageRuntimeUiClient.ts +++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimeUiClient.ts @@ -4,10 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vs/base/common/lifecycle'; -import { Event } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { IRuntimeClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance'; -import { BusyEvent, ClearConsoleEvent, UiFrontendEvent, OpenEditorEvent, OpenWorkspaceEvent, PromptStateEvent, ShowMessageEvent, WorkingDirectoryEvent, ExecuteCommandEvent, ShowUrlEvent, SetEditorSelectionsEvent } from './positronUiComm'; +import { BusyEvent, ClearConsoleEvent, UiFrontendEvent, OpenEditorEvent, OpenWorkspaceEvent, PromptStateEvent, ShowMessageEvent, WorkingDirectoryEvent, ExecuteCommandEvent, ShowUrlEvent, SetEditorSelectionsEvent, ShowHtmlFileEvent } from './positronUiComm'; import { PositronUiCommInstance } from 'vs/workbench/services/languageRuntime/common/positronUiCommInstance'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { URI } from 'vs/base/common/uri'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { POSITRON_PREVIEW_PLOTS_IN_VIEWER } from 'vs/workbench/contrib/positronPreview/browser/positronPreview.contribution'; /** @@ -52,6 +58,12 @@ export interface IUiClientMessageOutputEvent extends IUiClientMessageOutput, IRuntimeClientEvent { } + +export interface IShowHtmlUriEvent { + uri: URI; + event: ShowHtmlFileEvent; +} + /** * A UI client instance. This client instance represents the global Positron window, and * its lifetime is tied to the lifetime of the Positron window. @@ -73,6 +85,13 @@ export class UiClientInstance extends Disposable { onDidWorkingDirectory: Event; onDidExecuteCommand: Event; onDidShowUrl: Event; + onDidShowHtmlFile: Event; + + /** Emitter wrapper for Show URL events */ + private readonly _onDidShowUrlEmitter = this._register(new Emitter()); + + /** Emitter wrapper for Show HTML File events */ + private readonly _onDidShowHtmlFileEmitter = this._register(new Emitter()); /** * Creates a new frontend client instance. @@ -82,6 +101,10 @@ export class UiClientInstance extends Disposable { */ constructor( private readonly _client: IRuntimeClientInstance, + private readonly _commandService: ICommandService, + private readonly _logService: ILogService, + private readonly _openerService: IOpenerService, + private readonly _configurationService: IConfigurationService, ) { super(); this._register(this._client); @@ -96,6 +119,68 @@ export class UiClientInstance extends Disposable { this.onDidPromptState = this._comm.onDidPromptState; this.onDidWorkingDirectory = this._comm.onDidWorkingDirectory; this.onDidExecuteCommand = this._comm.onDidExecuteCommand; - this.onDidShowUrl = this._comm.onDidShowUrl; + this.onDidShowUrl = this._onDidShowUrlEmitter.event; + this.onDidShowHtmlFile = this._onDidShowHtmlFileEmitter.event; + + // Wrap the ShowUrl event to resolve incoming external URIs from the + // backend before broadcasting them to the frontend. + this._register(this._comm.onDidShowUrl(async e => { + try { + let uri = URI.parse(e.url); + try { + const resolvedUri = await this._openerService.resolveExternalUri(uri); + uri = resolvedUri.resolved; + } catch { + // Noop; use the original URI + } + const resolvedEvent: ShowUrlEvent = { + url: uri.toString(), + }; + this._onDidShowUrlEmitter.fire(resolvedEvent); + } catch { + this._onDidShowUrlEmitter.fire(e); + } + })); + + // Wrap the ShowHtmlFile event to start a proxy server for the HTML file. + this._register(this._comm.onDidShowHtmlFile(async e => { + try { + const url = await this._commandService.executeCommand( + 'positronProxy.startHtmlProxyServer', + e.path + ); + + if (!url) { + throw new Error('Failed to start HTML file proxy server'); + } + + let uri = URI.parse(url); + try { + const resolvedUri = await this._openerService.resolveExternalUri(uri); + uri = resolvedUri.resolved; + } catch { + // Noop; use the original URI + } + + if (e.is_plot) { + // Check the configuration to see if we should open the plot + // in the Viewer tab. If so, clear the `is_plot` flag so that + // we open the file in the Viewer. + const openInViewer = this._configurationService.getValue(POSITRON_PREVIEW_PLOTS_IN_VIEWER); + if (openInViewer) { + e.is_plot = false; + } + } + + const resolvedEvent: IShowHtmlUriEvent = { + uri, + event: e, + }; + + this._onDidShowHtmlFileEmitter.fire(resolvedEvent); + } catch (error) { + this._logService.error(`Failed to show HTML file ${e.path}: ${error}`); + } + })); } } diff --git a/src/vs/workbench/services/languageRuntime/common/positronUiComm.ts b/src/vs/workbench/services/languageRuntime/common/positronUiComm.ts index e156cb810d6..18a816a6def 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronUiComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronUiComm.ts @@ -280,6 +280,35 @@ export interface ShowUrlEvent { } +/** + * Event: Show an HTML file in Positron + */ +export interface ShowHtmlFileEvent { + /** + * The fully qualified filesystem path to the HTML file to display + */ + path: string; + + /** + * A title to be displayed in the viewer. May be empty, and can be + * superseded by the title in the HTML file. + */ + title: string; + + /** + * Whether the HTML file is a plot-like object + */ + is_plot: boolean; + + /** + * The desired height of the HTML viewer, in pixels. The special value 0 + * indicates that no particular height is desired, and -1 indicates that + * the viewer should be as tall as possible. + */ + height: number; + +} + /** * Request: Create a new document with text contents * @@ -432,7 +461,8 @@ export enum UiFrontendEvent { ExecuteCommand = 'execute_command', OpenWorkspace = 'open_workspace', SetEditorSelections = 'set_editor_selections', - ShowUrl = 'show_url' + ShowUrl = 'show_url', + ShowHtmlFile = 'show_html_file' } export enum UiFrontendRequest { @@ -466,6 +496,7 @@ export class PositronUiComm extends PositronBaseComm { this.onDidOpenWorkspace = super.createEventEmitter('open_workspace', ['path', 'new_window']); this.onDidSetEditorSelections = super.createEventEmitter('set_editor_selections', ['selections']); this.onDidShowUrl = super.createEventEmitter('show_url', ['url']); + this.onDidShowHtmlFile = super.createEventEmitter('show_html_file', ['path', 'title', 'is_plot', 'height']); } /** @@ -552,5 +583,11 @@ export class PositronUiComm extends PositronBaseComm { * Viewer pane visible. */ onDidShowUrl: Event; + /** + * Show an HTML file in Positron + * + * Causes the HTML file to be shown in Positron. + */ + onDidShowHtmlFile: Event; } diff --git a/src/vs/workbench/services/positronPlots/common/positronPlots.ts b/src/vs/workbench/services/positronPlots/common/positronPlots.ts index f4f2bebf7d8..ecdb9fdbab2 100644 --- a/src/vs/workbench/services/positronPlots/common/positronPlots.ts +++ b/src/vs/workbench/services/positronPlots/common/positronPlots.ts @@ -158,6 +158,11 @@ export interface IPositronPlotsService { */ copyPlotToClipboard(): Promise; + /** + * Opens the selected plot in a new window. + */ + openPlotInNewWindow(): void; + /** * Saves the plot. */ diff --git a/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts b/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts index 407e2f8d124..1f313b3855d 100644 --- a/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts +++ b/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts @@ -22,6 +22,7 @@ import { ILanguageService } from 'vs/editor/common/languages/language'; import { ResourceMap } from 'vs/base/common/map'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { ICommandService } from 'vs/platform/commands/common/commands'; /** * Utility class for tracking state changes in a language runtime session. @@ -137,6 +138,7 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession this._register(new Emitter); constructor( + @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ILanguageService private readonly _languageService: ILanguageService, @ILanguageRuntimeService private readonly _languageRuntimeService: ILanguageRuntimeService, @@ -1202,7 +1204,7 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession session.createClient (RuntimeClientType.Ui, {}).then(client => { // Create the UI client instance wrapping the client instance. - const uiClient = new UiClientInstance(client); + const uiClient = new UiClientInstance(client, this._commandService, this._logService, this._openerService, this._configurationService); this._register(uiClient); // When the UI client instance emits an event, broadcast @@ -1297,6 +1299,15 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession } }); })); + this._register(uiClient.onDidShowHtmlFile(event => { + this._onDidReceiveRuntimeEventEmitter.fire({ + session_id: session.sessionId, + event: { + name: UiFrontendEvent.ShowHtmlFile, + data: event + } + }); + })); }); }