Repository: posit-dev/positron
Pull request #7704: Revamp how plots are named and saved
Author: login: softwarenerd
Created: 2025-05-14T21:26:19Z | Merged: 2025-05-15T23:12:04Z

## Description

This PR addresses #5872 by revamping how plots are named and saved.

Now, when you open a plot, it will be named "plot-1", "plot-2", and so on, across sessions. If you open the plot in an editor window, you will see its name:

<img width="1776" alt="image" src="https://github.com/user-attachments/assets/6fe5c1f5-14eb-4664-ab76-91127f4f5f70" />

&nbsp;
Instead of the unsightly UUID, as outlined in the original issue.

Additionally, when you save a plot, you will now see the plot name in the save dialogs. For example:

<img width="514" alt="image" src="https://github.com/user-attachments/assets/80523e56-6b1b-4db7-b910-32a6eba1d33e" />

&nbsp;
<img width="834" alt="image" src="https://github.com/user-attachments/assets/92bdc20f-d538-475e-bcfe-113587b210cb" />

&nbsp;
Which will help users not have to deal with coming up with a unique file name each time they save a plot.

Tests:
@:plots

### Release Notes

#### New Features

- N/A

#### Bug Fixes

- #5872 Revamp how plots are named and saved. Now plots are named, "plot-1", "plot-2", and so on.

### QA Notes




