diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py index 86f54716b404..ad03f88bea5c 100644 --- a/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py @@ -19,7 +19,8 @@ import hashlib import io import logging -from typing import TYPE_CHECKING, cast +import sys +from typing import TYPE_CHECKING, Any, cast import matplotlib from matplotlib.backend_bases import FigureManagerBase @@ -38,6 +39,26 @@ matplotlib.interactive(True) # noqa: FBT003 +def _detect_plotting_library() -> str: + """ + Detect the most likely high-level plotting library in use. + + Checks sys.modules for known plotting libraries that build on matplotlib, + returning the most specific library name found. + """ + # Check for high-level libraries that build on matplotlib + # Order matters - check more specific libraries first + # Note: We don't include pandas here because pandas.plotting is auto-loaded + # when pandas is imported, even if not used for plotting. + library_priority = ["seaborn", "plotnine"] + + for module_name in library_priority: + if module_name in sys.modules: + return module_name + + return "matplotlib" + + class FigureManagerPositron(FigureManagerBase): """ Interface for the matplotlib backend to interact with the Positron frontend. @@ -62,9 +83,23 @@ def __init__(self, canvas: FigureCanvasPositron, num: int | str): super().__init__(canvas, num) + kernel = cast("PositronIPyKernel", PositronIPyKernel.instance()) + + # Get the execution context from the current shell message + parent = kernel.get_parent("shell") + header: dict[str, Any] = cast("dict[str, Any]", parent.get("header", {})) + content: dict[str, Any] = cast("dict[str, Any]", parent.get("content", {})) + execution_id: str = header.get("msg_id", "") + code: str = content.get("code", "") + + # Detect which plotting library was used + kind = _detect_plotting_library() + # Create the plot instance via the plots service. - self._plots_service = cast("PositronIPyKernel", PositronIPyKernel.instance()).plots_service - self._plot = self._plots_service.create_plot(canvas.render, canvas.intrinsic_size) + self._plots_service = kernel.plots_service + self._plot = self._plots_service.create_plot( + canvas.render, canvas.intrinsic_size, kind, execution_id, code, num + ) @property def closed(self) -> bool: diff --git a/extensions/positron-python/python_files/posit/positron/plot_comm.py b/extensions/positron-python/python_files/posit/positron/plot_comm.py index 76d54164bb9d..924f87f7f94f 100644 --- a/extensions/positron-python/python_files/posit/positron/plot_comm.py +++ b/extensions/positron-python/python_files/posit/positron/plot_comm.py @@ -68,6 +68,28 @@ class IntrinsicSize(BaseModel): ) +class PlotMetadata(BaseModel): + """ + The plot's metadata + """ + + name: StrictStr = Field( + description="A human-readable name for the plot", + ) + + kind: StrictStr = Field( + description="The kind of plot e.g. 'Matplotlib', 'ggplot2', etc.", + ) + + execution_id: StrictStr = Field( + description="The ID of the code fragment that produced the plot", + ) + + code: StrictStr = Field( + description="The code fragment that produced the plot", + ) + + class PlotResult(BaseModel): """ A rendered plot @@ -128,6 +150,9 @@ class PlotBackendRequest(str, enum.Enum): # Get the intrinsic size of a plot, if known. GetIntrinsicSize = "get_intrinsic_size" + # Get metadata for the plot + GetMetadata = "get_metadata" + # Render a plot Render = "render" @@ -148,6 +173,21 @@ class GetIntrinsicSizeRequest(BaseModel): ) +class GetMetadataRequest(BaseModel): + """ + Get metadata for the plot + """ + + method: Literal[PlotBackendRequest.GetMetadata] = Field( + description="The JSON-RPC method name (get_metadata)", + ) + + jsonrpc: str = Field( + default="2.0", + description="The JSON-RPC version specifier", + ) + + class RenderParams(BaseModel): """ Requests a plot to be rendered. The plot data is returned in a @@ -192,6 +232,7 @@ class PlotBackendMessageContent(BaseModel): comm_id: str data: Union[ GetIntrinsicSizeRequest, + GetMetadataRequest, RenderRequest, ] = Field(..., discriminator="method") @@ -221,6 +262,8 @@ class UpdateParams(BaseModel): IntrinsicSize.update_forward_refs() +PlotMetadata.update_forward_refs() + PlotResult.update_forward_refs() PlotSize.update_forward_refs() @@ -229,6 +272,8 @@ class UpdateParams(BaseModel): GetIntrinsicSizeRequest.update_forward_refs() +GetMetadataRequest.update_forward_refs() + RenderParams.update_forward_refs() RenderRequest.update_forward_refs() diff --git a/extensions/positron-python/python_files/posit/positron/plots.py b/extensions/positron-python/python_files/posit/positron/plots.py index 035acd98d567..e3b79d9243a3 100644 --- a/extensions/positron-python/python_files/posit/positron/plots.py +++ b/extensions/positron-python/python_files/posit/positron/plots.py @@ -12,9 +12,11 @@ from .plot_comm import ( GetIntrinsicSizeRequest, + GetMetadataRequest, IntrinsicSize, PlotBackendMessageContent, PlotFrontendEvent, + PlotMetadata, PlotResult, PlotSize, PlotUnit, @@ -50,6 +52,14 @@ class Plot: A callable that renders the plot. See `plot_comm.RenderRequest` for parameter details. intrinsic_size The intrinsic size of the plot in inches. + kind + The kind of plot, e.g., 'Matplotlib', 'Seaborn', 'plotnine'. + execution_id + The ID of the execute_request that produced the plot. + code + The code fragment that produced the plot. + figure_num + The matplotlib figure number, used for generating plot names. """ def __init__( @@ -57,10 +67,18 @@ def __init__( comm: PositronComm, render: Renderer, intrinsic_size: tuple[int, int], + kind: str, + execution_id: str, + code: str, + figure_num: int | str, ) -> None: self._comm = comm self._render = render self._intrinsic_size = intrinsic_size + self._kind = kind + self._execution_id = execution_id + self._code = code + self._figure_num = figure_num self._closed = False @@ -115,6 +133,8 @@ def _handle_msg( ) elif isinstance(request, GetIntrinsicSizeRequest): self._handle_get_intrinsic_size() + elif isinstance(request, GetMetadataRequest): + self._handle_get_metadata() else: logger.warning(f"Unhandled request: {request}") @@ -137,10 +157,21 @@ def _handle_get_intrinsic_size(self) -> None: width=self._intrinsic_size[0], height=self._intrinsic_size[1], unit=PlotUnit.Inches, - source="Matplotlib", + source=self._kind, ).dict() self._comm.send_result(data=result) + def _handle_get_metadata(self) -> None: + # Generate a short but meaningful name for the plot + name = f"{self._kind} {self._figure_num}" + result = PlotMetadata( + name=name, + kind=self._kind, + execution_id=self._execution_id, + code=self._code, + ).dict() + self._comm.send_result(data=result) + def _handle_close(self, _msg: JsonRecord) -> None: self.close() @@ -169,7 +200,15 @@ def __init__(self, target_name: str, session_mode: SessionMode): self._plots: list[Plot] = [] - def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot: + def create_plot( + self, + render: Renderer, + intrinsic_size: tuple[int, int], + kind: str, + execution_id: str, + code: str, + figure_num: int | str, + ) -> Plot: """ Create a plot. @@ -179,6 +218,14 @@ def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot A callable that renders the plot. See `plot_comm.RenderRequest` for parameter details. intrinsic_size The intrinsic size of the plot in inches. + kind + The kind of plot, e.g., 'Matplotlib', 'Seaborn', 'plotnine'. + execution_id + The ID of the execute_request that produced the plot. + code + The code fragment that produced the plot. + figure_num + The matplotlib figure number, used for generating plot names. See Also -------- @@ -187,7 +234,7 @@ def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot comm_id = str(uuid.uuid4()) logger.info(f"Creating plot with comm {comm_id}") plot_comm = PositronComm.create(self._target_name, comm_id) - plot = Plot(plot_comm, render, intrinsic_size) + plot = Plot(plot_comm, render, intrinsic_size, kind, execution_id, code, figure_num) self._plots.append(plot) return plot diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py b/extensions/positron-python/python_files/posit/positron/tests/test_plots.py index 1f704ff54488..e662fab18158 100644 --- a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py +++ b/extensions/positron-python/python_files/posit/positron/tests/test_plots.py @@ -132,12 +132,33 @@ def test_mpl_get_intrinsic_size(shell: PositronShell, plots_service: PlotsServic "width": intrinsic_size[0], "height": intrinsic_size[1], "unit": PlotUnit.Inches.value, - "source": "Matplotlib", + "source": "matplotlib", } ) ] +def test_mpl_get_metadata(shell: PositronShell, plots_service: PlotsService) -> None: + # Create a plot. + plot_comm = _create_mpl_plot(shell, plots_service) + + # Send a get_metadata request to the plot comm. + msg = json_rpc_request("get_metadata", {}, comm_id="dummy_comm_id") + plot_comm.handle_msg(msg) + + # Check that the response includes the expected metadata. + assert len(plot_comm.messages) == 1 + response = plot_comm.messages[0] + result = response["data"]["result"] + + # Verify the metadata structure + assert result["kind"] == "matplotlib" + assert result["name"] == "matplotlib 1" + # execution_id and code may be empty in test context since there's no real execute_request + assert "execution_id" in result + assert "code" in result + + def test_mpl_show(shell: PositronShell, plots_service: PlotsService) -> None: plot_comm = _create_mpl_plot(shell, plots_service) diff --git a/positron/comms/plot-backend-openrpc.json b/positron/comms/plot-backend-openrpc.json index c4dc011b38ff..9d8acc73e73b 100644 --- a/positron/comms/plot-backend-openrpc.json +++ b/positron/comms/plot-backend-openrpc.json @@ -43,6 +43,44 @@ } } }, + { + "name": "get_metadata", + "summary": "Get metadata for the plot", + "description": "Get metadata for the plot", + "params": [], + "result": { + "required": true, + "schema": { + "name": "plot_metadata", + "description": "The plot's metadata", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique, human-readable name for the plot" + }, + "kind": { + "type": "string", + "description": "The kind of plot e.g. 'Matplotlib', 'ggplot2', etc." + }, + "execution_id": { + "type": "string", + "description": "The ID of the code fragment that produced the plot" + }, + "code": { + "description": "The code fragment that produced the plot", + "type": "string" + } + }, + "required": [ + "name", + "kind", + "execution_id", + "code" + ] + } + } + }, { "name": "render", "summary": "Render a plot", diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.css b/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.css index f28644afb240..ecb962b7716c 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.css +++ b/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.css @@ -13,6 +13,7 @@ position: absolute; top: 0; left: -10px; + opacity: 0; } @keyframes positronActivityInput-fadeIn { @@ -20,12 +21,25 @@ 100% { opacity: 1; } } +@keyframes positronActivityInput-revealFadeInOut { + 0% { opacity: 0; } + 15% { opacity: 1; } + 85% { opacity: 1; } + 100% { opacity: 0; } +} + .activity-input.executing .progress-bar { background-color: var(--vscode-positronConsole-ansiGreen); opacity: 0; animation: positronActivityInput-fadeIn 0.25s ease-in 0.25s 1 forwards; } +.activity-input.revealed .progress-bar { + background-color: var(--vscode-focusBorder); + opacity: 0; + animation: positronActivityInput-revealFadeInOut 2s ease-in-out 1 forwards; +} + .activity-input .prompt { text-align: right; display: inline-block; diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.tsx index b9540b3f0e36..1221f96348c3 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/activityInput.tsx @@ -220,7 +220,7 @@ export const ActivityInput = (props: ActivityInputProps) => { // Render colorized lines. return (
- {state === ActivityItemInputState.Executing &&
} +
{colorizedOutputLines.map((outputLine, index) =>
@@ -236,7 +236,7 @@ export const ActivityInput = (props: ActivityInputProps) => { // Render non-colorized lines. return (
- {state === ActivityItemInputState.Executing &&
} +
{props.activityItemInput.codeOutputLines.map((outputLine, index) =>
diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx index 2cd07b2157f2..43e855907721 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx @@ -292,6 +292,34 @@ export const ConsoleInstance = (props: ConsoleInstanceProps) => { setRuntimeAttached(!!runtime); })); + // Add the onDidRequestRevealExecution event handler. + disposableStore.add(props.positronConsoleInstance.onDidRequestRevealExecution((executionId) => { + // Find the element with the matching data-execution-id attribute. + const element = consoleInstanceRef.current.querySelector( + `[data-execution-id="${executionId}"]` + ); + if (!element) { + return; + } + + // Find the activity-input element within this activity. + const activityInput = element.querySelector('.activity-input'); + if (!activityInput) { + return; + } + + // Scroll the element into view. + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Add the 'revealed' class to trigger the highlight animation. + activityInput.classList.add('revealed'); + + // Remove the 'revealed' class after the animation completes (2 seconds). + disposableTimeout(() => { + activityInput.classList.remove('revealed'); + }, 2000, disposableStore); + })); + // Return the cleanup function that will dispose of the event handlers. return () => disposableStore.dispose(); }, [positronConsoleContext.activePositronConsoleInstance?.attachedRuntimeSession, positronConsoleContext.activePositronConsoleInstance, services.configurationService, services.positronPlotsService, services.runtimeSessionService, services.viewsService, props.positronConsoleInstance, props.reactComponentContainer, scrollToBottom]); diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/runtimeActivity.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/runtimeActivity.tsx index 7b709628f36c..98e2f36cd92b 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/runtimeActivity.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/runtimeActivity.tsx @@ -44,7 +44,7 @@ export interface RuntimeActivityProps { export const RuntimeActivity = (props: RuntimeActivityProps) => { // Render. return ( -
+
{props.runtimeItemActivity.activityItems.filter(activityItem => !activityItem.isHidden).map(activityItem => { if (activityItem instanceof ActivityItemInput) { diff --git a/src/vs/workbench/contrib/positronIPyWidgets/test/browser/positronIPyWidgetsService.test.ts b/src/vs/workbench/contrib/positronIPyWidgets/test/browser/positronIPyWidgetsService.test.ts index fb14cf1b1be8..49d3360d58d3 100644 --- a/src/vs/workbench/contrib/positronIPyWidgets/test/browser/positronIPyWidgetsService.test.ts +++ b/src/vs/workbench/contrib/positronIPyWidgets/test/browser/positronIPyWidgetsService.test.ts @@ -100,8 +100,8 @@ suite('Positron - PositronIPyWidgetsService', () => { assert.strictEqual(plotClient.id, message.id); assert.deepStrictEqual(plotClient.metadata, { id: message.id, - parent_id: message.parent_id, created: Date.parse(message.when), + execution_id: '', session_id: session.sessionId, code: '', output_id: message.output_id, diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx index 86363f78e6d1..6911cfaa4e47 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx @@ -28,6 +28,7 @@ import { OpenInEditorMenuButton } from './openInEditorMenuButton.js'; import { DarkFilterMenuButton } from './darkFilterMenuButton.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js'; +import { PlotCodeMenuButton } from './plotCodeMenuButton.js'; // Constants. const kPaddingLeft = 14; @@ -93,6 +94,9 @@ export const ActionBars = (props: PropsWithChildren) => { && (selectedPlot instanceof PlotClientInstance || selectedPlot instanceof StaticPlotClient); + // Enable code actions when the plot has code metadata + const enableCodeActions = hasPlots && !!selectedPlot?.metadata.code; + // Only show the "Open in editor" button when in the main window const showOpenInEditorButton = enableEditorPlot && props.displayLocation === PlotsDisplayLocation.MainWindow; @@ -209,6 +213,9 @@ export const ActionBars = (props: PropsWithChildren) => { tooltip={openInEditorTab} /> } + {enableCodeActions && + + } {enableDarkFilter && diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx index 5c2679967a61..74e8d5dc863e 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx @@ -46,6 +46,7 @@ export const DynamicPlotInstance = (props: DynamicPlotInstanceProps) => { const [uri, setUri] = useState(''); const [error, setError] = useState(''); const progressRef = React.useRef(null); + const plotName = props.plotClient.metadata.name ? props.plotClient.metadata.name : 'Plot ' + props.plotClient.id; useEffect(() => { const ratio = DOM.getActiveWindow().devicePixelRatio; @@ -176,9 +177,7 @@ export const DynamicPlotInstance = (props: DynamicPlotInstanceProps) => { // Render method for the plot image. const renderedImage = () => { return { // Consider: we probably want a more explicit loading state; as written we // will show the old URI until the new one is ready. if (uri) { - return {'Plot; + return {props.plotClient.metadata.name; } else { return ; } diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/placeholderThumbnail.css b/src/vs/workbench/contrib/positronPlots/browser/components/placeholderThumbnail.css index ad42f4634d05..280dfbdb2b42 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/placeholderThumbnail.css +++ b/src/vs/workbench/contrib/positronPlots/browser/components/placeholderThumbnail.css @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -7,8 +7,8 @@ display: flex; align-items: center; justify-content: center; - height: 75px; - width: 75px; + height: 64px; + width: 64px; background-color: var(--vscode-input-background); cursor: pointer; } diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotCodeMenuButton.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/plotCodeMenuButton.tsx new file mode 100644 index 000000000000..18fff433acef --- /dev/null +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotCodeMenuButton.tsx @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +// React. +import React, { useEffect, useState } from 'react'; + +// Other dependencies. +import { localize } from '../../../../../nls.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ActionBarMenuButton } from '../../../../../platform/positronActionBar/browser/components/actionBarMenuButton.js'; +import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js'; +import { IPositronPlotClient } from '../../../../services/positronPlots/common/positronPlots.js'; +import { CodeAttributionSource } from '../../../../services/positronConsole/common/positronConsoleCodeExecution.js'; +import { IPositronPlotMetadata } from '../../../../services/languageRuntime/common/languageRuntimePlotClient.js'; + +// Localized strings. +const plotCodeActionsTooltip = localize('positronPlotCodeActions', "Plot code actions"); +const copyCode = localize('positronPlots.copyCode', "Copy Code"); +const revealInConsole = localize('positronPlots.revealInConsole', "Reveal Code in Console"); +const runCodeAgain = localize('positronPlots.runCodeAgain', "Run Code Again"); + +/** + * PlotCodeMenuButtonProps interface. + */ +interface PlotCodeMenuButtonProps { + readonly plotClient: IPositronPlotClient; +} + +/** + * PlotCodeMenuButton component. + * @param props A PlotCodeMenuButtonProps that contains the component properties. + * @returns The rendered component. + */ +export const PlotCodeMenuButton = (props: PlotCodeMenuButtonProps) => { + // Context hooks. + const services = usePositronReactServicesContext(); + + // State to track metadata changes. + const [metadata, setMetadata] = useState(props.plotClient.metadata); + + // Subscribe to metadata updates. + useEffect(() => { + const disposable = props.plotClient.onDidUpdateMetadata?.(newMetadata => { + setMetadata(newMetadata); + }); + return () => disposable?.dispose(); + }, [props.plotClient]); + + // Get metadata from state. + const plotCode = metadata.code; + const executionId = metadata.execution_id; + const sessionId = metadata.session_id; + const languageId = metadata.language; + + // Builds the actions. + const actions = (): IAction[] => { + return [ + { + id: 'copyCode', + label: copyCode, + tooltip: '', + class: 'codicon codicon-copy', + enabled: !!plotCode, + run: () => { + services.clipboardService.writeText(plotCode); + const trimmedCode = plotCode.substring(0, 20) + (plotCode.length > 20 ? '...' : ''); + services.notificationService.info( + localize('positronPlots.copyCodeInfo', "Plot code copied to clipboard: {0}", trimmedCode) + ); + } + }, + { + id: 'revealInConsole', + label: revealInConsole, + tooltip: '', + class: 'codicon codicon-go-to-file', + enabled: !!executionId && !!sessionId, + run: () => { + try { + services.positronConsoleService.revealExecution(sessionId!, executionId!); + } catch (error) { + // It's very possible that the code that generated this + // plot has been removed from the console (e.g. if the + // console was cleared). In that case, just log a + // warning and show a notification. + if (error instanceof Error) { + services.logService.warn(error.message); + } + services.notificationService.warn( + localize('positronPlots.revealInConsoleError', "The code that generated this plot is no longer present in the console.") + ); + } + } + }, + { + id: 'runCodeAgain', + label: runCodeAgain, + tooltip: '', + class: 'codicon codicon-run', + enabled: !!plotCode && !!sessionId && !!languageId, + run: async () => { + await services.positronConsoleService.executeCode( + languageId!, + sessionId, + plotCode, + { source: CodeAttributionSource.Interactive }, + true + ); + } + } + ]; + }; + + return ( + + ); +}; diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.css b/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.css new file mode 100644 index 000000000000..d640232eaccd --- /dev/null +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.css @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Plot thumbnail name label - positioned at the bottom of the thumbnail. + */ +.plot-thumbnail-name { + padding: 1px 4px; + background-color: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-list-inactiveSelectionForeground); + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 3px; + font-size: 9px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + z-index: 1; + flex-shrink: 0; + margin-top: 2px; +} + +.plot-thumbnail.selected .plot-thumbnail-name { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + border-color: var(--vscode-focusBorder); +} + +.plot-thumbnail-name-text { + display: block; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; +} + +.plot-thumbnail-button { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; + border: none; + background: none; + cursor: pointer; + height: 100%; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.plot-thumbnail-button .image-wrapper { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.plot-thumbnail-button .image-wrapper img { + max-width: 100%; + max-height: 64px; + object-fit: contain; +} + +/* When no name is present, image can use more vertical space */ +.plot-thumbnail-button:not(:has(.plot-thumbnail-name)) .image-wrapper img { + max-height: 82px; +} diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.tsx index 857208f2fccd..b1e32e9c1b20 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotGalleryThumbnail.tsx @@ -3,12 +3,16 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +// CSS. +import './plotGalleryThumbnail.css'; + // React. -import React, { PropsWithChildren, useRef } from 'react'; +import React, { PropsWithChildren, useMemo, useRef } from 'react'; // Other dependencies. import { IPositronPlotClient } from '../../../../services/positronPlots/common/positronPlots.js'; import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js'; +import { localize } from '../../../../../nls.js'; /** * PlotGalleryThumbnailProps interface. @@ -20,6 +24,8 @@ interface PlotGalleryThumbnailProps { focusNextPlotThumbnail: (currentPlotId: string) => void; } +const removePlotTitle = localize('positronRemovePlot', "Remove plot"); + /** * PlotGalleryThumbnail component. This component renders a thumbnail of a plot * instance as a child component, and is used as a wrapper for all plot thumbnails. @@ -41,12 +47,15 @@ export const PlotGalleryThumbnail = (props: PropsWithChildren { - if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key == 'k' || e.key == 'h') { e.preventDefault(); props.focusPreviousPlotThumbnail(props.plotClient.id); - } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key == 'j' || e.key == 'l') { e.preventDefault(); props.focusNextPlotThumbnail(props.plotClient.id); + } else if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'x') { + e.preventDefault(); + removePlot(); } else if (e.key === 'Enter' || e.key === ' ') { // if the focus is on the remove button, call the removePlot function if (e.target === plotRemoveButtonRef.current) { @@ -66,25 +75,34 @@ export const PlotGalleryThumbnail = (props: PropsWithChildren { + return props.plotClient.metadata.name; + }, [props.plotClient.metadata.name]); + return ( -
+
diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.css b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.css index d827d50107e1..7b2bde831ed9 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.css +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.css @@ -13,3 +13,87 @@ .vs-dark .dark-filter-auto .plot-thumbnail img.plot { filter: invert(1) hue-rotate(180deg); } + +/** + * Plot info header showing session name and plot name. + */ +.plots-container .plot-info-header { + display: flex; + align-items: center; + padding: 0 4px; + flex-shrink: 0; + overflow: hidden; + background-color: var(--vscode-editor-background); +} + +.plots-container .plot-info-header .plot-info-text { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + white-space: nowrap; + overflow: hidden; +} + +.plots-container .plot-info-header .plot-session-name { + font-size: 11px; + color: var(--vscode-foreground); + opacity: 0.8; + padding: 3px 5px; + border-radius: 5px; + border: 1px solid var(--vscode-panel-border); + margin-right: 4px; + background: none; + cursor: pointer; +} + +.plots-container .plot-info-header .plot-name { + font-size: 12px; + color: var(--vscode-foreground); + opacity: 0.8; + margin-right: 5px; +} + +.plots-container .plot-info-header .plot-code-button { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 2px 4px; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + max-width: 200px; + overflow: hidden; +} + +.plots-container .plot-info-header .plot-code-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.plots-container .plot-info-header .plot-code-button .plot-code-text { + font-family: var(--monaco-monospace-font); + font-size: 11px; + color: var(--vscode-foreground); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.plots-container .plot-info-header .plot-code-button .codicon { + flex-shrink: 0; + color: var(--vscode-foreground); + opacity: 0.8; +} + +.plots-container .plot-info-header .plot-code-button .codicon-chevron-down { + font-size: 10px; + margin-left: 2px; +} + +.plots-container .plot-info-header .plot-code-button:hover .plot-code-text, +.plots-container .plot-info-header .plot-code-button:hover .codicon { + opacity: 1; +} diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx index 6538ca2f2228..b793069e8cd5 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx @@ -7,7 +7,7 @@ import './plotsContainer.css'; // React. -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; // Other dependencies. import * as DOM from '../../../../../base/browser/dom.js'; @@ -45,7 +45,12 @@ interface PlotContainerProps { * The number of pixels (height or width) to use for the history portion of the * plots container. */ -export const HistoryPx = 100; +export const HistoryPx = 110; + +/** + * The number of pixels (height) to use for the plot info header row. + */ +export const PlotInfoHeaderPx = 30; /** * PlotContainer component; holds the plot instances. @@ -67,8 +72,67 @@ export const PlotsContainer = (props: PlotContainerProps) => { const historyPx = props.showHistory ? HistoryPx : 0; const historyEdge = historyBottom ? 'history-bottom' : 'history-right'; - const plotHeight = historyBottom && props.height > 0 ? props.height - historyPx : props.height; - const plotWidth = historyBottom || props.width <= 0 ? props.width : props.width - historyPx; + // Account for the plot info header when calculating plot dimensions + const plotHeight = historyBottom && props.height > 0 ? props.height - historyPx - PlotInfoHeaderPx : props.height - PlotInfoHeaderPx; + const plotWidth = historyBottom || props.width <= 0 ? props.width : props.width - (historyPx + 1); + + // Get the current plot instance + const currentPlotInstance = useMemo(() => + positronPlotsContext.positronPlotInstances.find( + (plotInstance) => plotInstance.id === positronPlotsContext.selectedInstanceId + ), + [positronPlotsContext.positronPlotInstances, positronPlotsContext.selectedInstanceId] + ); + + // State to track metadata updates and trigger re-renders + const [metadataVersion, setMetadataVersion] = React.useState(0); + + // State to track session name updates and trigger re-renders + const [sessionNameVersion, setSessionNameVersion] = React.useState(0); + + // Listen for metadata updates from the plots service (service-level event) + useEffect(() => { + const disposable = services.positronPlotsService.onDidUpdatePlotMetadata((plotId) => { + // Only trigger re-render if the updated plot is the current one + if (plotId === positronPlotsContext.selectedInstanceId) { + setMetadataVersion(v => v + 1); + } + }); + return () => disposable.dispose(); + }, [services.positronPlotsService, positronPlotsContext.selectedInstanceId]); + + // Listen for session name updates to update the displayed session name + useEffect(() => { + const disposable = services.runtimeSessionService.onDidUpdateSessionName((session) => { + // Only trigger re-render if the updated session is the current plot's session + if (currentPlotInstance && session.sessionId === currentPlotInstance.metadata.session_id) { + setSessionNameVersion(v => v + 1); + } + }); + return () => disposable.dispose(); + }, [services.runtimeSessionService, currentPlotInstance]); + + // Get the session name for the current plot + const sessionName = useMemo(() => { + if (!currentPlotInstance) { + return undefined; + } + const sessionId = currentPlotInstance.metadata.session_id; + const session = services.runtimeSessionService.getSession(sessionId); + if (session) { + // Use dynState.sessionName to get the current (possibly renamed) session name + return session.dynState.sessionName; + } + // Fallback to the language name from metadata if session is not found + return currentPlotInstance.metadata.language; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPlotInstance, services.runtimeSessionService, metadataVersion, sessionNameVersion]); + + // Get the plot name from metadata (reactive to metadata updates) + const plotName = useMemo(() => { + return currentPlotInstance?.metadata.name; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPlotInstance, metadataVersion]); // Plot history useEffect to handle scrolling, mouse wheel events, and keyboard navigation. useEffect(() => { @@ -85,7 +149,7 @@ export const PlotsContainer = (props: PlotContainerProps) => { const selectedPlot = plotHistory.querySelector('.selected'); if (selectedPlot) { // If there is a selected plot, scroll it into view. - selectedPlot.scrollIntoView({ behavior: 'smooth' }); + selectedPlot.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } else { // If there isn't a selected plot, scroll the history to the end to // show the most recently generated plot. @@ -254,7 +318,7 @@ export const PlotsContainer = (props: PlotContainerProps) => { return; } const plotThumbnailElement = plotHistory.querySelector( - `.plot-thumbnail[data-plot-id="${plotId}"]` + `.plot-thumbnail-button[data-plot-id="${plotId}"]` ) as HTMLButtonElement; if (plotThumbnailElement) { plotThumbnailElement.focus(); @@ -297,6 +361,27 @@ export const PlotsContainer = (props: PlotContainerProps) => { focusPlotThumbnail(nextPlotInstance.id); } + /** + * Navigates to the code that generated the current plot. + */ + const navigateToCode = () => { + if (!currentPlotInstance) { + return; + } + // If we have an execution ID, reveal the execution in the Positron + // Console; otherwise, just activate the session. + if (currentPlotInstance.metadata.execution_id) { + services.positronConsoleService.revealExecution( + currentPlotInstance.metadata.session_id, + currentPlotInstance.metadata.execution_id + ); + } else { + services.positronConsoleService.setActivePositronConsoleSession( + currentPlotInstance.metadata.session_id + ); + } + }; + /** * Renders a thumbnail of either a DynamicPlotInstance (resizable plot), a * StaticPlotInstance (static plot image), or a WebviewPlotInstance @@ -341,17 +426,41 @@ export const PlotsContainer = (props: PlotContainerProps) => {
; }; + // Render the plot info header showing the session name and plot name. + const renderPlotInfoHeader = () => { + if (!currentPlotInstance) { + return null; + } + + // If no info to display, show a placeholder to maintain consistent height + if (!sessionName && !plotName) { + return
+   +
; + } + + return
+ + {sessionName && } + {plotName && {plotName}} + +
; + }; + // If there are no plot instances, show a placeholder; otherwise, show the // most recently generated plot. return (
-
- {positronPlotsContext.positronPlotInstances.length === 0 && -
} - {positronPlotsContext.positronPlotInstances.map((plotInstance, index) => ( - plotInstance.id === positronPlotsContext.selectedInstanceId && - render(plotInstance) - ))} +
+ {positronPlotsContext.positronPlotInstances.length > 0 && renderPlotInfoHeader()} +
+ {positronPlotsContext.positronPlotInstances.length === 0 && +
} + {positronPlotsContext.positronPlotInstances.map((plotInstance, index) => ( + plotInstance.id === positronPlotsContext.selectedInstanceId && + render(plotInstance) + ))} +
{props.showHistory && renderHistory()}
diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/staticPlotInstance.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/staticPlotInstance.tsx index d0d39dc0c36a..800153aacf7c 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/staticPlotInstance.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/staticPlotInstance.tsx @@ -34,6 +34,7 @@ export const StaticPlotInstance = (props: StaticPlotInstanceProps) => { const [width, setWidth] = useState(1); const [height, setHeight] = useState(1); const resizeObserver = useRef(null!); + const plotName = props.plotClient.metadata.name ? props.plotClient.metadata.name : 'Plot ' + props.plotClient.id; useEffect(() => { resizeObserver.current = new ResizeObserver((entries: ResizeObserverEntry[]) => { @@ -55,7 +56,7 @@ export const StaticPlotInstance = (props: StaticPlotInstanceProps) => { return (
{ - return {'Plot; + return {props.plotClient.metadata.name; }; diff --git a/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts index 3c2151658cdc..2e948c584224 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/htmlPlotClient.ts @@ -12,6 +12,7 @@ import { PreviewHtml } from '../../positronPreview/browser/previewHtml.js'; import { WebviewExtensionDescription } from '../../webview/browser/webview.js'; import { IShowHtmlUriEvent } from '../../../services/languageRuntime/common/languageRuntimeUiClient.js'; import { ILanguageRuntimeSession } from '../../../services/runtimeSession/common/runtimeSessionService.js'; +import { localize } from '../../../../nls.js'; /** * A Positron plot instance that contains content from an HTML file. @@ -31,19 +32,24 @@ export class HtmlPlotClient extends WebviewPlotClient { * @param _positronPreviewService The preview service. * @param _session The runtime session that emitted the output. * @param _event The event that triggered the preview. + * @param executionId The ID of the execution that generated this HTML (if known). + * @param code The code that generated this HTML (if known). */ constructor( private readonly _positronPreviewService: IPositronPreviewService, private readonly _openerService: IOpenerService, private readonly _session: ILanguageRuntimeSession, - private readonly _event: IShowHtmlUriEvent) { + private readonly _event: IShowHtmlUriEvent, + executionId?: string, + code?: string) { // Create the metadata for the plot. super({ id: `plot-${HtmlPlotClient._nextId++}`, - parent_id: '', + execution_id: executionId ?? '', created: Date.now(), session_id: _session.sessionId, - code: '', + name: localize('positronPlots.htmlPlotClient.defaultName', "interactive {0}", HtmlPlotClient._nextId), + code: code ?? '', }); } diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx index 1cadce7c6ed4..94a1dad71901 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx @@ -89,7 +89,7 @@ interface DirectoryState { const SavePlotModalDialog = (props: SavePlotModalDialogProps) => { const [directory, setDirectory] = React.useState({ value: props.suggestedPath ?? URI.file(''), valid: true }); - const [name, setName] = React.useState({ value: props.plotClient.metadata.suggested_file_name ?? 'plot', valid: true }); + const [name, setName] = React.useState({ value: props.plotClient.metadata.name ?? props.plotClient.metadata.suggested_file_name ?? 'plot', valid: true }); const [format, setFormat] = React.useState(PlotRenderFormat.Png); const [enableIntrinsicSize, setEnableIntrinsicSize] = React.useState(props.enableIntrinsicSize); const [width, setWidth] = React.useState({ value: props.plotSize?.width ?? 100, valid: true }); diff --git a/src/vs/workbench/contrib/positronPlots/browser/notebookMultiMessagePlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/notebookMultiMessagePlotClient.ts index 334f17e4ac2b..26979c9ed02f 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/notebookMultiMessagePlotClient.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/notebookMultiMessagePlotClient.ts @@ -40,7 +40,7 @@ export class NotebookMultiMessagePlotClient extends WebviewPlotClient { // Create the metadata for the plot. super({ id: _displayMessage.id, - parent_id: _displayMessage.parent_id, + execution_id: _displayMessage.parent_id, created: Date.parse(_displayMessage.when), session_id: _session.sessionId, code: code ? code : '', diff --git a/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts b/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts index 7882587b1ce3..7eccce906afd 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/notebookOutputPlotClient.ts @@ -37,7 +37,7 @@ export class NotebookOutputPlotClient extends WebviewPlotClient { // Create the metadata for the plot. super({ id: _message.id, - parent_id: _message.parent_id, + execution_id: _message.parent_id, created: Date.parse(_message.when), session_id: _session.sessionId, code: code ? code : '', diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlots.css b/src/vs/workbench/contrib/positronPlots/browser/positronPlots.css index e1f26110303f..148c9ca9d25b 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlots.css +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlots.css @@ -14,6 +14,13 @@ flex-direction: row; } +.plots-container .plot-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + .plots-container.history-bottom { flex-direction: column; } @@ -39,7 +46,7 @@ .history-right .plot-history-scroller { border-left: 1px solid var(--vscode-statusBar-border); - width: 100px; + width: 110px; } .history-right .plot-history { @@ -47,11 +54,11 @@ height: fit-content; margin-top: 10px; margin-bottom: 10px; + padding-bottom: 10px; } .history-right .plot-thumbnail { margin-top: 5px; - margin-bottom: 5px; } /* @@ -65,8 +72,8 @@ .history-bottom .plot-history-scroller { border-top: 1px solid var(--vscode-statusBar-border); - min-height: 100px; - height: 100px; + height: 110px; + min-width: 0; } .history-bottom .plot-history { @@ -74,11 +81,11 @@ width: fit-content; margin-right: 10px; margin-left: 10px; + flex-shrink: 0; } .history-bottom .plot-thumbnail { - margin-left: 5px; - margin-right: 5px; + width: 100px; } .selected-plot { @@ -89,18 +96,19 @@ } .plot-thumbnail { - height: 80px; - width: 80px; padding: 0; opacity: 0.75; transition: opacity 0.2s ease-in-out; } -.plot-thumbnail button { - cursor: pointer; - overflow: hidden; - margin: 0; - padding: 0; +.history-bottom .plot-thumbnail, +.history-right .plot-thumbnail { + margin-top: 5px; + margin-bottom: 5px; +} + +.plot-thumbnail-button{ + border: 1px solid transparent; } .plot-thumbnail button:active img, @@ -127,9 +135,8 @@ border: 1px dotted var(--vscode-editorWidget-border); } -.plot-thumbnail.selected img { - border-radius: 3px; - border: 1px solid var(--vscode-editorWidget-border); +.plot-thumbnail.selected .image-wrapper { + border: none; } /** @@ -141,7 +148,7 @@ } .plot-thumbnail:hover .plot-close { - opacity: 1; + opacity: 0.75; transition: opacity 0.2s ease-in-out; } @@ -157,8 +164,7 @@ position: relative; } -.plot-instance .image-wrapper img, -.plot-thumbnail .image-wrapper img { +.plot-instance .image-wrapper img { display: block; max-width: 100%; max-height: 100%; @@ -169,6 +175,11 @@ transform: translate(-50%, -50%); } +.plot-thumbnail .image-wrapper img { + max-width: 100%; + max-height: 100%; +} + .plot-instance { height: 100%; width: 100%; @@ -187,11 +198,24 @@ .plot-close { position: absolute; - top: 0; - right: 0; + top: 3px; + right: 3px; padding: 2px; cursor: pointer; opacity: 0; + transition: opacity 0.2s ease-in-out; + border: 1px solid; + border-radius: 3px; +} + +.plot-history .plot-thumbnail .plot-close { + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border-color: var(--vscode-button-border); +} + +.plot-history .plot-thumbnail .plot-close:hover { + opacity: 1; } .monaco-pane-view .pane .plots-container .monaco-progress-container { diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts index 33e5d3397c4b..38bb0538af50 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts @@ -132,6 +132,9 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe /** The emitter for the onDidRemovePlot event */ private readonly _onDidRemovePlot = new Emitter(); + /** The emitter for the onDidUpdatePlotMetadata event */ + private readonly _onDidUpdatePlotMetadata = new Emitter(); + /** The emitter for the onDidChangePlotsRenderSettings event */ private readonly _onDidChangePlotsRenderSettings = new Emitter(); @@ -754,7 +757,6 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe created: Date.now(), id: client.getClientId(), session_id: session.sessionId, - parent_id: '', code: '', location: PlotClientLocation.View, suggested_file_name: createSuggestedFileNameForPlot(this._storageService), @@ -762,7 +764,10 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe zoom_level: ZoomLevel.Fit, }; const commProxy = this.createCommProxy(client, metadata); - plotClients.push(this.createRuntimePlotClient(commProxy, metadata)); + const plotClient = this.createRuntimePlotClient(commProxy, metadata); + // Fetch metadata from the backend to populate kind, name, execution_id, and code + this.fetchAndUpdateMetadata(commProxy, metadata, plotClient); + plotClients.push(plotClient); } } else { console.warn( @@ -837,7 +842,6 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe created: Date.parse(event.message.when), id: clientId, session_id: session.sessionId, - parent_id: event.message.parent_id, code, pre_render: data?.pre_render, suggested_file_name: createSuggestedFileNameForPlot(this._storageService), @@ -848,6 +852,8 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe // Register the plot client const commProxy = this.createCommProxy(event.client, metadata); const plotClient = this.createRuntimePlotClient(commProxy, metadata); + // Fetch metadata from the backend to populate kind, name, execution_id, and code + this.fetchAndUpdateMetadata(commProxy, metadata, plotClient); this.registerPlotClient(plotClient, true); // Raise the Plots pane so the plot is visible. @@ -1069,6 +1075,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe onDidEmitPlot: Event = this._onDidEmitPlot.event; onDidSelectPlot: Event = this._onDidSelectPlot.event; onDidRemovePlot: Event = this._onDidRemovePlot.event; + onDidUpdatePlotMetadata: Event = this._onDidUpdatePlotMetadata.event; onDidReplacePlots: Event = this._onDidReplacePlots.event; onDidChangeHistoryPolicy: Event = this._onDidChangeHistoryPolicy.event; onDidChangeDarkFilterMode: Event = this._onDidChangeDarkFilterMode.event; @@ -1394,8 +1401,14 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe // Look up the session const session = this._runtimeSessionService.getSession(sessionId); + // Get the most recent execution ID and code, if available. + const executionId = this._recentExecutionIds.length > 0 + ? this._recentExecutionIds[this._recentExecutionIds.length - 1] + : undefined; + const code = executionId ? this._recentExecutions.get(executionId) : undefined; + // Create the plot client. - const plotClient = new HtmlPlotClient(this._positronPreviewService, this._openerService, session!, event); + const plotClient = new HtmlPlotClient(this._positronPreviewService, this._openerService, session!, event, executionId, code); // Register the new plot client this.registerWebviewPlotClient(plotClient); @@ -1537,6 +1550,49 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe plotClient.dispose(); } + /** + * Fetches metadata from the backend and updates the metadata object. + * The metadata object is updated in place with the fetched values. + * + * @param commProxy The comm proxy to use for fetching metadata + * @param metadata The metadata object to update + * @param plotClient Optional plot client to notify when metadata is updated + */ + private async fetchAndUpdateMetadata( + commProxy: PositronPlotCommProxy, + metadata: IPositronPlotMetadata, + plotClient?: PlotClientInstance + ): Promise { + try { + const backendMetadata = await commProxy.getMetadata(); + // Update the metadata with the fetched values + metadata.kind = backendMetadata.kind; + metadata.name = backendMetadata.name; + metadata.execution_id = backendMetadata.execution_id; + // Only update code if we don't already have it from recent executions + if (!metadata.code) { + metadata.code = backendMetadata.code; + } + // Store the updated metadata + this.storePlotMetadata(metadata); + // Notify the plot client if provided - also update its metadata copy + if (plotClient) { + plotClient.metadata.kind = backendMetadata.kind; + plotClient.metadata.name = backendMetadata.name; + plotClient.metadata.execution_id = backendMetadata.execution_id; + if (!plotClient.metadata.code) { + plotClient.metadata.code = backendMetadata.code; + } + plotClient.notifyMetadataUpdated(); + } + // Fire the service-level event to notify UI components + this._onDidUpdatePlotMetadata.fire(metadata.id); + } catch (err) { + // Log the error but don't fail - we can still use the plot with partial metadata + this._logService.warn(`Failed to fetch metadata for plot ${metadata.id}: ${err}`); + } + } + /** * Creates a new communication proxy for the given client and metadata. * diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsState.tsx b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsState.tsx index 84208526f2ee..178e6374eb33 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsState.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsState.tsx @@ -16,6 +16,8 @@ export interface PositronPlotsState { readonly positronPlotInstances: IPositronPlotClient[]; selectedInstanceId: string; selectedInstanceIndex: number; + /** Counter that increments when any plot's metadata changes, used to trigger re-renders. */ + metadataVersion: number; } /** @@ -39,6 +41,9 @@ export const usePositronPlotsState = (): PositronPlotsState => { (p => p.id === initialSelectedId); const [selectedInstanceIndex, setSelectedInstanceIndex] = useState(initialSelectedIndex); + // Counter to trigger re-renders when metadata changes. + const [metadataVersion, setMetadataVersion] = useState(0); + // Add event handlers. useEffect(() => { const disposableStore = new DisposableStore(); @@ -87,9 +92,14 @@ export const usePositronPlotsState = (): PositronPlotsState => { setPositronPlotInstances(plots); })); + // Listen for metadata updates. + disposableStore.add(services.positronPlotsService.onDidUpdatePlotMetadata(() => { + setMetadataVersion(v => v + 1); + })); + // Return the clean up for our event handlers. return () => disposableStore.dispose(); }, [services.positronPlotsService]); - return { positronPlotInstances, selectedInstanceId, selectedInstanceIndex }; + return { positronPlotInstances, selectedInstanceId, selectedInstanceIndex, metadataVersion }; }; diff --git a/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx b/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx index 2c3070cc95c6..8fe07c80b791 100644 --- a/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx +++ b/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx @@ -147,7 +147,7 @@ export class PositronPlotsEditor extends EditorPane implements IPositronPlotsEdi await super.setInput(input, options, context, token); - input.setName(this._plotClient.metadata.suggested_file_name ?? createSuggestedFileNameForPlot(this.storageService)); + input.setName(this._plotClient.metadata.name ?? this._plotClient.metadata.suggested_file_name ?? createSuggestedFileNameForPlot(this.storageService)); if (isZoomablePlotClient(this._plotClient)) { this._register(this._plotClient.onDidChangeZoomLevel((zoomLevel: ZoomLevel) => { diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts index efe17e61c46b..04836c7d7101 100644 --- a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts +++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts @@ -52,12 +52,18 @@ export interface IPositronPlotMetadata { /** The plot's moment of creation, in milliseconds since the Epoch */ created: number; + /** The kind of the plot (e.g. 'Matplotlib', 'ggplot2', etc.) */ + kind?: string; + + /** A unique, human-readable name for the plot */ + name?: string; + + /** The execution ID that created the plot */ + execution_id?: string; + /** The code that created the plot, if known. */ code: string; - /** The plot's parent message ID; useful for jumping to associated spot in the console */ - parent_id: string; - /** The ID of the runtime session that created the plot */ session_id: string; @@ -173,6 +179,12 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien onDidChangeZoomLevel: Event; private readonly _zoomLevelEmitter = new Emitter(); + /** + * Event that fires when the plot's metadata is updated. + */ + onDidUpdateMetadata: Event; + private readonly _updateMetadataEmitter = new Emitter(); + /** * Creates a new plot client instance. * @@ -238,6 +250,9 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien // Connect the zoom level emitter event this.onDidChangeZoomLevel = this._zoomLevelEmitter.event; + // Connect the metadata update emitter event + this.onDidUpdateMetadata = this._updateMetadataEmitter.event; + // Listen to our own state changes this._register(this.onDidChangeState((state) => { this._state = state; @@ -308,6 +323,14 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien return this._commProxy.getIntrinsicSize(); } + /** + * Notifies listeners that the plot's metadata has been updated. + * This should be called after the metadata object has been modified. + */ + public notifyMetadataUpdated(): void { + this._updateMetadataEmitter.fire(this.metadata); + } + get sizingPolicy() { return this._sizingPolicy; } diff --git a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts index e6e1a689ed0c..8e637d4b0281 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts @@ -37,6 +37,32 @@ export interface IntrinsicSize { } +/** + * The plot's metadata + */ +export interface PlotMetadata { + /** + * A human-readable name for the plot + */ + name: string; + + /** + * The kind of plot e.g. 'Matplotlib', 'ggplot2', etc. + */ + kind: string; + + /** + * The ID of the code fragment that produced the plot + */ + execution_id: string; + + /** + * The code fragment that produced the plot + */ + code: string; + +} + /** * A rendered plot */ @@ -169,6 +195,7 @@ export enum PlotFrontendEvent { export enum PlotBackendRequest { GetIntrinsicSize = 'get_intrinsic_size', + GetMetadata = 'get_metadata', Render = 'render' } @@ -195,6 +222,18 @@ export class PositronPlotComm extends PositronBaseComm { return super.performRpc('get_intrinsic_size', [], []); } + /** + * Get metadata for the plot + * + * Get metadata for the plot + * + * + * @returns The plot's metadata + */ + getMetadata(): Promise { + return super.performRpc('get_metadata', [], []); + } + /** * Render a plot * diff --git a/src/vs/workbench/services/languageRuntime/common/positronPlotCommProxy.ts b/src/vs/workbench/services/languageRuntime/common/positronPlotCommProxy.ts index f217a30f4ba6..8928eac56b6e 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronPlotCommProxy.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronPlotCommProxy.ts @@ -156,6 +156,15 @@ export class PositronPlotCommProxy extends Disposable { return this._currentIntrinsicSize; } + /** + * Get metadata for the plot. + * + * @returns A promise that resolves to the plot metadata. + */ + public getMetadata(): ReturnType { + return this._sessionRenderQueue.queueMetadataRequest(this._comm); + } + /** * Renders a plot. The request is queued if a render is already in progress. * diff --git a/src/vs/workbench/services/languageRuntime/common/positronPlotRenderQueue.ts b/src/vs/workbench/services/languageRuntime/common/positronPlotRenderQueue.ts index 8566f7a49e21..c8671bf57118 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronPlotRenderQueue.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronPlotRenderQueue.ts @@ -9,7 +9,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IPlotSize } from '../../positronPlots/common/sizingPolicy.js'; import { ILanguageRuntimeSession } from '../../runtimeSession/common/runtimeSessionService.js'; import { RuntimeState } from './languageRuntimeService.js'; -import { PlotRenderFormat, PositronPlotComm, IntrinsicSize } from './positronPlotComm.js'; +import { PlotRenderFormat, PositronPlotComm, IntrinsicSize, PlotMetadata } from './positronPlotComm.js'; import { padBase64 } from './utils.js'; /** @@ -17,13 +17,14 @@ import { padBase64 } from './utils.js'; */ export enum OperationType { Render = 'render', - GetIntrinsicSize = 'get_intrinsic_size' + GetIntrinsicSize = 'get_intrinsic_size', + GetMetadata = 'get_metadata' } /** * The result of a plot operation. */ -export type PlotOperationResult = IRenderedPlot | IntrinsicSize | undefined; +export type PlotOperationResult = IRenderedPlot | IntrinsicSize | PlotMetadata | undefined; /** * A rendered plot. @@ -318,6 +319,29 @@ export class PositronPlotRenderQueue extends Disposable { }); } + /** + * Queue a metadata request. + * + * @param comm The comm to use for the operation + */ + public queueMetadataRequest(comm: PositronPlotComm): Promise { + // Cancel any existing metadata requests for the same plot + this.cancelExistingOperations(comm, OperationType.GetMetadata); + + const operationRequest: PlotOperationRequest = { + type: OperationType.GetMetadata + }; + + const deferredOperation = this.queueOperation(operationRequest, comm); + return deferredOperation.promise.then((result) => { + if (result && typeof result === 'object' && 'name' in result && 'kind' in result) { + return result as PlotMetadata; + } else { + throw new Error('Invalid metadata result'); + } + }); + } + /** * Cancel existing operations in the queue for the same plot and operation * type. Used to avoid unnecessary work, e.g. when a new render request is @@ -416,6 +440,18 @@ export class PositronPlotRenderQueue extends Disposable { this._isProcessing = false; this.processQueue(); }); + } else if (operationRequest.type === OperationType.GetMetadata) { + // Handle metadata operation + queuedOperation.comm.getMetadata().then((metadata) => { + queuedOperation.operation.complete(metadata); + }).catch((err) => { + // Handle the error + queuedOperation.operation.error(err); + }).finally(() => { + // Mark processing as complete and process the next item in the queue + this._isProcessing = false; + this.processQueue(); + }); } else { // Unknown operation type queuedOperation.operation.error(new Error(`Unknown operation type: ${operationRequest.type}`)); diff --git a/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts b/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts index 75a1326312ad..9c8a0f50959b 100644 --- a/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts @@ -153,6 +153,17 @@ export interface IPositronConsoleService { * @param focus A value which indicates whether to focus the console. */ showNotebookConsole(notebookUri: URI, focus: boolean): void; + + /** + * Reveals and highlights the console input associated with the given execution ID. + * This will activate the console instance for the given session, scroll to the input, + * and briefly highlight it to draw attention to it. + * + * @param sessionId The session ID of the console instance. + * @param executionId The execution ID of the input to reveal. + * @throws an error if the console instance or execution cannot be found. + */ + revealExecution(sessionId: string, executionId: string): void; } /** @@ -336,6 +347,12 @@ export interface IPositronConsoleInstance { */ readonly onDidAttachSession: Event; + /** + * The onDidRequestRevealExecution event. Fired when an execution should be + * revealed and highlighted in the console. + */ + readonly onDidRequestRevealExecution: Event; + /** * The onDidChangeWidthInChars event. */ @@ -480,4 +497,13 @@ export interface IPositronConsoleInstance { * Gets the currently attached runtime, or undefined if none. */ attachedRuntimeSession: ILanguageRuntimeSession | undefined; + + /** + * Reveals and highlights the console input associated with the given execution ID. + * This will scroll to the input and briefly highlight it. + * + * @param executionId The execution ID of the input to reveal. + * @returns `true` if the execution was found and revealed, `false` otherwise. + */ + revealExecution(executionId: string): boolean; } diff --git a/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts b/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts index 5a3e80a372c5..4b741d8032aa 100644 --- a/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts @@ -951,6 +951,31 @@ export class PositronConsoleService extends Disposable implements IPositronConso this._onDidDeletePositronConsoleInstanceEmitter.fire(consoleInstance); } + /** + * Reveals and highlights the console input associated with the given execution ID. + * + * @param sessionId The session ID of the console instance. + * @param executionId The execution ID of the input to reveal. + */ + revealExecution(sessionId: string, executionId: string): void { + // Find the console instance with the given session ID. + const consoleInstance = this._positronConsoleInstancesBySessionId.get(sessionId); + if (!consoleInstance) { + throw new Error(`Cannot reveal execution: no Positron console instance found for session ID ${sessionId}.`); + } + + // Open the console view to ensure it's visible. + this._viewsService.openView(POSITRON_CONSOLE_VIEW_ID, false); + + // Set this console instance as active. + this.setActivePositronConsoleInstance(consoleInstance); + + // Ask the console instance to reveal the execution. + if (!consoleInstance.revealExecution(executionId)) { + throw new Error(`Cannot reveal execution: execution ID ${executionId} not found in session ID ${sessionId}.`); + } + } + /** * Sets the active Positron console instance. * @param positronConsoleInstance @@ -1171,6 +1196,11 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst private readonly _onDidAttachRuntime = this._register( new Emitter); + /** + * The onDidRequestRevealExecution event emitter. + */ + private readonly _onDidRequestRevealExecutionEmitter = this._register(new Emitter); + /** * Provides access to the code editor, if it's available. Note that we generally prefer to * interact with this editor indirectly, since its state is managed by React. @@ -1467,6 +1497,11 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst */ readonly onDidAttachSession = this._onDidAttachRuntime.event; + /** + * onDidRequestRevealExecution event. + */ + readonly onDidRequestRevealExecution = this._onDidRequestRevealExecutionEmitter.event; + /** * Emitted when the width of the console changes. */ @@ -1832,6 +1867,24 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst ); } + /** + * Reveals and highlights the console input associated with the given execution ID. + * + * @param executionId The execution ID of the input to reveal. + * @returns `true` if the execution was found and revealed, `false` otherwise. + */ + revealExecution(executionId: string): boolean { + // Try to find the activity item for this execution ID. + const activity = this._runtimeItemActivities.get(executionId); + if (!activity) { + return false; + } + + // Fire the event to request the UI to reveal and highlight this execution. + this._onDidRequestRevealExecutionEmitter.fire(executionId); + return true; + } + //#endregion IPositronConsoleInstance Implementation //#region Public Methods diff --git a/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts b/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts index f3ce0b8b5c04..ecb98bcca377 100644 --- a/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts @@ -211,6 +211,19 @@ export class TestPositronConsoleService implements IPositronConsoleService { // Doesn't do anything } + /** + * Reveals and highlights the console input associated with the given execution ID. + * @param sessionId The session ID of the console instance. + * @param executionId The execution ID of the input to reveal. + */ + revealExecution(sessionId: string, executionId: string): void { + const instance = this._positronConsoleInstances.find(instance => instance.sessionId === sessionId); + if (instance) { + this.setActivePositronConsoleSession(sessionId); + instance.revealExecution(executionId); + } + } + /** * Creates a test code execution event. */ @@ -279,6 +292,7 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { private readonly _onDidRequestRestartEmitter = new Emitter(); private readonly _onDidAttachSessionEmitter = new Emitter(); private readonly _onDidChangeWidthInCharsEmitter = new Emitter(); + private readonly _onDidRequestRevealExecutionEmitter = new Emitter(); private _state: PositronConsoleState = PositronConsoleState.Ready; private _trace: boolean = false; @@ -362,6 +376,10 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { return this._onDidAttachSessionEmitter.event; } + get onDidRequestRevealExecution(): Event { + return this._onDidRequestRevealExecutionEmitter.event; + } + get onDidChangeWidthInChars(): Event { return this._onDidChangeWidthInCharsEmitter.event; } @@ -582,6 +600,16 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { return this._attachedRuntimeSession; } + /** + * Reveals and highlights the console input associated with the given execution ID. + * @param executionId The execution ID of the input to reveal. + * @returns `true` if the execution was found and revealed, `false` otherwise. + */ + revealExecution(executionId: string): boolean { + this._onDidRequestRevealExecutionEmitter.fire(executionId); + return true; + } + /** * Attach a runtime session to this console instance. * @param session The session to attach. diff --git a/src/vs/workbench/services/positronPlots/common/positronPlots.ts b/src/vs/workbench/services/positronPlots/common/positronPlots.ts index 1521481e43ac..d0cbc0a8eec7 100644 --- a/src/vs/workbench/services/positronPlots/common/positronPlots.ts +++ b/src/vs/workbench/services/positronPlots/common/positronPlots.ts @@ -29,6 +29,11 @@ export const IPositronPlotsService = createDecorator(POSI export interface IPositronPlotClient extends IDisposable { readonly id: string; readonly metadata: IPositronPlotMetadata; + + /** + * Event that fires when the plot's metadata changes. + */ + readonly onDidUpdateMetadata?: Event; } export interface IZoomablePlotClient { @@ -182,6 +187,12 @@ export interface IPositronPlotsService { */ readonly onDidRemovePlot: Event; + /** + * Notifies subscribers when a plot's metadata has been updated. The ID + * of the plot is the event payload. + */ + readonly onDidUpdatePlotMetadata: Event; + /** * Notifies subscribers when the list of Positron plot instances is replaced * with a new list. The new list of plots is the event paylod. This event is diff --git a/src/vs/workbench/services/positronPlots/test/common/testPlotsServiceHelper.ts b/src/vs/workbench/services/positronPlots/test/common/testPlotsServiceHelper.ts index cdfd3c565832..db7fb7a02a60 100644 --- a/src/vs/workbench/services/positronPlots/test/common/testPlotsServiceHelper.ts +++ b/src/vs/workbench/services/positronPlots/test/common/testPlotsServiceHelper.ts @@ -19,7 +19,7 @@ export function createTestPlotsServiceWithPlots(): TestPositronPlotsService { id: 'test-plot-1', session_id: 'test-session', created: Date.now(), - parent_id: '', + execution_id: '', code: 'plot(1:10)', zoom_level: ZoomLevel.Fit, }); @@ -28,7 +28,7 @@ export function createTestPlotsServiceWithPlots(): TestPositronPlotsService { id: 'test-plot-2', session_id: 'test-session', created: Date.now() + 1000, // Created later - parent_id: '', + execution_id: '', code: 'hist(rnorm(100))', zoom_level: ZoomLevel.Fit, }); diff --git a/src/vs/workbench/services/positronPlots/test/common/testPositronPlotClient.ts b/src/vs/workbench/services/positronPlots/test/common/testPositronPlotClient.ts index 80c67d189329..20db593008e4 100644 --- a/src/vs/workbench/services/positronPlots/test/common/testPositronPlotClient.ts +++ b/src/vs/workbench/services/positronPlots/test/common/testPositronPlotClient.ts @@ -23,7 +23,6 @@ export class TestPositronPlotClient extends Disposable implements IPositronPlotC id: generateUuid(), session_id: 'test-session', created: Date.now(), - parent_id: '', code: 'test code', zoom_level: ZoomLevel.Fit, } diff --git a/src/vs/workbench/services/positronPlots/test/common/testPositronPlotsService.ts b/src/vs/workbench/services/positronPlots/test/common/testPositronPlotsService.ts index 444d8a89798b..284614ab55bb 100644 --- a/src/vs/workbench/services/positronPlots/test/common/testPositronPlotsService.ts +++ b/src/vs/workbench/services/positronPlots/test/common/testPositronPlotsService.ts @@ -76,6 +76,12 @@ export class TestPositronPlotsService extends Disposable implements IPositronPlo private readonly _onDidRemovePlotEmitter = this._register(new Emitter()); + /** + * The onDidUpdatePlotMetadata event emitter. + */ + private readonly _onDidUpdatePlotMetadataEmitter = + this._register(new Emitter()); + /** * The onDidReplacePlots event emitter. */ @@ -201,6 +207,11 @@ export class TestPositronPlotsService extends Disposable implements IPositronPlo */ readonly onDidRemovePlot = this._onDidRemovePlotEmitter.event; + /** + * The onDidUpdatePlotMetadata event. + */ + readonly onDidUpdatePlotMetadata = this._onDidUpdatePlotMetadataEmitter.event; + /** * The onDidReplacePlots event. */