From 4c93db3b21d7db9d799888bea77da3f1fa47e0dd Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 13 Nov 2023 14:41:34 -0800 Subject: [PATCH 1/2] feat: support heap snapshots Supports heap snapshots using the new @vscode/v8-heap-parser module. The parser runs in a Node.js worker_thread on the extension host, and is queried by postmessage RPC from connected webviews. This adds a table view and a cytoscape-powered graph view of retainers, in the 'flame' package, as seen in pacific standup yesterday. Most of the work in this PR is reworking the existing structures to support asynchronously loading data. Heap snapshots are _big_, far bigger than CPU profiles or heap profiles, so we don't want to load it into memory all at once. --- package-lock.json | 120 +++++++- packages/vscode-js-profile-core/package.json | 3 +- .../src/client/filter.tsx | 15 +- .../client/{rich-filter.css => filterBar.css} | 0 .../src/client/filterBar.tsx | 10 + .../src/client/pageLoader.css | 29 ++ .../src/client/pageLoader.tsx | 8 + .../src/client/rich-filter.tsx | 5 +- .../src/client/vscodeApi.tsx | 4 +- .../src/common/model.ts | 5 +- .../src/common/types.ts | 13 +- .../vscode-js-profile-core/src/cpu/layout.tsx | 9 +- .../src/heap/layout.tsx | 9 +- .../src/heapsnapshot/editorProvider.ts | 158 ++++++++++ .../src/heapsnapshot/heapsnapshotWorker.ts | 101 +++++++ .../src/heapsnapshot/rpc.ts | 101 +++++++ .../src/heapsnapshot/useGraph.ts | 58 ++++ .../vscode-js-profile-core/src/ql/index.ts | 151 +++++++++- .../src/ql/parser.test.ts | 22 +- .../src/reopenWithEditor.ts | 14 +- packages/vscode-js-profile-flame/package.json | 26 +- .../src/client/cpu/client.tsx | 15 +- .../src/client/heap/client.tsx | 15 +- .../vscode-js-profile-flame/src/extension.ts | 10 + .../src/heapsnapshot-client/client.css | 12 + .../src/heapsnapshot-client/client.tsx | 226 +++++++++++++++ .../vscode-js-profile-flame/webpack.config.js | 23 ++ .../webpack.cpu-client.js | 4 - .../webpack.extension.js | 1 - .../webpack.heap-client.js | 4 - .../webpack.realtime.js | 4 - packages/vscode-js-profile-table/package.json | 22 +- .../src/common/base-time-view-row.tsx | 46 ++- .../src/common/base-time-view.tsx | 273 +++++++++++++++--- .../src/common/get-global-unique-id.ts | 5 +- .../src/common/time-view.css | 6 + .../src/common/types.ts | 5 +- .../src/cpu-client/client.tsx | 10 +- .../src/cpu-client/time-view.tsx | 40 ++- .../vscode-js-profile-table/src/extension.ts | 9 + .../src/heap-client/client.tsx | 10 +- .../src/heap-client/time-view.tsx | 38 ++- .../src/heapsnapshot-client/client.tsx | 110 +++++++ .../src/heapsnapshot-client/time-view.tsx | 146 ++++++++++ .../vscode-js-profile-table/webpack.config.js | 19 ++ .../webpack.cpu-client.js | 4 - .../webpack.extension.js | 1 - .../webpack.heap-client.js | 4 - scripts/webpack.extension.js | 11 +- scripts/webpack.heapsnapshot-worker.js | 19 ++ 50 files changed, 1733 insertions(+), 220 deletions(-) rename packages/vscode-js-profile-core/src/client/{rich-filter.css => filterBar.css} (100%) create mode 100644 packages/vscode-js-profile-core/src/client/filterBar.tsx create mode 100644 packages/vscode-js-profile-core/src/client/pageLoader.css create mode 100644 packages/vscode-js-profile-core/src/client/pageLoader.tsx create mode 100644 packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts create mode 100644 packages/vscode-js-profile-core/src/heapsnapshot/heapsnapshotWorker.ts create mode 100644 packages/vscode-js-profile-core/src/heapsnapshot/rpc.ts create mode 100644 packages/vscode-js-profile-core/src/heapsnapshot/useGraph.ts create mode 100644 packages/vscode-js-profile-flame/src/heapsnapshot-client/client.css create mode 100644 packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx create mode 100644 packages/vscode-js-profile-flame/webpack.config.js delete mode 100644 packages/vscode-js-profile-flame/webpack.cpu-client.js delete mode 100644 packages/vscode-js-profile-flame/webpack.extension.js delete mode 100644 packages/vscode-js-profile-flame/webpack.heap-client.js delete mode 100644 packages/vscode-js-profile-flame/webpack.realtime.js create mode 100644 packages/vscode-js-profile-table/src/heapsnapshot-client/client.tsx create mode 100644 packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx create mode 100644 packages/vscode-js-profile-table/webpack.config.js delete mode 100644 packages/vscode-js-profile-table/webpack.cpu-client.js delete mode 100644 packages/vscode-js-profile-table/webpack.extension.js delete mode 100644 packages/vscode-js-profile-table/webpack.heap-client.js create mode 100644 scripts/webpack.heapsnapshot-worker.js diff --git a/package-lock.json b/package-lock.json index 5150b62..24b4679 100644 --- a/package-lock.json +++ b/package-lock.json @@ -829,6 +829,12 @@ "@types/node": "*" } }, + "node_modules/@types/cytoscape": { + "version": "3.19.15", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.19.15.tgz", + "integrity": "sha512-v1PNoMBzoIrOGZfuU/PFwDEPxfP4GnfVCTrZPx4M2G4EFS7BV/FLCCoVMOzdBG98MJbNBXpx1LCrs8wh0vybEw==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -3045,6 +3051,29 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "dev": true }, + "node_modules/cytoscape": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.27.0.tgz", + "integrity": "sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg==", + "dependencies": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-klay": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", + "integrity": "sha512-VwPj0VR25GPfy6qXVQRi/MYlZM/zkdvRhHlgqbM//lSvstgM6fhp3ik/uM8Wr8nlhskfqz/M1fIDmR6UckbS2A==", + "dependencies": { + "klayjs": "^0.4.1" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -4864,6 +4893,11 @@ "he": "bin/he" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -5826,6 +5860,11 @@ "node": ">=0.10.0" } }, + "node_modules/klayjs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.4.1.tgz", + "integrity": "sha512-WUNxuO7O79TEkxCj6OIaK5TJBkaWaR/IKNTakgV9PwDn+mrr63MLHed34AcE2yTaDntgO6l0zGFIzhcoTeroTA==" + }, "node_modules/launch-editor": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", @@ -5915,8 +5954,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -7463,6 +7501,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -10351,20 +10400,29 @@ "packages/vscode-js-profile-core": { "version": "1.0.6", "dependencies": { - "@vscode/codicons": "^0.0.35" + "@vscode/codicons": "^0.0.35", + "@vscode/v8-heap-parser": "^0.1.0" } }, + "packages/vscode-js-profile-core/node_modules/@vscode/v8-heap-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vscode/v8-heap-parser/-/v8-heap-parser-0.1.0.tgz", + "integrity": "sha512-3EvQak7EIOLyIGz+IP9qSwRmP08ZRWgTeoRgAXPVkkDXZ8riqJ7LDtkgx++uHBiJ3MUaSdlUYPZcLFFw7E6zGg==" + }, "packages/vscode-js-profile-flame": { "version": "1.0.7", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.35", "chroma-js": "^2.4.2", + "cytoscape": "^3.27.0", + "cytoscape-klay": "^3.1.4", "vscode-js-profile-core": "*", "vscode-webview-tools": "^0.1.1" }, "devDependencies": { "@types/chroma-js": "^2.4.3", + "@types/cytoscape": "^3.19.15", "@types/resize-observer-browser": "^0.1.7" }, "engines": { @@ -10376,6 +10434,7 @@ "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.35", + "pretty-bytes": "^6.1.1", "vscode-js-profile-core": "*" }, "engines": { @@ -10863,6 +10922,12 @@ "@types/node": "*" } }, + "@types/cytoscape": { + "version": "3.19.15", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.19.15.tgz", + "integrity": "sha512-v1PNoMBzoIrOGZfuU/PFwDEPxfP4GnfVCTrZPx4M2G4EFS7BV/FLCCoVMOzdBG98MJbNBXpx1LCrs8wh0vybEw==", + "dev": true + }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -12515,6 +12580,23 @@ } } }, + "cytoscape": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.27.0.tgz", + "integrity": "sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg==", + "requires": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + } + }, + "cytoscape-klay": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", + "integrity": "sha512-VwPj0VR25GPfy6qXVQRi/MYlZM/zkdvRhHlgqbM//lSvstgM6fhp3ik/uM8Wr8nlhskfqz/M1fIDmR6UckbS2A==", + "requires": { + "klayjs": "^0.4.1" + } + }, "date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -13871,6 +13953,11 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -14557,6 +14644,11 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "klayjs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.4.1.tgz", + "integrity": "sha512-WUNxuO7O79TEkxCj6OIaK5TJBkaWaR/IKNTakgV9PwDn+mrr63MLHed34AcE2yTaDntgO6l0zGFIzhcoTeroTA==" + }, "launch-editor": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", @@ -14625,8 +14717,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.debounce": { "version": "4.0.8", @@ -15720,6 +15811,11 @@ "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true }, + "pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==" + }, "pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -17188,16 +17284,27 @@ "vscode-js-profile-core": { "version": "file:packages/vscode-js-profile-core", "requires": { - "@vscode/codicons": "^0.0.35" + "@vscode/codicons": "^0.0.35", + "@vscode/v8-heap-parser": "^0.1.0" + }, + "dependencies": { + "@vscode/v8-heap-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vscode/v8-heap-parser/-/v8-heap-parser-0.1.0.tgz", + "integrity": "sha512-3EvQak7EIOLyIGz+IP9qSwRmP08ZRWgTeoRgAXPVkkDXZ8riqJ7LDtkgx++uHBiJ3MUaSdlUYPZcLFFw7E6zGg==" + } } }, "vscode-js-profile-flame": { "version": "file:packages/vscode-js-profile-flame", "requires": { "@types/chroma-js": "^2.4.3", + "@types/cytoscape": "^3.19.15", "@types/resize-observer-browser": "^0.1.7", "@vscode/codicons": "^0.0.35", "chroma-js": "^2.4.2", + "cytoscape": "^3.27.0", + "cytoscape-klay": "^3.1.4", "vscode-js-profile-core": "*", "vscode-webview-tools": "^0.1.1" } @@ -17206,6 +17313,7 @@ "version": "file:packages/vscode-js-profile-table", "requires": { "@vscode/codicons": "^0.0.35", + "pretty-bytes": "^6.1.1", "vscode-js-profile-core": "*" } }, diff --git a/packages/vscode-js-profile-core/package.json b/packages/vscode-js-profile-core/package.json index 15add09..937ccce 100644 --- a/packages/vscode-js-profile-core/package.json +++ b/packages/vscode-js-profile-core/package.json @@ -14,6 +14,7 @@ "watch:css": "npm run compile:css && chokidar \"src/**/*.css\" -c \"cpy --parents --cwd=src '**/*.css' ../out/esm\"" }, "dependencies": { - "@vscode/codicons": "^0.0.35" + "@vscode/codicons": "^0.0.35", + "@vscode/v8-heap-parser": "^0.1.0" } } diff --git a/packages/vscode-js-profile-core/src/client/filter.tsx b/packages/vscode-js-profile-core/src/client/filter.tsx index f67793b..eb36ea0 100644 --- a/packages/vscode-js-profile-core/src/client/filter.tsx +++ b/packages/vscode-js-profile-core/src/client/filter.tsx @@ -2,7 +2,7 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import { h, FunctionComponent, ComponentChild } from 'preact'; +import { ComponentChild, FunctionComponent, h } from 'preact'; import { useCallback } from 'preact/hooks'; import styles from './filter.css'; @@ -11,10 +11,12 @@ import styles from './filter.css'; */ export const Filter: FunctionComponent<{ value: string; + type?: string; + min?: number; onChange: (value: string) => void; placeholder?: string; foot?: ComponentChild; -}> = ({ value, onChange, placeholder = 'Filter for function', foot }) => { +}> = ({ value, min, type, onChange, placeholder = 'Filter for function', foot }) => { const onChangeRaw = useCallback( (evt: Event) => { onChange((evt.target as HTMLInputElement).value); @@ -24,7 +26,14 @@ export const Filter: FunctionComponent<{ return (
- + {foot}
); diff --git a/packages/vscode-js-profile-core/src/client/rich-filter.css b/packages/vscode-js-profile-core/src/client/filterBar.css similarity index 100% rename from packages/vscode-js-profile-core/src/client/rich-filter.css rename to packages/vscode-js-profile-core/src/client/filterBar.css diff --git a/packages/vscode-js-profile-core/src/client/filterBar.tsx b/packages/vscode-js-profile-core/src/client/filterBar.tsx new file mode 100644 index 0000000..13b80ac --- /dev/null +++ b/packages/vscode-js-profile-core/src/client/filterBar.tsx @@ -0,0 +1,10 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { FunctionComponent, h } from 'preact'; +import styles from './filterBar.css'; + +export const FilterBar: FunctionComponent = ({ children }) => ( +
{children}
+); diff --git a/packages/vscode-js-profile-core/src/client/pageLoader.css b/packages/vscode-js-profile-core/src/client/pageLoader.css new file mode 100644 index 0000000..b3086e9 --- /dev/null +++ b/packages/vscode-js-profile-core/src/client/pageLoader.css @@ -0,0 +1,29 @@ +.progress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + pointer-events: none; + overflow: hidden; + z-index: 1; +} + +.progress::before { + content: ""; + position: absolute; + inset: 0; + width: 2%; + animation-name: progress; + animation-duration: 4s; + animation-iteration-count: infinite; + animation-timing-function: linear; + transform: translate3d(0px, 0px, 0px); + background: var(--vscode-progressBar-background); +} + +@keyframes progress { + from { transform: translateX(0%) scaleX(1) } + 50% { transform: translateX(2500%) scaleX(3) } + to { transform: translateX(4900%) scaleX(1) } +} diff --git a/packages/vscode-js-profile-core/src/client/pageLoader.tsx b/packages/vscode-js-profile-core/src/client/pageLoader.tsx new file mode 100644 index 0000000..d6eaacb --- /dev/null +++ b/packages/vscode-js-profile-core/src/client/pageLoader.tsx @@ -0,0 +1,8 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { FunctionComponent, h } from 'preact'; +import style from './pageLoader.css'; + +export const PageLoader: FunctionComponent = () =>
; diff --git a/packages/vscode-js-profile-core/src/client/rich-filter.tsx b/packages/vscode-js-profile-core/src/client/rich-filter.tsx index fcc95fe..a4cd4de 100644 --- a/packages/vscode-js-profile-core/src/client/rich-filter.tsx +++ b/packages/vscode-js-profile-core/src/client/rich-filter.tsx @@ -8,6 +8,7 @@ import { ComponentChild, Fragment, FunctionComponent, h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import { IDataSource, IQueryResults, evaluate } from '../ql'; import { Filter } from './filter'; +import { FilterBar } from './filterBar'; import styles from './rich-filter.css'; import { ToggleButton } from './toggle-button'; import { usePersistedState } from './usePersistedState'; @@ -70,7 +71,7 @@ export const richFilter = }, [regex, caseSensitive, text, data]); return ( -
+ {error &&
{error}
} {foot} -
+ ); }; diff --git a/packages/vscode-js-profile-core/src/client/vscodeApi.tsx b/packages/vscode-js-profile-core/src/client/vscodeApi.tsx index 5657eb0..089afdb 100644 --- a/packages/vscode-js-profile-core/src/client/vscodeApi.tsx +++ b/packages/vscode-js-profile-core/src/client/vscodeApi.tsx @@ -15,10 +15,12 @@ export interface IVscodeApi { declare const acquireVsCodeApi: () => IVscodeApi; +export const vscodeApi = acquireVsCodeApi(); + /** * Context key for the VS Code API object. */ -export const VsCodeApi = createContext(acquireVsCodeApi()); +export const VsCodeApi = createContext(vscodeApi); /** * Parses the vscode CSS variables from the document. diff --git a/packages/vscode-js-profile-core/src/common/model.ts b/packages/vscode-js-profile-core/src/common/model.ts index a388e7e..4e5bbdd 100644 --- a/packages/vscode-js-profile-core/src/common/model.ts +++ b/packages/vscode-js-profile-core/src/common/model.ts @@ -22,9 +22,8 @@ export interface INode { src?: ISourceLocation; } -export interface ICommonNode extends INode { - children: { [id: number]: ICommonNode }; - childrenSize: number; +export interface ICommonNode { + id: number; parent?: ICommonNode; } diff --git a/packages/vscode-js-profile-core/src/common/types.ts b/packages/vscode-js-profile-core/src/common/types.ts index 26ebe0a..860602b 100644 --- a/packages/vscode-js-profile-core/src/common/types.ts +++ b/packages/vscode-js-profile-core/src/common/types.ts @@ -3,6 +3,7 @@ *--------------------------------------------------------*/ import { Protocol as Cdp } from 'devtools-protocol'; +import { GraphRPCCall } from '../heapsnapshot/rpc'; import { ISourceLocation } from '../location-mapping'; export interface IAnnotationLocation { @@ -50,7 +51,17 @@ export interface IOpenDocumentMessage { export interface IReopenWithEditor { type: 'reopenWith'; viewType: string; + toSide?: boolean; + withQuery?: string; requireExtension?: string; } -export type Message = IOpenDocumentMessage | IReopenWithEditor; +/** + * Calls a graph method, used in the heapsnapshot. + */ +export interface ICallHeapGraph { + type: 'callGraph'; + inner: GraphRPCCall; +} + +export type Message = IOpenDocumentMessage | IReopenWithEditor | ICallHeapGraph; diff --git a/packages/vscode-js-profile-core/src/cpu/layout.tsx b/packages/vscode-js-profile-core/src/cpu/layout.tsx index 5b8ea73..6eaf334 100644 --- a/packages/vscode-js-profile-core/src/cpu/layout.tsx +++ b/packages/vscode-js-profile-core/src/cpu/layout.tsx @@ -5,10 +5,11 @@ import { ComponentType, Fragment, FunctionComponent, h } from 'preact'; import { useMemo, useState } from 'preact/hooks'; import { RichFilterComponent, richFilter } from '../client/rich-filter'; import styles from '../common/layout.css'; -import { IDataSource, IQueryResults } from '../ql'; +import { DataProvider, IDataSource, IQueryResults } from '../ql'; export interface IBodyProps { - data: IQueryResults; + query: IQueryResults; + data: DataProvider; } type CpuProfileLayoutComponent = FunctionComponent<{ @@ -49,7 +50,9 @@ export const cpuProfileLayoutFactory = (): CpuProfileLayoutComponent => { foot={footer} />
-
{filteredData && }
+
+ {filteredData && } +
); }; diff --git a/packages/vscode-js-profile-core/src/heap/layout.tsx b/packages/vscode-js-profile-core/src/heap/layout.tsx index f7a78a8..73a4954 100644 --- a/packages/vscode-js-profile-core/src/heap/layout.tsx +++ b/packages/vscode-js-profile-core/src/heap/layout.tsx @@ -5,10 +5,11 @@ import { ComponentType, Fragment, FunctionComponent, h } from 'preact'; import { useMemo, useState } from 'preact/hooks'; import { RichFilterComponent, richFilter } from '../client/rich-filter'; import styles from '../common/layout.css'; -import { IDataSource, IQueryResults } from '../ql'; +import { DataProvider, IDataSource, IQueryResults } from '../ql'; export interface IBodyProps { - data: IQueryResults; + query: IQueryResults; + data: DataProvider; } type HeapProfileLayoutComponent = FunctionComponent<{ @@ -49,7 +50,9 @@ export const heapProfileLayoutFactory = (): HeapProfileLayoutComponent => foot={footer} /> -
{filteredData && }
+
+ {filteredData && } +
); }; diff --git a/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts b/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts new file mode 100644 index 0000000..2062f1e --- /dev/null +++ b/packages/vscode-js-profile-core/src/heapsnapshot/editorProvider.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Worker } from 'worker_threads'; +import { bundlePage } from '../bundlePage'; +import { Message } from '../common/types'; +import { reopenWithEditor } from '../reopenWithEditor'; + +interface IWorker extends vscode.Disposable { + worker: Worker; +} + +class HeapSnapshotDocument implements vscode.CustomDocument { + constructor( + public readonly uri: vscode.Uri, + public readonly value: IWorker, + ) {} + + /** + * @inheritdoc + */ + public dispose() { + this.value.dispose(); + } +} + +// a bit of a hack: the table and flame chart views are separate extensions, +// and the user can open heap retainers in the flame view, but we don't want +// to have to parse the heap profile twice to get the same info. So have a +// global collection the other extension can access. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const workerRegistry = ((globalThis as any).__jsHeapSnapshotWorkers ??= new (class { + private readonly workers = new Map< + /* uri */ string, + { worker: Worker; rc: number; closer?: NodeJS.Timeout } + >(); + public async create(uri: vscode.Uri): Promise { + let rec = this.workers.get(uri.with({ query: '' }).toString()); + if (!rec) { + const worker = new Worker(`${__dirname}/heapsnapshotWorker.js`, { + workerData: uri.scheme === 'file' ? uri.fsPath : await vscode.workspace.fs.readFile(uri), + }); + rec = { worker, rc: 0 }; + this.workers.set(uri.toString(), rec); + } + + rec.rc++; + if (rec.closer) { + clearTimeout(rec.closer); + rec.closer = undefined; + } + + return { + worker: rec.worker, + dispose: () => { + if (!--rec!.rc) { + // avoid stopping the worker if the webview was just moved around: + rec!.closer = setTimeout(() => { + rec!.worker.terminate(); + this.workers.delete(uri.toString()); + }, 5000); + } + }, + }; + } +})()); + +export class HeapSnapshotEditorProvider + implements vscode.CustomEditorProvider +{ + public readonly onDidChangeCustomDocument = new vscode.EventEmitter().event; + + constructor( + private readonly bundle: vscode.Uri, + private readonly extraConsts: Record = {}, + ) {} + + /** + * @inheritdoc + */ + async openCustomDocument(uri: vscode.Uri) { + const worker = await workerRegistry.create(uri); + return new HeapSnapshotDocument(uri, worker); + } + + /** + * @inheritdoc + */ + public async resolveCustomEditor( + 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 listener = (message: unknown) => { + webviewPanel.webview.postMessage({ method: 'graphRet', message }); + }; + + document.value.worker.on('message', listener); + webviewPanel.onDidDispose(() => { + document.value.worker.removeListener('message', listener); + }); + + 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, + }); + } + + /** + * @inheritdoc + */ + public async saveCustomDocument() { + // no-op + } + + /** + * @inheritdoc + */ + public async revertCustomDocument() { + // no-op + } + + /** + * @inheritdoc + */ + public async backupCustomDocument() { + return { id: '', delete: () => undefined }; + } + + /** + * @inheritdoc + */ + public saveCustomDocumentAs(document: HeapSnapshotDocument, destination: vscode.Uri) { + return vscode.workspace.fs.copy(document.uri, destination, { overwrite: true }); + } +} diff --git a/packages/vscode-js-profile-core/src/heapsnapshot/heapsnapshotWorker.ts b/packages/vscode-js-profile-core/src/heapsnapshot/heapsnapshotWorker.ts new file mode 100644 index 0000000..432c704 --- /dev/null +++ b/packages/vscode-js-profile-core/src/heapsnapshot/heapsnapshotWorker.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { Node, RetainerNode } from '@vscode/v8-heap-parser'; +import { readFile } from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; +import { + EdgeType, + GraphRPCCall, + GraphRPCMethods, + IClassGroup, + INode, + IRetainingNode, + NodeType, +} from './rpc'; + +if (!parentPort) { + throw new Error('must be run in worker thread'); +} + +const graph = Promise.all([ + typeof workerData === 'string' ? readFile(workerData) : Promise.resolve(workerData as Uint8Array), + import('@vscode/v8-heap-parser'), +]).then(async ([f, r]) => { + const { decode_bytes, init_panic_hook } = await r.default; + init_panic_hook(); + return decode_bytes(f); +}); + +export const isMethod = ( + method: K, + t: GraphRPCCall, +): t is GraphRPCCall => t.method === method; + +const mapInto = ( + arr: readonly T[], + mapper: (v: T, i: number) => R, +): R[] => { + const transmuted = new Array(arr.length); + for (let i = 0; i < arr.length; i++) { + const value = arr[i]; + transmuted[i] = mapper(value, i); + value.free(); + } + + return transmuted; +}; + +const processNodes = (nodes: readonly (Node | RetainerNode)[]): (INode | IRetainingNode)[] => + mapInto(nodes, node => ({ + name: node.name(), + childrenLen: node.children_len, + id: node.id, + index: node.index, + retainedSize: Number(node.retained_size), + selfSize: Number(node.self_size), + type: node.typ as unknown as NodeType, + retainsIndex: (node as RetainerNode).retains_index, + edgeType: (node as RetainerNode).edge_typ as unknown as EdgeType, + })); + +parentPort.on('message', (message: GraphRPCCall) => { + graph + .then(g => { + if (isMethod('getClassGroups', message)) { + return mapInto( + g.get_class_groups(...message.args, false), + (group, index): IClassGroup => ({ + name: group.name(), + index, + retainedSize: Number(group.retained_size), + selfSize: Number(group.self_size), + childrenLen: group.children_len, + }), + ); + } else if (isMethod('getClassChildren', message)) { + return processNodes(g.class_children(...message.args)); + } else if (isMethod('getNodeChildren', message)) { + return processNodes(g.node_children(...message.args)); + } else if (isMethod('getRetainers', message)) { + return processNodes(g.get_all_retainers(...message.args)); + } else { + throw new Error(`unknown method ${message.method}`); + } + }) + .then(ok => + parentPort!.postMessage({ + id: message.id, + result: { ok }, + }), + ) + .catch(err => + parentPort!.postMessage({ + id: message.id, + result: { err: err.stack || err.message || String(err) }, + }), + ); +}); diff --git a/packages/vscode-js-profile-core/src/heapsnapshot/rpc.ts b/packages/vscode-js-profile-core/src/heapsnapshot/rpc.ts new file mode 100644 index 0000000..b734992 --- /dev/null +++ b/packages/vscode-js-profile-core/src/heapsnapshot/rpc.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import type { WasmSortBy } from '@vscode/v8-heap-parser'; + +/** Mirrored into this file to avoid concrete dependencies on the wasm bundle from the webview */ +export const enum EdgeType { + Context = 0, + Element = 1, + Property = 2, + Internal = 3, + Hidden = 4, + Shortcut = 5, + Weak = 6, + Invisible = 7, + Other = 8, +} + +/** Mirrored into this file to avoid concrete dependencies on the wasm bundle from the webview */ +export const enum NodeType { + Hidden = 0, + Array = 1, + String = 2, + Object = 3, + Code = 4, + Closure = 5, + RegExp = 6, + Number = 7, + Native = 8, + Syntheic = 9, + ConcatString = 10, + SliceString = 11, + BigInt = 12, + Other = 13, +} + +export interface IClassGroup { + name: string; + index: number; + childrenLen: number; + retainedSize: number; + selfSize: number; +} + +export interface INode { + name: string; + id: number; + index: number; + retainedSize: number; + selfSize: number; + childrenLen: number; + type: NodeType; +} + +export interface IRetainingNode { + name: string; + id: number; + index: number; + retainedSize: number; + selfSize: number; + childrenLen: number; + type: NodeType; + edgeType: EdgeType; + retainsIndex: number; +} + +export type GraphRPCInterface = { + getClassGroups(start: number, end: number): Promise; + + getClassChildren( + classIndex: number, + start: number, + end: number, + sortBy: WasmSortBy, + ): Promise; + + getNodeChildren( + parentIndex: number, + start: number, + end: number, + sortBy: WasmSortBy, + ): Promise; + + getRetainers(parentIndex: number, maxDistance: number): Promise; +}; + +export type GraphRPCMethods = keyof GraphRPCInterface; + +export type GraphRPCCall = { + id: number; + args: GraphRPCInterface[K] extends (...args: infer A) => unknown ? A : never; + method: K; +}; + +export type GraphRPCResult = { + id: number; + result: + | { ok: GraphRPCInterface[K] extends (...args: unknown[]) => infer R ? R : never } + | { err: string }; +}; diff --git a/packages/vscode-js-profile-core/src/heapsnapshot/useGraph.ts b/packages/vscode-js-profile-core/src/heapsnapshot/useGraph.ts new file mode 100644 index 0000000..0f286b4 --- /dev/null +++ b/packages/vscode-js-profile-core/src/heapsnapshot/useGraph.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { useContext, useMemo } from 'preact/hooks'; +import { IVscodeApi, VsCodeApi } from '../client/vscodeApi'; +import { ICallHeapGraph } from '../common/types'; +import { GraphRPCInterface, GraphRPCResult } from './rpc'; + +// intentionally global to prevent hooks from clobbering each other +let callNo = Math.floor(Math.random() * 0x7fffffff); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const doGraphRpc = (vscode: IVscodeApi, method: string, args: unknown[]) => { + const id = callNo++; + + vscode.postMessage({ + type: 'callGraph', + inner: { method: method, args, id } as any, + }); + + return new Promise((resolve, reject) => { + const listener = (event: MessageEvent<{ method: string; message: GraphRPCResult }>) => { + if (event.data?.method === 'graphRet' && event.data.message.id === id) { + window.removeEventListener('message', listener); + + const result = event.data.message.result; + if ('ok' in result) { + resolve(result.ok); + } else { + reject(new Error(result.err)); + } + } + }; + + window.addEventListener('message', listener); + }); +}; + +/** Exposes the graph RPC interface to the component. */ +export const useGraph = (): GraphRPCInterface => { + const vscode = useContext(VsCodeApi) as IVscodeApi; + + return useMemo( + () => + new Proxy( + {}, + { + get: + (_, method: keyof GraphRPCInterface) => + (...args: unknown[]) => + doGraphRpc(vscode, method, args), + }, + ) as GraphRPCInterface, + [], + ); +}; diff --git a/packages/vscode-js-profile-core/src/ql/index.ts b/packages/vscode-js-profile-core/src/ql/index.ts index 70c1464..05980f6 100644 --- a/packages/vscode-js-profile-core/src/ql/index.ts +++ b/packages/vscode-js-profile-core/src/ql/index.ts @@ -7,16 +7,141 @@ import { Property } from './types'; export * from './types'; +type ReadRange = (start: number, end: number, sort?: (a: T, b: T) => number) => Promise; + +export class DataProvider { + private data?: T[]; + private _read?: ReadRange; + private sortFn?: (a: T, b: T) => number; + private asyncLoads: { upTo: number; p: Promise }[] = []; + private children = new Map>(); + + /** Gets whether the end of data has been reached. */ + public get eof() { + return this.data?.length === this.length || Array.isArray(this._read); + } + + /** Gets the data already loaded */ + public get loaded(): readonly T[] { + return this.data || []; + } + + /** Creates a data provider that reads from static arrays. */ + public static fromArray(value: T[], getChildren: (item: T) => T[]): DataProvider { + return this.fromTopLevelArray(value, v => DataProvider.fromArray(getChildren(v), getChildren)); + } + + /** Creates a data provider that has a static array for the top-level children, and async thereafter. */ + public static fromTopLevelArray( + value: T[], + getChildren: (item: T) => DataProvider, + ): DataProvider { + const dp = new DataProvider(value.length, () => Promise.resolve(value), getChildren); + dp.data = value; + return dp; + } + + /** Creates a data provider that delegates to the function for reading and writing. */ + public static fromProvider( + length: number, + read: ReadRange, + getChildren: (item: T) => DataProvider, + ): DataProvider { + return DataProvider.fromProvider(length, read, getChildren); + } + + constructor( + public readonly length: number, + read: T[] | ReadRange, + private readonly _getChildren: (item: T) => DataProvider, + ) { + if (Array.isArray(read) || read instanceof Array) { + this.data = read; + } else { + this._read = read; + } + } + + /** Recursively updates the sorting used for data in the provider. */ + public setSort(sort?: (a: T, b: T) => number) { + this.sortFn = sort; + if (!this.eof) { + // if we didn't read all the data from the provider, we need to throw away + // any data we read before. + this.data = undefined; + this.asyncLoads = []; + } else if (this.data) { + this.data.sort(sort); + } + + for (const child of this.children.values()) { + child.setSort(sort); + } + } + + /** Gets a data provider for the children of the item. */ + public getChildren(item: T): DataProvider { + let children = this.children.get(item); + if (!children) { + children = this._getChildren(item); + this.children.set(item, children); + } + + return children; + } + + /** + * Gets whether the all data possible to load until the `upTo` length has + * already been loaded, or is being loaded. + */ + public didReadUpTo(upTo: number) { + if (this.eof || !this._read) { + return true; + } + + const load = this.asyncLoads[this.asyncLoads.length - 1]; + return !!(load && load.upTo >= upTo); + } + + /** Reads so that the data is at least `upToLength` long, unless we reach the end */ + public async read(upTo: number): Promise { + // not a data source that loads asynchronously: + if (!this._read) { + return Promise.resolve(this.loaded); + } + + const last = this.asyncLoads[this.asyncLoads.length - 1] || { upTo: 0, p: Promise.resolve() }; + // already loaded past `upTo`: + if (last.upTo >= upTo) { + return last.p; + } + + const p = last.p.then(async () => { + const newData = await this._read!(last.upTo, upTo, this.sortFn); + if (!this.data?.length) { + this.data = newData; + } else { + this.data = this.data.concat(newData); + } + + return this.data; + }); + + this.asyncLoads.push({ upTo, p }); + + return p; + } +} + /** * Data source that provides a stream of items, and includes the list of * accessible properties and a function that can be used to recurse into * children. */ export interface IDataSource { - data: ReadonlyArray; + data: DataProvider; properties: { [key: string]: Property }; genericMatchStr: (node: T) => string; - getChildren: (node: T) => ReadonlyArray; } export interface IQuery { @@ -26,23 +151,28 @@ export interface IQuery { caseSensitive: boolean; } -export interface IQueryResults { +export type IQueryResults = { + all: boolean; selected: Set; selectedAndParents: Set; -} +}; export const evaluate = (q: IQuery): IQueryResults => { const filter = compile(lex(q.input), q); - const results: IQueryResults = { selected: new Set(), selectedAndParents: new Set() }; - for (const model of q.datasource.data) { - filterDeep(q.datasource, filter, model, results); + const results: IQueryResults = { + selected: new Set(), + selectedAndParents: new Set(), + all: !q.input.trim(), + }; + for (const model of q.datasource.data.loaded) { + filterDeep(q.datasource.data, filter, model, results); } return results; }; const filterDeep = ( - s: IDataSource, + s: DataProvider, filter: (model: T) => boolean, model: T, results: IQueryResults, @@ -54,8 +184,9 @@ const filterDeep = ( anyChild = true; } - for (const child of s.getChildren(model)) { - if (filterDeep(s, filter, child, results)) { + const children = s.getChildren(model); + for (const child of children.loaded) { + if (filterDeep(children, filter, child, results)) { results.selectedAndParents.add(model); anyChild = true; } diff --git a/packages/vscode-js-profile-core/src/ql/parser.test.ts b/packages/vscode-js-profile-core/src/ql/parser.test.ts index c034ccc..f02fc18 100644 --- a/packages/vscode-js-profile-core/src/ql/parser.test.ts +++ b/packages/vscode-js-profile-core/src/ql/parser.test.ts @@ -3,8 +3,8 @@ *--------------------------------------------------------*/ import { describe, expect, it } from 'vitest'; -import { IDataSource, IQuery, PropertyType } from '.'; -import { compile, lex, LexOutput, ParseError, Token } from './parser'; +import { DataProvider, IDataSource, IQuery, PropertyType } from '.'; +import { LexOutput, ParseError, Token, compile, lex } from './parser'; describe('ql', () => { describe('lex', () => { @@ -85,13 +85,15 @@ describe('ql', () => { } const datasource: IDataSource = { - data: [ - { username: 'u11', age: 10 }, - { username: 'u21', age: 20 }, - { username: 'u12', age: 30 }, - { username: 'u22', age: 40 }, - ], - getChildren: () => [], + data: DataProvider.fromArray( + [ + { username: 'u11', age: 10 }, + { username: 'u21', age: 20 }, + { username: 'u12', age: 30 }, + { username: 'u22', age: 40 }, + ], + () => [], + ), properties: { username: { accessor: u => u.username, @@ -165,7 +167,7 @@ describe('ql', () => { if (typeof out === 'string') { expect(getFilter).to.throw(ParseError, out); } else { - const models = datasource.data.filter(getFilter()).map(u => u.username); + const models = datasource.data.loaded.filter(getFilter()).map(u => u.username); expect(models).to.deep.equal(out); } }); diff --git a/packages/vscode-js-profile-core/src/reopenWithEditor.ts b/packages/vscode-js-profile-core/src/reopenWithEditor.ts index a6b0923..397b32d 100644 --- a/packages/vscode-js-profile-core/src/reopenWithEditor.ts +++ b/packages/vscode-js-profile-core/src/reopenWithEditor.ts @@ -4,12 +4,22 @@ import * as vscode from 'vscode'; -export function reopenWithEditor(uri: vscode.Uri, viewType: string, requireExtension?: string) { +export function reopenWithEditor( + uri: vscode.Uri, + viewType: string, + requireExtension?: string, + toSide?: boolean, +) { if (requireExtension && !vscode.extensions.all.some(e => e.id === requireExtension)) { vscode.commands.executeCommand('workbench.extensions.action.showExtensionsWithIds', [ requireExtension, ]); } else { - vscode.commands.executeCommand('vscode.openWith', uri, viewType, vscode.ViewColumn.Active); + vscode.commands.executeCommand( + 'vscode.openWith', + uri, + viewType, + toSide ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active, + ); } } diff --git a/packages/vscode-js-profile-flame/package.json b/packages/vscode-js-profile-flame/package.json index d6497ff..8278f59 100644 --- a/packages/vscode-js-profile-flame/package.json +++ b/packages/vscode-js-profile-flame/package.json @@ -28,17 +28,8 @@ }, "scripts": { "pack": "vsce package --yarn", - "compile": "rimraf out && concurrently \"npm:compile:*\"", - "compile:cpu-client": "webpack --mode production --config webpack.cpu-client.js", - "compile:heap-client": "webpack --mode production --config webpack.heap-client.js", - "compile:realtime": "webpack --mode production --config webpack.realtime.js", - "compile:ext": "webpack --mode production --config webpack.extension.js --target node", - "compile:ext:web": "webpack --mode production --config webpack.extension.js --target web", - "watch": "concurrently \"npm:watch:*\"", - "watch:cpu-client": "webpack --mode development --config webpack.cpu-client.js --watch", - "watch:heap-client": "webpack --mode development --config webpack.heap-client.js --watch", - "watch:realtime": "webpack --mode development --config webpack.realtime.js --watch", - "watch:ext": "webpack --mode development --config webpack.extension.js --watch --target node" + "compile": "rimraf out && webpack --mode production", + "watch": "webpack --mode development --watch" }, "icon": "resources/logo.png", "contributes": { @@ -62,6 +53,16 @@ "filenamePattern": "*.heapprofile" } ] + }, + { + "viewType": "jsProfileVisualizer.heapsnapshot.flame", + "displayName": "Heap Snapshot Retainers Graph Visualizer", + "priority": "option", + "selector": [ + { + "filenamePattern": "*.heapsnapshot" + } + ] } ], "views": { @@ -159,11 +160,14 @@ }, "devDependencies": { "@types/chroma-js": "^2.4.3", + "@types/cytoscape": "^3.19.15", "@types/resize-observer-browser": "^0.1.7" }, "dependencies": { "@vscode/codicons": "^0.0.35", "chroma-js": "^2.4.2", + "cytoscape": "^3.27.0", + "cytoscape-klay": "^3.1.4", "vscode-js-profile-core": "*", "vscode-webview-tools": "^0.1.1" } diff --git a/packages/vscode-js-profile-flame/src/client/cpu/client.tsx b/packages/vscode-js-profile-flame/src/client/cpu/client.tsx index 2f9964a..2aa386b 100644 --- a/packages/vscode-js-profile-flame/src/client/cpu/client.tsx +++ b/packages/vscode-js-profile-flame/src/client/cpu/client.tsx @@ -11,11 +11,11 @@ import { VsCodeApi } from 'vscode-js-profile-core/out/esm/client/vscodeApi'; import { IReopenWithEditor } from 'vscode-js-profile-core/out/esm/common/types'; import { cpuProfileLayoutFactory } from 'vscode-js-profile-core/out/esm/cpu/layout'; import { IProfileModel } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; import styles from '../common/client.css'; import { IColumn } from '../common/types'; import { FlameGraph } from './flame-graph'; -import { buildColumns, buildLeftHeavyColumns, LocationAccessor } from './stacks'; +import { LocationAccessor, buildColumns, buildLeftHeavyColumns } from './stacks'; declare const MODEL: IProfileModel; @@ -77,12 +77,12 @@ const Root: FunctionComponent = () => { const cols = leftHeavy ? getLeftHeavyCols() : getTimelineCols(); const FlameGraphWrapper: FunctionComponent<{ - data: IQueryResults; + query: IQueryResults; }> = useCallback( - ({ data }) => { + ({ query }) => { const filtered = useMemo( - () => LocationAccessor.getFilteredColumns(cols, data.selectedAndParents), - [data], + () => LocationAccessor.getFilteredColumns(cols, query.selectedAndParents), + [query], ); return ; }, @@ -92,8 +92,7 @@ const Root: FunctionComponent = () => { return ( n.children, + data: DataProvider.fromArray(LocationAccessor.rootAccessors(cols), n => n.children), genericMatchStr: n => [n.callFrame.functionName, n.callFrame.url, n.src?.source.path ?? ''].join(' '), properties: { diff --git a/packages/vscode-js-profile-flame/src/client/heap/client.tsx b/packages/vscode-js-profile-flame/src/client/heap/client.tsx index bf06d0d..32560f7 100644 --- a/packages/vscode-js-profile-flame/src/client/heap/client.tsx +++ b/packages/vscode-js-profile-flame/src/client/heap/client.tsx @@ -9,11 +9,11 @@ import { VsCodeApi } from 'vscode-js-profile-core/out/esm/client/vscodeApi'; import { IReopenWithEditor } from 'vscode-js-profile-core/out/esm/common/types'; import { heapProfileLayoutFactory } from 'vscode-js-profile-core/out/esm/heap/layout'; import { IProfileModel } from 'vscode-js-profile-core/out/esm/heap/model'; -import { IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; import styles from '../common/client.css'; import { IColumn } from '../common/types'; import { FlameGraph } from './flame-graph'; -import { buildColumns, TreeNodeAccessor } from './stacks'; +import { TreeNodeAccessor, buildColumns } from './stacks'; declare const MODEL: IProfileModel; @@ -57,12 +57,12 @@ const Root: FunctionComponent = () => { const cols = getTimelineCols(); const FlameGraphWrapper: FunctionComponent<{ - data: IQueryResults; + query: IQueryResults; }> = useCallback( - ({ data }) => { + ({ query }) => { const filtered = useMemo( - () => TreeNodeAccessor.getFilteredColumns(cols, data.selectedAndParents), - [data], + () => TreeNodeAccessor.getFilteredColumns(cols, query.selectedAndParents), + [query], ); return ; }, @@ -72,8 +72,7 @@ const Root: FunctionComponent = () => { return ( n.children, + data: DataProvider.fromArray(TreeNodeAccessor.rootAccessors(cols), n => n.children), genericMatchStr: n => [n.callFrame.functionName, n.callFrame.url, n.src?.source.path ?? ''].join(' '), properties: { diff --git a/packages/vscode-js-profile-flame/src/extension.ts b/packages/vscode-js-profile-flame/src/extension.ts index d67da92..fc710ed 100644 --- a/packages/vscode-js-profile-flame/src/extension.ts +++ b/packages/vscode-js-profile-flame/src/extension.ts @@ -13,6 +13,7 @@ const allConfig = [Config.PollInterval, Config.ViewDuration, Config.Easing]; import * as vscode from 'vscode'; import { CpuProfileEditorProvider } from 'vscode-js-profile-core/out/cpu/editorProvider'; import { HeapProfileEditorProvider } from 'vscode-js-profile-core/out/heap/editorProvider'; +import { HeapSnapshotEditorProvider } from 'vscode-js-profile-core/out/heapsnapshot/editorProvider'; import { ProfileCodeLensProvider } from 'vscode-js-profile-core/out/profileCodeLensProvider'; import { createMetrics } from './realtime/metrics'; import { readRealtimeSettings, RealtimeSessionTracker } from './realtimeSessionTracker'; @@ -49,6 +50,15 @@ 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.window.registerWebviewViewProvider(RealtimeWebviewProvider.viewType, realtime), vscode.workspace.onDidChangeConfiguration(evt => { diff --git a/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.css b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.css new file mode 100644 index 0000000..d66e601 --- /dev/null +++ b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.css @@ -0,0 +1,12 @@ +.graph { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; +} + +.toolbar { + position: relative; + z-index: 1; +} diff --git a/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx new file mode 100644 index 0000000..f29bdec --- /dev/null +++ b/packages/vscode-js-profile-flame/src/heapsnapshot-client/client.tsx @@ -0,0 +1,226 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import cytoscape from 'cytoscape'; +import { Fragment, FunctionComponent, h, render } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { Filter } from 'vscode-js-profile-core/out/esm/client/filter'; +import { FilterBar } from 'vscode-js-profile-core/out/esm/client/filterBar'; +import { PageLoader } from 'vscode-js-profile-core/out/esm/client/pageLoader'; +import { vscodeApi } from 'vscode-js-profile-core/out/esm/client/vscodeApi'; +import { EdgeType, IRetainingNode } from 'vscode-js-profile-core/out/esm/heapsnapshot/rpc'; +import { doGraphRpc } from 'vscode-js-profile-core/out/esm/heapsnapshot/useGraph'; +import { parseColors } from 'vscode-webview-tools'; +import styles from './client.css'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// 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'); + +const DEFAULT_RETAINER_DISTANCE = 4; + +const Root: FunctionComponent = () => { + const [maxDistance, setMaxDistance] = useState(); + + return ( + +
+ + setMaxDistance(Number(v))} + type="number" + min={1} + value={maxDistance ? String(maxDistance) : ''} + placeholder={`Maximum retainer distance (default: ${DEFAULT_RETAINER_DISTANCE})`} + > + +
+ +
+ ); +}; + +const Graph: FunctionComponent<{ maxDistance: number }> = ({ maxDistance }) => { + const [container, setContainer] = useState(null); + const [loading, setLoading] = useState(false); + const [nodes, setNodes] = useState(); + + useEffect(() => { + doGraphRpc(vscodeApi, 'getRetainers', [Number(index), maxDistance]).then(r => + setNodes(r as IRetainingNode[]), + ); + }, [maxDistance]); + + useEffect(() => { + if (!container || !nodes) { + setLoading(true); + return; + } + + const colors = parseColors(); + const cy = cytoscape({ + container, + autounselectify: true, + elements: nodes.flatMap(node => { + const r: cytoscape.ElementDefinition[] = [ + { + data: { id: String(node.index), name: node.name || '' }, + }, + ]; + if (node.index !== node.retainsIndex) { + r.push({ + data: { + id: `${node.index}-${node.retainsIndex}`, + type: getLabelForEdge(node.edgeType), + source: String(node.index), + target: String(node.retainsIndex), + }, + }); + } + + return r; + }), + + style: [ + // the stylesheet for the graph + { + selector: 'node', + style: { + 'background-color': colors['editorWidget-background'], + 'border-color': colors['editorWidget-border'], + 'border-width': 1, + color: colors['editor-foreground'], + label: 'data(name)', + 'font-size': 11, + }, + }, + + { + selector: 'edge', + style: { + width: 2, + 'line-color': colors['editorWidget-border'], + 'target-arrow-color': colors['editorWidget-border'], + 'target-arrow-shape': 'triangle', + 'curve-style': 'straight', + 'font-size': 11, + color: colors['editor-foreground'], + }, + }, + + { + selector: 'node.highlighted', + style: { + 'background-color': colors['charts-blue'], + 'border-color': colors['editorWidget-border'], + }, + }, + { + selector: 'edge.highlighted', + style: { + 'line-color': colors['charts-blue'], + 'target-arrow-color': colors['charts-blue'], + label: 'data(type)', + }, + }, + ], + + layout: { + name: 'klay', + animate: false, + nodeDimensionsIncludeLabels: true, + klay: { + // preferred since this opens to the side by default + direction: 'DOWN', + // makes the graph more deterministic, without it the retained node + // can end up in the middle vs. in nice layers + nodeLayering: 'LONGEST_PATH', + // determinism + randomizationSeed: 42, + // not sure this does anything with a prescribed direction and + // layering, it's here for the vibes + aspectRatio: window.innerWidth / window.innerHeight, + }, + } as any, + }); + + const root = cy.$(`#${index}`); + root.style('background-color', colors['charts-blue']); + + attachPathHoverHandle(root, cy); + + cy.viewport({ + zoom: 1, + pan: { + // center the node horizontally, and most of the way towards the bottom, + // since we dictated the layout is "down" + x: -root.position().x + window.innerWidth / 2, + y: -root.position().y + window.innerHeight / 1.2, + }, + }); + + setLoading(false); + + return () => cy.destroy(); + }, [container, nodes]); + + return ( + + {loading && ( + + + {nodes ? 'Building graph layout...' : 'Parsing snapshot...'} + + )} +
+ + ); +}; + +const container = document.createElement('div'); +container.classList.add(styles.wrapper); +document.body.appendChild(container); +render(, container); + +function attachPathHoverHandle(root: cytoscape.CollectionReturnValue, graph: cytoscape.Core) { + let lastPath: cytoscape.CollectionReturnValue | null = null; + graph.on('mouseover', 'node', ev => { + lastPath = graph.elements().dijkstra({ root: ev.target, directed: true }).pathTo(root); + lastPath.addClass('highlighted'); + }); + + graph.on('mouseout', 'node', () => { + if (lastPath) { + lastPath.removeClass('highlighted'); + } + }); +} + +function getLabelForEdge(edge: EdgeType) { + switch (edge) { + case EdgeType.Context: + return 'Context'; + case EdgeType.Hidden: + return 'Hidden'; + case EdgeType.Internal: + return 'Internal'; + case EdgeType.Element: + return 'Element'; + case EdgeType.Property: + return 'Property'; + case EdgeType.Invisible: + return 'Invisible'; + case EdgeType.Shortcut: + return 'Shortcut'; + case EdgeType.Weak: + return 'Weak'; + case EdgeType.Other: + return 'Other'; + } +} diff --git a/packages/vscode-js-profile-flame/webpack.config.js b/packages/vscode-js-profile-flame/webpack.config.js new file mode 100644 index 0000000..e5e529a --- /dev/null +++ b/packages/vscode-js-profile-flame/webpack.config.js @@ -0,0 +1,23 @@ +module.exports = [ + require('../../scripts/webpack.heapsnapshot-worker')(__dirname), + require('../../scripts/webpack.extension')(__dirname, 'node'), + ...(process.argv.includes('--watch') + ? [] + : [require('../../scripts/webpack.extension')(__dirname, 'web')]), + { + ...require('../../scripts/webpack.client')(__dirname, 'realtime'), + entry: `./src/realtime/client.ts`, + }, + { + ...require('../../scripts/webpack.client')(__dirname, 'heap-client'), + entry: `./src/client/heap/client.tsx`, + }, + { + ...require('../../scripts/webpack.client')(__dirname, 'cpu-client'), + entry: `./src/client/cpu/client.tsx`, + }, + { + ...require('../../scripts/webpack.client')(__dirname, 'heapsnapshot-client'), + entry: `./src/heapsnapshot-client/client.tsx`, + }, +]; diff --git a/packages/vscode-js-profile-flame/webpack.cpu-client.js b/packages/vscode-js-profile-flame/webpack.cpu-client.js deleted file mode 100644 index 2b5b17a..0000000 --- a/packages/vscode-js-profile-flame/webpack.cpu-client.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require('../../scripts/webpack.client')(__dirname, 'cpu-client'), - entry: `./src/client/cpu/client.tsx`, -}; diff --git a/packages/vscode-js-profile-flame/webpack.extension.js b/packages/vscode-js-profile-flame/webpack.extension.js deleted file mode 100644 index d9d5282..0000000 --- a/packages/vscode-js-profile-flame/webpack.extension.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../../scripts/webpack.extension')(__dirname); diff --git a/packages/vscode-js-profile-flame/webpack.heap-client.js b/packages/vscode-js-profile-flame/webpack.heap-client.js deleted file mode 100644 index 8a89103..0000000 --- a/packages/vscode-js-profile-flame/webpack.heap-client.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require('../../scripts/webpack.client')(__dirname, 'heap-client'), - entry: `./src/client/heap/client.tsx`, -}; diff --git a/packages/vscode-js-profile-flame/webpack.realtime.js b/packages/vscode-js-profile-flame/webpack.realtime.js deleted file mode 100644 index feabdb6..0000000 --- a/packages/vscode-js-profile-flame/webpack.realtime.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require('../../scripts/webpack.client')(__dirname, 'realtime'), - entry: `./src/realtime/client.ts`, -}; diff --git a/packages/vscode-js-profile-table/package.json b/packages/vscode-js-profile-table/package.json index f2e40f3..7482b8c 100644 --- a/packages/vscode-js-profile-table/package.json +++ b/packages/vscode-js-profile-table/package.json @@ -23,15 +23,8 @@ }, "scripts": { "pack": "vsce package --yarn", - "compile": "rimraf out && concurrently \"npm:compile:*\"", - "compile:cpu-client": "webpack --mode production --config webpack.cpu-client.js", - "compile:heap-client": "webpack --mode production --config webpack.heap-client.js", - "compile:ext": "webpack --mode production --config webpack.extension.js --target node", - "compile:ext:web": "webpack --mode production --config webpack.extension.js --target web", - "watch": "concurrently \"npm:watch:*\"", - "watch:cpu-client": "webpack --mode development --config webpack.cpu-client.js --watch", - "watch:heap-client": "webpack --mode development --config webpack.heap-client.js --watch", - "watch:ext": "webpack --mode development --config webpack.extension.js --watch --target node" + "compile": "rimraf out && webpack --mode production", + "watch": "webpack --mode development --watch" }, "icon": "resources/icon.png", "publisher": "ms-vscode", @@ -60,6 +53,16 @@ "filenamePattern": "*.heapprofile" } ] + }, + { + "viewType": "jsProfileVisualizer.heapsnapshot.table", + "displayName": "Heap Snapshot Table Visualizer", + "priority": "default", + "selector": [ + { + "filenamePattern": "*.heapsnapshot" + } + ] } ], "commands": [ @@ -82,6 +85,7 @@ }, "dependencies": { "@vscode/codicons": "^0.0.35", + "pretty-bytes": "^6.1.1", "vscode-js-profile-core": "*" } } diff --git a/packages/vscode-js-profile-table/src/common/base-time-view-row.tsx b/packages/vscode-js-profile-table/src/common/base-time-view-row.tsx index 015d703..08512c0 100644 --- a/packages/vscode-js-profile-table/src/common/base-time-view-row.tsx +++ b/packages/vscode-js-profile-table/src/common/base-time-view-row.tsx @@ -4,43 +4,34 @@ import * as ChevronDown from '@vscode/codicons/src/icons/chevron-down.svg'; import * as ChevronRight from '@vscode/codicons/src/icons/chevron-right.svg'; -import { FunctionComponent, h } from 'preact'; -import { useCallback, useContext } from 'preact/hooks'; +import { ComponentChild, FunctionComponent, h } from 'preact'; +import { useCallback } from 'preact/hooks'; 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 { getNodeText } from 'vscode-js-profile-core/out/esm/common/display'; -import { IOpenDocumentMessage } from 'vscode-js-profile-core/out/esm/common/types'; -import { IGraphNode } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { ITreeNode } from 'vscode-js-profile-core/out/esm/heap/model'; +import { ICommonNode } from 'vscode-js-profile-core/out/esm/common/model'; import { IRowProps } from './base-time-view'; import getGlobalUniqueId from './get-global-unique-id'; import styles from './time-view.css'; export const makeBaseTimeViewRow = - (): FunctionComponent> => + (): FunctionComponent< + IRowProps & { rowText: ComponentChild; locationText?: string; virtual?: boolean } + > => ({ node, depth, + numChildren, expanded, position, onKeyDown: onKeyDownRaw, onFocus: onFocusRaw, + onClick, onExpanded, children, + rowText, + locationText, + virtual = !locationText, }) => { - const vscode = useContext(VsCodeApi); - const onClick = useCallback( - (evt: MouseEvent) => - vscode.postMessage({ - type: 'openDocument', - callFrame: node.callFrame, - location: (node as IGraphNode).src, - toSide: evt.altKey, - }), - [vscode, node], - ); - const onToggleExpand = useCallback(() => onExpanded(!expanded, node), [expanded, node]); const onKeyDown = useCallback( @@ -61,12 +52,10 @@ export const makeBaseTimeViewRow = const expand = ( - {node.childrenSize > 0 ? : null} + {numChildren > 0 ? : null} ); - const location = getNodeText(node); - return (
{children} - {!location ? ( + {!locationText ? (
- {expand} {node.callFrame.functionName} + {expand} {rowText}
) : (
- {expand} {node.callFrame.functionName} + {expand} {rowText} - {location} + {locationText}
diff --git a/packages/vscode-js-profile-table/src/common/base-time-view.tsx b/packages/vscode-js-profile-table/src/common/base-time-view.tsx index 48cafed..c5d39c2 100644 --- a/packages/vscode-js-profile-table/src/common/base-time-view.tsx +++ b/packages/vscode-js-profile-table/src/common/base-time-view.tsx @@ -4,12 +4,18 @@ import { ComponentChild, ComponentType, Fragment, FunctionComponent, h } from 'preact'; import VirtualList from 'preact-virtual-list'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { + StateUpdater, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; import { addToSet, removeFromSet, toggleInSet } from 'vscode-js-profile-core/out/esm/array'; import { ICommonNode } from 'vscode-js-profile-core/out/esm/common/model'; -import { IGraphNode } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { ITreeNode } from 'vscode-js-profile-core/out/esm/heap/model'; -import { IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; import styles from './time-view.css'; import { SortFn } from './types'; @@ -27,65 +33,137 @@ export interface IRowProps { depth: number; position: number; expanded: boolean; + numChildren: number; onExpanded: (isExpanded: boolean, target: T) => void; onKeyDown: (evt: KeyboardEvent, target: T) => void; onFocus: (target: T) => void; + onClick?: (evt: MouseEvent) => void; } +const DEFAULT_CHILDREN_LOAD_LEN = 100; + +const onDidFinishRead = ( + node: T, + promise: Promise, + setChildLoads: StateUpdater>>, +): void => { + setChildLoads(prev => { + if (prev.get(node) === promise) { + const next = new Map(prev); + next.delete(node); + return next; + } else { + return prev; + } + }); +}; + export const makeBaseTimeView = - (): FunctionComponent<{ + (): FunctionComponent<{ query: IQueryResults; - data: T[]; - sortFn: SortFn | undefined; + data: DataProvider; + sortFn: SortFn | undefined; header: ComponentChild; row: ComponentType>; }> => ({ data, header, query, sortFn, row: Row }) => { - type NodeAtDepth = { node: T; depth: number; position: number }; + /** + * Type for rendered nodes. `node` is omitted for the 'footer' of the + * category. + */ + type NodeAtDepth = { + node: T; + entireSubtree?: boolean; + isFooter?: boolean; + provider: DataProvider; + depth: number; + position: number; + }; + + /** Map of nodes to the provider that created them. */ + const providers = useRef>>(new Map()); + + /** Map of nodes to promises that resolve when all their children are loaded. */ + const [childLoads, setChildLoads] = useState>>(new Map()); const listRef = useRef<{ base: HTMLElement }>(); const [focused, setFocused] = useState(undefined); const [expanded, setExpanded] = useState>(new Set()); - const getSortedChildren = (node: T) => { - const children = Object.values(node.children); - if (sortFn) { - children.sort((a, b) => sortFn(b) - sortFn(a)); - } + // const getSortedChildren = (node: T) => { + // const children = Object.values(node.children); + // if (sortFn) { + // children.sort((a, b) => sortFn(b) - sortFn(a)); + // } - return children; - }; + // return children; + // }; // 1. Top level sorted items - const sorted = useMemo( - () => (sortFn ? data.slice().sort((a, b) => sortFn(b) - sortFn(a)) : data), - [data, sortFn], - ); + const sorted = useMemo(() => { + const topLevel = sortFn ? data.loaded.slice().sort(sortFn) : data.loaded; + for (const node of topLevel) { + data.setSort(sortFn); + providers.current.set(node, data); + } + return topLevel; + }, [data, sortFn]); // 2. Expand nested child nodes const rendered = useMemo(() => { const output: NodeAtDepth[] = sorted .filter(node => query.selectedAndParents.has(node)) - .map(node => ({ node, position: 1, depth: 0 })); + .map(node => ({ node, position: 1, depth: 0, provider: data })); for (let i = 0; i < output.length; i++) { - const { node, depth } = output[i]; + const { node, depth, isFooter, entireSubtree } = output[i]; + if (isFooter) { + continue; // footer of previous depth + } + if (expanded.has(node)) { - const toAdd = getSortedChildren(node).map((node, i) => ({ - node, - position: i + 1, - depth: depth + 1, - })); - output.splice(i + 1, 0, ...toAdd); - // we don't increment i further since we want to recurse and expand these nodes + const children = providers.current.get(node)?.getChildren(node); + if (children) { + for (const child of children.loaded) { + providers.current.set(child, children); + } + + const toAdd: NodeAtDepth[] = []; + for (const child of children.loaded) { + if (query.all || query.selectedAndParents.has(child) || entireSubtree) { + toAdd.push({ + node: child, + position: i + 1, + depth: depth + 1, + provider: children, + entireSubtree: entireSubtree || query.selected.has(child), + }); + } + } + // footer: + if (query.all) { + toAdd.push({ + isFooter: true, + node, + position: i + toAdd.length, + depth: depth + 1, + provider: children, + }); + } + + output.splice(i + 1, 0, ...toAdd); + // we don't increment i further since we want to recurse and expand these nodes + } } } return output; - }, [sorted, expanded, sortFn, query]); + }, [sorted, expanded, sortFn, query, childLoads]); const onKeyDown = useCallback( (evt: KeyboardEvent, node: T) => { + const provider = providers.current.get(node); + let nextFocus: T | undefined; switch (evt.key) { case 'Enter': @@ -106,13 +184,15 @@ export const makeBaseTimeView = nextFocus = node.parent as T; } break; - case 'ArrowRight': - if (node.childrenSize > 0 && !expanded.has(node)) { + case 'ArrowRight': { + const children = provider?.getChildren(node); + if (children?.length && !expanded.has(node)) { setExpanded(addToSet(expanded, node)); } else { - nextFocus = rendered.find(n => n.node.parent === node)?.node; + nextFocus = rendered.find(n => n.node?.parent === node)?.node; } break; + } case 'Home': if (listRef.current) { listRef.current.base.scrollTop = 0; @@ -127,13 +207,18 @@ export const makeBaseTimeView = nextFocus = rendered[rendered.length - 1]?.node; break; - case '*': + case '*': { const nextExpanded = new Set(expanded); - for (const child of Object.values(focused?.parent?.children || {})) { - nextExpanded.add(child); + if (focused && focused.parent) { + const parent = focused?.parent; + const parentProvider = parent && providers.current.get(parent as T); + for (const child of parentProvider?.getChildren(focused).loaded || []) { + nextExpanded.add(child); + } + setExpanded(nextExpanded); } - setExpanded(nextExpanded); break; + } } if (nextFocus) { @@ -141,9 +226,30 @@ export const makeBaseTimeView = evt.preventDefault(); } }, - [rendered, expanded, getSortedChildren], + [rendered, expanded], ); + useEffect(() => { + setChildLoads(prev => { + let next: Map> | undefined; + for (const node of expanded) { + const children = providers.current.get(node)?.getChildren(node); + if (children && !children.didReadUpTo(DEFAULT_CHILDREN_LOAD_LEN)) { + next ??= new Map(prev); + const promise: Promise = children + .read(DEFAULT_CHILDREN_LOAD_LEN) + .then(() => onDidFinishRead(node, promise, setChildLoads)); + + next.set(node, promise); + } + } + + return next || prev; + }); + // note: sortFn is used here since changing the sort order can cause + // data to be thrown away and we would need to re-request it. + }, [expanded, sortFn]); + useEffect(() => listRef.current?.base.setAttribute('role', 'tree'), [listRef.current]); useLayoutEffect(() => { @@ -173,18 +279,43 @@ export const makeBaseTimeView = }); }; + const onLoadMore = useCallback((nodeUn: unknown, dataProvider: DataProvider) => { + const dp = dataProvider as DataProvider; + const node = nodeUn as T; + + setChildLoads(prev => { + const next = new Map(prev); + const promise: Promise = dp + .read(dp.loaded.length + DEFAULT_CHILDREN_LOAD_LEN) + .then(() => onDidFinishRead(node, promise, setChildLoads)); + next.set(node, promise); + return next; + }); + }, []); + const renderRow = useCallback( - (row: NodeAtDepth) => ( - - ), + (row: NodeAtDepth) => + row.isFooter ? ( + + ) : ( + + ), [expanded, setExpanded, onKeyDown], ); @@ -198,7 +329,53 @@ export const makeBaseTimeView = renderRow={renderRow} rowHeight={25} overscanCount={100} + sync /> ); }; + +const FooterRow: FunctionComponent<{ + depth: number; + position: number; + node: unknown; + promise: Promise | undefined; + dataProvider: DataProvider; + onLoadMore: (node: unknown, evt: DataProvider) => void; +}> = ({ depth, position, node, dataProvider, promise, onLoadMore }) => { + const [isLoading, setIsLoading] = useState(!!promise); + if (dataProvider.eof) { + return null; + } + + useEffect(() => { + if (!promise) { + setIsLoading(false); + } else { + promise.finally(() => setIsLoading(false)); + } + }, [promise]); + + return ( +
+
+ {isLoading ? ( + 'Loading...' + ) : ( + + onLoadMore(node, dataProvider)}> + Load more rows + + + )} +
+
+ ); +}; diff --git a/packages/vscode-js-profile-table/src/common/get-global-unique-id.ts b/packages/vscode-js-profile-table/src/common/get-global-unique-id.ts index 64c5cea..ed06739 100644 --- a/packages/vscode-js-profile-table/src/common/get-global-unique-id.ts +++ b/packages/vscode-js-profile-table/src/common/get-global-unique-id.ts @@ -2,10 +2,9 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import { IGraphNode } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { ITreeNode } from 'vscode-js-profile-core/out/esm/heap/model'; +import { ICommonNode } from 'vscode-js-profile-core/out/esm/common/model'; -export default (node: IGraphNode | ITreeNode) => { +export default (node: ICommonNode) => { const parts = [node.id]; for (let n = node.parent; n; n = n.parent) { parts.push(n.id); diff --git a/packages/vscode-js-profile-table/src/common/time-view.css b/packages/vscode-js-profile-table/src/common/time-view.css index b85af8c..2e29837 100644 --- a/packages/vscode-js-profile-table/src/common/time-view.css +++ b/packages/vscode-js-profile-table/src/common/time-view.css @@ -21,6 +21,11 @@ margin: 2px 4px; } +.footer { + margin-left: calc(220px + 24px) !important; + cursor: pointer; +} + .duration, .heading.timing { text-align: right; @@ -110,6 +115,7 @@ .expander svg, .expander { width: 1em; + cursor: pointer; } .row:hover .expander { diff --git a/packages/vscode-js-profile-table/src/common/types.ts b/packages/vscode-js-profile-table/src/common/types.ts index 64cbb17..d130cb4 100644 --- a/packages/vscode-js-profile-table/src/common/types.ts +++ b/packages/vscode-js-profile-table/src/common/types.ts @@ -2,7 +2,4 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import { ILocation } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { IHeapProfileNode } from 'vscode-js-profile-core/out/esm/heap/model'; - -export type SortFn = (node: ILocation | IHeapProfileNode) => number; +export type SortFn = (a: T, b: T) => number; diff --git a/packages/vscode-js-profile-table/src/cpu-client/client.tsx b/packages/vscode-js-profile-table/src/cpu-client/client.tsx index b4085a9..d387919 100644 --- a/packages/vscode-js-profile-table/src/cpu-client/client.tsx +++ b/packages/vscode-js-profile-table/src/cpu-client/client.tsx @@ -5,7 +5,7 @@ import { FunctionComponent, h, render } from 'preact'; import { createBottomUpGraph } from 'vscode-js-profile-core/out/esm/cpu/bottomUpGraph'; import { cpuProfileLayoutFactory } from 'vscode-js-profile-core/out/esm/cpu/layout'; import { IGraphNode, IProfileModel } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; import styles from '../common/client.css'; import OpenFlameButton from '../common/open-flame-buttom'; import { TimeView } from './time-view'; @@ -16,8 +16,9 @@ const graph = createBottomUpGraph(MODEL); const allChildren = Object.values(graph.children); const TimeViewWrapper: FunctionComponent<{ - data: IQueryResults; -}> = ({ data }) => ; + query: IQueryResults; + data: DataProvider; +}> = ({ query, data }) => ; const CpuProfileLayout = cpuProfileLayoutFactory(); @@ -27,8 +28,7 @@ document.body.appendChild(container); render( Object.values(n.children), + data: DataProvider.fromArray(allChildren, n => Object.values(n.children)), genericMatchStr: n => [n.callFrame.functionName, n.callFrame.url, n.src?.source.path ?? ''].join(' '), properties: { diff --git a/packages/vscode-js-profile-table/src/cpu-client/time-view.tsx b/packages/vscode-js-profile-table/src/cpu-client/time-view.tsx index 7c33ab2..d39e72f 100644 --- a/packages/vscode-js-profile-table/src/cpu-client/time-view.tsx +++ b/packages/vscode-js-profile-table/src/cpu-client/time-view.tsx @@ -4,28 +4,31 @@ import * as ChevronDown from '@vscode/codicons/src/icons/chevron-down.svg'; import { FunctionComponent, h } from 'preact'; -import { useCallback, useState } from 'preact/hooks'; +import { useCallback, useContext, useState } from 'preact/hooks'; 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 { getNodeText } from 'vscode-js-profile-core/out/esm/common/display'; +import { IOpenDocumentMessage } from 'vscode-js-profile-core/out/esm/common/types'; import { decimalFormat } from 'vscode-js-profile-core/out/esm/cpu/display'; -import { IGraphNode, ILocation } from 'vscode-js-profile-core/out/esm/cpu/model'; -import { IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; +import { IGraphNode } from 'vscode-js-profile-core/out/esm/cpu/model'; +import { DataProvider, IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; import { IRowProps, makeBaseTimeView } from '../common/base-time-view'; import { makeBaseTimeViewRow } from '../common/base-time-view-row'; import ImpactBar from '../common/impact-bar'; import styles from '../common/time-view.css'; import { SortFn } from '../common/types'; -const selfTime: SortFn = n => (n as ILocation).selfTime; -const aggTime: SortFn = n => (n as ILocation).aggregateTime; +const selfTime: SortFn = n => n.selfTime; +const aggTime: SortFn = n => n.aggregateTime; const BaseTimeView = makeBaseTimeView(); export const TimeView: FunctionComponent<{ query: IQueryResults; - data: IGraphNode[]; + data: DataProvider; }> = ({ data, query }) => { - const [sortFn, setSortFn] = useState(() => selfTime); + const [sortFn, setSortFn] = useState | undefined>(() => selfTime); return ( SortFn | undefined) => void; + sortFn: SortFn | undefined; + onChangeSort: (newFn: () => SortFn | undefined) => void; }> = ({ sortFn, onChangeSort }) => (
> = props => { root = root.parent; } + const vscode = useContext(VsCodeApi); + const onClick = useCallback( + (evt: MouseEvent) => + vscode.postMessage({ + type: 'openDocument', + callFrame: node.callFrame, + location: node.src, + toSide: evt.altKey, + }), + [vscode, node], + ); + return ( - +
{decimalFormat.format(node.selfTime / 1000)}ms diff --git a/packages/vscode-js-profile-table/src/extension.ts b/packages/vscode-js-profile-table/src/extension.ts index 65e46e2..3a22103 100644 --- a/packages/vscode-js-profile-table/src/extension.ts +++ b/packages/vscode-js-profile-table/src/extension.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { CpuProfileEditorProvider } from 'vscode-js-profile-core/out/cpu/editorProvider'; import { DownloadFileProvider } from 'vscode-js-profile-core/out/download-file-provider'; import { HeapProfileEditorProvider } from 'vscode-js-profile-core/out/heap/editorProvider'; +import { HeapSnapshotEditorProvider } from 'vscode-js-profile-core/out/heapsnapshot/editorProvider'; import { ProfileCodeLensProvider } from 'vscode-js-profile-core/out/profileCodeLensProvider'; export function activate(context: vscode.ExtensionContext) { @@ -36,6 +37,14 @@ export function activate(context: vscode.ExtensionContext) { }, }, ), + vscode.window.registerCustomEditorProvider( + 'jsProfileVisualizer.heapsnapshot.table', + 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.workspace.registerTextDocumentContentProvider( 'js-viz-download', new DownloadFileProvider(), diff --git a/packages/vscode-js-profile-table/src/heap-client/client.tsx b/packages/vscode-js-profile-table/src/heap-client/client.tsx index d2ac2e4..14d4958 100644 --- a/packages/vscode-js-profile-table/src/heap-client/client.tsx +++ b/packages/vscode-js-profile-table/src/heap-client/client.tsx @@ -5,7 +5,7 @@ import { FunctionComponent, h, render } from 'preact'; import { heapProfileLayoutFactory } from 'vscode-js-profile-core/out/esm/heap/layout'; import { IProfileModel, ITreeNode } from 'vscode-js-profile-core/out/esm/heap/model'; import { createTree } from 'vscode-js-profile-core/out/esm/heap/tree'; -import { IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; import styles from '../common/client.css'; import OpenFlameButton from '../common/open-flame-buttom'; import { TimeView } from './time-view'; @@ -16,8 +16,9 @@ const tree = createTree(MODEL); const allChildren = Object.values(tree.children); const TimeViewWrapper: FunctionComponent<{ - data: IQueryResults; -}> = ({ data }) => ; + query: IQueryResults; + data: DataProvider; +}> = ({ query, data }) => ; const HeapProfileLayout = heapProfileLayoutFactory(); @@ -27,8 +28,7 @@ document.body.appendChild(container); render( Object.values(n.children), + data: DataProvider.fromArray(allChildren, n => Object.values(n.children)), genericMatchStr: n => [n.callFrame.functionName, n.callFrame.url, n.src?.source.path ?? ''].join(' '), properties: { diff --git a/packages/vscode-js-profile-table/src/heap-client/time-view.tsx b/packages/vscode-js-profile-table/src/heap-client/time-view.tsx index d76389f..d154458 100644 --- a/packages/vscode-js-profile-table/src/heap-client/time-view.tsx +++ b/packages/vscode-js-profile-table/src/heap-client/time-view.tsx @@ -4,28 +4,31 @@ import * as ChevronDown from '@vscode/codicons/src/icons/chevron-down.svg'; import { FunctionComponent, h } from 'preact'; -import { useCallback, useState } from 'preact/hooks'; +import { useCallback, useContext, useState } from 'preact/hooks'; 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 { getNodeText } from 'vscode-js-profile-core/out/esm/common/display'; +import { IOpenDocumentMessage } from 'vscode-js-profile-core/out/esm/common/types'; import { decimalFormat } from 'vscode-js-profile-core/out/esm/heap/display'; import { IHeapProfileNode, ITreeNode } from 'vscode-js-profile-core/out/esm/heap/model'; -import { IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; +import { DataProvider, IQueryResults } from 'vscode-js-profile-core/out/esm/ql'; import { IRowProps, makeBaseTimeView } from '../common/base-time-view'; import { makeBaseTimeViewRow } from '../common/base-time-view-row'; import ImpactBar from '../common/impact-bar'; import styles from '../common/time-view.css'; import { SortFn } from '../common/types'; -const selfSize: SortFn = n => (n as IHeapProfileNode).selfSize; -const totalSize: SortFn = n => (n as IHeapProfileNode).totalSize; +const selfSize: SortFn = n => (n as IHeapProfileNode).selfSize; +const totalSize: SortFn = n => (n as IHeapProfileNode).totalSize; const BaseTimeView = makeBaseTimeView(); export const TimeView: FunctionComponent<{ query: IQueryResults; - data: ITreeNode[]; + data: DataProvider; }> = ({ data, query }) => { - const [sortFn, setSortFn] = useState(() => selfSize); + const [sortFn, setSortFn] = useState | undefined>(() => selfSize); return ( (); const TimeViewHeader: FunctionComponent<{ - sortFn: SortFn | undefined; - onChangeSort: (newFn: () => SortFn | undefined) => void; + sortFn: SortFn | undefined; + onChangeSort: (newFn: () => SortFn | undefined) => void; }> = ({ sortFn, onChangeSort }) => (
> = props => { root = root.parent; } + const vscode = useContext(VsCodeApi); + const onClick = useCallback( + (evt: MouseEvent) => + vscode.postMessage({ + type: 'openDocument', + callFrame: node.callFrame, + location: node.src, + toSide: evt.altKey, + }), + [vscode, node], + ); + return ( - +
{decimalFormat.format(node.selfSize / 1000)}kB diff --git a/packages/vscode-js-profile-table/src/heapsnapshot-client/client.tsx b/packages/vscode-js-profile-table/src/heapsnapshot-client/client.tsx new file mode 100644 index 0000000..f1bd367 --- /dev/null +++ b/packages/vscode-js-profile-table/src/heapsnapshot-client/client.tsx @@ -0,0 +1,110 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ +import { FunctionComponent, h, render } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { PageLoader } from 'vscode-js-profile-core/out/esm/client/pageLoader'; +import { cpuProfileLayoutFactory } from 'vscode-js-profile-core/out/esm/cpu/layout'; +import { GraphRPCInterface } from 'vscode-js-profile-core/out/esm/heapsnapshot/rpc'; +import { useGraph } from 'vscode-js-profile-core/out/esm/heapsnapshot/useGraph'; +import { DataProvider, IQueryResults, PropertyType } from 'vscode-js-profile-core/out/esm/ql'; +import styles from '../common/client.css'; +import OpenFlameButton from '../common/open-flame-buttom'; +import { SortFn } from '../common/types'; +import { TableNode, TimeView, sortByName, sortBySelfSize } from './time-view'; + +const TimeViewWrapper: FunctionComponent<{ + query: IQueryResults; + data: DataProvider; +}> = ({ query, data }) => ; + +const CpuProfileLayout = cpuProfileLayoutFactory(); + +const convertSorter = (sort?: SortFn): number => { + /** Mirror of WasmSortBy in the v8_heap_parser.d.ts, but we don't want to import the wasm here */ + const enum WasmSortBy { + SelfSize = 0, + RetainedSize = 1, + Name = 2, + } + + if (sort === sortBySelfSize) { + return WasmSortBy.SelfSize; + } else if (sort === sortByName) { + return WasmSortBy.Name; + } else { + return WasmSortBy.RetainedSize; + } +}; + +const makeNestedDataProvider = ( + parent: TableNode, + graph: GraphRPCInterface, +): DataProvider => + new DataProvider( + parent.childrenLen, + (start, end, sort) => + ('type' in parent + ? graph.getNodeChildren(parent.index, start, end, convertSorter(sort)) + : graph.getClassChildren(parent.index, start, end, convertSorter(sort)) + ).then((items: TableNode[]) => { + for (const item of items) { + item.parent = parent; + } + return items; + }), + n => makeNestedDataProvider(n, graph), + ); + +const Root: FunctionComponent = () => { + const graph = useGraph(); + const [classGroups, setClassGroups] = useState(undefined); + + useEffect(() => { + graph + .getClassGroups(0, 10_000) + .then(items => items.map(item => ({ ...item, id: item.index }))) + .then(setClassGroups, setClassGroups); + }, []); + + if (classGroups === undefined) { + return ; + } + if (classGroups instanceof Error) { + return
{String(classGroups)}
; + } + + return ( + makeNestedDataProvider(n, graph)), + genericMatchStr: n => `${n.name} ${n.id}`, + properties: { + object: { + type: PropertyType.String, + accessor: n => n.name, + }, + selfSize: { + type: PropertyType.Number, + accessor: n => n.selfSize, + }, + retainedSize: { + type: PropertyType.Number, + accessor: n => n.retainedSize, + }, + id: { + type: PropertyType.Number, + accessor: n => n.id, + }, + }, + }} + body={TimeViewWrapper} + filterFooter={OpenFlameButton} + /> + ); +}; + +const container = document.createElement('div'); +container.classList.add(styles.wrapper); +document.body.appendChild(container); +render(, container); 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 new file mode 100644 index 0000000..6475589 --- /dev/null +++ b/packages/vscode-js-profile-table/src/heapsnapshot-client/time-view.tsx @@ -0,0 +1,146 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as ChevronDown from '@vscode/codicons/src/icons/chevron-down.svg'; +import * as TypeHierarchySub from '@vscode/codicons/src/icons/type-hierarchy-sub.svg'; +import { Fragment, FunctionComponent, h } from 'preact'; +import { useCallback, useContext, useMemo, useState } from 'preact/hooks'; +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 { 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'; +import { makeBaseTimeViewRow } from '../common/base-time-view-row'; +import ImpactBar from '../common/impact-bar'; +import styles from '../common/time-view.css'; +import { SortFn } from '../common/types'; + +export type TableNode = (IClassGroup | INode) & { + id: number; + parent?: TableNode; +}; + +const BaseTimeView = makeBaseTimeView(); + +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); + +export const TimeView: FunctionComponent<{ + query: IQueryResults; + data: DataProvider; +}> = ({ query, data }) => { + const [sortFn, setSortFn] = useState | undefined>(undefined); + + return ( + } + row={useMemo(() => timeViewRow(data), [data])} + /> + ); +}; + +const TimeViewHeader: FunctionComponent<{ + sort: SortFn | undefined; + onChangeSort: (newFn: () => SortFn | undefined) => void; +}> = ({ sort, onChangeSort }) => ( +
+
onChangeSort(() => (sort === sortBySelfSize ? undefined : sortBySelfSize)), + [sort], + )} + > + {sort === sortBySelfSize && } + Self Size +
+
onChangeSort(() => (sort === sortByRetainedSize ? undefined : sortByRetainedSize)), + [sort], + )} + > + {sort === sortByRetainedSize && } + Retained Size +
+
+); + +const BaseTimeViewRow = makeBaseTimeViewRow(); + +const timeViewRow = + (data: DataProvider): FunctionComponent> => + props => { + const { node } = props; + const { selfSize, retainedSize } = + node.parent || + data.loaded.reduce( + (acc, n) => { + acc.selfSize += n.selfSize; + acc.retainedSize += n.retainedSize; + return acc; + }, + { selfSize: 0, retainedSize: 0 }, + ); + + const vscode = useContext(VsCodeApi); + const onClick = useCallback( + (evt: MouseEvent) => { + evt.stopPropagation(); + vscode.postMessage({ + type: 'reopenWith', + withQuery: `index=${node.index}`, + toSide: true, + viewType: 'jsProfileVisualizer.heapsnapshot.flame', + requireExtension: 'ms-vscode.vscode-js-profile-flame', + }); + }, + [vscode, node.index], + ); + + return ( + + {node.parent && ( + + + + )}{' '} + {node.name} + {node.parent ? ` @${node.id}` : ''} + + } + > +
+ + {prettyBytes(node.selfSize)} +
+
+ + {prettyBytes(node.retainedSize)} +
+
+ ); + }; diff --git a/packages/vscode-js-profile-table/webpack.config.js b/packages/vscode-js-profile-table/webpack.config.js new file mode 100644 index 0000000..0fd34c5 --- /dev/null +++ b/packages/vscode-js-profile-table/webpack.config.js @@ -0,0 +1,19 @@ +module.exports = [ + require('../../scripts/webpack.heapsnapshot-worker')(__dirname), + require('../../scripts/webpack.extension')(__dirname, 'node'), + ...(process.argv.includes('--watch') + ? [] + : [require('../../scripts/webpack.extension')(__dirname, 'web')]), + { + ...require('../../scripts/webpack.client')(__dirname, 'cpu-client'), + entry: `./src/cpu-client/client.tsx`, + }, + { + ...require('../../scripts/webpack.client')(__dirname, 'heap-client'), + entry: `./src/heap-client/client.tsx`, + }, + { + ...require('../../scripts/webpack.client')(__dirname, 'heapsnapshot-client'), + entry: `./src/heapsnapshot-client/client.tsx`, + }, +]; diff --git a/packages/vscode-js-profile-table/webpack.cpu-client.js b/packages/vscode-js-profile-table/webpack.cpu-client.js deleted file mode 100644 index 5f964c4..0000000 --- a/packages/vscode-js-profile-table/webpack.cpu-client.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require('../../scripts/webpack.client')(__dirname, 'cpu-client'), - entry: `./src/cpu-client/client.tsx`, -}; diff --git a/packages/vscode-js-profile-table/webpack.extension.js b/packages/vscode-js-profile-table/webpack.extension.js deleted file mode 100644 index d9d5282..0000000 --- a/packages/vscode-js-profile-table/webpack.extension.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../../scripts/webpack.extension')(__dirname); diff --git a/packages/vscode-js-profile-table/webpack.heap-client.js b/packages/vscode-js-profile-table/webpack.heap-client.js deleted file mode 100644 index eabab1e..0000000 --- a/packages/vscode-js-profile-table/webpack.heap-client.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require('../../scripts/webpack.client')(__dirname, 'heap-client'), - entry: `./src/heap-client/client.tsx`, -}; diff --git a/scripts/webpack.extension.js b/scripts/webpack.extension.js index 3d4b750..9b6f485 100644 --- a/scripts/webpack.extension.js +++ b/scripts/webpack.extension.js @@ -1,20 +1,20 @@ const path = require('path'); const production = process.argv.includes('production'); -const node = process.argv.includes('node'); -module.exports = dirname => ({ +module.exports = (dirname, target) => ({ mode: production ? 'production' : 'development', devtool: production ? false : 'source-map', entry: './src/extension.ts', + target, output: { path: path.join(dirname, 'out'), - filename: process.argv.includes('web') ? 'extension.web.js' : 'extension.js', + filename: target === 'web' ? 'extension.web.js' : 'extension.js', libraryTarget: 'commonjs2', }, resolve: { extensions: ['.ts', '.js', '.json'], ...( - node ? {} : { + target === 'node' ? {} : { fallback: { path: require.resolve('path-browserify'), os: require.resolve('os-browserify/browser'), @@ -45,4 +45,7 @@ module.exports = dirname => ({ }, ], }, + experiments: { + syncWebAssembly: true, + }, }); diff --git a/scripts/webpack.heapsnapshot-worker.js b/scripts/webpack.heapsnapshot-worker.js new file mode 100644 index 0000000..880b53c --- /dev/null +++ b/scripts/webpack.heapsnapshot-worker.js @@ -0,0 +1,19 @@ +const path = require('path'); +const production = process.argv.includes('production'); + +module.exports = dirname => ({ + mode: production ? 'production' : 'development', + devtool: production ? false : 'source-map', + entry: '../vscode-js-profile-core/out/heapsnapshot/heapsnapshotWorker.js', + target: 'node', + output: { + path: path.join(dirname, 'out'), + filename: 'heapsnapshotWorker.js', + }, + resolve: { + conditionNames: ['bundler', 'module', 'require'], + }, + experiments: { + asyncWebAssembly: true, + }, +}); From e56666613d86fa34c7f01930cbebc4295c360517 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 15 Nov 2023 08:44:11 -0800 Subject: [PATCH 2/2] bump versions --- package-lock.json | 4 ++-- packages/vscode-js-profile-flame/package-lock.json | 4 ++-- packages/vscode-js-profile-flame/package.json | 2 +- packages/vscode-js-profile-table/package-lock.json | 4 ++-- packages/vscode-js-profile-table/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24b4679..019f3cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10410,7 +10410,7 @@ "integrity": "sha512-3EvQak7EIOLyIGz+IP9qSwRmP08ZRWgTeoRgAXPVkkDXZ8riqJ7LDtkgx++uHBiJ3MUaSdlUYPZcLFFw7E6zGg==" }, "packages/vscode-js-profile-flame": { - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.35", @@ -10430,7 +10430,7 @@ } }, "packages/vscode-js-profile-table": { - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.35", diff --git a/packages/vscode-js-profile-flame/package-lock.json b/packages/vscode-js-profile-flame/package-lock.json index 6a97b7e..287678f 100644 --- a/packages/vscode-js-profile-flame/package-lock.json +++ b/packages/vscode-js-profile-flame/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-js-profile-flame", - "version": "1.0.7", + "version": "1.0.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-js-profile-flame", - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.30", diff --git a/packages/vscode-js-profile-flame/package.json b/packages/vscode-js-profile-flame/package.json index 8278f59..ee520a5 100644 --- a/packages/vscode-js-profile-flame/package.json +++ b/packages/vscode-js-profile-flame/package.json @@ -1,7 +1,7 @@ { "name": "vscode-js-profile-flame", "displayName": "Flame Chart Visualizer for JavaScript Profiles", - "version": "1.0.7", + "version": "1.0.8", "description": "Flame graph visualizer for Heap and CPU profiles taken from the JavaScript debugger", "author": "Connor Peet ", "homepage": "https://github.com/microsoft/vscode-js-profile-visualizer#readme", diff --git a/packages/vscode-js-profile-table/package-lock.json b/packages/vscode-js-profile-table/package-lock.json index c08fca6..1c3eb3c 100644 --- a/packages/vscode-js-profile-table/package-lock.json +++ b/packages/vscode-js-profile-table/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-js-profile-table", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-js-profile-table", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.28" diff --git a/packages/vscode-js-profile-table/package.json b/packages/vscode-js-profile-table/package.json index 7482b8c..7621aaa 100644 --- a/packages/vscode-js-profile-table/package.json +++ b/packages/vscode-js-profile-table/package.json @@ -1,6 +1,6 @@ { "name": "vscode-js-profile-table", - "version": "1.0.6", + "version": "1.0.7", "displayName": "Table Visualizer for JavaScript Profiles", "description": "Text visualizer for profiles taken from the JavaScript debugger", "author": "Connor Peet ",