diff --git a/packages/vscode-js-profile-core/src/common/types.ts b/packages/vscode-js-profile-core/src/common/types.ts index 860602b..4a855a2 100644 --- a/packages/vscode-js-profile-core/src/common/types.ts +++ b/packages/vscode-js-profile-core/src/common/types.ts @@ -56,6 +56,17 @@ export interface IReopenWithEditor { requireExtension?: string; } +/** + * Reopens the current document with the given editor, optionally only if + * the given extension is installed. + */ +export interface IRunCommand { + type: 'command'; + command: string; + args: unknown[]; + requireExtension?: string; +} + /** * Calls a graph method, used in the heapsnapshot. */ @@ -64,4 +75,4 @@ export interface ICallHeapGraph { inner: GraphRPCCall; } -export type Message = IOpenDocumentMessage | IReopenWithEditor | ICallHeapGraph; +export type Message = IOpenDocumentMessage | IRunCommand | IReopenWithEditor | ICallHeapGraph; diff --git a/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts b/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts index 8fe5e27..0bcd58e 100644 --- a/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts +++ b/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { bundlePage } from '../bundlePage'; import { Message } from '../common/types'; -import { reopenWithEditor } from '../reopenWithEditor'; +import { reopenWithEditor, requireExtension } from '../reopenWithEditor'; import { GraphRPCCall } from './rpc'; import { startWorker } from './startWorker'; @@ -19,7 +19,7 @@ interface IWorker extends vscode.Disposable { worker: Workerish; } -class HeapSnapshotDocument implements vscode.CustomDocument { +export class HeapSnapshotDocument implements vscode.CustomDocument { constructor( public readonly uri: vscode.Uri, public readonly value: IWorker, @@ -73,6 +73,53 @@ const workerRegistry = ((globalThis as any).__jsHeapSnapshotWorkers ??= new (cla } })()); +export const createHeapSnapshotWorker = (uri: vscode.Uri): Promise => + workerRegistry.create(uri); + +export const setupHeapSnapshotWebview = async ( + { worker }: IWorker, + bundle: vscode.Uri, + uri: vscode.Uri, + webview: vscode.Webview, + extraConsts: Record, +) => { + webview.onDidReceiveMessage((message: Message) => { + switch (message.type) { + case 'reopenWith': + reopenWithEditor( + uri.with({ query: message.withQuery }), + message.viewType, + message.requireExtension, + message.toSide, + ); + return; + case 'command': + requireExtension(message.requireExtension, () => + vscode.commands.executeCommand(message.command, ...message.args), + ); + return; + case 'callGraph': + worker.postMessage(message.inner); + return; + default: + console.warn(`Unknown request from webview: ${JSON.stringify(message)}`); + } + }); + + const listener = worker.onMessage((message: unknown) => { + webview.postMessage({ method: 'graphRet', message }); + }); + + webview.options = { enableScripts: true }; + webview.html = await bundlePage(webview.asWebviewUri(bundle), { + SNAPSHOT_URI: webview.asWebviewUri(uri).toString(), + DOCUMENT_URI: uri.toString(), + ...extraConsts, + }); + + return listener; +}; + export class HeapSnapshotEditorProvider implements vscode.CustomEditorProvider { @@ -87,7 +134,7 @@ export class HeapSnapshotEditorProvider * @inheritdoc */ async openCustomDocument(uri: vscode.Uri) { - const worker = await workerRegistry.create(uri); + const worker = await createHeapSnapshotWorker(uri); return new HeapSnapshotDocument(uri, worker); } @@ -98,36 +145,16 @@ export class HeapSnapshotEditorProvider document: HeapSnapshotDocument, webviewPanel: vscode.WebviewPanel, ): Promise { - webviewPanel.webview.onDidReceiveMessage((message: Message) => { - switch (message.type) { - case 'reopenWith': - reopenWithEditor( - document.uri.with({ query: message.withQuery }), - message.viewType, - message.requireExtension, - message.toSide, - ); - return; - case 'callGraph': - document.value.worker.postMessage(message.inner); - return; - default: - console.warn(`Unknown request from webview: ${JSON.stringify(message)}`); - } - }); + const disposable = await setupHeapSnapshotWebview( + document.value, + this.bundle, + document.uri, + webviewPanel.webview, + this.extraConsts, + ); - const listener = document.value.worker.onMessage((message: unknown) => { - webviewPanel.webview.postMessage({ method: 'graphRet', message }); - }); webviewPanel.onDidDispose(() => { - listener.dispose(); - }); - - webviewPanel.webview.options = { enableScripts: true }; - webviewPanel.webview.html = await bundlePage(webviewPanel.webview.asWebviewUri(this.bundle), { - SNAPSHOT_URI: webviewPanel.webview.asWebviewUri(document.uri).toString(), - DOCUMENT_URI: document.uri.toString(), - ...this.extraConsts, + disposable.dispose(); }); } diff --git a/packages/vscode-js-profile-core/src/reopenWithEditor.ts b/packages/vscode-js-profile-core/src/reopenWithEditor.ts index 397b32d..2087a3f 100644 --- a/packages/vscode-js-profile-core/src/reopenWithEditor.ts +++ b/packages/vscode-js-profile-core/src/reopenWithEditor.ts @@ -4,22 +4,29 @@ import * as vscode from 'vscode'; +export function requireExtension(extension: string | undefined, thenDo: () => T): T | undefined { + if (requireExtension && !vscode.extensions.all.some(e => e.id === extension)) { + vscode.commands.executeCommand('workbench.extensions.action.showExtensionsWithIds', [ + requireExtension, + ]); + return undefined; + } + + return thenDo(); +} + export function reopenWithEditor( uri: vscode.Uri, viewType: string, - requireExtension?: string, + requireExtensionId?: string, toSide?: boolean, ) { - if (requireExtension && !vscode.extensions.all.some(e => e.id === requireExtension)) { - vscode.commands.executeCommand('workbench.extensions.action.showExtensionsWithIds', [ - requireExtension, - ]); - } else { + return requireExtension(requireExtensionId, () => vscode.commands.executeCommand( 'vscode.openWith', uri, viewType, toSide ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active, - ); - } + ), + ); } diff --git a/packages/vscode-js-profile-flame/README.md b/packages/vscode-js-profile-flame/README.md index 1e9a182..0b4f60f 100644 --- a/packages/vscode-js-profile-flame/README.md +++ b/packages/vscode-js-profile-flame/README.md @@ -20,7 +20,7 @@ You can further configure the realtime performance view with the following user ### Flame Chart View -You can open a `.cpuprofile` file (such as one taken by clicking the "profile" button in the realtime performance view), then click the 🔥 button in the upper right to open a flame chart view. +You can open a `.cpuprofile` or `.heapprofile` file (such as one taken by clicking the "profile" button in the realtime performance view), then click the 🔥 button in the upper right to open a flame chart view. By default, this view shows chronological "snapshots" of your program's stack taken roughly each millisecond. You can zoom and explore the flamechart, and ctrl or cmd+click on stacks to jump to the stack location. @@ -33,3 +33,9 @@ This view groups call stacks and orders them by time, creating a visual represen ![](/packages/vscode-js-profile-flame/resources/flame-leftheavy.png) The flame chart color is tweakable via the `charts-red` color token in your VS Code theme. + +### Memory Graph View + +You can open a `.heapsnapshot` file in VS Code and click on the "graph" icon beside an object in memory to view a chart of its retainers: + +![](/packages/vscode-js-profile-flame/resources/retainers.png) diff --git a/packages/vscode-js-profile-flame/package.json b/packages/vscode-js-profile-flame/package.json index ee520a5..e0984ec 100644 --- a/packages/vscode-js-profile-flame/package.json +++ b/packages/vscode-js-profile-flame/package.json @@ -32,6 +32,10 @@ "watch": "webpack --mode development --watch" }, "icon": "resources/logo.png", + "activationEvents": [ + "onCommand:jsProfileVisualizer.heapsnapshot.flame.show", + "onWebviewPanel:jsProfileVisualizer.heapsnapshot.flame.show" + ], "contributes": { "customEditors": [ { @@ -53,16 +57,6 @@ "filenamePattern": "*.heapprofile" } ] - }, - { - "viewType": "jsProfileVisualizer.heapsnapshot.flame", - "displayName": "Heap Snapshot Retainers Graph Visualizer", - "priority": "option", - "selector": [ - { - "filenamePattern": "*.heapsnapshot" - } - ] } ], "views": { diff --git a/packages/vscode-js-profile-flame/resources/retainers.png b/packages/vscode-js-profile-flame/resources/retainers.png new file mode 100644 index 0000000..2f00faa Binary files /dev/null and b/packages/vscode-js-profile-flame/resources/retainers.png differ diff --git a/packages/vscode-js-profile-flame/src/extension.ts b/packages/vscode-js-profile-flame/src/extension.ts index f6d762f..c9cfaf6 100644 --- a/packages/vscode-js-profile-flame/src/extension.ts +++ b/packages/vscode-js-profile-flame/src/extension.ts @@ -12,11 +12,14 @@ const allConfig = [Config.PollInterval, Config.ViewDuration, Config.Easing]; import * as vscode from 'vscode'; import { CpuProfileEditorProvider } from 'vscode-js-profile-core/out/cpu/editorProvider'; +import { + createHeapSnapshotWorker, + setupHeapSnapshotWebview, +} from 'vscode-js-profile-core/out/esm/heapsnapshot/editorProvider'; import { HeapProfileEditorProvider } from 'vscode-js-profile-core/out/heap/editorProvider'; -import { HeapSnapshotEditorProvider } from 'vscode-js-profile-core/out/esm/heapsnapshot/editorProvider'; import { ProfileCodeLensProvider } from 'vscode-js-profile-core/out/profileCodeLensProvider'; import { createMetrics } from './realtime/metrics'; -import { readRealtimeSettings, RealtimeSessionTracker } from './realtimeSessionTracker'; +import { RealtimeSessionTracker, readRealtimeSettings } from './realtimeSessionTracker'; import { RealtimeWebviewProvider } from './realtimeWebviewProvider'; export function activate(context: vscode.ExtensionContext) { @@ -50,15 +53,44 @@ export function activate(context: vscode.ExtensionContext) { }, ), - vscode.window.registerCustomEditorProvider( - 'jsProfileVisualizer.heapsnapshot.flame', - new HeapSnapshotEditorProvider( - vscode.Uri.joinPath(context.extensionUri, 'out', 'heapsnapshot-client.bundle.js'), - ), - // note: context is not retained when hidden, unlike other editors, because - // the model is kept in a worker_thread and accessed via RPC + vscode.commands.registerCommand( + 'jsProfileVisualizer.heapsnapshot.flame.show', + async ({ uri: rawUri, index, name }) => { + const panel = vscode.window.createWebviewPanel( + 'jsProfileVisualizer.heapsnapshot.flame', + vscode.l10n.t('Memory Graph: {0}', name), + vscode.ViewColumn.Beside, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'out')], + }, + ); + + const uri = vscode.Uri.parse(rawUri); + const worker = await createHeapSnapshotWorker(uri); + const webviewDisposable = await setupHeapSnapshotWebview( + worker, + vscode.Uri.joinPath(context.extensionUri, 'out', 'heapsnapshot-client.bundle.js'), + uri, + panel.webview, + { SNAPSHOT_INDEX: index }, + ); + + panel.onDidDispose(() => { + worker.dispose(); + webviewDisposable.dispose(); + }); + }, ), + // there's no state we actually need to serialize/deserialize, but register + // this so VS Code knows that it can + vscode.window.registerWebviewPanelSerializer('jsProfileVisualizer.heapsnapshot.flame.show', { + deserializeWebviewPanel() { + return Promise.resolve(); + }, + }), + vscode.window.registerWebviewViewProvider(RealtimeWebviewProvider.viewType, realtime), vscode.workspace.onDidChangeConfiguration(evt => { diff --git a/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx index f29bdec..a81516e 100644 --- a/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx +++ b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx @@ -19,9 +19,7 @@ import styles from './client.css'; // eslint-disable-next-line @typescript-eslint/no-var-requires cytoscape.use(require('cytoscape-klay')); -declare const DOCUMENT_URI: string; -const snapshotUri = new URL(DOCUMENT_URI.replace(/\%3D/g, '=')); -const index = snapshotUri.searchParams.get('index'); +declare const SNAPSHOT_INDEX: number; const DEFAULT_RETAINER_DISTANCE = 4; @@ -52,7 +50,7 @@ const Graph: FunctionComponent<{ maxDistance: number }> = ({ maxDistance }) => { const [nodes, setNodes] = useState(); useEffect(() => { - doGraphRpc(vscodeApi, 'getRetainers', [Number(index), maxDistance]).then(r => + doGraphRpc(vscodeApi, 'getRetainers', [Number(SNAPSHOT_INDEX), maxDistance]).then(r => setNodes(r as IRetainingNode[]), ); }, [maxDistance]); @@ -150,7 +148,7 @@ const Graph: FunctionComponent<{ maxDistance: number }> = ({ maxDistance }) => { } as any, }); - const root = cy.$(`#${index}`); + const root = cy.$(`#${SNAPSHOT_INDEX}`); root.style('background-color', colors['charts-blue']); attachPathHoverHandle(root, cy); diff --git a/packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx b/packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx index 0db1263..9d8ef92 100644 --- a/packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx +++ b/packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx @@ -10,7 +10,7 @@ import prettyBytes from 'pretty-bytes'; import { Icon } from 'vscode-js-profile-core/out/esm/client/icons'; import { classes } from 'vscode-js-profile-core/out/esm/client/util'; import { VsCodeApi } from 'vscode-js-profile-core/out/esm/client/vscodeApi'; -import { IReopenWithEditor } from 'vscode-js-profile-core/out/esm/common/types'; +import { IRunCommand } from 'vscode-js-profile-core/out/esm/common/types'; import { IClassGroup, INode } from 'vscode-js-profile-core/out/esm/heapsnapshot/rpc'; import { DataProvider, IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; import { IRowProps, makeBaseTimeView } from '../common/base-time-view'; @@ -26,6 +26,8 @@ export type TableNode = (IClassGroup | INode) & { const BaseTimeView = makeBaseTimeView(); +declare const DOCUMENT_URI: string; + export const sortBySelfSize: SortFn = (a, b) => b.selfSize - a.selfSize; export const sortByRetainedSize: SortFn = (a, b) => b.retainedSize - a.retainedSize; export const sortByName: SortFn = (a, b) => a.name.localeCompare(b.name); @@ -100,11 +102,10 @@ const timeViewRow = const onClick = useCallback( (evt: MouseEvent) => { evt.stopPropagation(); - vscode.postMessage({ - type: 'reopenWith', - withQuery: `index=${node.index}`, - toSide: true, - viewType: 'jsProfileVisualizer.heapsnapshot.flame', + vscode.postMessage({ + type: 'command', + command: 'jsProfileVisualizer.heapsnapshot.flame.show', + args: [{ uri: DOCUMENT_URI, index: node.index, name: node.name }], requireExtension: 'ms-vscode.vscode-js-profile-flame', }); },