Diff:
diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx
index c02e23c6c088..2d954859cd0f 100644
--- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx
+++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx
@@ -138,7 +138,7 @@ interface DirectoryState {
 
 const SavePlotModalDialog = (props: SavePlotModalDialogProps) => {
 	const [directory, setDirectory] = React.useState<DirectoryState>({ value: props.suggestedPath ?? URI.file(''), valid: true });
-	const [name, setName] = React.useState({ value: 'plot', valid: true });
+	const [name, setName] = React.useState({ value: 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/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts
index c5aff66b2729..df14449f8874 100644
--- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts
+++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts
@@ -10,7 +10,7 @@ import { ILanguageRuntimeMessageOutput, LanguageRuntimeSessionMode, RuntimeOutpu
 import { ILanguageRuntimeSession, IRuntimeClientInstance, IRuntimeSessionService, RuntimeClientType } from '../../../services/runtimeSession/common/runtimeSessionService.js';
 import { HTMLFileSystemProvider } from '../../../../platform/files/browser/htmlFileSystemProvider.js';
 import { IFileService } from '../../../../platform/files/common/files.js';
-import { DarkFilter, HistoryPolicy, IPositronPlotClient, IPositronPlotsService, PlotRenderFormat, PlotRenderSettings, POSITRON_PLOTS_VIEW_ID } from '../../../services/positronPlots/common/positronPlots.js';
+import { createSuggestedFileNameForPlot, DarkFilter, HistoryPolicy, IPositronPlotClient, IPositronPlotsService, PlotRenderFormat, PlotRenderSettings, POSITRON_PLOTS_VIEW_ID } from '../../../services/positronPlots/common/positronPlots.js';
 import { Emitter, Event } from '../../../../base/common/event.js';
 import { StaticPlotClient } from '../../../services/positronPlots/common/staticPlotClient.js';
 import { IStorageService, StorageTarget, StorageScope } from '../../../../platform/storage/common/storage.js';
@@ -53,6 +53,7 @@ import { IPathService } from '../../../services/path/common/pathService.js';
 import { DynamicPlotInstance } from './components/dynamicPlotInstance.js';
 import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
 import { ISettableObservable, observableValue } from '../../../../base/common/observableInternal/base.js';
+import { joinPath } from '../../../../base/common/resources.js';
 
 /** The maximum number of recent executions to store. */
 const MaxRecentExecutions = 10;
@@ -562,7 +563,12 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 					let registered = false;
 					if (storedMetadata) {
 						try {
+							// Parse the plot metadata. If the metadata doesn't have a suggested file name, generate one.
 							const metadata = JSON.parse(storedMetadata) as IPositronPlotMetadata;
+							if (!metadata.suggested_file_name) {
+								metadata.suggested_file_name = createSuggestedFileNameForPlot(this._storageService);
+							}
+
 							const commProxy = this.createCommProxy(client, metadata);
 							plotClients.push(this.createRuntimePlotClient(commProxy, metadata));
 							registered = true;
@@ -581,6 +587,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 							parent_id: '',
 							code: '',
 							location: PlotClientLocation.View,
+							suggested_file_name: createSuggestedFileNameForPlot(this._storageService),
 						};
 						const commProxy = this.createCommProxy(client, metadata);
 						plotClients.push(this.createRuntimePlotClient(commProxy, metadata));
@@ -661,6 +668,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 					parent_id: event.message.parent_id,
 					code,
 					pre_render: data?.pre_render,
+					suggested_file_name: createSuggestedFileNameForPlot(this._storageService),
 				};
 
 				// Register the plot client
@@ -731,7 +739,11 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 
 		if (storedMetadata) {
 			try {
+				// Parse the plot metadata. If the metadata doesn't have a suggested file name, generate one.
 				const metadata = JSON.parse(storedMetadata) as IPositronPlotMetadata;
+				if (!metadata.suggested_file_name) {
+					metadata.suggested_file_name = createSuggestedFileNameForPlot(this._storageService);
+				}
 				this.createEditorPlot(metadata, commProxy);
 
 				this.openEditor(plotId, this.getPreferredEditorGroup(), metadata);
@@ -827,7 +839,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 		plotClient.register(plotClient.onDidChangeSizingPolicy((policy) => {
 			this.selectSizingPolicy(policy.id);
 		}));
-  }
+	}
 
 	/**
 	 * Creates a new static plot client instance and registers it with the
@@ -840,7 +852,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 		sessionId: string,
 		message: ILanguageRuntimeMessageOutput,
 		code?: string) {
-		this.registerNewPlotClient(new StaticPlotClient(sessionId, message, code));
+		this.registerNewPlotClient(new StaticPlotClient(this._storageService, sessionId, message, code));
 	}
 
 	/**
@@ -1007,7 +1019,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 					if (plotClient instanceof StaticPlotClient) {
 						// if it's a static plot, save the image to disk
 						const uri = plotClient.uri;
-						this.showSavePlotDialog(uri);
+						this.showSavePlotDialog(uri, plotClient.metadata.suggested_file_name);
 					} else if (plotClient instanceof PlotClientInstance) {
 						// if it's a dynamic plot, present options dialog
 						showSavePlotModalDialog(
@@ -1071,11 +1083,11 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 		return {
 			mime: mime,
 			data: imageData,
-			type: mime.split('/')[1]
+			type: mime.split('/')[1].split(';')[0],
 		};
 	}
 
-	showSavePlotDialog(uri: string) {
+	showSavePlotDialog(uri: string, suggestedFileName?: string) {
 		const dataUri = this.splitPlotDataUri(uri);
 
 		if (!dataUri) {
@@ -1084,19 +1096,23 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 
 		const extension = dataUri.type;
 
-		this._fileDialogService.showSaveDialog({
-			title: 'Save Plot',
-			filters:
-				[
-					{
-						extensions: [extension],
-						name: extension.toUpperCase(),
-					},
-				],
-		}).then(result => {
-			if (result) {
-				this.savePlotAs({ path: result, uri });
-			}
+		this._fileDialogService.defaultFilePath().then(defaultPath => {
+			const defaultUri = joinPath(defaultPath, suggestedFileName ?? 'plot');
+			this._fileDialogService.showSaveDialog({
+				title: localize('positron.savePlot', "Save Plot"),
+				defaultUri,
+				filters:
+					[
+						{
+							name: extension.toUpperCase(),
+							extensions: [extension],
+						},
+					],
+			}).then(result => {
+				if (result) {
+					this.savePlotAs({ path: result, uri });
+				}
+			});
 		});
 	}
 
diff --git a/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx b/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx
index a8eec27f25a2..4e7f3fd97eb7 100644
--- a/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx
+++ b/src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx
@@ -30,7 +30,7 @@ import { EditorPlotsContainer } from './editorPlotsContainer.js';
 import { PositronPlotsEditorInput } from './positronPlotsEditorInput.js';
 import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
 import { ILanguageRuntimeService } from '../../../services/languageRuntime/common/languageRuntimeService.js';
-import { IPositronPlotClient, IPositronPlotsService } from '../../../services/positronPlots/common/positronPlots.js';
+import { createSuggestedFileNameForPlot, IPositronPlotClient, IPositronPlotsService } from '../../../services/positronPlots/common/positronPlots.js';
 import { IPreferencesService } from '../../../services/preferences/common/preferences.js';
 import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js';
 
@@ -109,7 +109,7 @@ export class PositronPlotsEditor extends EditorPane implements IPositronPlotsEdi
 		@IConfigurationService private readonly _configurationService: IConfigurationService,
 		@IContextKeyService private readonly _contextKeyService: IContextKeyService,
 		@IContextMenuService private readonly _contextMenuService: IContextMenuService,
-		@IStorageService storageService: IStorageService,
+		@IStorageService private readonly storageService: IStorageService,
 		@ITelemetryService telemetryService: ITelemetryService,
 		@IThemeService themeService: IThemeService,
 		@ILayoutService private readonly _layoutService: ILayoutService,
@@ -177,7 +177,7 @@ export class PositronPlotsEditor extends EditorPane implements IPositronPlotsEdi
 			throw new Error('Plot client not found');
 		}
 
-		input.setName(`Plot: ${this._plotClient.id}`);
+		input.setName(this._plotClient.metadata.suggested_file_name ?? createSuggestedFileNameForPlot(this.storageService));
 
 		this.renderContainer(this._plotClient);
 		this.onSizeChanged((event: ISize) => {
diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts
index af97f442d9f3..8743d4731451 100644
--- a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts
+++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts
@@ -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.
  *--------------------------------------------------------------------------------------------*/
 
@@ -66,6 +66,9 @@ export interface IPositronPlotMetadata {
 
 	/** The pre-rendering of the plot for initial display, if any. */
 	pre_render?: PlotResult;
+
+	/** The suggested file name of the plot. */
+	suggested_file_name?: string;
 }
 
 /**
diff --git a/src/vs/workbench/services/positronPlots/common/positronPlots.ts b/src/vs/workbench/services/positronPlots/common/positronPlots.ts
index a6ab418fda85..75a2d59dfb6d 100644
--- a/src/vs/workbench/services/positronPlots/common/positronPlots.ts
+++ b/src/vs/workbench/services/positronPlots/common/positronPlots.ts
@@ -8,6 +8,7 @@ import { Event } from '../../../../base/common/event.js';
 import { IPlotSize, IPositronPlotSizingPolicy } from './sizingPolicy.js';
 import { IDisposable } from '../../../../base/common/lifecycle.js';
 import { IPositronPlotMetadata } from '../../languageRuntime/common/languageRuntimePlotClient.js';
+import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
 
 export const POSITRON_PLOTS_VIEW_ID = 'workbench.panel.positronPlots';
 
@@ -67,6 +68,18 @@ export enum DarkFilter {
 	Auto = 'auto'
 }
 
+/**
+ * Creates a suggested file name for a plot.
+ * @param storageService The storage service to use.
+ * @returns A suggested file name for the plot.
+ */
+export const createSuggestedFileNameForPlot = (storageService: IStorageService) => {
+	const key = 'positron.plotNumber';
+	const plotNumber = storageService.getNumber(key, StorageScope.APPLICATION, 0) + 1;
+	storageService.store(key, plotNumber, StorageScope.APPLICATION, StorageTarget.MACHINE);
+	return `plot-${plotNumber}`;
+};
+
 /**
  * IPositronPlotsService interface.
  */
diff --git a/src/vs/workbench/services/positronPlots/common/staticPlotClient.ts b/src/vs/workbench/services/positronPlots/common/staticPlotClient.ts
index ff14c9f3c138..e29c7aecf9f0 100644
--- a/src/vs/workbench/services/positronPlots/common/staticPlotClient.ts
+++ b/src/vs/workbench/services/positronPlots/common/staticPlotClient.ts
@@ -1,12 +1,13 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2023-2024 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.
  *--------------------------------------------------------------------------------------------*/
 
 import { Disposable } from '../../../../base/common/lifecycle.js';
+import { IStorageService } from '../../../../platform/storage/common/storage.js';
 import { IPositronPlotMetadata } from '../../languageRuntime/common/languageRuntimePlotClient.js';
 import { ILanguageRuntimeMessageOutput } from '../../languageRuntime/common/languageRuntimeService.js';
-import { IPositronPlotClient } from './positronPlots.js';
+import { createSuggestedFileNameForPlot, IPositronPlotClient } from './positronPlots.js';
 
 /**
  * Creates a static plot client from a language runtime message.
@@ -16,7 +17,7 @@ export class StaticPlotClient extends Disposable implements IPositronPlotClient
 	public readonly mimeType;
 	public readonly data;
 
-	constructor(sessionId: string, message: ILanguageRuntimeMessageOutput,
+	constructor(storageService: IStorageService, sessionId: string, message: ILanguageRuntimeMessageOutput,
 		public readonly code?: string) {
 		super();
 
@@ -27,6 +28,7 @@ export class StaticPlotClient extends Disposable implements IPositronPlotClient
 			created: Date.parse(message.when),
 			session_id: sessionId,
 			code: code ? code : '',
+			suggested_file_name: createSuggestedFileNameForPlot(storageService),
 		};
 
 		// Find the image MIME type. This is guaranteed to exist since we only create this object if


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR revamps how plots are named and saved by replacing the unsightly UUIDs with sequential names (e.g., “plot-1”, “plot-2”) that persist across sessions.  
- Updated the static plot client to include a suggested file name generated via a shared helper.  
- Introduced and integrated the createSuggestedFileNameForPlot helper into multiple components and dialogs.  
- Modified save dialogs and plot editors to display the suggested file name.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| src/vs/workbench/services/positronPlots/common/staticPlotClient.ts | Updated constructor to accept a storage service and initialize metadata with a suggested file name. |
| src/vs/workbench/services/positronPlots/common/positronPlots.ts | Added the helper function for generating suggested plot file names. |
| src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts | Modified metadata interface to include an optional suggested file name. |
| src/vs/workbench/contrib/positronPlotsEditor/browser/positronPlotsEditor.tsx | Updated plot naming in the editor to display the suggested file name. |
| src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts | Integrated the suggested file name functionality in various methods and dialogs. |
| src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx | Updated default plot name state to use the suggested file name if available. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts:1036**
* While the additional split helps exclude extra parameters from the MIME type, ensure that this approach robustly handles unexpected MIME formats or invalid data to avoid potential runtime errors.
```
type: mime.split('/')[1].split(';')[0],
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T21:26:57Z
user: login: jmcphers
body: Nice, this looks and feels better! 

My only quibble is that I don't like that the number is basically going to go up forever so in a few months you're going to be seeing tabs with names like `plot-123891`. How would you feel about session scoping the plot numbers? Then you'd get a `plot-1` every time you fully closed and reopened Positron (or started a Workbench session). You could store the counter in the ephemeral session store to accomplish that.
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-05-14T23:48:35Z

Review comments:
diff_hunk: @@ -27,6 +28,7 @@ export class StaticPlotClient extends Disposable implements IPositronPlotClient
 			created: Date.parse(message.when),
 			session_id: sessionId,
 			code: code ? code : '',
+			suggested_file_name: createSuggestedFileNameForPlot(storageService),
path: src/vs/workbench/services/positronPlots/common/staticPlotClient.ts
user: login: Copilot
body: [nitpick] Consider adding an inline comment here to document why createSuggestedFileNameForPlot is used during metadata initialization, for improved code clarity.
created_at: 2025-05-14T21:26:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -539,7 +540,12 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe
 					let registered = false;
 					if (storedMetadata) {
 						try {
+							// Parse the plot metadata. If the metadata doesn't have a suggested file name, generate one.
 							const metadata = JSON.parse(storedMetadata) as IPositronPlotMetadata;
+							if (!metadata.suggested_file_name) {
path: src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts
user: login: Copilot
body: [nitpick] The logic to initialize suggested_file_name is repeated in several methods; consider refactoring this into a dedicated helper function to reduce duplication.
created_at: 2025-05-14T21:26:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-14T21:26:36Z
body: <!-- PR Tags -->
<!-- 
🚨 RED ALERT! ✋ Rule breaker detected! Tags don’t go here, they belong above ^ in the PR description using this proper format: `@:tag`. Changing them here won't do anything (trust us, we’ve tried). Confused? Check out the README hyperlink.
-->

**E2E Tests** 🚀
This PR will run tests tagged with: `@:critical` `@:plots`

<sup>[readme](https://github.com/posit-dev/positron/blob/main/test/e2e/README.md#pull-requests-and-test-tags)</sup>&nbsp;&nbsp;<sup>[valid tags](https://github.com/posit-dev/positron/blob/main/test/e2e/infra/test-runner/test-tags.ts)</sup>
author_association: NONE
user: login: softwarenerd
created_at: 2025-05-15T04:58:03Z
body: > Nice, this looks and feels better!
> 
> My only quibble is that I don't like that the number is basically going to go up forever so in a few months you're going to be seeing tabs with names like `plot-123891`. How would you feel about session scoping the plot numbers? Then you'd get a `plot-1` every time you fully closed and reopened Positron (or started a Workbench session). You could store the counter in the ephemeral session store to accomplish that.

I can do that. I started with this approach to see what feedback there would be. I'll let @timtmok chime in with his thoughts, and then adjust the PR.
author_association: CONTRIBUTOR
user: login: timtmok
created_at: 2025-05-15T14:13:14Z
body: Using the ephemeral state service is a good idea to reset the counter. Overall, it'll be a lot easier to differentiate between multiple plots and screen readers will be quick to read the title.
author_association: CONTRIBUTOR