Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4f38a8cb | ||
|
|
9361a6baf2 | ||
|
|
ede9c74d50 | ||
|
|
f758130455 | ||
|
|
f3a1c09c9b | ||
|
|
069c0194a6 | ||
|
|
64fe369c42 | ||
|
|
f55c53f699 | ||
|
|
a0b3fe8420 | ||
|
|
9452aeae82 | ||
|
|
b26cdb5be4 | ||
|
|
1c5bdba36e | ||
|
|
c3b35d7b0f | ||
|
|
dfaefe54fd | ||
|
|
7514f4c0c9 | ||
|
|
ff447c2719 | ||
|
|
668bb032ba | ||
|
|
9ed1eb192c | ||
|
|
8620432cbc | ||
|
|
dead3f9ad9 | ||
|
|
80b747a55e | ||
|
|
351994972d | ||
|
|
ca1abfdd32 | ||
|
|
dee9e5ade4 | ||
|
|
2077a905a9 | ||
|
|
e969178e65 | ||
|
|
56f6459af9 | ||
|
|
3f79e0fe96 | ||
|
|
d30bb2ef7e | ||
|
|
fd4195da10 | ||
|
|
707462e9cf | ||
|
|
375040e892 | ||
|
|
5ae9abcf1d | ||
|
|
bdd9301c59 | ||
|
|
cc9750923a | ||
|
|
c3074b7343 |
+11
-4
@@ -1,13 +1,20 @@
|
||||
module.exports = {
|
||||
parser: 'typescript-eslint-parser',
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
plugins: ['prettier'],
|
||||
plugins: ['prettier', '@typescript-eslint', 'react-hooks'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'error',
|
||||
{
|
||||
additionalHooks: '(useSelector|useAppSelector|useActionCreator)',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 14.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Coveralls Parallel
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
github-token: ${{ secrets.github_token }}
|
||||
flag-name: run-${{ matrix.node-version }}
|
||||
parallel: true
|
||||
|
||||
finish:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Coveralls Finished
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
github-token: ${{ secrets.github_token }}
|
||||
parallel-finished: true
|
||||
@@ -1,3 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '9'
|
||||
+102
-39
@@ -1,152 +1,215 @@
|
||||
## Unreleased
|
||||
|
||||
## [1.10.0] - 2020-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Support for importing profiles from Safari [[#300](https://github.com/jlfwong/speedscope/pull/300)] (by [@radex](https://github.com/radex))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed browser not opening on Windows when using the CLI [[#307](https://github.com/jlfwong/speedscope/pull/307)] (by [@spillerrec](https://github.com/spillerrec))
|
||||
- Fixed import of UTF-16 encoded files w/ BOM [[#314](https://github.com/jlfwong/speedscope/pull/314)]
|
||||
- Removed accidental dependency on React [[#315](https://github.com/jlfwong/speedscope/pull/315)]
|
||||
|
||||
## [1.9.0] - 2020-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- Provide prev/next buttons to cycle through search results, make search results more visually prominen [[#304](https://github.com/jlfwong/speedscope/pull/304)]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix accumulated errors in Chrome profile imports caused by zeroed negative timeDeltas [[#305](https://github.com/jlfwong/speedscope/pull/305)] (by [@taneliang](https://github.com/taneliang))
|
||||
|
||||
## [1.8.0] - 2020-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added search highlighting in time order & left heavy views [[#297](https://github.com/jlfwong/speedscope/pull/297)]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix performance issues for the caller/callee flamegraphs in the sandwich view [[#296](https://github.com/jlfwong/speedscope/pull/296)]
|
||||
|
||||
## [1.7.0] - 2020-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced filtering via Ctrl+F/Cmd+F into the sandwich view [[#293](https://github.com/jlfwong/speedscope/pull/293)]
|
||||
|
||||
## [1.6.0] - 2020-05-30
|
||||
|
||||
### Added
|
||||
|
||||
- Improved profile/thread selection UI [[#282](https://github.com/jlfwong/speedscope/pull/282)]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crash instead of incorrectly interpreting profiles with incorrectly ordered events [[#273](https://github.com/jlfwong/speedscope/pull/273)]
|
||||
- A large refactor to upgrade to Preact X was performed [[#267](https://github.com/jlfwong/speedscope/pull/267)]
|
||||
|
||||
## [1.5.3] - 2020-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bump dependency versions to unbreak build [[#253](https://github.com/jlfwong/speedscope/pull/253)] (by [@jlfwong](https://github.com/jlfwong), with changes from [@Archerlly](https://github.com/Archerlly)'s [#215](https://github.com/jlfwong/speedscope/pull/215))
|
||||
- Trace event: Prevent event re-ordering from generating incorrect flamegraphs ([#252](https://github.com/jlfwong/speedscope/pull/252), with changes from [@hwajaywang](https://github.com/hwajaywang)'s [#249](https://github.com/jlfwong/speedscope/pull/249))
|
||||
- Make tooltip width wider [[#239](https://github.com/jlfwong/speedscope/pull/239)] (by [@miso11](https://github.com/miso11))
|
||||
|
||||
## [1.5.2] - 2019-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix emscripten remapping when symbols are hex-escaped, like `a\20b` [[#233](https://github.com/jlfwong/speedscope/pull/233)] (by [@jyc](https://github.com/jyc))
|
||||
|
||||
## [1.5.1] - 2019-06-06
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed import of trace event files which contain unmatched "E" events (#222) (by @jlfwong)
|
||||
- Fixed import of trace event files which contain unmatched "E" events ([#222](https://github.com/jlfwong/speedscope/pull/222)) (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
## [1.5.0] - 2019-02-17
|
||||
|
||||
### Added
|
||||
|
||||
* Support importing unterminated JSON in simple cases (#208) (by @jlfwong)
|
||||
- Support importing unterminated JSON in simple cases ([#208](https://github.com/jlfwong/speedscope/pull/208)) (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix crash when importing from stackpro without raw_timestamp_deltas (#207) (by @jlfwong)
|
||||
* Alert instead of crash when importing a file containing no profiles (#205) (by @jlfwong)
|
||||
* Fixed import of multithreaded profiles from Chrome 66 (#206) (by @jlfwong)
|
||||
* Fixed import of instruments trace files with missing run number (#203) (by @Archerlly)
|
||||
- Fix crash when importing from stackprof without raw_timestamp_deltas ([#207](https://github.com/jlfwong/speedscope/pull/207)) (by [@jlfwong](https://github.com/jlfwong))
|
||||
- Alert instead of crash when importing a file containing no profiles ([#205](https://github.com/jlfwong/speedscope/pull/205)) (by [@jlfwong](https://github.com/jlfwong))
|
||||
- Fixed import of multithreaded profiles from Chrome 66 ([#206](https://github.com/jlfwong/speedscope/pull/206)) (by [@jlfwong](https://github.com/jlfwong))
|
||||
- Fixed import of instruments trace files with missing run number ([#203](https://github.com/jlfwong/speedscope/pull/203)) (by [@Archerlly](https://github.com/Archerlly))
|
||||
|
||||
## [1.4.1] - 2019-01-22
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix importing of Trace Event Format files with no ts field on M events [#198] (by @jlfwong)
|
||||
- Fix importing of Trace Event Format files with no ts field on M events [[#198](https://github.com/jlfwong/speedscope/pull/198)] (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
## [1.4.0] - 2019-01-22
|
||||
|
||||
### Added
|
||||
|
||||
* Import v8 cpu profile (old format) [#177] (by @vmarchaud)
|
||||
* Import basic "Trace Event Format" profiles [#197] (by @jlfwong)
|
||||
- Import v8 cpu profile (old format) [[#177](https://github.com/jlfwong/speedscope/pull/177)] (by [@vmarchaud](https://github.com/vmarchaud))
|
||||
- Import basic "Trace Event Format" profiles [[#197](https://github.com/jlfwong/speedscope/pull/197)] (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
## [1.3.2] - 2018-12-03
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed import of multithreaded Chrome profiles [#19] (by @jlfwong)
|
||||
- Fixed import of multithreaded Chrome profiles [[#19](https://github.com/jlfwong/speedscope/pull/19)] (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
## [1.3.1] - 2018-11-08
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed a file import performance regression by using TextDecoder [#188] (by @jlfwong)
|
||||
- Fixed a file import performance regression by using TextDecoder [[#188](https://github.com/jlfwong/speedscope/pull/188)] (by [@jlfwong](https://github.com/jlfwong))
|
||||
|
||||
## [1.3.0] - 2018-10-29
|
||||
|
||||
### Added
|
||||
|
||||
* Support import from Haskell GHC JSON format support [#183] (by @trishume)
|
||||
- Support import from Haskell GHC JSON format support [[#183](https://github.com/jlfwong/speedscope/pull/183)] (by [@trishume](https://github.com/trishume))
|
||||
|
||||
### Fixed
|
||||
|
||||
* Make the wasd keymappings work on azerty keyboards [#184] (by @vrischmann)
|
||||
* Fix import of binary formats via profileURL [#179] (by @f-hj)
|
||||
- Make the wasd keymappings work on azerty keyboards [[#184](https://github.com/jlfwong/speedscope/pull/184)] (by [@vrischmann](https://github.com/vrischmann))
|
||||
- Fix import of binary formats via profileURL [[#179](https://github.com/jlfwong/speedscope/pull/179)] (by [@f](https://github.com/f)-hj)
|
||||
|
||||
## [1.2.0] - 2018-10-08
|
||||
|
||||
### Added
|
||||
|
||||
* Add import of v8 heap allocation profile [#170] (by @vmarchaud)
|
||||
- Add import of v8 heap allocation profile [[#170](https://github.com/jlfwong/speedscope/pull/170)] (by [@vmarchaud](https://github.com/vmarchaud))
|
||||
|
||||
## [1.1.0] - 2018-09-26
|
||||
|
||||
### Added
|
||||
|
||||
* Add go tool pprof import support [#165]
|
||||
- Add go tool pprof import support [[#165](https://github.com/jlfwong/speedscope/pull/165)]
|
||||
|
||||
## [1.0.4] - 2018-09-12
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix import from Chrome < 69 when there are multiple profiles [#161]
|
||||
- Fix import from Chrome < 69 when there are multiple profiles [[#161](https://github.com/jlfwong/speedscope/pull/161)]
|
||||
|
||||
## [1.0.3] - 2018-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix import for Chrome 69, support leading idle time before first call [#160]
|
||||
- Fix import for Chrome 69, support leading idle time before first call [[#160](https://github.com/jlfwong/speedscope/pull/160)]
|
||||
|
||||
## [1.0.2] - 2018-09-04
|
||||
|
||||
### Fixed
|
||||
|
||||
* Allow optional CR before LF when probing collapsed stacks files [#154]
|
||||
* Fix import for Firefox 63 [#156]
|
||||
* Change time formatting for minutes from 1.50min to 1:30 [#153] (by @Alex-Diez)
|
||||
- Allow optional CR before LF when probing collapsed stacks files [[#154](https://github.com/jlfwong/speedscope/pull/154)]
|
||||
- Fix import for Firefox 63 [[#156](https://github.com/jlfwong/speedscope/pull/156)]
|
||||
- Change time formatting for minutes from 1.50min to 1:30 [[#153](https://github.com/jlfwong/speedscope/pull/153)] (by [@Alex](https://github.com/Alex)-Diez)
|
||||
|
||||
## [1.0.1] - 2018-08-23
|
||||
|
||||
* Fixed an issue where flamegraph bounds were not always being cleared correctly, leading to visual artifacts [#150]
|
||||
- Fixed an issue where flamegraph bounds were not always being cleared correctly, leading to visual artifacts [[#150](https://github.com/jlfwong/speedscope/pull/150)]
|
||||
|
||||
## [1.0.0] - 2018-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed rendering issues when switching between screens w/ different `devicePixelRatios` [#147]
|
||||
- Fixed rendering issues when switching between screens w/ different `devicePixelRatios` [[#147](https://github.com/jlfwong/speedscope/pull/147)]
|
||||
|
||||
## [0.7.1] - 2018-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
* Removed dependency on regl in order to allow speedscope to run in strict content-security-policy environments [#140]
|
||||
* Fixed text culling bug [#143]
|
||||
- Removed dependency on regl in order to allow speedscope to run in strict content-security-policy environments [[#140](https://github.com/jlfwong/speedscope/pull/140)]
|
||||
- Fixed text culling bug [[#143](https://github.com/jlfwong/speedscope/pull/143)]
|
||||
|
||||
## [0.7.0] - 2018-08-16
|
||||
|
||||
### Added
|
||||
|
||||
* Added support to import from linux `perf script` [#135]
|
||||
- Added support to import from linux `perf script` [[#135](https://github.com/jlfwong/speedscope/pull/135)]
|
||||
|
||||
## [0.6.0] - 2018-08-14
|
||||
|
||||
### Added
|
||||
|
||||
* Added support for multiple threads/processes [#130]
|
||||
* Import all runs & threads from Instruments .trace files instead of just main thread from selected run [#130]
|
||||
- Added support for multiple threads/processes [[#130](https://github.com/jlfwong/speedscope/pull/130)]
|
||||
- Import all runs & threads from Instruments .trace files instead of just main thread from selected run [[#130](https://github.com/jlfwong/speedscope/pull/130)]
|
||||
|
||||
### Fixed
|
||||
|
||||
* Ensure the JSON schema has actual contents [#133]
|
||||
- Ensure the JSON schema has actual contents [[#133](https://github.com/jlfwong/speedscope/pull/133)]
|
||||
|
||||
## [0.5.1] - 2018-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed broken CLI
|
||||
- Fixed broken CLI
|
||||
|
||||
## [0.5.0] - 2018-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix emscripten remapping when symbols contain dashes, like `527:i32s-div` [#129]
|
||||
* Improved firefox import speed and fixed bugs in it [#128]
|
||||
* Prevent non-contiguous blocks in the time ordered flamechart from appearing as a single node for selection [#123]
|
||||
* Prevent dragging from changing selection [#122]
|
||||
* Clamp zoom to prevent floating point issues [#121]
|
||||
* Preserve view state when switching tabs [#100]
|
||||
- Fix emscripten remapping when symbols contain dashes, like `527:i32s-div` [[#129](https://github.com/jlfwong/speedscope/pull/129)]
|
||||
- Improved firefox import speed and fixed bugs in it [[#128](https://github.com/jlfwong/speedscope/pull/128)]
|
||||
- Prevent non-contiguous blocks in the time ordered flamechart from appearing as a single node for selection [[#123](https://github.com/jlfwong/speedscope/pull/123)]
|
||||
- Prevent dragging from changing selection [[#122](https://github.com/jlfwong/speedscope/pull/122)]
|
||||
- Clamp zoom to prevent floating point issues [[#121](https://github.com/jlfwong/speedscope/pull/121)]
|
||||
- Preserve view state when switching tabs [[#100](https://github.com/jlfwong/speedscope/pull/100)]
|
||||
|
||||
## [0.4.0] - 2018-07-21
|
||||
|
||||
### Added
|
||||
|
||||
* Support for importing v8 logs from node [#98]
|
||||
* Optionally read from stdin via cli [#99]
|
||||
- Support for importing v8 logs from node [[#98](https://github.com/jlfwong/speedscope/pull/98)]
|
||||
- Optionally read from stdin via cli [[#99](https://github.com/jlfwong/speedscope/pull/99)]
|
||||
|
||||
## [0.3.0] - 2018-07-18
|
||||
|
||||
### Added
|
||||
|
||||
* Support for remapping profiles using a wasm symbol file [#93]
|
||||
- Support for remapping profiles using a wasm symbol file [[#93](https://github.com/jlfwong/speedscope/pull/93)]
|
||||
|
||||
@@ -36,13 +36,16 @@ speedscope is designed to ingest profiles from a variety of different profilers
|
||||
- JavaScript
|
||||
- [Importing from Chrome](https://github.com/jlfwong/speedscope/wiki/Importing-from-Chrome)
|
||||
- [Importing from Firefox](https://github.com/jlfwong/speedscope/wiki/Importing-from-Firefox)
|
||||
- [Importing from Safari](https://github.com/jlfwong/speedscope/wiki/Importing-from-Safari)
|
||||
- [Importing from Node.js](https://github.com/jlfwong/speedscope/wiki/Importing-from-Node.js)
|
||||
- Ruby
|
||||
- [Importing from stackprof](https://github.com/jlfwong/speedscope/wiki/Importing-from-stackprof-(ruby))
|
||||
- [Importing from rbspy](https://github.com/jlfwong/speedscope/wiki/Importing-from-rbspy-(ruby))
|
||||
- [Importing from ruby-prof](https://github.com/jlfwong/speedscope/wiki/Importing-from-ruby-prof)
|
||||
- Python
|
||||
- [Importing from py-spy](https://github.com/jlfwong/speedscope/wiki/Importing-from-py-spy-(python))
|
||||
- [pyspeedscope](https://github.com/windelbouwman/pyspeedscope)
|
||||
- [Importing from Austin](https://github.com/p403n1x87/austin#speedscope)
|
||||
- Go
|
||||
- [Importing from pprof](https://github.com/jlfwong/speedscope/wiki/Importing-from-pprof-(go))
|
||||
- Rust
|
||||
@@ -50,6 +53,7 @@ speedscope is designed to ingest profiles from a variety of different profilers
|
||||
- Native code
|
||||
- [Importing from Instruments.app](https://github.com/jlfwong/speedscope/wiki/Importing-from-Instruments.app) (macOS)
|
||||
- [Importing from `perf`](https://github.com/jlfwong/speedscope/wiki/Importing-from-perf-(linux)) (linux)
|
||||
- [Importing from .NET Core](https://github.com/jlfwong/speedscope/wiki/Importing-from-.NET-Core)
|
||||
- [Importing from GHC (Haskell)](https://github.com/jlfwong/speedscope/wiki/Importing-from-Haskell)
|
||||
- [Importing from custom sources](https://github.com/jlfwong/speedscope/wiki/Importing-from-custom-sources)
|
||||
|
||||
@@ -113,6 +117,8 @@ Once a profile has loaded, the main view is split into two: the top area is the
|
||||
* `Cmd+O`/`Ctrl+O` to open a new profile
|
||||
* `n`: Go to next profile/thread if one is available
|
||||
* `p`: Go to previous profile/thread if one is available
|
||||
* `t`: Open the profile/thread selector if available
|
||||
* `Cmd+F`/`Ctrl+F`: to open search. While open, `Enter` and `Shift+Enter` cycle through results
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@ const fs = require('fs')
|
||||
const os = require('os')
|
||||
const stream = require('stream')
|
||||
|
||||
const opn = require('opn')
|
||||
const open = require('open')
|
||||
|
||||
const helpString = `Usage: speedscope [filepath]
|
||||
|
||||
@@ -89,7 +89,11 @@ async function main() {
|
||||
|
||||
console.log('Opening', urlToOpen, 'in your default browser')
|
||||
|
||||
await opn(urlToOpen, {wait: false})
|
||||
// We'd like to avoid blocking the terminal on the browsing closing,
|
||||
// but for some reason this doesn't work at all on Windows if we
|
||||
// don't use wait: true.
|
||||
const wait = process.platform === "win32";
|
||||
await open(urlToOpen, {wait})
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Generated
+7050
-8903
File diff suppressed because it is too large
Load Diff
+42
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "speedscope",
|
||||
"version": "1.5.1",
|
||||
"version": "1.10.0",
|
||||
"description": "",
|
||||
"repository": "jlfwong/speedscope",
|
||||
"main": "index.js",
|
||||
@@ -13,48 +13,70 @@
|
||||
"prettier": "prettier --write 'src/**/*.ts' 'src/**/*.tsx'",
|
||||
"lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'",
|
||||
"jest": "./scripts/test-setup.sh && jest --runInBand",
|
||||
"coverage": "npm run jest -- --coverage && coveralls < coverage/lcov.info",
|
||||
"test": "tsc --noEmit && npm run lint && npm run coverage",
|
||||
"coverage": "npm run jest -- --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "./scripts/ci.sh",
|
||||
"serve": "parcel assets/index.html --open --no-autoinstall"
|
||||
},
|
||||
"files": ["bin/cli.js", "dist/release/**", "!*.map"],
|
||||
"browserslist": ["last 2 Chrome versions", "last 2 Firefox versions"],
|
||||
"files": [
|
||||
"bin/cli.js",
|
||||
"dist/release/**",
|
||||
"!*.map"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 2 Chrome versions",
|
||||
"last 2 Firefox versions"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "22.2.3",
|
||||
"@types/jszip": "3.1.4",
|
||||
"@types/node": "10.1.4",
|
||||
"@types/node": "14.0.1",
|
||||
"@types/pako": "1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "2.33.0",
|
||||
"@typescript-eslint/parser": "2.33.0",
|
||||
"acorn": "7.2.0",
|
||||
"aphrodite": "2.1.0",
|
||||
"coveralls": "3.0.1",
|
||||
"eslint": "4.19.1",
|
||||
"eslint": "6.0.0",
|
||||
"eslint-plugin-prettier": "2.6.0",
|
||||
"jest": "23.0.1",
|
||||
"eslint-plugin-react-hooks": "4.0.2",
|
||||
"jest": "24.3.0",
|
||||
"jsverify": "0.8.3",
|
||||
"jszip": "3.1.5",
|
||||
"pako": "1.0.6",
|
||||
"parcel-bundler": "1.9.2",
|
||||
"preact": "8.2.7",
|
||||
"preact-redux": "jlfwong/preact-redux#a56dcc4",
|
||||
"prettier": "1.12.0",
|
||||
"parcel-bundler": "1.12.4",
|
||||
"preact": "10.4.1",
|
||||
"prettier": "2.0.4",
|
||||
"protobufjs": "6.8.8",
|
||||
"quicktype": "15.0.45",
|
||||
"redux": "^4.0.0",
|
||||
"ts-jest": "22.4.6",
|
||||
"typescript": "2.8.1",
|
||||
"redux": "^4.0.5",
|
||||
"ts-jest": "24.3.0",
|
||||
"typescript": "3.9.2",
|
||||
"typescript-eslint-parser": "17.0.1",
|
||||
"typescript-json-schema": "0.42.0",
|
||||
"uglify-es": "3.2.2"
|
||||
},
|
||||
"jest": {
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
"./src/jest-setup.js"
|
||||
],
|
||||
"testRegex": "\\.test\\.tsx?$",
|
||||
"collectCoverageFrom": ["**/*.{ts,tsx}", "!**/*.d.{ts,tsx}"],
|
||||
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json"]
|
||||
"collectCoverageFrom": [
|
||||
"**/*.{ts,tsx}",
|
||||
"!**/*.d.{ts,tsx}"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"json"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"opn": "5.3.0"
|
||||
"open": "7.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,4 +4,5 @@ module.exports = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
};
|
||||
arrowParens: 'avoid'
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://www.speedscope.app/file-format-schema.json",
|
||||
"shared": {
|
||||
"frames": [{"name": "A"}]
|
||||
},
|
||||
"profiles": [
|
||||
{
|
||||
"type": "evented",
|
||||
"name": "p1",
|
||||
"unit": "none",
|
||||
"startValue": 0,
|
||||
"endValue": 100,
|
||||
"events": [{"type": "O", "frame": 0, "at": 0}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://www.speedscope.app/file-format-schema.json",
|
||||
"shared": {
|
||||
"frames": [{"name": "A"}, {"name": "B"}]
|
||||
},
|
||||
"profiles": [
|
||||
{
|
||||
"type": "evented",
|
||||
"name": "p1",
|
||||
"unit": "none",
|
||||
"startValue": 0,
|
||||
"endValue": 100,
|
||||
"events": [
|
||||
{"type": "O", "frame": 0, "at": 0},
|
||||
{"type": "C", "frame": 0, "at": 1},
|
||||
{"type": "O", "frame": 1, "at": 2},
|
||||
{"type": "O", "frame": 0, "at": 2},
|
||||
{"type": "O", "frame": 0, "at": 3},
|
||||
{"type": "C", "frame": 0, "at": 4},
|
||||
{"type": "C", "frame": 1, "at": 4},
|
||||
{"type": "C", "frame": 0, "at": 5}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "A", "ts": 0},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "B", "ts": 0},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "C", "ts": 0},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "C", "ts": 1},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "B", "ts": 2},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "A", "ts": 3},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "A", "ts": 4},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "B", "ts": 5},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "C", "ts": 6},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "C", "ts": 7},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "B", "ts": 7},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "A", "ts": 7},
|
||||
{"tid": 1, "ph": "B", "pid": 0, "name": "X", "ts": 7},
|
||||
{"tid": 1, "ph": "E", "pid": 0, "name": "X", "ts": 10}
|
||||
]
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run coverage
|
||||
node scripts/generate-file-format-schema-json.js > /dev/null
|
||||
@@ -3,7 +3,7 @@ const child_process = require('child_process')
|
||||
|
||||
// Convert the file-format-spec.ts file into a json schema file
|
||||
let jsonSchema = child_process.execSync(
|
||||
'node_modules/.bin/quicktype --lang schema ./src/lib/file-format-spec.ts',
|
||||
'node_modules/.bin/typescript-json-schema ./src/lib/file-format-spec.ts --titles --required --topRef "*"',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
|
||||
@@ -24,9 +24,7 @@ export class CanvasContext {
|
||||
const webGLInfo = this.gl.getWebGLInfo()
|
||||
if (webGLInfo) {
|
||||
console.log(
|
||||
`WebGL initialized. renderer: ${webGLInfo.renderer}, vendor: ${
|
||||
webGLInfo.vendor
|
||||
}, version: ${webGLInfo.version}`,
|
||||
`WebGL initialized. renderer: ${webGLInfo.renderer}, vendor: ${webGLInfo.vendor}, version: ${webGLInfo.version}`,
|
||||
)
|
||||
}
|
||||
;(window as any)['testContextLoss'] = () => {
|
||||
|
||||
@@ -175,8 +175,8 @@ export class FlamechartRenderer {
|
||||
// and the blue channel to indicate the color bucket to render.
|
||||
// We add one to each so we have zero reserved for the background color.
|
||||
const color = new Color(
|
||||
(1 + i % 255) / 256,
|
||||
(1 + stackDepth % 255) / 256,
|
||||
(1 + (i % 255)) / 256,
|
||||
(1 + (stackDepth % 255)) / 256,
|
||||
(1 + this.flamechart.getColorBucketForFrame(frame.node.frame)) / 256,
|
||||
)
|
||||
batch.addRect(configSpaceBounds, color)
|
||||
@@ -288,10 +288,10 @@ export class FlamechartRenderer {
|
||||
const configSpaceContentWidth = this.flamechart.getTotalWeight()
|
||||
const numAtlasEntriesPerLayer = Math.pow(2, zoomLevel)
|
||||
const left = Math.floor(
|
||||
numAtlasEntriesPerLayer * configSpaceSrcRect.left() / configSpaceContentWidth,
|
||||
(numAtlasEntriesPerLayer * configSpaceSrcRect.left()) / configSpaceContentWidth,
|
||||
)
|
||||
const right = Math.ceil(
|
||||
numAtlasEntriesPerLayer * configSpaceSrcRect.right() / configSpaceContentWidth,
|
||||
(numAtlasEntriesPerLayer * configSpaceSrcRect.right()) / configSpaceContentWidth,
|
||||
)
|
||||
|
||||
const nLayers = this.flamechart.getLayers().length
|
||||
|
||||
+8
-2
@@ -455,7 +455,8 @@ export namespace WebGL {
|
||||
widthInAppUnits: number,
|
||||
heightInAppUnits: number,
|
||||
) {
|
||||
const bounds = this._gl.canvas.getBoundingClientRect()
|
||||
let canvas = this._gl.canvas as HTMLCanvasElement
|
||||
const bounds = canvas.getBoundingClientRect()
|
||||
|
||||
if (
|
||||
this._width === widthInAppUnits &&
|
||||
@@ -467,7 +468,6 @@ export namespace WebGL {
|
||||
return
|
||||
}
|
||||
|
||||
let canvas = this._gl.canvas
|
||||
let style = canvas.style
|
||||
canvas.width = widthInPixels
|
||||
canvas.height = heightInPixels
|
||||
@@ -1086,11 +1086,17 @@ export namespace WebGL {
|
||||
|
||||
_compileShader(gl: WebGLRenderingContext, type: GLenum, source: string) {
|
||||
let shader = gl.createShader(type)
|
||||
if (!shader) {
|
||||
throw new Error('Failed to create shader')
|
||||
}
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
throw new Error(`${gl.getShaderInfoLog(shader)}`)
|
||||
}
|
||||
if (!this._program) {
|
||||
throw new Error('Tried to attach shader before program was created')
|
||||
}
|
||||
gl.attachShader(this._program, shader)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ export class ViewportRectangleRenderer {
|
||||
private buffer: Graphics.VertexBuffer
|
||||
|
||||
constructor(private gl: Graphics.Context) {
|
||||
const vertices = [[-1, 1], [1, 1], [-1, -1], [1, -1]]
|
||||
const vertices = [
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
]
|
||||
const floats: number[] = []
|
||||
for (let v of vertices) {
|
||||
floats.push(v[0])
|
||||
|
||||
@@ -45,7 +45,14 @@ export class RectangleBatch {
|
||||
return this.buffer
|
||||
}
|
||||
|
||||
const corners = [[0, 0], [1, 0], [0, 1], [1, 0], [0, 1], [1, 1]]
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
]
|
||||
|
||||
const bytes = new Uint8Array(vertexFormat.stride * corners.length * this.rects.length)
|
||||
const floats = new Float32Array(bytes.buffer)
|
||||
|
||||
@@ -104,6 +104,114 @@ exports[`importFromBGFlameGraph with CRLF: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromBGFlameGraph with CRLF: profileGroup.name 1`] = `"simple-crlf.txt"`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Big Endian 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "a",
|
||||
"line": undefined,
|
||||
"name": "a",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 14,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "b",
|
||||
"line": undefined,
|
||||
"name": "b",
|
||||
"selfWeight": 5,
|
||||
"totalWeight": 14,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "c",
|
||||
"line": undefined,
|
||||
"name": "c",
|
||||
"selfWeight": 5,
|
||||
"totalWeight": 5,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "d",
|
||||
"line": undefined,
|
||||
"name": "d",
|
||||
"selfWeight": 4,
|
||||
"totalWeight": 4,
|
||||
},
|
||||
],
|
||||
"name": "simple-utf16-be.txt",
|
||||
"stacks": Array [
|
||||
"a;b;c 2",
|
||||
"a;b;d 4",
|
||||
"a;b;c 3",
|
||||
"a;b 5",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Big Endian: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Big Endian: profileGroup.name 1`] = `"simple-utf16-be.txt"`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Little Endian 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "a",
|
||||
"line": undefined,
|
||||
"name": "a",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 14,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "b",
|
||||
"line": undefined,
|
||||
"name": "b",
|
||||
"selfWeight": 5,
|
||||
"totalWeight": 14,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "c",
|
||||
"line": undefined,
|
||||
"name": "c",
|
||||
"selfWeight": 5,
|
||||
"totalWeight": 5,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "d",
|
||||
"line": undefined,
|
||||
"name": "d",
|
||||
"selfWeight": 4,
|
||||
"totalWeight": 4,
|
||||
},
|
||||
],
|
||||
"name": "simple-utf16-le.txt",
|
||||
"stacks": Array [
|
||||
"a;b;c 2",
|
||||
"a;b;d 4",
|
||||
"a;b;c 3",
|
||||
"a;b 5",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Little Endian: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromBGFlameGraph with UTF-16, Little Endian: profileGroup.name 1`] = `"simple-utf16-le.txt"`;
|
||||
|
||||
exports[`importFromBGFlameGraph: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromBGFlameGraph: profileGroup.name 1`] = `"simple.txt"`;
|
||||
|
||||
@@ -516,97 +516,6 @@ exports[`importFromChromeTimeline Chrome 69: indexToView 1`] = `0`;
|
||||
exports[`importFromChromeTimeline Chrome 69: profileGroup.name 1`] = `"simple.json"`;
|
||||
|
||||
exports[`importFromChromeTimeline Workers Chrome 66 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": -1,
|
||||
"file": "",
|
||||
"key": "(program)::-1:-1",
|
||||
"line": -1,
|
||||
"name": "(program)",
|
||||
"selfWeight": 819120,
|
||||
"totalWeight": 819120,
|
||||
},
|
||||
Frame {
|
||||
"col": 20,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "onmessage:http://172.30.3.176:7777/worker.js:14:20",
|
||||
"line": 14,
|
||||
"name": "onmessage",
|
||||
"selfWeight": 639,
|
||||
"totalWeight": 857719,
|
||||
},
|
||||
Frame {
|
||||
"col": 14,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "alpha:http://172.30.3.176:7777/worker.js:8:14",
|
||||
"line": 8,
|
||||
"name": "alpha",
|
||||
"selfWeight": 19976,
|
||||
"totalWeight": 835149,
|
||||
},
|
||||
Frame {
|
||||
"col": 14,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "gamma:http://172.30.3.176:7777/worker.js:0:14",
|
||||
"line": 0,
|
||||
"name": "gamma",
|
||||
"selfWeight": 22197,
|
||||
"totalWeight": 30000,
|
||||
},
|
||||
Frame {
|
||||
"col": -1,
|
||||
"file": "",
|
||||
"key": "postMessage::-1:-1",
|
||||
"line": -1,
|
||||
"name": "postMessage",
|
||||
"selfWeight": 1278,
|
||||
"totalWeight": 21931,
|
||||
},
|
||||
],
|
||||
"name": "worker.json - DedicatedWorker thread",
|
||||
"stacks": Array [
|
||||
" 9.07ms",
|
||||
"(program) 5.49ms",
|
||||
"onmessage;alpha;gamma 3.69ms",
|
||||
"onmessage;postMessage 212.00µs",
|
||||
"onmessage;postMessage;(program) 3.97ms",
|
||||
"onmessage;alpha;gamma 3.82ms",
|
||||
"onmessage;postMessage 414.00µs",
|
||||
"onmessage;postMessage;(program) 3.71ms",
|
||||
"onmessage;alpha;gamma 1.13ms",
|
||||
"onmessage 188.00µs",
|
||||
"onmessage;alpha;gamma 2.48ms",
|
||||
"onmessage;alpha;gamma;(program) 3.89ms",
|
||||
"onmessage;alpha;gamma 373.00µs",
|
||||
"onmessage;alpha 184.00µs",
|
||||
"onmessage;alpha;gamma 3.36ms",
|
||||
"onmessage;alpha;gamma;(program) 3.91ms",
|
||||
"onmessage;alpha;gamma 3.94ms",
|
||||
"onmessage;postMessage 205.00µs",
|
||||
"onmessage;postMessage;(program) 3.91ms",
|
||||
"onmessage 451.00µs",
|
||||
"onmessage;alpha;gamma 566.00µs",
|
||||
"onmessage;alpha 194.00µs",
|
||||
"onmessage;alpha;gamma 2.09ms",
|
||||
"onmessage;alpha 189.00µs",
|
||||
"onmessage;alpha;gamma 746.00µs",
|
||||
"onmessage;postMessage 211.00µs",
|
||||
"onmessage;postMessage;(program) 4.00ms",
|
||||
"onmessage;alpha 4.80ms",
|
||||
"onmessage;alpha;(program) 5.28ms",
|
||||
"onmessage;alpha 4.70ms",
|
||||
"onmessage;postMessage 236.00µs",
|
||||
"onmessage;postMessage;(program) 5.06ms",
|
||||
"onmessage;alpha 5.07ms",
|
||||
"onmessage;alpha;(program) 4.81ms",
|
||||
"onmessage;alpha 4.84ms",
|
||||
"onmessage;alpha;(program) 775.08ms",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromChromeTimeline Workers Chrome 66 2`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
@@ -724,7 +633,7 @@ Object {
|
||||
"line": 0,
|
||||
"name": "(anonymous)",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 2591,
|
||||
"totalWeight": 2524,
|
||||
},
|
||||
Frame {
|
||||
"col": -1,
|
||||
@@ -732,8 +641,8 @@ Object {
|
||||
"key": "Worker::-1:-1",
|
||||
"line": -1,
|
||||
"name": "Worker",
|
||||
"selfWeight": 873,
|
||||
"totalWeight": 2591,
|
||||
"selfWeight": 806,
|
||||
"totalWeight": 2524,
|
||||
},
|
||||
Frame {
|
||||
"col": 29,
|
||||
@@ -822,7 +731,7 @@ Object {
|
||||
"(program) 11.20ms",
|
||||
" 296.00µs",
|
||||
"(program) 1.90ms",
|
||||
"(anonymous);Worker 873.00µs",
|
||||
"(anonymous);Worker 806.00µs",
|
||||
"(anonymous);Worker;(program) 1.72ms",
|
||||
" 670.00µs",
|
||||
"(program) 28.45ms",
|
||||
@@ -913,7 +822,98 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromChromeTimeline Workers Chrome 66: indexToView 1`] = `1`;
|
||||
exports[`importFromChromeTimeline Workers Chrome 66 2`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": -1,
|
||||
"file": "",
|
||||
"key": "(program)::-1:-1",
|
||||
"line": -1,
|
||||
"name": "(program)",
|
||||
"selfWeight": 819120,
|
||||
"totalWeight": 819120,
|
||||
},
|
||||
Frame {
|
||||
"col": 20,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "onmessage:http://172.30.3.176:7777/worker.js:14:20",
|
||||
"line": 14,
|
||||
"name": "onmessage",
|
||||
"selfWeight": 639,
|
||||
"totalWeight": 857719,
|
||||
},
|
||||
Frame {
|
||||
"col": 14,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "alpha:http://172.30.3.176:7777/worker.js:8:14",
|
||||
"line": 8,
|
||||
"name": "alpha",
|
||||
"selfWeight": 19976,
|
||||
"totalWeight": 835149,
|
||||
},
|
||||
Frame {
|
||||
"col": 14,
|
||||
"file": "http://172.30.3.176:7777/worker.js",
|
||||
"key": "gamma:http://172.30.3.176:7777/worker.js:0:14",
|
||||
"line": 0,
|
||||
"name": "gamma",
|
||||
"selfWeight": 22197,
|
||||
"totalWeight": 30000,
|
||||
},
|
||||
Frame {
|
||||
"col": -1,
|
||||
"file": "",
|
||||
"key": "postMessage::-1:-1",
|
||||
"line": -1,
|
||||
"name": "postMessage",
|
||||
"selfWeight": 1278,
|
||||
"totalWeight": 21931,
|
||||
},
|
||||
],
|
||||
"name": "worker.json - DedicatedWorker thread",
|
||||
"stacks": Array [
|
||||
" 9.07ms",
|
||||
"(program) 5.49ms",
|
||||
"onmessage;alpha;gamma 3.69ms",
|
||||
"onmessage;postMessage 212.00µs",
|
||||
"onmessage;postMessage;(program) 3.97ms",
|
||||
"onmessage;alpha;gamma 3.82ms",
|
||||
"onmessage;postMessage 414.00µs",
|
||||
"onmessage;postMessage;(program) 3.71ms",
|
||||
"onmessage;alpha;gamma 1.13ms",
|
||||
"onmessage 188.00µs",
|
||||
"onmessage;alpha;gamma 2.48ms",
|
||||
"onmessage;alpha;gamma;(program) 3.89ms",
|
||||
"onmessage;alpha;gamma 373.00µs",
|
||||
"onmessage;alpha 184.00µs",
|
||||
"onmessage;alpha;gamma 3.36ms",
|
||||
"onmessage;alpha;gamma;(program) 3.91ms",
|
||||
"onmessage;alpha;gamma 3.94ms",
|
||||
"onmessage;postMessage 205.00µs",
|
||||
"onmessage;postMessage;(program) 3.91ms",
|
||||
"onmessage 451.00µs",
|
||||
"onmessage;alpha;gamma 566.00µs",
|
||||
"onmessage;alpha 194.00µs",
|
||||
"onmessage;alpha;gamma 2.09ms",
|
||||
"onmessage;alpha 189.00µs",
|
||||
"onmessage;alpha;gamma 746.00µs",
|
||||
"onmessage;postMessage 211.00µs",
|
||||
"onmessage;postMessage;(program) 4.00ms",
|
||||
"onmessage;alpha 4.80ms",
|
||||
"onmessage;alpha;(program) 5.28ms",
|
||||
"onmessage;alpha 4.70ms",
|
||||
"onmessage;postMessage 236.00µs",
|
||||
"onmessage;postMessage;(program) 5.06ms",
|
||||
"onmessage;alpha 5.07ms",
|
||||
"onmessage;alpha;(program) 4.81ms",
|
||||
"onmessage;alpha 4.84ms",
|
||||
"onmessage;alpha;(program) 775.08ms",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromChromeTimeline Workers Chrome 66: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromChromeTimeline Workers Chrome 66: profileGroup.name 1`] = `"worker.json"`;
|
||||
|
||||
@@ -1305,7 +1305,7 @@ Object {
|
||||
"line": 0,
|
||||
"name": "(anonymous)",
|
||||
"selfWeight": 542,
|
||||
"totalWeight": 6218,
|
||||
"totalWeight": 6115,
|
||||
},
|
||||
Frame {
|
||||
"col": 25,
|
||||
@@ -1314,7 +1314,7 @@ Object {
|
||||
"line": 30,
|
||||
"name": "insertTextScript",
|
||||
"selfWeight": 392,
|
||||
"totalWeight": 977,
|
||||
"totalWeight": 874,
|
||||
},
|
||||
Frame {
|
||||
"col": 25,
|
||||
@@ -1322,8 +1322,8 @@ Object {
|
||||
"key": "insertHeaderNode:chrome-extension://denbgaamihkadbghdceggmchnflmhpmk/contentScript.js:57:25",
|
||||
"line": 57,
|
||||
"name": "insertHeaderNode",
|
||||
"selfWeight": 292,
|
||||
"totalWeight": 585,
|
||||
"selfWeight": 189,
|
||||
"totalWeight": 482,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
@@ -1558,7 +1558,7 @@ Object {
|
||||
"(anonymous);(anonymous);(anonymous);(program) 27.49ms",
|
||||
"(anonymous) 542.00µs",
|
||||
"(anonymous);insertTextScript 392.00µs",
|
||||
"(anonymous);insertTextScript;insertHeaderNode 292.00µs",
|
||||
"(anonymous);insertTextScript;insertHeaderNode 189.00µs",
|
||||
"(anonymous);insertTextScript;insertHeaderNode;appendChild;(anonymous);(anonymous) 148.00µs",
|
||||
"(anonymous);insertTextScript;insertHeaderNode;appendChild 145.00µs",
|
||||
"(anonymous);listenForMessage;get webstore 148.00µs",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`importFromSafari 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": 13,
|
||||
"file": "__InjectedScript_InjectedScriptSource.js",
|
||||
"key": "injectModule:__InjectedScript_InjectedScriptSource.js:109:13",
|
||||
"line": 109,
|
||||
"name": "injectModule",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 0.001,
|
||||
},
|
||||
Frame {
|
||||
"col": 10,
|
||||
"file": "__InjectedScript_CommandLineAPIModuleSource.js",
|
||||
"key": ":__InjectedScript_CommandLineAPIModuleSource.js:2:10",
|
||||
"line": 2,
|
||||
"name": "(anonymous)",
|
||||
"selfWeight": 0.001,
|
||||
"totalWeight": 0.001,
|
||||
},
|
||||
Frame {
|
||||
"col": 1,
|
||||
"file": "file:///speedscope/sample/programs/javascript/simple.js",
|
||||
"key": "(program):file:///speedscope/sample/programs/javascript/simple.js:1:1",
|
||||
"line": 1,
|
||||
"name": "(program)",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 0.03248933597933502,
|
||||
},
|
||||
Frame {
|
||||
"col": 15,
|
||||
"file": "file:///speedscope/sample/programs/javascript/simple.js",
|
||||
"key": "alpha:file:///speedscope/sample/programs/javascript/simple.js:1:15",
|
||||
"line": 1,
|
||||
"name": "alpha",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 0.03248933597933502,
|
||||
},
|
||||
Frame {
|
||||
"col": 15,
|
||||
"file": "file:///speedscope/sample/programs/javascript/simple.js",
|
||||
"key": "delta:file:///speedscope/sample/programs/javascript/simple.js:14:15",
|
||||
"line": 14,
|
||||
"name": "delta",
|
||||
"selfWeight": 0.003094222474222382,
|
||||
"totalWeight": 0.020112446082445484,
|
||||
},
|
||||
Frame {
|
||||
"col": 15,
|
||||
"file": "file:///speedscope/sample/programs/javascript/simple.js",
|
||||
"key": "gamma:file:///speedscope/sample/programs/javascript/simple.js:20:15",
|
||||
"line": 20,
|
||||
"name": "gamma",
|
||||
"selfWeight": 0.029395113505112636,
|
||||
"totalWeight": 0.029395113505112636,
|
||||
},
|
||||
Frame {
|
||||
"col": 14,
|
||||
"file": "file:///speedscope/sample/programs/javascript/simple.js",
|
||||
"key": "beta:file:///speedscope/sample/programs/javascript/simple.js:8:14",
|
||||
"line": 8,
|
||||
"name": "beta",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 0.012376889896889526,
|
||||
},
|
||||
Frame {
|
||||
"col": 102,
|
||||
"file": "",
|
||||
"key": "firstOpenSearchURLString::4:102",
|
||||
"line": 4,
|
||||
"name": "firstOpenSearchURLString",
|
||||
"selfWeight": 0.0005174240213818848,
|
||||
"totalWeight": 0.0005174240213818848,
|
||||
},
|
||||
],
|
||||
"name": "Grabación de Control temporal 1",
|
||||
"stacks": Array [
|
||||
"injectModule;(anonymous) 1.00ms",
|
||||
" 39.93ms",
|
||||
"(program);alpha;delta;gamma 10.83ms",
|
||||
" 2.46ms",
|
||||
"(program);alpha;delta 3.09ms",
|
||||
"(program);alpha;beta;gamma 4.64ms",
|
||||
"(program);alpha;delta;gamma 1.55ms",
|
||||
"(program);alpha;beta;gamma 1.55ms",
|
||||
"(program);alpha;delta;gamma 3.09ms",
|
||||
"(program);alpha;beta;gamma 4.64ms",
|
||||
"(program);alpha;delta;gamma 1.55ms",
|
||||
"(program);alpha;beta;gamma 1.55ms",
|
||||
" 253.50ms",
|
||||
"firstOpenSearchURLString 517.42µs",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importFromSafari: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importFromSafari: profileGroup.name 1`] = `"Grabación de Control temporal 1"`;
|
||||
@@ -35,6 +35,64 @@ exports[`importTraceEvents bad E events: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importTraceEvents bad E events: profileGroup.name 1`] = `"too-many-end-events.json"`;
|
||||
|
||||
exports[`importTraceEvents event re-ordering 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "A",
|
||||
"line": undefined,
|
||||
"name": "A",
|
||||
"selfWeight": 2,
|
||||
"totalWeight": 6,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "B",
|
||||
"line": undefined,
|
||||
"name": "B",
|
||||
"selfWeight": 2,
|
||||
"totalWeight": 4,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "C",
|
||||
"line": undefined,
|
||||
"name": "C",
|
||||
"selfWeight": 2,
|
||||
"totalWeight": 2,
|
||||
},
|
||||
Frame {
|
||||
"col": undefined,
|
||||
"file": undefined,
|
||||
"key": "X",
|
||||
"line": undefined,
|
||||
"name": "X",
|
||||
"selfWeight": 3,
|
||||
"totalWeight": 3,
|
||||
},
|
||||
],
|
||||
"name": "pid 0, tid 1",
|
||||
"stacks": Array [
|
||||
"A;B;C 1.00µs",
|
||||
"A;B 1.00µs",
|
||||
"A 1.00µs",
|
||||
" 1.00µs",
|
||||
"A 1.00µs",
|
||||
"A;B 1.00µs",
|
||||
"A;B;C 1.00µs",
|
||||
"X 3.00µs",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`importTraceEvents event re-ordering: indexToView 1`] = `0`;
|
||||
|
||||
exports[`importTraceEvents event re-ordering: profileGroup.name 1`] = `"must-retain-original-order.json"`;
|
||||
|
||||
exports[`importTraceEvents multiprocess 1`] = `
|
||||
Object {
|
||||
"frames": Array [
|
||||
|
||||
@@ -7,3 +7,11 @@ test('importFromBGFlameGraph', async () => {
|
||||
test('importFromBGFlameGraph with CRLF', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/stackcollapse/simple-crlf.txt')
|
||||
})
|
||||
|
||||
test('importFromBGFlameGraph with UTF-16, Little Endian', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/stackcollapse/simple-utf16-le.txt')
|
||||
})
|
||||
|
||||
test('importFromBGFlameGraph with UTF-16, Big Endian', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/stackcollapse/simple-utf16-be.txt')
|
||||
})
|
||||
|
||||
+17
-9
@@ -224,6 +224,10 @@ export function importFromChromeCPUProfile(chromeProfile: CPUProfile): Profile {
|
||||
// Ref: https://github.com/v8/v8/blob/44bd8fd7/src/inspector/js_protocol.json#L1485
|
||||
let elapsed = chromeProfile.timeDeltas[0]
|
||||
|
||||
// Prevents negative time deltas from causing bad data. See
|
||||
// https://github.com/jlfwong/speedscope/pull/305 for details.
|
||||
let lastValidElapsed = elapsed
|
||||
|
||||
let lastNodeId = NaN
|
||||
|
||||
// The chrome CPU profile format doesn't collapse identical samples. We'll do that
|
||||
@@ -232,22 +236,26 @@ export function importFromChromeCPUProfile(chromeProfile: CPUProfile): Profile {
|
||||
const nodeId = chromeProfile.samples[i]
|
||||
if (nodeId != lastNodeId) {
|
||||
samples.push(nodeId)
|
||||
sampleTimes.push(elapsed)
|
||||
if (elapsed < lastValidElapsed) {
|
||||
sampleTimes.push(lastValidElapsed)
|
||||
} else {
|
||||
sampleTimes.push(elapsed)
|
||||
lastValidElapsed = elapsed
|
||||
}
|
||||
}
|
||||
|
||||
if (i === chromeProfile.samples.length - 1) {
|
||||
if (!isNaN(lastNodeId)) {
|
||||
samples.push(lastNodeId)
|
||||
sampleTimes.push(elapsed)
|
||||
if (elapsed < lastValidElapsed) {
|
||||
sampleTimes.push(lastValidElapsed)
|
||||
} else {
|
||||
sampleTimes.push(elapsed)
|
||||
lastValidElapsed = elapsed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let timeDelta = chromeProfile.timeDeltas[i + 1]
|
||||
if (timeDelta < 0) {
|
||||
// This is super noisy, but can be helpful when debugging strange data
|
||||
// console.warn('Substituting zero for unexpected time delta:', timeDelta, 'at index', i)
|
||||
timeDelta = 0
|
||||
}
|
||||
|
||||
const timeDelta = chromeProfile.timeDeltas[i + 1]
|
||||
elapsed += timeDelta
|
||||
lastNodeId = nodeId
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {importSpeedscopeProfiles} from '../lib/file-format'
|
||||
import {importFromV8ProfLog} from './v8proflog'
|
||||
import {importFromLinuxPerf} from './linux-tools-perf'
|
||||
import {importFromHaskell} from './haskell'
|
||||
import {importFromSafari} from './safari'
|
||||
import {ProfileDataSource, TextProfileDataSource, MaybeCompressedDataReader} from './utils'
|
||||
import {importAsPprofProfile} from './pprof'
|
||||
import {decodeBase64} from '../lib/utils'
|
||||
@@ -131,6 +132,9 @@ async function _importProfileGroup(dataSource: ProfileDataSource): Promise<Profi
|
||||
} else if (fileName.endsWith('.heapprofile')) {
|
||||
console.log('Importing as Chrome Heap Profile')
|
||||
return toGroup(importFromChromeHeapProfile(JSON.parse(contents)))
|
||||
} else if (fileName.endsWith('-recording.json')) {
|
||||
console.log('Importing as Safari profile')
|
||||
return toGroup(importFromSafari(JSON.parse(contents)))
|
||||
}
|
||||
|
||||
// Second pass: Try to guess what file format it is based on structure
|
||||
@@ -169,6 +173,9 @@ async function _importProfileGroup(dataSource: ProfileDataSource): Promise<Profi
|
||||
} else if ('rts_arguments' in parsed && 'initial_capabilities' in parsed) {
|
||||
console.log('Importing as Haskell GHC JSON Profile')
|
||||
return importFromHaskell(parsed)
|
||||
} else if ('recording' in parsed && 'sampleStackTraces' in parsed.recording) {
|
||||
console.log('Importing as Safari profile')
|
||||
return toGroup(importFromSafari(JSON.parse(contents)))
|
||||
}
|
||||
} else {
|
||||
// Format is not JSON
|
||||
|
||||
@@ -633,7 +633,7 @@ export function readInstrumentsKeyedArchive(buffer: ArrayBuffer): any {
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function decodeUTF8(bytes: Uint8Array): string {
|
||||
let text = String.fromCharCode.apply(String, bytes)
|
||||
let text = String.fromCharCode.apply(String, Array.from(bytes))
|
||||
if (text.slice(-1) === '\0') text = text.slice(0, -1) // Remove a single trailing null character if present
|
||||
return decodeURIComponent(escape(text))
|
||||
}
|
||||
@@ -740,7 +740,10 @@ function paternMatchObjectiveC(
|
||||
// Replace NSString with a string
|
||||
case 'NSString':
|
||||
case 'NSMutableString':
|
||||
return decodeUTF8(value['NS.bytes'])
|
||||
if (value['NS.string']) return value['NS.string']
|
||||
if (value['NS.bytes']) return decodeUTF8(value['NS.bytes'])
|
||||
console.warn(`Unexpected ${name} format: `, value)
|
||||
return null
|
||||
|
||||
// Replace NSArray with an Array
|
||||
case 'NSArray':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {checkProfileSnapshot} from '../lib/test-utils'
|
||||
|
||||
describe('importFromLinuxPerf', async () => {
|
||||
describe('importFromLinuxPerf', () => {
|
||||
test('simple.linux-perf.txt', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/linux-perf/simple.linux-perf.txt')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {checkProfileSnapshot} from '../lib/test-utils'
|
||||
|
||||
test('importFromSafari', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/Safari/13.1/simple.html-recording.json')
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
|
||||
import {TimeFormatter} from '../lib/value-formatters'
|
||||
|
||||
interface Record {
|
||||
type: string
|
||||
eventType?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
// timeline-record-type-cpu
|
||||
timestamp?: number
|
||||
usage?: number
|
||||
threads?: any[]
|
||||
// timeline-record-type-script
|
||||
details?: number | string | any
|
||||
extraDetails?: null | any
|
||||
// timeline-record-type-network
|
||||
archiveStartTime?: number
|
||||
entry?: any
|
||||
// timeline-record-type-layout
|
||||
quad?: number[]
|
||||
}
|
||||
|
||||
interface ExprLocation {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
interface StackFrame {
|
||||
sourceID: string
|
||||
name: string
|
||||
line: number
|
||||
column: number
|
||||
url: string
|
||||
expressionLocation?: ExprLocation
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
timestamp: number
|
||||
stackFrames: StackFrame[]
|
||||
}
|
||||
|
||||
interface Recording {
|
||||
displayName: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
discontinuities: any[]
|
||||
instrumentTypes: string[]
|
||||
records: Record[]
|
||||
markers: any[]
|
||||
memoryPressureEvents: any[]
|
||||
sampleStackTraces: Sample[]
|
||||
sampleDurations: number[]
|
||||
}
|
||||
|
||||
interface Overview {
|
||||
secondsPerPixel: number
|
||||
scrollStartTime: number
|
||||
selectionStartTime: number
|
||||
selectionDuration: number
|
||||
}
|
||||
|
||||
interface SafariProfile {
|
||||
version: number
|
||||
recording: Recording
|
||||
overview: Overview
|
||||
}
|
||||
|
||||
function makeStack(frames: StackFrame[]): FrameInfo[] {
|
||||
return frames
|
||||
.map(({name, url, line, column}) => ({
|
||||
key: `${name}:${url}:${line}:${column}`,
|
||||
file: url,
|
||||
line,
|
||||
col: column,
|
||||
name: name || '(anonymous)',
|
||||
}))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
export function importFromSafari(contents: SafariProfile): Profile | null {
|
||||
if (contents.version !== 1) {
|
||||
console.warn(`Unknown Safari profile version ${contents.version}... Might be incompatible.`)
|
||||
}
|
||||
|
||||
const {recording} = contents
|
||||
const {sampleStackTraces, sampleDurations} = recording
|
||||
|
||||
const count = sampleStackTraces.length
|
||||
if (count < 1) {
|
||||
console.warn('Empty profile')
|
||||
return null
|
||||
}
|
||||
|
||||
const profileDuration =
|
||||
sampleStackTraces[count - 1].timestamp - sampleStackTraces[0].timestamp + sampleDurations[0]
|
||||
const profile = new StackListProfileBuilder(profileDuration)
|
||||
|
||||
let previousEndTime = Number.MAX_VALUE
|
||||
|
||||
sampleStackTraces.forEach((sample, i) => {
|
||||
const endTime = sample.timestamp
|
||||
const duration = sampleDurations[i]
|
||||
const startTime = endTime - duration
|
||||
const idleDurationBefore = startTime - previousEndTime
|
||||
|
||||
// FIXME: 2ms is a lot, but Safari's timestamps and durations don't line up very well and will create
|
||||
// phantom idle time
|
||||
if (idleDurationBefore > 0.002) {
|
||||
profile.appendSampleWithWeight([], idleDurationBefore)
|
||||
}
|
||||
|
||||
profile.appendSampleWithWeight(makeStack(sample.stackFrames), duration)
|
||||
|
||||
previousEndTime = endTime
|
||||
})
|
||||
|
||||
profile.setValueFormatter(new TimeFormatter('seconds'))
|
||||
profile.setName(recording.displayName)
|
||||
return profile.build()
|
||||
}
|
||||
@@ -27,3 +27,7 @@ test('importTraceEvents partial json import whitespace padding', async () => {
|
||||
test('importTraceEvents bad E events', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/trace-event/too-many-end-events.json')
|
||||
})
|
||||
|
||||
test('importTraceEvents event re-ordering', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/trace-event/must-retain-original-order.json')
|
||||
})
|
||||
|
||||
+20
-17
@@ -157,27 +157,30 @@ function eventListToProfileGroup(events: TraceEvent[]): ProfileGroup {
|
||||
durationEvents.sort((a, b) => {
|
||||
if (a.ts < b.ts) return -1
|
||||
if (a.ts > b.ts) return 1
|
||||
if (a.pid < b.pid) return -1
|
||||
if (a.pid > b.pid) return 1
|
||||
if (a.tid < b.tid) return -1
|
||||
if (a.tid > b.tid) return 1
|
||||
|
||||
// We have to be careful with events that have the same timestamp
|
||||
if (a.pid === b.pid && a.tid === b.tid) {
|
||||
const aKey = keyForEvent(a)
|
||||
const bKey = keyForEvent(b)
|
||||
|
||||
if (aKey === bKey) {
|
||||
// If the two elements have the same key, we need to process the begin
|
||||
// event before the end event. This will be a zero-duration event.
|
||||
if (a.ph === 'B' && b.ph === 'E') return -1
|
||||
if (a.ph === 'E' && b.ph === 'B') return 1
|
||||
} else {
|
||||
// If the two elements have *different* keys, we want to process
|
||||
// the end of an event before the beginning of the event to prevent
|
||||
// out-of-order push/pops from the callstack.
|
||||
if (a.ph === 'B' && b.ph === 'E') return 1
|
||||
if (a.ph === 'E' && b.ph === 'B') return -1
|
||||
}
|
||||
// and the same pid/tid
|
||||
const aKey = keyForEvent(a)
|
||||
const bKey = keyForEvent(b)
|
||||
if (aKey === bKey) {
|
||||
// If the two elements have the same key, we need to process the begin
|
||||
// event before the end event. This will be a zero-duration event.
|
||||
if (a.ph === 'B' && b.ph === 'E') return -1
|
||||
if (a.ph === 'E' && b.ph === 'B') return 1
|
||||
} else {
|
||||
// If the two elements have *different* keys, we want to process
|
||||
// the end of an event before the beginning of the event to prevent
|
||||
// out-of-order push/pops from the call-stack.
|
||||
if (a.ph === 'B' && b.ph === 'E') return 1
|
||||
if (a.ph === 'E' && b.ph === 'B') return -1
|
||||
}
|
||||
|
||||
return -1
|
||||
// In all other cases, retain the original sort order.
|
||||
return 0
|
||||
})
|
||||
|
||||
if (durationEvents.length > 0) {
|
||||
|
||||
+20
-5
@@ -51,15 +51,30 @@ export class MaybeCompressedDataReader implements ProfileDataSource {
|
||||
|
||||
async readAsText(): Promise<string> {
|
||||
const buffer = await this.readAsArrayBuffer()
|
||||
let ret: string = ''
|
||||
|
||||
// By default, we assume the file is utf-8 encoded.
|
||||
let encoding = 'utf-8'
|
||||
|
||||
const array = new Uint8Array(buffer)
|
||||
if (array.length > 2) {
|
||||
if (array[0] === 0xff && array[1] === 0xfe) {
|
||||
// UTF-16, Little Endian encoding
|
||||
encoding = 'utf-16le'
|
||||
} else if (array[0] === 0xfe && array[1] === 0xff) {
|
||||
// UTF-16, Big Endian encoding
|
||||
encoding = 'utf-16be'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
const decoder = new TextDecoder()
|
||||
const decoder = new TextDecoder(encoding)
|
||||
return decoder.decode(buffer)
|
||||
} else {
|
||||
// JavaScript strings are UTF-16 encoded, but we're reading data
|
||||
// from disk that we're going to asusme is UTF-8 encoded.
|
||||
const array = new Uint8Array(buffer)
|
||||
// JavaScript strings are UTF-16 encoded, but we're reading data from disk
|
||||
// that we're going to blindly assume it's ASCII encoded. This codepath
|
||||
// only exists for older browser support.
|
||||
console.warn('This browser does not support TextDecoder. Decoding text as ASCII.')
|
||||
let ret: string = ''
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
ret += String.fromCharCode(array[i])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Versions of node before 10 had an unstable sort. This isn't really an issue in browsers
|
||||
// that speedscope supports, but for the purposes of supporting node 10, we'll polyfill
|
||||
// a stable sort to make the tests pass.
|
||||
//
|
||||
// See:
|
||||
// - https://v8.dev/features/stable-sort
|
||||
// - https://v8.dev/blog/array-sort
|
||||
// - https://github.com/jlfwong/speedscope/pull/254#issuecomment-575116995
|
||||
//
|
||||
// Once we stop supporting node 10, this can be removed.
|
||||
//
|
||||
// An alternative would be to change our sort implementation to be stable by definition
|
||||
// rather than relying upon native sort being stable. I don't want to do that because
|
||||
// we'd take a perf hit.
|
||||
//
|
||||
// Because we're not going to use this in our actual build, it's okay for this
|
||||
// to be inefficient.
|
||||
|
||||
;(function () {
|
||||
const nodeVersion = process.versions.node
|
||||
const versionParts = nodeVersion.split('.')
|
||||
const majorVersion = parseInt(versionParts[0], 10)
|
||||
|
||||
if (majorVersion > 10) {
|
||||
// Don't need to do the patch for newer node versions
|
||||
return
|
||||
}
|
||||
|
||||
const defaultCompareFunction = (a, b) => {
|
||||
const sa = '' + a
|
||||
const sb = '' + b
|
||||
if (sa < sb) return -1
|
||||
if (sa > sb) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const originalSort = Array.prototype.sort
|
||||
Array.prototype.sort = function (compareFunction) {
|
||||
const arrayWithIndices = this.map((x, i) => [x, i])
|
||||
originalSort.call(arrayWithIndices, (a, b) => {
|
||||
if (!compareFunction) {
|
||||
compareFunction = defaultCompareFunction
|
||||
}
|
||||
const res = compareFunction(a[0], b[0])
|
||||
if (res !== 0) return res
|
||||
return a[1] < b[1] ? -1 : 1
|
||||
})
|
||||
this.splice(0, this.length, ...arrayWithIndices.map(x => x[0]))
|
||||
return this
|
||||
}
|
||||
})()
|
||||
;(function () {
|
||||
// TextDecoder is a global API in browsers, but an imported API in node.
|
||||
//
|
||||
// Let's emulate it being a global API during tests.
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
})()
|
||||
@@ -211,3 +211,7 @@ Object {
|
||||
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: indexToView 1`] = `1`;
|
||||
|
||||
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: profileGroup.name 1`] = `"Two Samples"`;
|
||||
|
||||
exports[`importSpeedscopeProfiles invalid due to incomplete trace 1`] = `"Tried to complete profile construction with a non-empty stack"`;
|
||||
|
||||
exports[`importSpeedscopeProfiles invalid due to out of order events 1`] = `"Tried to leave frame \\"B\\" while frame \\"A\\" was at the top at 4"`;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// This file contains a collection of classes which make it easier to perform
|
||||
// batch rendering of Canvas2D primitives. The advantage of this over just doing
|
||||
// ctx.beginPath() ... ctx.rect(...) ... ctx.endPath() is that you can construct
|
||||
// several different batch renderers are the same time, then decide on their
|
||||
// paint order at the end.
|
||||
//
|
||||
// See FlamechartPanZoomView.renderOverlays for an example of how this is used.
|
||||
|
||||
export interface TextArgs {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export class BatchCanvasTextRenderer {
|
||||
private argsBatch: TextArgs[] = []
|
||||
|
||||
text(args: TextArgs) {
|
||||
this.argsBatch.push(args)
|
||||
}
|
||||
|
||||
fill(ctx: CanvasRenderingContext2D, color: string) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.fillStyle = color
|
||||
for (let args of this.argsBatch) {
|
||||
ctx.fillText(args.text, args.x, args.y)
|
||||
}
|
||||
this.argsBatch = []
|
||||
}
|
||||
}
|
||||
|
||||
export interface RectArgs {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export class BatchCanvasRectRenderer {
|
||||
private argsBatch: RectArgs[] = []
|
||||
|
||||
rect(args: RectArgs) {
|
||||
this.argsBatch.push(args)
|
||||
}
|
||||
|
||||
private drawPath(ctx: CanvasRenderingContext2D) {
|
||||
ctx.beginPath()
|
||||
for (let args of this.argsBatch) {
|
||||
ctx.rect(args.x, args.y, args.w, args.h)
|
||||
}
|
||||
ctx.closePath()
|
||||
this.argsBatch = []
|
||||
}
|
||||
|
||||
fill(ctx: CanvasRenderingContext2D, color: string) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.fillStyle = color
|
||||
this.drawPath(ctx)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
stroke(ctx: CanvasRenderingContext2D, color: string, lineWidth: number) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = lineWidth
|
||||
this.drawPath(ctx)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -15,19 +15,19 @@ export class Color {
|
||||
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_luma/chroma/hue
|
||||
|
||||
const hPrime = H / 60
|
||||
const X = C * (1 - Math.abs(hPrime % 2 - 1))
|
||||
const X = C * (1 - Math.abs((hPrime % 2) - 1))
|
||||
const [R1, G1, B1] =
|
||||
hPrime < 1
|
||||
? [C, X, 0]
|
||||
: hPrime < 2
|
||||
? [X, C, 0]
|
||||
: hPrime < 3
|
||||
? [0, C, X]
|
||||
: hPrime < 4
|
||||
? [0, X, C]
|
||||
: hPrime < 5
|
||||
? [X, 0, C]
|
||||
: [C, 0, X]
|
||||
? [X, C, 0]
|
||||
: hPrime < 3
|
||||
? [0, C, X]
|
||||
: hPrime < 4
|
||||
? [0, X, C]
|
||||
: hPrime < 5
|
||||
? [X, 0, C]
|
||||
: [C, 0, X]
|
||||
|
||||
const m = L - (0.3 * R1 + 0.59 * G1 + 0.11 * B1)
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@ test('importEmscriptenSymbolMap', () => {
|
||||
'c:C',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C']]))
|
||||
).toEqual(
|
||||
new Map([
|
||||
['a', 'A'],
|
||||
['b', 'B'],
|
||||
['c', 'C'],
|
||||
]),
|
||||
)
|
||||
|
||||
// Valid symbol map with trailing newline
|
||||
expect(
|
||||
@@ -25,7 +31,14 @@ test('importEmscriptenSymbolMap', () => {
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C'], ['d', 'D-D']]))
|
||||
).toEqual(
|
||||
new Map([
|
||||
['a', 'A'],
|
||||
['b', 'B'],
|
||||
['c', 'C'],
|
||||
['d', 'D-D'],
|
||||
]),
|
||||
)
|
||||
|
||||
// Valid symbol map with non-alpha characters
|
||||
expect(importEmscriptenSymbolMap('u6:__ZN8tinyxml210XMLCommentD0Ev\n')).toEqual(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {checkProfileSnapshot} from './test-utils'
|
||||
import {checkProfileSnapshot, expectImportFailure} from './test-utils'
|
||||
|
||||
describe('importSpeedscopeProfiles', async () => {
|
||||
describe('importSpeedscopeProfiles', () => {
|
||||
test('0.0.1 evented profile', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/speedscope/0.0.1/simple.speedscope.json')
|
||||
})
|
||||
@@ -12,4 +12,13 @@ describe('importSpeedscopeProfiles', async () => {
|
||||
test('0.6.0 multiple profiles', async () => {
|
||||
await checkProfileSnapshot('./sample/profiles/speedscope/0.6.0/two-sampled.speedscope.json')
|
||||
})
|
||||
|
||||
test('invalid due to out of order events', async () => {
|
||||
// See: https://github.com/jlfwong/speedscope/issues/272
|
||||
await expectImportFailure('./sample/profiles/speedscope/invalid/out-of-order-events.json')
|
||||
})
|
||||
|
||||
test('invalid due to incomplete trace', async () => {
|
||||
await expectImportFailure('./sample/profiles/speedscope/invalid/incomplete-trace.json')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,7 +138,10 @@ function importSpeedscopeProfile(
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const stack = samples[i]
|
||||
const weight = weights[i]
|
||||
profile.appendSampleWithWeight(stack.map(n => frameInfos[n]), weight)
|
||||
profile.appendSampleWithWeight(
|
||||
stack.map(n => frameInfos[n]),
|
||||
weight,
|
||||
)
|
||||
}
|
||||
|
||||
return profile.build()
|
||||
|
||||
+22
-1
@@ -1,7 +1,7 @@
|
||||
import {Frame, CallTreeNode} from './profile'
|
||||
|
||||
import {lastOf} from './utils'
|
||||
import {clamp} from './math'
|
||||
import {clamp, Rect, Vec2} from './math'
|
||||
|
||||
export interface FlamechartFrame {
|
||||
node: CallTreeNode
|
||||
@@ -90,6 +90,27 @@ export class Flamechart {
|
||||
return clamp(viewportWidth, minWidth, maxWidth)
|
||||
}
|
||||
|
||||
// Given a desired config-space viewport rectangle, clamp the rectangle so
|
||||
// that it fits within the given flamechart. This prevents the viewport from
|
||||
// extending past the bounds of the flamechart or zooming in too far.
|
||||
getClampedConfigSpaceViewportRect({
|
||||
configSpaceViewportRect,
|
||||
renderInverted,
|
||||
}: {
|
||||
configSpaceViewportRect: Rect
|
||||
renderInverted?: boolean
|
||||
}) {
|
||||
const configSpaceSize = new Vec2(this.getTotalWeight(), this.getLayers().length)
|
||||
const width = this.getClampedViewportWidth(configSpaceViewportRect.size.x)
|
||||
const size = configSpaceViewportRect.size.withX(width)
|
||||
const origin = Vec2.clamp(
|
||||
configSpaceViewportRect.origin,
|
||||
new Vec2(0, renderInverted ? 0 : -1),
|
||||
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
|
||||
)
|
||||
return new Rect(origin, configSpaceViewportRect.size.withX(width))
|
||||
}
|
||||
|
||||
constructor(private source: FlamechartDataSource) {
|
||||
const stack: FlamechartFrame[] = []
|
||||
const openFrame = (node: CallTreeNode, value: number) => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {fuzzyMatchStrings} from './fuzzy-find'
|
||||
import {sortBy} from './utils'
|
||||
|
||||
function assertMatches(texts: string[], pattern: string, expectedResults: string[]) {
|
||||
const results: {score: number; highlighted: string}[] = []
|
||||
|
||||
for (let text of texts) {
|
||||
const match = fuzzyMatchStrings(text, pattern)
|
||||
if (match == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
let highlighted = ''
|
||||
let last = 0
|
||||
for (let range of match.matchedRanges) {
|
||||
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
|
||||
last = range[1]
|
||||
}
|
||||
highlighted += text.slice(last)
|
||||
|
||||
results.push({score: match.score, highlighted})
|
||||
}
|
||||
|
||||
// Sort scores in descending order
|
||||
sortBy(results, r => -r.score)
|
||||
expect(results.map(r => r.highlighted)).toEqual(expectedResults)
|
||||
}
|
||||
|
||||
function assertMatch(text: string, pattern: string, expected: string) {
|
||||
assertMatches([text], pattern, [expected])
|
||||
}
|
||||
|
||||
function assertNoMatch(text: string, pattern: string) {
|
||||
assertMatches([text], pattern, [])
|
||||
}
|
||||
|
||||
describe('fuzzyMatchStrings', () => {
|
||||
test('no match', () => {
|
||||
assertNoMatch('a', 'b')
|
||||
assertNoMatch('aa', 'ab')
|
||||
assertNoMatch('a', 'aa')
|
||||
assertNoMatch('ca', 'ac')
|
||||
})
|
||||
|
||||
test('full text match', () => {
|
||||
assertMatch('hello', 'hello', '[hello]')
|
||||
assertMatch('multiple words', 'multiple words', '[multiple words]')
|
||||
})
|
||||
|
||||
test('case sensitivity', () => {
|
||||
assertMatch('HELLO', 'hello', '[HELLO]')
|
||||
assertMatch('Hello', 'hello', '[Hello]')
|
||||
assertNoMatch('hello', 'Hello')
|
||||
assertNoMatch('hello', 'HELLO')
|
||||
})
|
||||
|
||||
test('multiple occurrences', () => {
|
||||
assertMatch('hello hello', 'hello', '[hello] hello')
|
||||
assertMatch('hellohello', 'hello', '[hello]hello')
|
||||
})
|
||||
|
||||
test('prefer earlier matches', () => {
|
||||
assertMatches(['cab', 'ab'], 'ab', ['[ab]', 'c[ab]'])
|
||||
})
|
||||
|
||||
test('prefer shorter matches', () => {
|
||||
assertMatches(['abbc', 'abc', 'abbbc'], 'ac', ['[a]b[c]', '[a]bb[c]', '[a]bbb[c]'])
|
||||
})
|
||||
|
||||
test('prefer word boundaries', () => {
|
||||
assertMatches(['abc', 'a c'], 'ac', ['[a] [c]', '[a]b[c]'])
|
||||
})
|
||||
|
||||
test('prefer camelCase matches', () => {
|
||||
assertMatches(['downtown', 'OutNode'], 'n', ['Out[N]ode', 'dow[n]town'])
|
||||
})
|
||||
|
||||
test('prefer number prefix matches', () => {
|
||||
assertMatches(['211', 'a123'], '1', ['a[1]23', '2[1]1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* This file contains an implementation of fuzzy string matching.
|
||||
*/
|
||||
|
||||
export interface FuzzyMatch {
|
||||
// List of [start, end] indices in the haystack string that match the needle string
|
||||
matchedRanges: [number, number][]
|
||||
|
||||
// The score of the match for relative ranking. Higher scores indicate
|
||||
// "better" matches.
|
||||
score: number
|
||||
}
|
||||
|
||||
export function fuzzyMatchStrings(text: string, pattern: string): FuzzyMatch | null {
|
||||
return fzfFuzzyMatchV1(text, pattern)
|
||||
}
|
||||
|
||||
// The implementation here is based on FuzzyMatchV1, as described here:
|
||||
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L8-L15
|
||||
//
|
||||
// This is a hand-port to better understand what the code is doing and for added
|
||||
// clarity.
|
||||
//
|
||||
// Capitalized letters only match capitalized letters, but lower-case letters
|
||||
// match both.
|
||||
//
|
||||
// Note: fzf includes a normalization table for homoglyphs. I'm going to ignore that too
|
||||
// https://github.com/junegunn/fzf/blob/master/src/algo/normalize.go
|
||||
const charCodeLowerA = 'a'.charCodeAt(0)
|
||||
const charCodeLowerZ = 'z'.charCodeAt(0)
|
||||
const charCodeUpperA = 'A'.charCodeAt(0)
|
||||
const charCodeUpperZ = 'Z'.charCodeAt(0)
|
||||
const charCodeDigit0 = '0'.charCodeAt(0)
|
||||
const charCodeDigit9 = '9'.charCodeAt(0)
|
||||
|
||||
enum fzfCharClass {
|
||||
charNonWord,
|
||||
charLower,
|
||||
charUpper,
|
||||
charNumber,
|
||||
}
|
||||
|
||||
function fzfCharClassOf(char: string): fzfCharClass {
|
||||
const code = char.charCodeAt(0)
|
||||
if (charCodeLowerA <= code && code <= charCodeLowerZ) {
|
||||
return fzfCharClass.charLower
|
||||
} else if (charCodeUpperA <= code && code <= charCodeUpperZ) {
|
||||
return fzfCharClass.charUpper
|
||||
} else if (charCodeDigit0 <= code && code <= charCodeDigit9) {
|
||||
return fzfCharClass.charNumber
|
||||
}
|
||||
return fzfCharClass.charNonWord
|
||||
}
|
||||
|
||||
function charsMatch(textChar: string, patternChar: string): boolean {
|
||||
if (textChar === patternChar) return true
|
||||
|
||||
const patternCharCode = patternChar.charCodeAt(0)
|
||||
if (charCodeLowerA <= patternCharCode && patternCharCode <= charCodeLowerZ) {
|
||||
return textChar.charCodeAt(0) === patternCharCode - charCodeLowerA + charCodeUpperA
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function fzfFuzzyMatchV1(text: string, pattern: string): FuzzyMatch | null {
|
||||
if (pattern.length == 0) {
|
||||
return {matchedRanges: [], score: 0}
|
||||
}
|
||||
|
||||
// I removed the fzfAsciiFuzzyIndex code because it's not actually clear to
|
||||
// me that it's a very helpful optimization.
|
||||
|
||||
let pidx = 0
|
||||
let sidx = -1
|
||||
let eidx = -1
|
||||
|
||||
let lenRunes = text.length
|
||||
let lenPattern = pattern.length
|
||||
|
||||
// Forward pass: scan over the text pattern, identifying the earliest start
|
||||
// and the latest end to consider.
|
||||
for (let index = 0; index < lenRunes; index++) {
|
||||
let char = text[index]
|
||||
let pchar = pattern[pidx]
|
||||
if (charsMatch(char, pchar)) {
|
||||
if (sidx < 0) {
|
||||
sidx = index
|
||||
}
|
||||
pidx++
|
||||
if (pidx == lenPattern) {
|
||||
// We found the last character in the pattern! eidx is exclusive, so
|
||||
// we'll set it to the current index + 1.
|
||||
eidx = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eidx == -1) {
|
||||
// We couldn't find all the characters in the pattern. No match.
|
||||
return null
|
||||
}
|
||||
|
||||
// Assuming we found all the characters in the pattern, perform the backwards
|
||||
// pass.
|
||||
pidx--
|
||||
for (let index = eidx - 1; index >= sidx; index--) {
|
||||
const char = text[index]
|
||||
const pchar = pattern[pidx]
|
||||
if (charsMatch(char, pchar)) {
|
||||
pidx--
|
||||
if (pidx < 0) {
|
||||
// We found the first character of the pattern, scanning
|
||||
// backwards. This *may* have narrowed the match further.
|
||||
// For example, for the following inputs:
|
||||
//
|
||||
// text = "xxx a b c abc xxx"
|
||||
// pattern = "abc"
|
||||
//
|
||||
// For the forward pass, you get:
|
||||
//
|
||||
// "xxx a b c abc xxx"
|
||||
// start^ ^end
|
||||
//
|
||||
// But after the backward pass, we can narrow this to:
|
||||
//
|
||||
// "xxx a b c abc xxx"
|
||||
// start^ ^end
|
||||
sidx = index
|
||||
return fzfCalculateScore(text, pattern, sidx, eidx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should be unreachable.
|
||||
throw new Error('Implementation error. This must be a bug in fzfFuzzyMatchV1')
|
||||
}
|
||||
|
||||
const fzfScoreMatch = 16
|
||||
const fzfScoreGapStart = -3
|
||||
const fzfScoreGapExtension = -1
|
||||
const fzfBonusBoundary = fzfScoreMatch / 2
|
||||
const fzfBonusNonWord = fzfScoreMatch / 2
|
||||
const fzfBonusCamel123 = fzfBonusBoundary + fzfScoreGapExtension
|
||||
const fzfBonusConsecutive = -(fzfScoreGapStart + fzfScoreGapExtension)
|
||||
const fzfBonusFirstCharMultiplier = 2
|
||||
|
||||
function bonusFor(prevClass: fzfCharClass, curClass: fzfCharClass): number {
|
||||
if (prevClass === fzfCharClass.charNonWord && curClass !== fzfCharClass.charNonWord) {
|
||||
// Prefer matching at word boundaries
|
||||
//
|
||||
// This should prefer "a c" over "abc" for a pattern of "ac".
|
||||
return fzfBonusBoundary
|
||||
}
|
||||
|
||||
if (
|
||||
(prevClass === fzfCharClass.charLower && curClass == fzfCharClass.charUpper) ||
|
||||
(prevClass !== fzfCharClass.charNumber && curClass == fzfCharClass.charNumber)
|
||||
) {
|
||||
// Prefer matching at the transition point between lower & upper for camelCase,
|
||||
// and from transition from letter to number for identifiers like letter123.
|
||||
//
|
||||
// This should prefer "OutNode" over "phone" for a pattern of "n",
|
||||
// and "abc123" over "x211" for a pattern of "1".
|
||||
return fzfBonusCamel123
|
||||
}
|
||||
|
||||
if (curClass === fzfCharClass.charNonWord) {
|
||||
return fzfBonusNonWord
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function fzfCalculateScore(text: string, pattern: string, sidx: number, eidx: number): FuzzyMatch {
|
||||
let pidx = 0
|
||||
let score = 0
|
||||
let inGap = false
|
||||
let consecutive = 0
|
||||
let firstBonus = 0
|
||||
let pos: number[] = new Array(pattern.length)
|
||||
let prevClass = fzfCharClass.charNonWord
|
||||
|
||||
if (sidx > 0) {
|
||||
prevClass = fzfCharClassOf(text[sidx - 1])
|
||||
}
|
||||
for (let idx = sidx; idx < eidx; idx++) {
|
||||
let char = text[idx]
|
||||
let curClass = fzfCharClassOf(char)
|
||||
if (charsMatch(char, pattern[pidx])) {
|
||||
pos[pidx] = idx
|
||||
score += fzfScoreMatch
|
||||
let bonus = bonusFor(prevClass, curClass)
|
||||
if (consecutive == 0) {
|
||||
firstBonus = bonus
|
||||
} else {
|
||||
// Break consecutive chunk
|
||||
if (bonus === fzfBonusBoundary) {
|
||||
firstBonus = bonus
|
||||
}
|
||||
bonus = Math.max(bonus, firstBonus, fzfBonusConsecutive)
|
||||
}
|
||||
if (pidx === 0) {
|
||||
score += bonus * fzfBonusFirstCharMultiplier
|
||||
} else {
|
||||
score += bonus
|
||||
}
|
||||
inGap = false
|
||||
consecutive++
|
||||
pidx++
|
||||
} else {
|
||||
if (inGap) {
|
||||
// Penalize gaps (this bonus is negative)
|
||||
score += fzfScoreGapExtension
|
||||
} else {
|
||||
// Penalize the beginning of gaps more harshly
|
||||
score += fzfScoreGapStart
|
||||
}
|
||||
inGap = true
|
||||
consecutive = 0
|
||||
firstBonus = 0
|
||||
}
|
||||
prevClass = curClass
|
||||
}
|
||||
|
||||
if (pidx !== pattern.length) {
|
||||
throw new Error(
|
||||
'fzfCalculateScore should only be called when pattern is found between sidx and eidx',
|
||||
)
|
||||
}
|
||||
|
||||
let matchedRanges: [number, number][] = [[pos[0], pos[0] + 1]]
|
||||
for (let i = 1; i < pos.length; i++) {
|
||||
const curPos = pos[i]
|
||||
const curRange = matchedRanges[matchedRanges.length - 1]
|
||||
if (curRange[1] === curPos) {
|
||||
curRange[1] = curPos + 1
|
||||
} else {
|
||||
matchedRanges.push([curPos, curPos + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
matchedRanges,
|
||||
}
|
||||
}
|
||||
+25
-45
@@ -13,9 +13,10 @@ test('clamp', () => {
|
||||
// Change this to jsc.integer to debug failures more easily
|
||||
let numericType = jsc.number
|
||||
|
||||
const arbitraryVec2 = jsc
|
||||
.record({x: jsc.integer, y: numericType})
|
||||
.smap(v => new Vec2(v.x, v.y), v => v)
|
||||
const arbitraryVec2 = jsc.record({x: jsc.integer, y: numericType}).smap(
|
||||
v => new Vec2(v.x, v.y),
|
||||
v => v,
|
||||
)
|
||||
|
||||
const positiveVec2 = jsc.suchthat(arbitraryVec2, v => v.x > 0 && v.y > 0)
|
||||
|
||||
@@ -28,23 +29,25 @@ const arbitraryTransform = jsc
|
||||
m11: numericType,
|
||||
m12: numericType,
|
||||
})
|
||||
.smap(t => new AffineTransform(t.m00, t.m01, t.m02, t.m10, t.m11, t.m12), t => t)
|
||||
.smap(
|
||||
t => new AffineTransform(t.m00, t.m01, t.m02, t.m10, t.m11, t.m12),
|
||||
t => t,
|
||||
)
|
||||
|
||||
const invertibleTransform = jsc.suchthat(arbitraryTransform, t => t.det() != 0)
|
||||
|
||||
const simpleTransform = jsc.suchthat(
|
||||
jsc
|
||||
.record({scale: arbitraryVec2, translation: arbitraryVec2})
|
||||
.smap(
|
||||
t => AffineTransform.withScale(t.scale).withTranslation(t.translation),
|
||||
t => ({scale: t.getScale(), translation: t.getTranslation()}),
|
||||
),
|
||||
jsc.record({scale: arbitraryVec2, translation: arbitraryVec2}).smap(
|
||||
t => AffineTransform.withScale(t.scale).withTranslation(t.translation),
|
||||
t => ({scale: t.getScale(), translation: t.getTranslation()}),
|
||||
),
|
||||
t => t.det() != 0,
|
||||
)
|
||||
|
||||
const arbitraryRect = jsc
|
||||
.record({origin: arbitraryVec2, size: positiveVec2})
|
||||
.smap(r => new Rect(r.origin, r.size), r => r)
|
||||
const arbitraryRect = jsc.record({origin: arbitraryVec2, size: positiveVec2}).smap(
|
||||
r => new Rect(r.origin, r.size),
|
||||
r => r,
|
||||
)
|
||||
|
||||
describe('Vec2', () => {
|
||||
test('constructor', () => {
|
||||
@@ -263,51 +266,35 @@ describe('AffineTransform', () => {
|
||||
expect(new AffineTransform(0, 0, 0, 0, 0, 0).inverted()).toBe(null)
|
||||
|
||||
jsc.assertForall(invertibleTransform, t => {
|
||||
return t
|
||||
.inverted()!
|
||||
.inverted()!
|
||||
.approxEquals(t)
|
||||
return t.inverted()!.inverted()!.approxEquals(t)
|
||||
})
|
||||
})
|
||||
|
||||
test('translation', () => {
|
||||
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
|
||||
return t
|
||||
.withTranslation(v1)
|
||||
.getTranslation()
|
||||
.equals(v1)
|
||||
return t.withTranslation(v1).getTranslation().equals(v1)
|
||||
})
|
||||
|
||||
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
|
||||
const initialTranslation = t.getTranslation()
|
||||
return t
|
||||
.translatedBy(v1)
|
||||
.getTranslation()
|
||||
.approxEquals(initialTranslation.plus(v1))
|
||||
return t.translatedBy(v1).getTranslation().approxEquals(initialTranslation.plus(v1))
|
||||
})
|
||||
})
|
||||
|
||||
test('scale', () => {
|
||||
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
|
||||
return t
|
||||
.withScale(v1)
|
||||
.getScale()
|
||||
.equals(v1)
|
||||
return t.withScale(v1).getScale().equals(v1)
|
||||
})
|
||||
})
|
||||
|
||||
test('transformVector', () => {
|
||||
// Vector transformation are translation-invariant
|
||||
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
|
||||
return AffineTransform.withTranslation(v1)
|
||||
.transformVector(v2)
|
||||
.approxEquals(v2)
|
||||
return AffineTransform.withTranslation(v1).transformVector(v2).approxEquals(v2)
|
||||
})
|
||||
|
||||
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
|
||||
return AffineTransform.withScale(v1)
|
||||
.transformVector(v2)
|
||||
.approxEquals(v2.timesPointwise(v1))
|
||||
return AffineTransform.withScale(v1).transformVector(v2).approxEquals(v2.timesPointwise(v1))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,15 +306,11 @@ describe('AffineTransform', () => {
|
||||
|
||||
test('transformPosition', () => {
|
||||
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
|
||||
return AffineTransform.withTranslation(v1)
|
||||
.transformPosition(v2)
|
||||
.approxEquals(v2.plus(v1))
|
||||
return AffineTransform.withTranslation(v1).transformPosition(v2).approxEquals(v2.plus(v1))
|
||||
})
|
||||
|
||||
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
|
||||
return AffineTransform.withScale(v1)
|
||||
.transformPosition(v2)
|
||||
.approxEquals(v2.timesPointwise(v1))
|
||||
return AffineTransform.withScale(v1).transformPosition(v2).approxEquals(v2.timesPointwise(v1))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -359,10 +342,7 @@ describe('AffineTransform', () => {
|
||||
|
||||
test('times', () => {
|
||||
jsc.assertForall(invertibleTransform, invertibleTransform, (t1, t2) => {
|
||||
return t1
|
||||
.times(t2)
|
||||
.times(t2.inverted()!)
|
||||
.approxEquals(t1)
|
||||
return t1.times(t2).times(t2.inverted()!).approxEquals(t1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* As of Preact 10.x, they no longer have an officially supported preact-redux library.
|
||||
* It's possible to use react-redux with some hacks, but these hacks cause npm run pack
|
||||
* to error out because of (intentionally) unmet peer dependencies.
|
||||
*
|
||||
* I could stack more hacks to fix this problem, but I'd rather just drop the dependency
|
||||
* and remove the need to do any dependency hacking by writing the very small part of
|
||||
* react-redux that I actually need myself.
|
||||
*/
|
||||
|
||||
import {h} from 'preact'
|
||||
import * as redux from 'redux'
|
||||
import {createContext, ComponentChildren} from 'preact'
|
||||
import {Dispatch, Action} from './typed-redux'
|
||||
import {useState, useContext, useCallback, useLayoutEffect} from 'preact/hooks'
|
||||
|
||||
const PreactRedux = createContext<redux.Store<any> | null>(null)
|
||||
|
||||
interface ProviderProps {
|
||||
store: redux.Store<any>
|
||||
children?: ComponentChildren
|
||||
}
|
||||
|
||||
export function Provider(props: ProviderProps) {
|
||||
return <PreactRedux.Provider value={props.store} children={props.children} />
|
||||
}
|
||||
|
||||
function useStore<T>(): redux.Store<T> {
|
||||
const store = useContext(PreactRedux)
|
||||
if (store == null) {
|
||||
throw new Error('Called useStore when no store exists in context')
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
export function useDispatch(): Dispatch {
|
||||
const store = useStore()
|
||||
return store.dispatch
|
||||
}
|
||||
|
||||
export function useActionCreator<T, U>(
|
||||
creator_: (payload: T) => Action<U>,
|
||||
cacheArgs: any[],
|
||||
): (t: T) => void {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
const creator = useCallback(creator_, cacheArgs)
|
||||
|
||||
return useCallback((t: T) => dispatch(creator(t)), [dispatch, creator])
|
||||
}
|
||||
|
||||
export function useSelector<T, U>(selector_: (t: T) => U, cacheArgs: any[]): U {
|
||||
const store = useStore<T>()
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
const selector = useCallback(selector_, cacheArgs)
|
||||
|
||||
const getValueFromStore = useCallback(() => selector(store.getState()), [store, selector])
|
||||
const [value, setValue] = useState(getValueFromStore)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// We need to setValue here because it's possible something has changed the
|
||||
// value in the store between the useSelector call and layout. In most cases
|
||||
// this should no-op.
|
||||
setValue(getValueFromStore())
|
||||
|
||||
return store.subscribe(() => {
|
||||
setValue(getValueFromStore())
|
||||
})
|
||||
}, [store, getValueFromStore])
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {Profile, Frame, CallTreeNode} from './profile'
|
||||
import {FuzzyMatch, fuzzyMatchStrings} from './fuzzy-find'
|
||||
import {Flamechart, FlamechartFrame} from './flamechart'
|
||||
import {Rect, Vec2} from './math'
|
||||
|
||||
export enum FlamechartType {
|
||||
CHRONO_FLAME_CHART,
|
||||
LEFT_HEAVY_FLAME_GRAPH,
|
||||
}
|
||||
|
||||
// A utility class for storing cached search results to avoid recomputation when
|
||||
// the search results & profile did not change.
|
||||
export class ProfileSearchResults {
|
||||
constructor(readonly profile: Profile, readonly searchQuery: string) {}
|
||||
|
||||
private matches: Map<Frame, FuzzyMatch> | null = null
|
||||
getMatchForFrame(frame: Frame): FuzzyMatch | null {
|
||||
if (!this.matches) {
|
||||
this.matches = new Map()
|
||||
this.profile.forEachFrame(frame => {
|
||||
const match = fuzzyMatchStrings(frame.name, this.searchQuery)
|
||||
if (match == null) return
|
||||
this.matches!.set(frame, match)
|
||||
})
|
||||
}
|
||||
return this.matches.get(frame) || null
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlamechartSearchMatch {
|
||||
configSpaceBounds: Rect
|
||||
node: CallTreeNode
|
||||
}
|
||||
|
||||
interface CachedFlamechartResult {
|
||||
matches: FlamechartSearchMatch[]
|
||||
indexForNode: Map<CallTreeNode, number>
|
||||
}
|
||||
|
||||
export class FlamechartSearchResults {
|
||||
constructor(readonly flamechart: Flamechart, readonly profileResults: ProfileSearchResults) {}
|
||||
|
||||
private matches: CachedFlamechartResult | null = null
|
||||
private getResults(): CachedFlamechartResult {
|
||||
if (this.matches == null) {
|
||||
const matches: FlamechartSearchMatch[] = []
|
||||
const indexForNode = new Map<CallTreeNode, number>()
|
||||
const visit = (frame: FlamechartFrame, depth: number) => {
|
||||
const {node} = frame
|
||||
if (this.profileResults.getMatchForFrame(node.frame)) {
|
||||
const configSpaceBounds = new Rect(
|
||||
new Vec2(frame.start, depth),
|
||||
new Vec2(frame.end - frame.start, 1),
|
||||
)
|
||||
indexForNode.set(node, matches.length)
|
||||
matches.push({configSpaceBounds, node})
|
||||
}
|
||||
|
||||
frame.children.forEach(child => {
|
||||
visit(child, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
const layers = this.flamechart.getLayers()
|
||||
if (layers.length > 0) {
|
||||
layers[0].forEach(frame => visit(frame, 0))
|
||||
}
|
||||
|
||||
this.matches = {matches, indexForNode}
|
||||
}
|
||||
return this.matches
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.getResults().matches.length
|
||||
}
|
||||
|
||||
indexOf(node: CallTreeNode): number | null {
|
||||
const result = this.getResults().indexForNode.get(node)
|
||||
return result === undefined ? null : result
|
||||
}
|
||||
|
||||
at(index: number): FlamechartSearchMatch {
|
||||
const matches = this.getResults().matches
|
||||
if (index < 0 || index >= matches.length) {
|
||||
throw new Error(`Index ${index} out of bounds in list of ${matches.length} matches.`)
|
||||
}
|
||||
return matches[index]
|
||||
}
|
||||
}
|
||||
+49
-46
@@ -26,19 +26,22 @@ function toStackList(profile: Profile, grouped: boolean): string[] {
|
||||
const curStack: (number | string)[] = []
|
||||
let lastValue = 0
|
||||
|
||||
function openFrame(node: CallTreeNode, value: number) {
|
||||
function maybeEmit(value: number) {
|
||||
if (lastValue != value) {
|
||||
stackList.push(curStack.map(k => `${k}`).join(';'))
|
||||
stackList.push(
|
||||
curStack.map(k => `${k}`).join(';') + ` ${profile.formatValue(value - lastValue)}`,
|
||||
)
|
||||
lastValue = value
|
||||
}
|
||||
curStack.push(node.frame.key)
|
||||
}
|
||||
|
||||
function openFrame(node: CallTreeNode, value: number) {
|
||||
maybeEmit(value)
|
||||
curStack.push(node.frame.name)
|
||||
}
|
||||
|
||||
function closeFrame(node: CallTreeNode, value: number) {
|
||||
if (lastValue != value) {
|
||||
stackList.push(curStack.map(k => `${k}`).join(';'))
|
||||
lastValue = value
|
||||
}
|
||||
maybeEmit(value)
|
||||
curStack.pop()
|
||||
}
|
||||
|
||||
@@ -82,49 +85,49 @@ function verifyProfile(profile: Profile) {
|
||||
|
||||
expect(toStackList(profile, false)).toEqual([
|
||||
// prettier-ignore
|
||||
'a',
|
||||
'a;b',
|
||||
'a;b;d',
|
||||
'a;b;c',
|
||||
'',
|
||||
'a',
|
||||
'a;b',
|
||||
'a;b;b',
|
||||
'a;b;e',
|
||||
'a',
|
||||
'a 1',
|
||||
'a;b 2',
|
||||
'a;b;d 1',
|
||||
'a;b;c 1',
|
||||
' 1',
|
||||
'a 1',
|
||||
'a;b 1',
|
||||
'a;b;b 1',
|
||||
'a;b;e 1',
|
||||
'a 1',
|
||||
])
|
||||
|
||||
expect(toStackList(profile, true)).toEqual([
|
||||
// prettier-ignore
|
||||
'a;b;e',
|
||||
'a;b;b',
|
||||
'a;b;c',
|
||||
'a;b;d',
|
||||
'a;b',
|
||||
'a',
|
||||
'a;b;d 1',
|
||||
'a;b;c 1',
|
||||
'a;b;b 1',
|
||||
'a;b;e 1',
|
||||
'a;b 3',
|
||||
'a 3',
|
||||
])
|
||||
|
||||
const flattened = profile.getProfileWithRecursionFlattened()
|
||||
expect(toStackList(flattened, false)).toEqual([
|
||||
// prettier-ignore
|
||||
'a',
|
||||
'a;b',
|
||||
'a;b;d',
|
||||
'a;b;c',
|
||||
'',
|
||||
'a',
|
||||
'a;b',
|
||||
'a;b;e',
|
||||
'a',
|
||||
'a 1',
|
||||
'a;b 2',
|
||||
'a;b;d 1',
|
||||
'a;b;c 1',
|
||||
' 1',
|
||||
'a 1',
|
||||
'a;b 2',
|
||||
'a;b;e 1',
|
||||
'a 1',
|
||||
])
|
||||
|
||||
expect(toStackList(flattened, true)).toEqual([
|
||||
// prettier-ignore
|
||||
'a;b;e',
|
||||
'a;b;c',
|
||||
'a;b;d',
|
||||
'a;b',
|
||||
'a',
|
||||
'a;b;d 1',
|
||||
'a;b;c 1',
|
||||
'a;b;e 1',
|
||||
'a;b 4',
|
||||
'a 3',
|
||||
])
|
||||
}
|
||||
|
||||
@@ -259,9 +262,9 @@ test('getInvertedProfileForCallersOf', () => {
|
||||
|
||||
expect(toStackList(inverted, false)).toEqual([
|
||||
// prettier-ignore
|
||||
'b',
|
||||
'b;a',
|
||||
'b;d',
|
||||
'b 1',
|
||||
'b;a 3',
|
||||
'b;d 1',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -287,10 +290,10 @@ test('getProfileForCalleesOf', () => {
|
||||
|
||||
expect(toStackList(inverted, false)).toEqual([
|
||||
// prettier-ignore
|
||||
'b',
|
||||
'b;c',
|
||||
'b;d',
|
||||
'b',
|
||||
'b 2',
|
||||
'b;c 1',
|
||||
'b;d 1',
|
||||
'b 1',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -313,8 +316,8 @@ test('getProfileWithRecursionFlattened', () => {
|
||||
|
||||
expect(toStackList(inverted, false)).toEqual([
|
||||
// prettier-ignore
|
||||
'a',
|
||||
'a;b',
|
||||
'a 1',
|
||||
'a;b 3',
|
||||
])
|
||||
|
||||
const framesInProfile = new Set<string | number>()
|
||||
|
||||
+33
-14
@@ -116,6 +116,13 @@ export class Profile {
|
||||
|
||||
protected frames = new KeyedSet<Frame>()
|
||||
|
||||
// Profiles store two call-trees.
|
||||
//
|
||||
// The "append order" call tree is the one in which nodes are ordered in
|
||||
// whatever order they were appended to their parent.
|
||||
//
|
||||
// The "grouped" call tree is one in which each node has at most one child per
|
||||
// frame. Nodes are ordered in decreasing order of weight
|
||||
protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
|
||||
protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
|
||||
|
||||
@@ -169,6 +176,17 @@ export class Profile {
|
||||
return this.totalNonIdleWeight
|
||||
}
|
||||
|
||||
// This is private because it should only be called in the ProfileBuilder
|
||||
// classes. Once a Profile instance has been constructed, it should be treated
|
||||
// as immutable.
|
||||
protected sortGroupedCallTree() {
|
||||
function visit(node: CallTreeNode) {
|
||||
node.children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
|
||||
node.children.forEach(visit)
|
||||
}
|
||||
visit(this.groupedCalltreeRoot)
|
||||
}
|
||||
|
||||
forEachCallGrouped(
|
||||
openFrame: (node: CallTreeNode, value: number) => void,
|
||||
closeFrame: (node: CallTreeNode, value: number) => void,
|
||||
@@ -180,10 +198,7 @@ export class Profile {
|
||||
|
||||
let childTime = 0
|
||||
|
||||
const children = [...node.children]
|
||||
children.sort((a, b) => (a.getTotalWeight() > b.getTotalWeight() ? -1 : 1))
|
||||
|
||||
children.forEach(function(child) {
|
||||
node.children.forEach(function (child) {
|
||||
visit(child, start + childTime)
|
||||
childTime += child.getTotalWeight()
|
||||
})
|
||||
@@ -250,12 +265,6 @@ export class Profile {
|
||||
this.frames.forEach(fn)
|
||||
}
|
||||
|
||||
forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
|
||||
for (let i = 0; i < this.samples.length; i++) {
|
||||
fn(this.samples[i], this.weights[i])
|
||||
}
|
||||
}
|
||||
|
||||
getProfileWithRecursionFlattened(): Profile {
|
||||
const builder = new CallTreeProfileBuilder()
|
||||
|
||||
@@ -507,7 +516,11 @@ export class StackListProfileBuilder extends Profile {
|
||||
this.setValueFormatter(new RawValueFormatter())
|
||||
}
|
||||
}
|
||||
this.totalWeight = Math.max(this.totalWeight, this.weights.reduce((a, b) => a + b, 0))
|
||||
this.totalWeight = Math.max(
|
||||
this.totalWeight,
|
||||
this.weights.reduce((a, b) => a + b, 0),
|
||||
)
|
||||
this.sortGroupedCallTree()
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -557,9 +570,7 @@ export class CallTreeProfileBuilder extends Profile {
|
||||
this.weights.push(value - this.lastValue)
|
||||
} else if (delta < 0) {
|
||||
throw new Error(
|
||||
`Samples must be provided in increasing order of cumulative value. Last sample was ${
|
||||
this.lastValue
|
||||
}, this sample was ${value}`,
|
||||
`Samples must be provided in increasing order of cumulative value. Last sample was ${this.lastValue}, this sample was ${value}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -602,6 +613,13 @@ export class CallTreeProfileBuilder extends Profile {
|
||||
throw new Error(`Trying to leave a ${frame.key} before any have been entered`)
|
||||
}
|
||||
leavingStackTop.freeze()
|
||||
|
||||
if (leavingStackTop.frame.key !== frame.key) {
|
||||
throw new Error(
|
||||
`Tried to leave frame "${frame.name}" while frame "${leavingStackTop.frame.name}" was at the top at ${value}`,
|
||||
)
|
||||
}
|
||||
|
||||
const delta = value - this.lastValue
|
||||
if (delta > 0) {
|
||||
this.samples.push(leavingStackTop)
|
||||
@@ -643,6 +661,7 @@ export class CallTreeProfileBuilder extends Profile {
|
||||
if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
|
||||
throw new Error('Tried to complete profile construction with a non-empty stack')
|
||||
}
|
||||
this.sortGroupedCallTree()
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ export class StatsPanel {
|
||||
this.msPanel.update(time - this.beginTime, 200)
|
||||
|
||||
if (time >= this.prevTime + 1000) {
|
||||
this.fpsPanel.update(this.frames * 1000 / (time - this.prevTime), 100)
|
||||
this.fpsPanel.update((this.frames * 1000) / (time - this.prevTime), 100)
|
||||
this.prevTime = time
|
||||
this.frames = 0
|
||||
}
|
||||
|
||||
@@ -97,3 +97,14 @@ export async function checkProfileSnapshot(filepath: string) {
|
||||
const reexported = exportProfileGroup(reimportedGroup)
|
||||
expect(exported).toEqual(reexported)
|
||||
}
|
||||
|
||||
export async function expectImportFailure(filepath: string) {
|
||||
const buffer = fs.readFileSync(filepath)
|
||||
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||
try {
|
||||
await importProfilesFromArrayBuffer(path.basename(filepath), arrayBuffer)
|
||||
fail('Expected import to fail but it succeeded')
|
||||
} catch (error) {
|
||||
expect(error.message).toMatchSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {buildTrimmedText, ELLIPSIS, remapRangesToTrimmedText} from './text-utils'
|
||||
import {fuzzyMatchStrings} from './fuzzy-find'
|
||||
|
||||
function assertTrimmed(text: string, length: number, expectedTrimmed: string) {
|
||||
expect(buildTrimmedText(text, length).trimmedString).toEqual(
|
||||
expectedTrimmed.replace('...', ELLIPSIS),
|
||||
)
|
||||
}
|
||||
|
||||
test('buildTrimmedText', () => {
|
||||
assertTrimmed('hello world', 1, '...')
|
||||
assertTrimmed('hello world', 2, 'h...')
|
||||
assertTrimmed('hello world', 3, 'h...d')
|
||||
assertTrimmed('hello world', 4, 'he...d')
|
||||
assertTrimmed('hello world', 10, 'hello...orld')
|
||||
assertTrimmed('hello world', 11, 'hello world')
|
||||
assertTrimmed('hello world', 100, 'hello world')
|
||||
})
|
||||
|
||||
function highlightText(text: string, highlightedRanges: [number, number][]): string {
|
||||
let last = 0
|
||||
let highlighted = ''
|
||||
for (let range of highlightedRanges) {
|
||||
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
|
||||
last = range[1]
|
||||
}
|
||||
highlighted += text.slice(last)
|
||||
return highlighted
|
||||
}
|
||||
|
||||
function assertTrimmedHighlight({
|
||||
text,
|
||||
pattern,
|
||||
expectedHighlighted,
|
||||
length,
|
||||
expectedHighlightedTrimmed,
|
||||
}: {
|
||||
text: string
|
||||
pattern: string
|
||||
expectedHighlighted: string
|
||||
length: number
|
||||
expectedHighlightedTrimmed: string
|
||||
}) {
|
||||
const match = fuzzyMatchStrings(text, pattern)
|
||||
const trimmed = buildTrimmedText(text, length)
|
||||
|
||||
if (!match) {
|
||||
fail()
|
||||
return
|
||||
}
|
||||
|
||||
const matchedRangesForTrimmedText = remapRangesToTrimmedText(trimmed, match.matchedRanges)
|
||||
const highlighted = highlightText(text, match.matchedRanges)
|
||||
const highlightedTrimmed = highlightText(trimmed.trimmedString, matchedRangesForTrimmedText)
|
||||
|
||||
expect(highlighted).toEqual(expectedHighlighted)
|
||||
expect(highlightedTrimmed).toEqual(expectedHighlightedTrimmed.replace('...', ELLIPSIS))
|
||||
}
|
||||
|
||||
test('remapRangesToTrimmedText', () => {
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'he',
|
||||
length: 4,
|
||||
expectedHighlighted: '[he]llo world',
|
||||
expectedHighlightedTrimmed: `[he]...d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'o w',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hell[o w]orld',
|
||||
expectedHighlightedTrimmed: `he[...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'ow',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hell[o] [w]orld',
|
||||
expectedHighlightedTrimmed: `he[...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'hello',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello] world',
|
||||
expectedHighlightedTrimmed: `[he...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'hello world',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello world]',
|
||||
expectedHighlightedTrimmed: `[he...d]`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'helloworld',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello] [world]',
|
||||
expectedHighlightedTrimmed: `[he...][d]`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'world',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hello [world]',
|
||||
expectedHighlightedTrimmed: `he[...d]`,
|
||||
})
|
||||
})
|
||||
+174
-8
@@ -18,22 +18,188 @@ export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: stri
|
||||
return measureTextCache.get(text)!
|
||||
}
|
||||
|
||||
function buildTrimmedText(text: string, length: number) {
|
||||
const prefixLength = Math.floor(length / 2)
|
||||
const prefix = text.substr(0, prefixLength)
|
||||
const suffix = text.substr(text.length - prefixLength, prefixLength)
|
||||
return prefix + ELLIPSIS + suffix
|
||||
interface TrimmedTextResult {
|
||||
trimmedString: string
|
||||
trimmedLength: number
|
||||
prefixLength: number
|
||||
suffixLength: number
|
||||
originalLength: number
|
||||
originalString: string
|
||||
}
|
||||
|
||||
export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||||
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
|
||||
// Trim text, placing an ellipsis in the middle, with a slight bias towards
|
||||
// keeping text from the beginning rather than the end
|
||||
export function buildTrimmedText(text: string, length: number): TrimmedTextResult {
|
||||
if (text.length <= length) {
|
||||
return {
|
||||
trimmedString: text,
|
||||
trimmedLength: text.length,
|
||||
prefixLength: text.length,
|
||||
suffixLength: 0,
|
||||
originalString: text,
|
||||
originalLength: text.length,
|
||||
}
|
||||
}
|
||||
|
||||
let prefixLength = Math.floor(length / 2)
|
||||
const suffixLength = length - prefixLength - 1
|
||||
const prefix = text.substr(0, prefixLength)
|
||||
const suffix = text.substr(text.length - suffixLength, suffixLength)
|
||||
const trimmedString = prefix + ELLIPSIS + suffix
|
||||
return {
|
||||
trimmedString,
|
||||
trimmedLength: trimmedString.length,
|
||||
prefixLength: prefix.length,
|
||||
suffixLength: suffix.length,
|
||||
originalString: text,
|
||||
originalLength: text.length,
|
||||
}
|
||||
}
|
||||
|
||||
// Trim text to fit within the given number of pixels on the canvas
|
||||
export function trimTextMid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): TrimmedTextResult {
|
||||
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) {
|
||||
return buildTrimmedText(text, text.length)
|
||||
}
|
||||
const [lo] = binarySearch(
|
||||
0,
|
||||
text.length,
|
||||
n => {
|
||||
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
|
||||
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n).trimmedString)
|
||||
},
|
||||
maxWidth,
|
||||
)
|
||||
return buildTrimmedText(text, lo)
|
||||
}
|
||||
|
||||
enum IndexTypeInTrimmed {
|
||||
IN_PREFIX,
|
||||
IN_SUFFIX,
|
||||
ELIDED,
|
||||
}
|
||||
|
||||
function getIndexTypeInTrimmed(result: TrimmedTextResult, index: number): IndexTypeInTrimmed {
|
||||
if (index < result.prefixLength) {
|
||||
return IndexTypeInTrimmed.IN_PREFIX
|
||||
} else if (index < result.originalLength - result.suffixLength) {
|
||||
return IndexTypeInTrimmed.ELIDED
|
||||
} else {
|
||||
return IndexTypeInTrimmed.IN_SUFFIX
|
||||
}
|
||||
}
|
||||
|
||||
export function remapRangesToTrimmedText(
|
||||
trimmedText: TrimmedTextResult,
|
||||
ranges: [number, number][],
|
||||
): [number, number][] {
|
||||
// We intentionally don't just re-run fuzzy matching on the trimmed
|
||||
// text, beacuse if the search query is "helloWorld", the frame name
|
||||
// is "application::helloWorld", and that gets trimmed down to
|
||||
// "appl...oWorld", we still want "oWorld" to be highlighted, even
|
||||
// though the string "appl...oWorld" is not matched by the query
|
||||
// "helloWorld".
|
||||
//
|
||||
// There's a weird case to consider here: what if the trimmedText is
|
||||
// also matched by the query, but results in a different match than
|
||||
// the original query? Consider, e.g. the search string of "ab". The
|
||||
// string "hello ab shabby" will be matched at the first "ab", but
|
||||
// may be trimmed to "hello...shabby". In this case, should we
|
||||
// highlight the "ab" hidden by the ellipsis, or the "ab" in
|
||||
// "shabby"? The code below highlights the ellipsis so that the
|
||||
// matched characters don't change as you zoom in and out.
|
||||
|
||||
const rangesToHighlightInTrimmedText: [number, number][] = []
|
||||
const lengthLoss = trimmedText.originalLength - trimmedText.trimmedLength
|
||||
let highlightedEllipsis = false
|
||||
|
||||
for (let [origStart, origEnd] of ranges) {
|
||||
let startPosType = getIndexTypeInTrimmed(trimmedText, origStart)
|
||||
let endPosType = getIndexTypeInTrimmed(trimmedText, origEnd - 1)
|
||||
|
||||
switch (startPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// The entire range fits in the prefix. Add it unmodified.
|
||||
rangesToHighlightInTrimmedText.push([origStart, origEnd])
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// The range starts in the prefix, but ends in the elided
|
||||
// section. Add just the prefix + one char for the ellipsis.
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
origStart,
|
||||
origStart + trimmedText.prefixLength + 1,
|
||||
])
|
||||
highlightedEllipsis = true
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// The range crosses from the prefix to the suffix.
|
||||
// Highlight everything including the ellipsis.
|
||||
rangesToHighlightInTrimmedText.push([origStart, origEnd - lengthLoss])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in elided and ends in prefix')
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// The match starts & ends within the elided section.
|
||||
if (!highlightedEllipsis) {
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
trimmedText.prefixLength,
|
||||
trimmedText.prefixLength + 1,
|
||||
])
|
||||
highlightedEllipsis = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// The match starts in elided, but ends in suffix.
|
||||
if (highlightedEllipsis) {
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
trimmedText.trimmedLength - trimmedText.suffixLength,
|
||||
origEnd - lengthLoss,
|
||||
])
|
||||
} else {
|
||||
rangesToHighlightInTrimmedText.push([trimmedText.prefixLength, origEnd - lengthLoss])
|
||||
highlightedEllipsis = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in suffix and ends in prefix')
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in suffix and ends in elided')
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// Match starts & ends in suffix
|
||||
rangesToHighlightInTrimmedText.push([origStart - lengthLoss, origEnd - lengthLoss])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return rangesToHighlightInTrimmedText
|
||||
}
|
||||
|
||||
+1
-27
@@ -1,6 +1,5 @@
|
||||
import {connect} from 'preact-redux'
|
||||
import * as redux from 'redux'
|
||||
import {ComponentConstructor, Component} from 'preact'
|
||||
import {Component} from 'preact'
|
||||
|
||||
export interface Action<TPayload> extends redux.Action<string> {
|
||||
payload: TPayload
|
||||
@@ -53,31 +52,6 @@ export function setter<T>(
|
||||
|
||||
export type Dispatch = redux.Dispatch<Action<any>>
|
||||
|
||||
// We make this into a single function invocation instead of the connect(map, map)(Component)
|
||||
// syntax to make better use of type inference.
|
||||
//
|
||||
// NOTE: To avoid this returning objects which do not compare shallow equal, it's the
|
||||
// responsibility of the caller to ensure that the props returned by map compare shallow
|
||||
// equal. This most importantly mean memoizing functions which wrap dispatch to avoid
|
||||
// all callback props from being regenerated on every call.
|
||||
export function createContainer<OwnProps, State, ComponentProps, ComponentType>(
|
||||
component: {
|
||||
new (props: ComponentProps): ComponentType
|
||||
},
|
||||
map: (state: State, dispatch: Dispatch, ownProps: OwnProps) => ComponentProps,
|
||||
): ComponentConstructor<OwnProps, {}> {
|
||||
const mapStateToProps = (state: State) => state
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({dispatch})
|
||||
const mergeProps = (
|
||||
stateProps: State,
|
||||
dispatchProps: {dispatch: Dispatch},
|
||||
ownProps: OwnProps,
|
||||
) => {
|
||||
return map(stateProps, dispatchProps.dispatch, ownProps)
|
||||
}
|
||||
return connect(mapStateToProps, mapDispatchToProps, mergeProps)(component)
|
||||
}
|
||||
|
||||
export type VoidState = {
|
||||
__dummyField: void
|
||||
}
|
||||
|
||||
+15
-13
@@ -4,7 +4,9 @@ export function lastOf<T>(ts: T[]): T | null {
|
||||
|
||||
export function sortBy<T>(ts: T[], key: (t: T) => number | string): void {
|
||||
function comparator(a: T, b: T) {
|
||||
return key(a) < key(b) ? -1 : 1
|
||||
const keyA = key(a)
|
||||
const keyB = key(b)
|
||||
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0
|
||||
}
|
||||
ts.sort(comparator)
|
||||
}
|
||||
@@ -178,15 +180,17 @@ export function lazyStatic<T>(cb: () => T): () => T {
|
||||
}
|
||||
}
|
||||
|
||||
const base64lookupTable = lazyStatic((): Map<string, number> => {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
const ret = new Map<string, number>()
|
||||
for (let i = 0; i < alphabet.length; i++) {
|
||||
ret.set(alphabet.charAt(i), i)
|
||||
}
|
||||
ret.set('=', -1)
|
||||
return ret
|
||||
})
|
||||
const base64lookupTable = lazyStatic(
|
||||
(): Map<string, number> => {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
const ret = new Map<string, number>()
|
||||
for (let i = 0; i < alphabet.length; i++) {
|
||||
ret.set(alphabet.charAt(i), i)
|
||||
}
|
||||
ret.set('=', -1)
|
||||
return ret
|
||||
},
|
||||
)
|
||||
|
||||
// NOTE: There are probably simpler solutions to this problem, but I have this written already, so
|
||||
// until we run into problems with this, let's just use this.
|
||||
@@ -210,9 +214,7 @@ export function decodeBase64(encoded: string): Uint8Array {
|
||||
|
||||
if (encoded.length % 4 !== 0) {
|
||||
throw new Error(
|
||||
`Invalid length for base64 encoded string. Expected length % 4 = 0, got length = ${
|
||||
encoded.length
|
||||
}`,
|
||||
`Invalid length for base64 encoded string. Expected length % 4 = 0, got length = ${encoded.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import {h, render} from 'preact'
|
||||
import {createApplicationStore} from './store'
|
||||
import {Provider} from 'preact-redux'
|
||||
import {createAppStore} from './store'
|
||||
import {ApplicationContainer} from './views/application-container'
|
||||
import {Provider} from './lib/preact-redux'
|
||||
|
||||
console.log(`speedscope v${require('../package.json').version}`)
|
||||
|
||||
@@ -15,7 +15,7 @@ if (module.hot) {
|
||||
}
|
||||
|
||||
const lastStore: any = (window as any)['store']
|
||||
const store = createApplicationStore(lastStore ? lastStore.getState() : {})
|
||||
const store = lastStore ? createAppStore(lastStore.getState()) : createAppStore()
|
||||
;(window as any)['store'] = store
|
||||
|
||||
render(
|
||||
|
||||
+3
-20
@@ -8,40 +8,23 @@ import {HashParams} from '../lib/hash-params'
|
||||
import {actionCreatorWithIndex} from './profiles-state'
|
||||
|
||||
export namespace actions {
|
||||
// Set the top-level profile group from which other data will be derived
|
||||
export const setProfileGroup = actionCreator<ProfileGroup>('setProfileGroup')
|
||||
|
||||
// Set the index into the profile group to view
|
||||
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
|
||||
|
||||
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
|
||||
|
||||
// Set which top-level view should be displayed
|
||||
export const setViewMode = actionCreator<ViewMode>('setViewMode')
|
||||
|
||||
// Set whether or not recursion should be flattened when viewing flamegraphs
|
||||
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
|
||||
|
||||
// Set whether a file drag is currently active. Used to indicate that the
|
||||
// application is a valid drop target.
|
||||
export const setSearchQuery = actionCreator<string>('setSearchQuery')
|
||||
export const setSearchIsActive = actionCreator<boolean>('setSearchIsActive')
|
||||
export const setDragActive = actionCreator<boolean>('setDragActive')
|
||||
|
||||
// Set whether the application is currently in a loading state. Used to
|
||||
// display a loading progress bar.
|
||||
export const setLoading = actionCreator<boolean>('setLoading')
|
||||
|
||||
// Set whether the application is in an errored state.
|
||||
export const setError = actionCreator<boolean>('setError')
|
||||
|
||||
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
|
||||
export const setHashParams = actionCreator<HashParams>('setHashParams')
|
||||
|
||||
export namespace sandwichView {
|
||||
// Set the table sorting method used for the sandwich view.
|
||||
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
|
||||
|
||||
export const setSelectedFrame = actionCreatorWithIndex<Frame | null>(
|
||||
'sandwichView.setSelectedFarmr',
|
||||
'sandwichView.setSelectedFrame',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+18
-23
@@ -40,32 +40,27 @@ export const getRowAtlas = memoizeByReference((canvasContext: CanvasContext) =>
|
||||
)
|
||||
})
|
||||
|
||||
export const getProfileWithRecursionFlattened = memoizeByReference((profile: Profile) =>
|
||||
profile.getProfileWithRecursionFlattened(),
|
||||
)
|
||||
|
||||
export const getProfileToView = memoizeByShallowEquality(
|
||||
({profile, flattenRecursion}: {profile: Profile; flattenRecursion: boolean}): Profile => {
|
||||
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
|
||||
},
|
||||
)
|
||||
export const getFrameToColorBucket = memoizeByReference((profile: Profile): Map<
|
||||
string | number,
|
||||
number
|
||||
> => {
|
||||
const frames: Frame[] = []
|
||||
profile.forEachFrame(f => frames.push(f))
|
||||
function key(f: Frame) {
|
||||
return (f.file || '') + f.name
|
||||
}
|
||||
function compare(a: Frame, b: Frame) {
|
||||
return key(a) > key(b) ? 1 : -1
|
||||
}
|
||||
frames.sort(compare)
|
||||
const frameToColorBucket = new Map<string | number, number>()
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
frameToColorBucket.set(frames[i].key, Math.floor(255 * i / frames.length))
|
||||
}
|
||||
export const getFrameToColorBucket = memoizeByReference(
|
||||
(profile: Profile): Map<string | number, number> => {
|
||||
const frames: Frame[] = []
|
||||
profile.forEachFrame(f => frames.push(f))
|
||||
function key(f: Frame) {
|
||||
return (f.file || '') + f.name
|
||||
}
|
||||
function compare(a: Frame, b: Frame) {
|
||||
return key(a) > key(b) ? 1 : -1
|
||||
}
|
||||
frames.sort(compare)
|
||||
const frameToColorBucket = new Map<string | number, number>()
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
frameToColorBucket.set(frames[i].key, Math.floor((255 * i) / frames.length))
|
||||
}
|
||||
|
||||
return frameToColorBucket
|
||||
})
|
||||
return frameToColorBucket
|
||||
},
|
||||
)
|
||||
|
||||
+67
-6
@@ -10,6 +10,11 @@ import {setter, Reducer} from '../lib/typed-redux'
|
||||
import {HashParams, getHashParams} from '../lib/hash-params'
|
||||
import {ProfileGroupState, profileGroup} from './profiles-state'
|
||||
import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
|
||||
import {useSelector} from '../lib/preact-redux'
|
||||
import {Profile} from '../lib/profile'
|
||||
import {FlamechartViewState} from './flamechart-view-state'
|
||||
import {SandwichViewState} from './sandwich-view-state'
|
||||
import {getProfileToView} from './getters'
|
||||
|
||||
export const enum ViewMode {
|
||||
CHRONO_FLAME_CHART,
|
||||
@@ -18,20 +23,43 @@ export const enum ViewMode {
|
||||
}
|
||||
|
||||
export interface ApplicationState {
|
||||
// The top-level profile group from which most other data will be derived
|
||||
profileGroup: ProfileGroupState
|
||||
|
||||
// Parameters defined by the URL encoded k=v pairs after the # in the URL
|
||||
hashParams: HashParams
|
||||
|
||||
glCanvas: HTMLCanvasElement | null
|
||||
|
||||
// Which top-level view should be displayed
|
||||
viewMode: ViewMode
|
||||
|
||||
// True if recursion should be flattened when viewing flamegraphs
|
||||
flattenRecursion: boolean
|
||||
|
||||
viewMode: ViewMode
|
||||
// The query used in top-level views
|
||||
//
|
||||
// An empty string indicates that the search is open by no filter is applied.
|
||||
// searchIsActive is stored separately, because we may choose to persist the
|
||||
// query even when the search input is closed.
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
|
||||
// True when a file drag is currently active. Used to indicate that the
|
||||
// application is a valid drop target.
|
||||
dragActive: boolean
|
||||
|
||||
// True when the application is currently in a loading state. Used to
|
||||
// display a loading progress bar.
|
||||
loading: boolean
|
||||
|
||||
// True when the application is an error state, e.g. because the profile
|
||||
// imported was invalid.
|
||||
error: boolean
|
||||
|
||||
// The table sorting method using for the sandwich view, specifying the column
|
||||
// to sort by, and the direction to sort that clumn.
|
||||
tableSortMethod: SortMethod
|
||||
|
||||
profileGroup: ProfileGroupState
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol
|
||||
@@ -41,9 +69,7 @@ const protocol = window.location.protocol
|
||||
// however, XHR will be unavailable to fetching files in adjacent directories.
|
||||
export const canUseXHR = protocol === 'http:' || protocol === 'https:'
|
||||
|
||||
export function createApplicationStore(
|
||||
initialState: Partial<ApplicationState>,
|
||||
): redux.Store<ApplicationState> {
|
||||
export function createAppStore(initialState?: ApplicationState): redux.Store<ApplicationState> {
|
||||
const hashParams = getHashParams()
|
||||
|
||||
const loading = canUseXHR && hashParams.profileURL != null
|
||||
@@ -57,6 +83,9 @@ export function createApplicationStore(
|
||||
|
||||
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
|
||||
|
||||
searchQuery: setter<string>(actions.setSearchQuery, ''),
|
||||
searchIsActive: setter<boolean>(actions.setSearchIsActive, false),
|
||||
|
||||
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
|
||||
|
||||
dragActive: setter<boolean>(actions.setDragActive, false),
|
||||
@@ -71,3 +100,35 @@ export function createApplicationStore(
|
||||
|
||||
return redux.createStore(reducer, initialState)
|
||||
}
|
||||
|
||||
export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArgs: any[]): T {
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
return useSelector(selector, cacheArgs)
|
||||
}
|
||||
|
||||
export interface ActiveProfileState {
|
||||
profile: Profile
|
||||
index: number
|
||||
chronoViewState: FlamechartViewState
|
||||
leftHeavyViewState: FlamechartViewState
|
||||
sandwichViewState: SandwichViewState
|
||||
}
|
||||
|
||||
export function useActiveProfileState(): ActiveProfileState | null {
|
||||
return useAppSelector(state => {
|
||||
const {profileGroup} = state
|
||||
if (!profileGroup) return null
|
||||
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
|
||||
|
||||
const index = profileGroup.indexToView
|
||||
const profileState = profileGroup.profiles[index]
|
||||
return {
|
||||
...profileGroup.profiles[profileGroup.indexToView],
|
||||
profile: getProfileToView({
|
||||
profile: profileState.profile,
|
||||
flattenRecursion: state.flattenRecursion,
|
||||
}),
|
||||
index: profileGroup.indexToView,
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {objectsHaveShallowEquality} from '../lib/utils'
|
||||
|
||||
export type ProfileGroupState = {
|
||||
name: string
|
||||
|
||||
// The index within the list of profiles currently being viewed
|
||||
indexToView: number
|
||||
|
||||
profiles: ProfileState[]
|
||||
} | null
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
import {Store, AnyAction} from 'redux'
|
||||
import {ApplicationState, createApplicationStore} from '.'
|
||||
import {ApplicationState, createAppStore} from '.'
|
||||
import {importSpeedscopeProfiles} from '../lib/file-format'
|
||||
|
||||
export function storeTest(name: string, cb: (store: Store<ApplicationState, AnyAction>) => void) {
|
||||
const store = createApplicationStore({})
|
||||
const store = createAppStore()
|
||||
test(name, () => {
|
||||
cb(store)
|
||||
})
|
||||
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
declare module 'preact-redux' {
|
||||
import {VNode, Component} from 'preact'
|
||||
import {Store} from 'redux'
|
||||
|
||||
// We just export the bare minimum here because we're going
|
||||
// to implement an API for readability convenience elsewhere
|
||||
export function connect(...args: any[]): any
|
||||
|
||||
export class Provider extends Component<{store: Store<any>}, {}> {
|
||||
render(): VNode
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,45 @@
|
||||
import {createContainer, Dispatch, bindActionCreator, ActionCreator} from '../lib/typed-redux'
|
||||
import {Application, ActiveProfileState} from './application'
|
||||
import {ApplicationState} from '../store'
|
||||
import {getProfileToView, getCanvasContext} from '../store/getters'
|
||||
import {h} from 'preact'
|
||||
import {Application} from './application'
|
||||
import {getCanvasContext} from '../store/getters'
|
||||
import {actions} from '../store/actions'
|
||||
import {Graphics} from '../gl/graphics'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useAppSelector, useActiveProfileState} from '../store'
|
||||
import {ProfileSearchContextProvider} from './search-view'
|
||||
|
||||
export const ApplicationContainer = createContainer(
|
||||
Application,
|
||||
(state: ApplicationState, dispatch: Dispatch) => {
|
||||
const {flattenRecursion, profileGroup} = state
|
||||
const {
|
||||
setLoading,
|
||||
setError,
|
||||
setProfileGroup,
|
||||
setDragActive,
|
||||
setViewMode,
|
||||
setGLCanvas,
|
||||
setFlattenRecursion,
|
||||
setProfileIndexToView,
|
||||
} = actions
|
||||
|
||||
let activeProfileState: ActiveProfileState | null = null
|
||||
if (profileGroup) {
|
||||
if (profileGroup.profiles.length > profileGroup.indexToView) {
|
||||
const index = profileGroup.indexToView
|
||||
const profileState = profileGroup.profiles[index]
|
||||
activeProfileState = {
|
||||
...profileGroup.profiles[profileGroup.indexToView],
|
||||
profile: getProfileToView({profile: profileState.profile, flattenRecursion}),
|
||||
index: profileGroup.indexToView,
|
||||
}
|
||||
}
|
||||
}
|
||||
export const ApplicationContainer = memo(() => {
|
||||
const appState = useAppSelector(state => state, [])
|
||||
const canvasContext = useAppSelector(
|
||||
state => (state.glCanvas ? getCanvasContext(state.glCanvas) : null),
|
||||
[],
|
||||
)
|
||||
|
||||
function wrapActionCreator<T>(actionCreator: ActionCreator<T>): (t: T) => void {
|
||||
return bindActionCreator(dispatch, actionCreator)
|
||||
}
|
||||
|
||||
// TODO(jlfwong): Cache this and resizeCanvas below to prevent re-renders
|
||||
// due to changing props.
|
||||
const setters = {
|
||||
setGLCanvas: wrapActionCreator(actions.setGLCanvas),
|
||||
setLoading: wrapActionCreator(actions.setLoading),
|
||||
setError: wrapActionCreator(actions.setError),
|
||||
setProfileGroup: wrapActionCreator(actions.setProfileGroup),
|
||||
setDragActive: wrapActionCreator(actions.setDragActive),
|
||||
setViewMode: wrapActionCreator(actions.setViewMode),
|
||||
setFlattenRecursion: wrapActionCreator(actions.setFlattenRecursion),
|
||||
setProfileIndexToView: wrapActionCreator(actions.setProfileIndexToView),
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfileState,
|
||||
dispatch,
|
||||
canvasContext: state.glCanvas ? getCanvasContext(state.glCanvas) : null,
|
||||
resizeCanvas: (
|
||||
widthInPixels: number,
|
||||
heightInPixels: number,
|
||||
widthInAppUnits: number,
|
||||
heightInAppUnits: number,
|
||||
) => {
|
||||
if (state.glCanvas) {
|
||||
const gl = getCanvasContext(state.glCanvas).gl
|
||||
gl.resize(widthInPixels, heightInPixels, widthInAppUnits, heightInAppUnits)
|
||||
gl.clear(new Graphics.Color(1, 1, 1, 1))
|
||||
}
|
||||
},
|
||||
...setters,
|
||||
...state,
|
||||
}
|
||||
},
|
||||
)
|
||||
return (
|
||||
<ProfileSearchContextProvider>
|
||||
<Application
|
||||
activeProfileState={useActiveProfileState()}
|
||||
canvasContext={canvasContext}
|
||||
setGLCanvas={useActionCreator(setGLCanvas, [])}
|
||||
setLoading={useActionCreator(setLoading, [])}
|
||||
setError={useActionCreator(setError, [])}
|
||||
setProfileGroup={useActionCreator(setProfileGroup, [])}
|
||||
setDragActive={useActionCreator(setDragActive, [])}
|
||||
setViewMode={useActionCreator(setViewMode, [])}
|
||||
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
|
||||
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
|
||||
{...appState}
|
||||
/>
|
||||
</ProfileSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
+17
-248
@@ -1,19 +1,18 @@
|
||||
import {h, Component} from 'preact'
|
||||
import {h} from 'preact'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
|
||||
|
||||
import {Profile, ProfileGroup} from '../lib/profile'
|
||||
import {FontFamily, FontSize, Colors, Sizes, Duration} from './style'
|
||||
import {ProfileGroup} from '../lib/profile'
|
||||
import {FontFamily, FontSize, Colors, Duration} from './style'
|
||||
import {importEmscriptenSymbolMap} from '../lib/emscripten'
|
||||
import {SandwichViewContainer} from './sandwich-view'
|
||||
import {saveToFile} from '../lib/file-format'
|
||||
import {ApplicationState, ViewMode, canUseXHR} from '../store'
|
||||
import {ApplicationState, ViewMode, canUseXHR, ActiveProfileState} from '../store'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
|
||||
import {SandwichViewState} from '../store/sandwich-view-state'
|
||||
import {FlamechartViewState} from '../store/flamechart-view-state'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {Graphics} from '../gl/graphics'
|
||||
import {Toolbar} from './toolbar'
|
||||
|
||||
const importModule = import('../import')
|
||||
// Force eager loading of the module
|
||||
@@ -50,155 +49,14 @@ async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntr
|
||||
declare function require(x: string): any
|
||||
const exampleProfileURL = require('../../sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
|
||||
|
||||
interface ToolbarProps extends ApplicationProps {
|
||||
browseForFile(): void
|
||||
saveFile(): void
|
||||
}
|
||||
|
||||
export class Toolbar extends StatelessComponent<ToolbarProps> {
|
||||
setTimeOrder = () => {
|
||||
this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
|
||||
}
|
||||
|
||||
setLeftHeavyOrder = () => {
|
||||
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
||||
}
|
||||
|
||||
setSandwichView = () => {
|
||||
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
|
||||
}
|
||||
|
||||
renderLeftContent() {
|
||||
if (!this.props.activeProfileState) return null
|
||||
|
||||
return (
|
||||
<div className={css(style.toolbarLeft)}>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
this.props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={this.setTimeOrder}
|
||||
>
|
||||
<span className={css(style.emoji)}>🕰</span>Time Order
|
||||
</div>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
this.props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={this.setLeftHeavyOrder}
|
||||
>
|
||||
<span className={css(style.emoji)}>⬅️</span>Left Heavy
|
||||
</div>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
this.props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={this.setSandwichView}
|
||||
>
|
||||
<span className={css(style.emoji)}>🥪</span>Sandwich
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderCenterContent() {
|
||||
const {activeProfileState, profileGroup} = this.props
|
||||
if (activeProfileState && profileGroup) {
|
||||
const {index} = activeProfileState
|
||||
if (profileGroup.profiles.length === 1) {
|
||||
return activeProfileState.profile.getName()
|
||||
} else {
|
||||
function makeNavButton(content: string, disabled: boolean, onClick: () => void) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={css(
|
||||
style.emoji,
|
||||
style.toolbarProfileNavButton,
|
||||
disabled && style.toolbarProfileNavButtonDisabled,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const prevButton = makeNavButton('⬅️', index === 0, () =>
|
||||
this.props.setProfileIndexToView(index - 1),
|
||||
)
|
||||
const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () =>
|
||||
this.props.setProfileIndexToView(index + 1),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css(style.toolbarCenter)}>
|
||||
{prevButton}
|
||||
{activeProfileState.profile.getName()}{' '}
|
||||
<span className={css(style.toolbarProfileIndex)}>
|
||||
({activeProfileState.index + 1}/{profileGroup.profiles.length})
|
||||
</span>
|
||||
{nextButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return '🔬speedscope'
|
||||
}
|
||||
|
||||
renderRightContent() {
|
||||
const importFile = (
|
||||
<div className={css(style.toolbarTab)} onClick={this.props.browseForFile}>
|
||||
<span className={css(style.emoji)}>⤵️</span>Import
|
||||
</div>
|
||||
)
|
||||
const help = (
|
||||
<div className={css(style.toolbarTab)}>
|
||||
<a
|
||||
href="https://github.com/jlfwong/speedscope#usage"
|
||||
className={css(style.noLinkStyle)}
|
||||
target="_blank"
|
||||
>
|
||||
<span className={css(style.emoji)}>❓</span>Help
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css(style.toolbarRight)}>
|
||||
{this.props.activeProfileState && (
|
||||
<div className={css(style.toolbarTab)} onClick={this.props.saveFile}>
|
||||
<span className={css(style.emoji)}>⤴️</span>Export
|
||||
</div>
|
||||
)}
|
||||
{importFile}
|
||||
{help}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={css(style.toolbar)}>
|
||||
{this.renderLeftContent()}
|
||||
{this.renderCenterContent()}
|
||||
{this.renderRightContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface GLCanvasProps {
|
||||
canvasContext: CanvasContext | null
|
||||
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
|
||||
}
|
||||
export class GLCanvas extends Component<GLCanvasProps, void> {
|
||||
export class GLCanvas extends StatelessComponent<GLCanvasProps> {
|
||||
private canvas: HTMLCanvasElement | null = null
|
||||
|
||||
private ref = (canvas?: Element) => {
|
||||
private ref = (canvas: Element | null) => {
|
||||
if (canvas instanceof HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
} else {
|
||||
@@ -209,7 +67,7 @@ export class GLCanvas extends Component<GLCanvasProps, void> {
|
||||
}
|
||||
|
||||
private container: HTMLElement | null = null
|
||||
private containerRef = (container?: Element) => {
|
||||
private containerRef = (container: Element | null) => {
|
||||
if (container instanceof HTMLElement) {
|
||||
this.container = container
|
||||
} else {
|
||||
@@ -271,14 +129,6 @@ export class GLCanvas extends Component<GLCanvasProps, void> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveProfileState {
|
||||
profile: Profile
|
||||
index: number
|
||||
chronoViewState: FlamechartViewState
|
||||
leftHeavyViewState: FlamechartViewState
|
||||
sandwichViewState: SandwichViewState
|
||||
}
|
||||
|
||||
export type ApplicationProps = ApplicationState & {
|
||||
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
@@ -323,8 +173,8 @@ export class Application extends StatelessComponent<ApplicationProps> {
|
||||
|
||||
if (this.props.hashParams.title) {
|
||||
profileGroup = {
|
||||
name: this.props.hashParams.title,
|
||||
...profileGroup,
|
||||
name: this.props.hashParams.title,
|
||||
}
|
||||
}
|
||||
document.title = `${profileGroup.name} - speedscope`
|
||||
@@ -402,6 +252,8 @@ export class Application extends StatelessComponent<ApplicationProps> {
|
||||
this.props.setDragActive(false)
|
||||
ev.preventDefault()
|
||||
|
||||
if (!ev.dataTransfer) return
|
||||
|
||||
const firstItem = ev.dataTransfer.items[0]
|
||||
if ('webkitGetAsEntry' in firstItem) {
|
||||
const webkitEntry: FileSystemDirectoryEntry = firstItem.webkitGetAsEntry()
|
||||
@@ -490,7 +342,9 @@ export class Application extends StatelessComponent<ApplicationProps> {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
|
||||
const pasted = (ev as ClipboardEvent).clipboardData.getData('text')
|
||||
const clipboardData = (ev as ClipboardEvent).clipboardData
|
||||
if (!clipboardData) return
|
||||
const pasted = clipboardData.getData('text')
|
||||
this.loadProfile(async () => {
|
||||
return await importProfilesFromText('From Clipboard', pasted)
|
||||
})
|
||||
@@ -611,7 +465,8 @@ export class Application extends StatelessComponent<ApplicationProps> {
|
||||
href="https://github.com/jlfwong/speedscope/issues"
|
||||
>
|
||||
report any issues on GitHub
|
||||
</a>.
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,7 +528,7 @@ export class Application extends StatelessComponent<ApplicationProps> {
|
||||
<Toolbar
|
||||
saveFile={this.saveFile}
|
||||
browseForFile={this.browseForFile}
|
||||
{...this.props as ApplicationProps}
|
||||
{...(this.props as ApplicationProps)}
|
||||
/>
|
||||
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
|
||||
{this.props.dragActive && <div className={css(style.dragTarget)} />}
|
||||
@@ -785,90 +640,4 @@ const style = StyleSheet.create({
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
toolbar: {
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
flexShrink: 0,
|
||||
background: Colors.BLACK,
|
||||
color: Colors.WHITE,
|
||||
textAlign: 'center',
|
||||
fontFamily: FontFamily.MONOSPACE,
|
||||
fontSize: FontSize.TITLE,
|
||||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||||
userSelect: 'none',
|
||||
},
|
||||
toolbarLeft: {
|
||||
position: 'absolute',
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
top: 0,
|
||||
left: 0,
|
||||
marginRight: 2,
|
||||
textAlign: 'left',
|
||||
},
|
||||
toolbarCenter: {
|
||||
paddingTop: 1,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
},
|
||||
toolbarRight: {
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
marginRight: 2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
toolbarProfileIndex: {
|
||||
color: Colors.LIGHT_GRAY,
|
||||
},
|
||||
toolbarProfileNavButton: {
|
||||
opacity: 0.8,
|
||||
fontSize: FontSize.TITLE,
|
||||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||||
':hover': {
|
||||
opacity: 1.0,
|
||||
},
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
marginLeft: '0.3em',
|
||||
marginRight: '0.3em',
|
||||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||||
},
|
||||
toolbarProfileNavButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
':hover': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
toolbarTab: {
|
||||
background: Colors.DARK_GRAY,
|
||||
marginTop: Sizes.SEPARATOR_HEIGHT,
|
||||
height: Sizes.TOOLBAR_TAB_HEIGHT,
|
||||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 8,
|
||||
display: 'inline-block',
|
||||
marginLeft: 2,
|
||||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||||
':hover': {
|
||||
background: Colors.GRAY,
|
||||
},
|
||||
},
|
||||
toolbarTabActive: {
|
||||
background: Colors.BRIGHT_BLUE,
|
||||
':hover': {
|
||||
background: Colors.BRIGHT_BLUE,
|
||||
},
|
||||
},
|
||||
noLinkStyle: {
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
emoji: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: '0px',
|
||||
marginRight: '0.3em',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import {memoizeByShallowEquality} from '../lib/utils'
|
||||
import {Profile, Frame} from '../lib/profile'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {
|
||||
createMemoizedFlamechartRenderer,
|
||||
FlamechartViewContainerProps,
|
||||
createFlamechartSetters,
|
||||
} from './flamechart-view-container'
|
||||
import {createContainer, Dispatch} from '../lib/typed-redux'
|
||||
import {ApplicationState} from '../store'
|
||||
import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
|
||||
const getCalleeProfile = memoizeByShallowEquality<
|
||||
{
|
||||
profile: Profile
|
||||
frame: Frame
|
||||
flattenRecursion: boolean
|
||||
},
|
||||
Profile
|
||||
>(({profile, frame, flattenRecursion}) => {
|
||||
let p = profile.getProfileForCalleesOf(frame)
|
||||
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
|
||||
})
|
||||
|
||||
const getCalleeFlamegraph = memoizeByShallowEquality<
|
||||
{
|
||||
calleeProfile: Profile
|
||||
getColorBucketForFrame: (frame: Frame) => number
|
||||
},
|
||||
Flamechart
|
||||
>(({calleeProfile, getColorBucketForFrame}) => {
|
||||
return new Flamechart({
|
||||
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
|
||||
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
|
||||
formatValue: calleeProfile.formatValue.bind(calleeProfile),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
})
|
||||
|
||||
const getCalleeFlamegraphRenderer = createMemoizedFlamechartRenderer()
|
||||
|
||||
export const CalleeFlamegraphView = createContainer(
|
||||
FlamechartWrapper,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
const {index, profile, sandwichViewState} = activeProfileState
|
||||
const {flattenRecursion, glCanvas} = state
|
||||
if (!profile) throw new Error('profile missing')
|
||||
if (!glCanvas) throw new Error('glCanvas missing')
|
||||
const {callerCallee} = sandwichViewState
|
||||
if (!callerCallee) throw new Error('callerCallee missing')
|
||||
const {selectedFrame} = callerCallee
|
||||
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
|
||||
const flamechart = getCalleeFlamegraph({
|
||||
calleeProfile: getCalleeProfile({profile, frame: selectedFrame, flattenRecursion}),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getCalleeFlamegraphRenderer({canvasContext, flamechart})
|
||||
|
||||
return {
|
||||
renderInverted: false,
|
||||
flamechart,
|
||||
flamechartRenderer,
|
||||
canvasContext,
|
||||
getCSSColorForFrame,
|
||||
...createFlamechartSetters(dispatch, FlamechartID.SANDWICH_CALLEES, index),
|
||||
// This overrides the setSelectedNode specified in createFlamechartSettesr
|
||||
setSelectedNode: () => {},
|
||||
...callerCallee.calleeFlamegraph,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
import {memoizeByShallowEquality, noop} from '../lib/utils'
|
||||
import {Profile, Frame} from '../lib/profile'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {
|
||||
createMemoizedFlamechartRenderer,
|
||||
FlamechartViewContainerProps,
|
||||
useFlamechartSetters,
|
||||
} from './flamechart-view-container'
|
||||
import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {useAppSelector} from '../store'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
|
||||
const getCalleeProfile = memoizeByShallowEquality<
|
||||
{
|
||||
profile: Profile
|
||||
frame: Frame
|
||||
flattenRecursion: boolean
|
||||
},
|
||||
Profile
|
||||
>(({profile, frame, flattenRecursion}) => {
|
||||
let p = profile.getProfileForCalleesOf(frame)
|
||||
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
|
||||
})
|
||||
|
||||
const getCalleeFlamegraph = memoizeByShallowEquality<
|
||||
{
|
||||
calleeProfile: Profile
|
||||
getColorBucketForFrame: (frame: Frame) => number
|
||||
},
|
||||
Flamechart
|
||||
>(({calleeProfile, getColorBucketForFrame}) => {
|
||||
return new Flamechart({
|
||||
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
|
||||
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
|
||||
formatValue: calleeProfile.formatValue.bind(calleeProfile),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
})
|
||||
|
||||
const getCalleeFlamegraphRenderer = createMemoizedFlamechartRenderer()
|
||||
|
||||
export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
const {index, profile, sandwichViewState} = activeProfileState
|
||||
const flattenRecursion = useAppSelector(state => state.flattenRecursion, [])
|
||||
const glCanvas = useAppSelector(state => state.glCanvas, [])
|
||||
|
||||
if (!profile) throw new Error('profile missing')
|
||||
if (!glCanvas) throw new Error('glCanvas missing')
|
||||
const {callerCallee} = sandwichViewState
|
||||
if (!callerCallee) throw new Error('callerCallee missing')
|
||||
const {selectedFrame} = callerCallee
|
||||
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
|
||||
const flamechart = getCalleeFlamegraph({
|
||||
calleeProfile: getCalleeProfile({profile, frame: selectedFrame, flattenRecursion}),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getCalleeFlamegraphRenderer({canvasContext, flamechart})
|
||||
|
||||
return (
|
||||
<FlamechartWrapper
|
||||
renderInverted={false}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import {h, Component} from 'preact'
|
||||
import {h} from 'preact'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {Colors, FontSize} from './style'
|
||||
|
||||
@@ -6,10 +6,8 @@ interface ColorChitProps {
|
||||
color: string
|
||||
}
|
||||
|
||||
export class ColorChit extends Component<ColorChitProps, {}> {
|
||||
render() {
|
||||
return <span className={css(style.stackChit)} style={{backgroundColor: this.props.color}} />
|
||||
}
|
||||
export function ColorChit(props: ColorChitProps) {
|
||||
return <span className={css(style.stackChit)} style={{backgroundColor: props.color}} />
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StyleDeclarationValue, css} from 'aphrodite'
|
||||
import {h, Component} from 'preact'
|
||||
import {h, Component, JSX} from 'preact'
|
||||
import {style} from './flamechart-style'
|
||||
import {formatPercent} from '../lib/utils'
|
||||
import {Frame, CallTreeNode} from '../lib/profile'
|
||||
@@ -19,8 +19,8 @@ class StatisticsTable extends Component<StatisticsTableProps, {}> {
|
||||
render() {
|
||||
const total = this.props.formatter(this.props.selectedTotal)
|
||||
const self = this.props.formatter(this.props.selectedSelf)
|
||||
const totalPerc = 100.0 * this.props.selectedTotal / this.props.grandTotal
|
||||
const selfPerc = 100.0 * this.props.selectedSelf / this.props.grandTotal
|
||||
const totalPerc = (100.0 * this.props.selectedTotal) / this.props.grandTotal
|
||||
const selfPerc = (100.0 * this.props.selectedSelf) / this.props.grandTotal
|
||||
|
||||
return (
|
||||
<div className={css(style.statsTable)}>
|
||||
@@ -62,7 +62,7 @@ class StackTraceView extends Component<StackTraceViewProps, {}> {
|
||||
row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
|
||||
|
||||
if (rows.length) {
|
||||
row.push(<span className={css(style.stackFileLine)}>> </span>)
|
||||
row.push(<span className={css(style.stackFileLine)}>> </span>)
|
||||
}
|
||||
row.push(frame.name)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ enum DraggingMode {
|
||||
|
||||
export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps, {}> {
|
||||
container: Element | null = null
|
||||
containerRef = (element?: Element) => {
|
||||
containerRef = (element: Element | null) => {
|
||||
this.container = element || null
|
||||
}
|
||||
|
||||
@@ -120,9 +120,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
|
||||
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
|
||||
const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
|
||||
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
|
||||
FontFamily.MONOSPACE
|
||||
}`
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
|
||||
@@ -396,7 +394,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
|
||||
this.updateCursor(configSpaceMouse)
|
||||
}
|
||||
|
||||
private overlayCanvasRef = (element?: Element) => {
|
||||
private overlayCanvasRef = (element: Element | null) => {
|
||||
if (element) {
|
||||
this.overlayCanvas = element as HTMLCanvasElement
|
||||
this.overlayCtx = this.overlayCanvas.getContext('2d')
|
||||
|
||||
@@ -4,10 +4,17 @@ import {Flamechart, FlamechartFrame} from '../lib/flamechart'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {FlamechartRenderer} from '../gl/flamechart-renderer'
|
||||
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
|
||||
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from '../lib/text-utils'
|
||||
import {
|
||||
cachedMeasureTextWidth,
|
||||
ELLIPSIS,
|
||||
trimTextMid,
|
||||
remapRangesToTrimmedText,
|
||||
} from '../lib/text-utils'
|
||||
import {style} from './flamechart-style'
|
||||
import {h, Component} from 'preact'
|
||||
import {css} from 'aphrodite'
|
||||
import {ProfileSearchResults} from '../lib/profile-search'
|
||||
import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers'
|
||||
|
||||
interface FlamechartFrameLabel {
|
||||
configSpaceBounds: Rect
|
||||
@@ -47,12 +54,14 @@ export interface FlamechartPanZoomViewProps {
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
|
||||
logicalSpaceViewportSize: Vec2
|
||||
setLogicalSpaceViewportBounds: (size: Vec2) => void
|
||||
setLogicalSpaceViewportSize: (size: Vec2) => void
|
||||
|
||||
searchResults: ProfileSearchResults | null
|
||||
}
|
||||
|
||||
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
|
||||
private container: Element | null = null
|
||||
private containerRef = (element?: Element) => {
|
||||
private containerRef = (element: Element | null) => {
|
||||
this.container = element || null
|
||||
}
|
||||
|
||||
@@ -65,7 +74,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
this.props.setConfigSpaceViewportRect(r)
|
||||
}
|
||||
|
||||
private overlayCanvasRef = (element?: Element) => {
|
||||
private overlayCanvasRef = (element: Element | null) => {
|
||||
if (element) {
|
||||
this.overlayCanvas = element as HTMLCanvasElement
|
||||
this.overlayCtx = this.overlayCanvas.getContext('2d')
|
||||
@@ -163,29 +172,8 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
|
||||
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
|
||||
|
||||
if (this.hoveredLabel) {
|
||||
let color = Colors.DARK_GRAY
|
||||
if (this.props.selectedNode === this.hoveredLabel.node) {
|
||||
color = Colors.DARK_BLUE
|
||||
}
|
||||
|
||||
ctx.lineWidth = 2 * devicePixelRatio
|
||||
ctx.strokeStyle = color
|
||||
|
||||
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
|
||||
ctx.strokeRect(
|
||||
Math.round(physicalViewBounds.left()),
|
||||
Math.round(physicalViewBounds.top()),
|
||||
Math.round(Math.max(0, physicalViewBounds.width())),
|
||||
Math.round(Math.max(0, physicalViewBounds.height())),
|
||||
)
|
||||
}
|
||||
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
|
||||
FontFamily.MONOSPACE
|
||||
}`
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillStyle = Colors.DARK_GRAY
|
||||
|
||||
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
|
||||
const minConfigSpaceWidthToRender = (
|
||||
@@ -194,6 +182,13 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
|
||||
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
|
||||
|
||||
const labelBatch = new BatchCanvasTextRenderer()
|
||||
const fadedLabelBatch = new BatchCanvasTextRenderer()
|
||||
const matchedTextHighlightBatch = new BatchCanvasRectRenderer()
|
||||
const directlySelectedOutlineBatch = new BatchCanvasRectRenderer()
|
||||
const indirectlySelectedOutlineBatch = new BatchCanvasRectRenderer()
|
||||
const matchedFrameBatch = new BatchCanvasRectRenderer()
|
||||
|
||||
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
|
||||
const width = frame.end - frame.start
|
||||
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
|
||||
@@ -228,22 +223,58 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
|
||||
if (physicalLabelBounds.width() > minWidthToRender) {
|
||||
const match = this.props.searchResults?.getMatchForFrame(frame.node.frame)
|
||||
|
||||
const trimmedText = trimTextMid(
|
||||
ctx,
|
||||
frame.node.frame.name,
|
||||
physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
|
||||
)
|
||||
|
||||
// Note that this is specifying the position of the starting text
|
||||
// baseline.
|
||||
ctx.fillText(
|
||||
trimmedText,
|
||||
physicalLabelBounds.left() + LABEL_PADDING_PX,
|
||||
Math.round(
|
||||
if (match) {
|
||||
const rangesToHighlightInTrimmedText = remapRangesToTrimmedText(
|
||||
trimmedText,
|
||||
match.matchedRanges,
|
||||
)
|
||||
|
||||
// Once we have the character ranges to highlight, we need to
|
||||
// actually do the highlighting.
|
||||
let lastEndIndex = 0
|
||||
let left = physicalLabelBounds.left() + LABEL_PADDING_PX
|
||||
|
||||
const padding = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2 - 2
|
||||
for (let [startIndex, endIndex] of rangesToHighlightInTrimmedText) {
|
||||
left += cachedMeasureTextWidth(
|
||||
ctx,
|
||||
trimmedText.trimmedString.substring(lastEndIndex, startIndex),
|
||||
)
|
||||
const highlightWidth = cachedMeasureTextWidth(
|
||||
ctx,
|
||||
trimmedText.trimmedString.substring(startIndex, endIndex),
|
||||
)
|
||||
matchedTextHighlightBatch.rect({
|
||||
x: left,
|
||||
y: physicalLabelBounds.top() + padding,
|
||||
w: highlightWidth,
|
||||
h: physicalViewSpaceFrameHeight - 2 * padding,
|
||||
})
|
||||
|
||||
left += highlightWidth
|
||||
lastEndIndex = endIndex
|
||||
}
|
||||
}
|
||||
|
||||
const batch = this.props.searchResults != null && !match ? fadedLabelBatch : labelBatch
|
||||
batch.text({
|
||||
text: trimmedText.trimmedString,
|
||||
|
||||
// This is specifying the position of the starting text baseline.
|
||||
x: physicalLabelBounds.left() + LABEL_PADDING_PX,
|
||||
y: Math.round(
|
||||
physicalLabelBounds.bottom() -
|
||||
(physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let child of frame.children) {
|
||||
@@ -251,18 +282,14 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
}
|
||||
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderFrameLabelAndChildren(frame)
|
||||
}
|
||||
|
||||
const frameOutlineWidth = 2 * window.devicePixelRatio
|
||||
ctx.strokeStyle = Colors.PALE_DARK_BLUE
|
||||
ctx.lineWidth = frameOutlineWidth
|
||||
const minConfigSpaceWidthToRenderOutline = (
|
||||
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
|
||||
).x
|
||||
const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
|
||||
if (!this.props.selectedNode) return
|
||||
|
||||
const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
|
||||
if (!this.props.selectedNode && this.props.searchResults == null) return
|
||||
const width = frame.end - frame.start
|
||||
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
|
||||
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
|
||||
@@ -273,44 +300,68 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
|
||||
|
||||
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
if (this.props.searchResults?.getMatchForFrame(frame.node.frame)) {
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
matchedFrameBatch.rect({
|
||||
x: Math.round(physicalRectBounds.left() + frameOutlineWidth / 2),
|
||||
y: Math.round(physicalRectBounds.top() + frameOutlineWidth / 2),
|
||||
w: Math.round(Math.max(0, physicalRectBounds.width() - frameOutlineWidth)),
|
||||
h: Math.round(Math.max(0, physicalRectBounds.height() - frameOutlineWidth)),
|
||||
})
|
||||
}
|
||||
|
||||
if (frame.node.frame === this.props.selectedNode.frame) {
|
||||
if (frame.node === this.props.selectedNode) {
|
||||
if (ctx.strokeStyle !== Colors.DARK_BLUE) {
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Colors.DARK_BLUE
|
||||
}
|
||||
} else {
|
||||
if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Colors.PALE_DARK_BLUE
|
||||
}
|
||||
}
|
||||
if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
|
||||
let batch =
|
||||
frame.node === this.props.selectedNode
|
||||
? directlySelectedOutlineBatch
|
||||
: indirectlySelectedOutlineBatch
|
||||
|
||||
// Identify the flamechart frames with a function that matches the
|
||||
// selected flamechart frame.
|
||||
ctx.rect(
|
||||
Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
|
||||
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
|
||||
Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
|
||||
Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
|
||||
)
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
batch.rect({
|
||||
x: Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
|
||||
y: Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
|
||||
w: Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
|
||||
h: Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let child of frame.children) {
|
||||
renderIndirectlySelectedFrameOutlines(child, depth + 1)
|
||||
renderSpecialFrameOutlines(child, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderIndirectlySelectedFrameOutlines(frame)
|
||||
renderSpecialFrameOutlines(frame)
|
||||
}
|
||||
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderFrameLabelAndChildren(frame)
|
||||
}
|
||||
|
||||
matchedFrameBatch.fill(ctx, Colors.ORANGE)
|
||||
matchedTextHighlightBatch.fill(ctx, Colors.YELLOW)
|
||||
fadedLabelBatch.fill(ctx, Colors.LIGHT_GRAY)
|
||||
labelBatch.fill(ctx, Colors.BLACK)
|
||||
indirectlySelectedOutlineBatch.stroke(ctx, Colors.PALE_DARK_BLUE, frameOutlineWidth)
|
||||
directlySelectedOutlineBatch.stroke(ctx, Colors.DARK_BLUE, frameOutlineWidth)
|
||||
|
||||
if (this.hoveredLabel) {
|
||||
let color = Colors.DARK_GRAY
|
||||
if (this.props.selectedNode === this.hoveredLabel.node) {
|
||||
color = Colors.DARK_BLUE
|
||||
}
|
||||
|
||||
ctx.lineWidth = 2 * devicePixelRatio
|
||||
ctx.strokeStyle = color
|
||||
|
||||
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
|
||||
ctx.strokeRect(
|
||||
Math.round(physicalViewBounds.left()),
|
||||
Math.round(physicalViewBounds.top()),
|
||||
Math.round(Math.max(0, physicalViewBounds.width())),
|
||||
Math.round(Math.max(0, physicalViewBounds.height())),
|
||||
)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
this.renderTimeIndicators()
|
||||
}
|
||||
@@ -398,7 +449,11 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
this.props.setLogicalSpaceViewportBounds(new Vec2(width, height))
|
||||
|
||||
const newSize = new Vec2(width, height)
|
||||
if (!newSize.equals(logicalSpaceViewportSize)) {
|
||||
this.props.setLogicalSpaceViewportSize(newSize)
|
||||
}
|
||||
}
|
||||
|
||||
onWindowResize = () => {
|
||||
@@ -688,6 +743,8 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
if (this.props.flamechart !== nextProps.flamechart) {
|
||||
this.hoveredLabel = null
|
||||
this.renderCanvas()
|
||||
} else if (this.props.searchResults !== nextProps.searchResults) {
|
||||
this.renderCanvas()
|
||||
} else if (this.props.selectedNode !== nextProps.selectedNode) {
|
||||
this.renderCanvas()
|
||||
} else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {memo} from 'preact/compat'
|
||||
import {useContext, useMemo, useCallback} from 'preact/hooks'
|
||||
import {SearchView, ProfileSearchContext} from './search-view'
|
||||
import {
|
||||
FlamechartSearchMatch,
|
||||
FlamechartSearchResults,
|
||||
ProfileSearchResults,
|
||||
} from '../lib/profile-search'
|
||||
import {Rect, Vec2} from '../lib/math'
|
||||
import {h, createContext, ComponentChildren} from 'preact'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
|
||||
export const FlamechartSearchContext = createContext<FlamechartSearchData | null>(null)
|
||||
|
||||
export interface FlamechartSearchProps {
|
||||
flamechart: Flamechart
|
||||
selectedNode: CallTreeNode | null
|
||||
setSelectedNode: (node: CallTreeNode | null) => void
|
||||
configSpaceViewportRect: Rect
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
children: ComponentChildren
|
||||
}
|
||||
|
||||
interface FlamechartSearchData {
|
||||
results: FlamechartSearchResults | null
|
||||
flamechart: Flamechart
|
||||
selectedNode: CallTreeNode | null
|
||||
setSelectedNode: (node: CallTreeNode | null) => void
|
||||
configSpaceViewportRect: Rect
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
}
|
||||
|
||||
export const FlamechartSearchContextProvider = ({
|
||||
flamechart,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
configSpaceViewportRect,
|
||||
setConfigSpaceViewportRect,
|
||||
children,
|
||||
}: FlamechartSearchProps) => {
|
||||
const profileSearchResults: ProfileSearchResults | null = useContext(ProfileSearchContext)
|
||||
const flamechartSearchResults: FlamechartSearchResults | null = useMemo(() => {
|
||||
if (profileSearchResults == null) {
|
||||
return null
|
||||
}
|
||||
return new FlamechartSearchResults(flamechart, profileSearchResults)
|
||||
}, [flamechart, profileSearchResults])
|
||||
|
||||
return (
|
||||
<FlamechartSearchContext.Provider
|
||||
value={{
|
||||
results: flamechartSearchResults,
|
||||
flamechart,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
configSpaceViewportRect,
|
||||
setConfigSpaceViewportRect,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FlamechartSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const FlamechartSearchView = memo(() => {
|
||||
const flamechartData = useContext(FlamechartSearchContext)
|
||||
|
||||
// TODO(jlfwong): This pattern is pretty gross, but I really don't want values
|
||||
// that can be undefined or null.
|
||||
const searchResults = flamechartData == null ? null : flamechartData.results
|
||||
const selectedNode = flamechartData == null ? null : flamechartData.selectedNode
|
||||
const setSelectedNode = flamechartData == null ? null : flamechartData.setSelectedNode
|
||||
const configSpaceViewportRect =
|
||||
flamechartData == null ? null : flamechartData.configSpaceViewportRect
|
||||
const setConfigSpaceViewportRect =
|
||||
flamechartData == null ? null : flamechartData.setConfigSpaceViewportRect
|
||||
const flamechart = flamechartData == null ? null : flamechartData.flamechart
|
||||
|
||||
const numResults = searchResults == null ? null : searchResults.count()
|
||||
const resultIndex: number | null = useMemo(() => {
|
||||
if (searchResults == null) return null
|
||||
if (selectedNode == null) return null
|
||||
return searchResults.indexOf(selectedNode)
|
||||
}, [searchResults, selectedNode])
|
||||
|
||||
const selectAndZoomToMatch = useCallback(
|
||||
(match: FlamechartSearchMatch) => {
|
||||
if (!setSelectedNode) return
|
||||
if (!flamechart) return
|
||||
if (!configSpaceViewportRect) return
|
||||
if (!setConfigSpaceViewportRect) return
|
||||
|
||||
// After the node is selected, we want to set the viewport so that the new
|
||||
// node can be seen clearly.
|
||||
//
|
||||
// TODO(jlfwong): The lack of animation here can be kind of jarring. It
|
||||
// would be nice to have some easier way for people to orient themselves
|
||||
// after the viewport shifted.
|
||||
const configSpaceResultBounds = match.configSpaceBounds
|
||||
|
||||
const viewportRect = new Rect(
|
||||
configSpaceResultBounds.origin.minus(new Vec2(0, 1)),
|
||||
configSpaceResultBounds.size.withY(configSpaceViewportRect.height()),
|
||||
)
|
||||
|
||||
setSelectedNode(match.node)
|
||||
setConfigSpaceViewportRect(
|
||||
flamechart.getClampedConfigSpaceViewportRect({configSpaceViewportRect: viewportRect}),
|
||||
)
|
||||
},
|
||||
[configSpaceViewportRect, setConfigSpaceViewportRect, setSelectedNode, flamechart],
|
||||
)
|
||||
|
||||
const {selectPrev, selectNext} = useMemo(() => {
|
||||
if (numResults == null || numResults === 0 || searchResults == null) {
|
||||
return {selectPrev: () => {}, selectNext: () => {}}
|
||||
}
|
||||
|
||||
return {
|
||||
selectPrev: () => {
|
||||
if (!searchResults?.at) return
|
||||
if (numResults == null || numResults === 0) return
|
||||
|
||||
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
|
||||
if (index < 0) index = numResults - 1
|
||||
const result = searchResults.at(index)
|
||||
selectAndZoomToMatch(result)
|
||||
},
|
||||
|
||||
selectNext: () => {
|
||||
if (!searchResults?.at) return
|
||||
if (numResults == null || numResults === 0) return
|
||||
|
||||
let index = resultIndex == null ? 0 : resultIndex + 1
|
||||
if (index >= numResults) index = 0
|
||||
const result = searchResults.at(index)
|
||||
selectAndZoomToMatch(result)
|
||||
},
|
||||
}
|
||||
}, [numResults, resultIndex, searchResults, searchResults?.at, selectAndZoomToMatch])
|
||||
|
||||
return (
|
||||
<SearchView
|
||||
resultIndex={resultIndex}
|
||||
numResults={numResults}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
import {h} from 'preact'
|
||||
import {FlamechartID, FlamechartViewState} from '../store/flamechart-view-state'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {FlamechartRenderer, FlamechartRendererOptions} from '../gl/flamechart-renderer'
|
||||
import {Dispatch, createContainer, ActionCreator} from '../lib/typed-redux'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {Frame, Profile, CallTreeNode} from '../lib/profile'
|
||||
import {memoizeByShallowEquality} from '../lib/utils'
|
||||
import {ApplicationState} from '../store'
|
||||
import {FlamechartView} from './flamechart-view'
|
||||
import {
|
||||
getRowAtlas,
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
createGetCSSColorForFrame,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {ActiveProfileState} from './application'
|
||||
import {Vec2, Rect} from '../lib/math'
|
||||
import {actions} from '../store/actions'
|
||||
import {memo} from 'preact/compat'
|
||||
import {ActiveProfileState} from '../store'
|
||||
import {FlamechartSearchContextProvider} from './flamechart-search-view'
|
||||
|
||||
interface FlamechartSetters {
|
||||
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
|
||||
@@ -25,46 +27,35 @@ interface FlamechartSetters {
|
||||
setSelectedNode: (node: CallTreeNode | null) => void
|
||||
}
|
||||
|
||||
interface WithFlamechartContext<T> {
|
||||
profileIndex: number
|
||||
args: {
|
||||
id: FlamechartID
|
||||
} & T
|
||||
}
|
||||
|
||||
export function createFlamechartSetters(
|
||||
dispatch: Dispatch,
|
||||
id: FlamechartID,
|
||||
profileIndex: number,
|
||||
): FlamechartSetters {
|
||||
function wrapActionCreator<T, U>(
|
||||
actionCreator: ActionCreator<WithFlamechartContext<U>>,
|
||||
map: (t: T) => U,
|
||||
): (t: T) => void {
|
||||
return (t: T) => {
|
||||
const args = Object.assign({}, map(t), {id})
|
||||
dispatch(actionCreator({profileIndex, args}))
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
setHoveredNode,
|
||||
setLogicalSpaceViewportSize,
|
||||
setConfigSpaceViewportRect,
|
||||
setSelectedNode,
|
||||
} = actions.flamechart
|
||||
const {
|
||||
setHoveredNode,
|
||||
setLogicalSpaceViewportSize,
|
||||
setConfigSpaceViewportRect,
|
||||
setSelectedNode,
|
||||
} = actions.flamechart
|
||||
|
||||
export function useFlamechartSetters(id: FlamechartID, profileIndex: number): FlamechartSetters {
|
||||
return {
|
||||
setNodeHover: wrapActionCreator(setHoveredNode, hover => ({hover})),
|
||||
setLogicalSpaceViewportSize: wrapActionCreator(
|
||||
setLogicalSpaceViewportSize,
|
||||
logicalSpaceViewportSize => ({logicalSpaceViewportSize}),
|
||||
setNodeHover: useActionCreator(
|
||||
(hover: {node: CallTreeNode; event: MouseEvent} | null) =>
|
||||
setHoveredNode({profileIndex, args: {id, hover}}),
|
||||
[profileIndex, id],
|
||||
),
|
||||
setConfigSpaceViewportRect: wrapActionCreator(
|
||||
setConfigSpaceViewportRect,
|
||||
configSpaceViewportRect => ({configSpaceViewportRect}),
|
||||
setLogicalSpaceViewportSize: useActionCreator(
|
||||
(logicalSpaceViewportSize: Vec2) =>
|
||||
setLogicalSpaceViewportSize({profileIndex, args: {id, logicalSpaceViewportSize}}),
|
||||
[profileIndex, id],
|
||||
),
|
||||
setConfigSpaceViewportRect: useActionCreator(
|
||||
(configSpaceViewportRect: Rect) =>
|
||||
setConfigSpaceViewportRect({profileIndex, args: {id, configSpaceViewportRect}}),
|
||||
[profileIndex, id],
|
||||
),
|
||||
setSelectedNode: useActionCreator(
|
||||
(selectedNode: CallTreeNode | null) =>
|
||||
setSelectedNode({profileIndex, args: {id, selectedNode}}),
|
||||
[profileIndex, id],
|
||||
),
|
||||
setSelectedNode: wrapActionCreator(setSelectedNode, selectedNode => ({selectedNode})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,34 +112,43 @@ export interface FlamechartViewContainerProps {
|
||||
glCanvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
export const ChronoFlamechartView = createContainer(
|
||||
FlamechartView,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
const {index, profile, chronoViewState} = activeProfileState
|
||||
export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = props
|
||||
const {index, profile, chronoViewState} = activeProfileState
|
||||
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
|
||||
const flamechart = getChronoViewFlamechart({profile, getColorBucketForFrame})
|
||||
const flamechartRenderer = getChronoViewFlamechartRenderer({
|
||||
canvasContext,
|
||||
flamechart,
|
||||
})
|
||||
const flamechart = getChronoViewFlamechart({profile, getColorBucketForFrame})
|
||||
const flamechartRenderer = getChronoViewFlamechartRenderer({
|
||||
canvasContext,
|
||||
flamechart,
|
||||
})
|
||||
|
||||
return {
|
||||
renderInverted: false,
|
||||
flamechart,
|
||||
flamechartRenderer,
|
||||
canvasContext,
|
||||
getCSSColorForFrame,
|
||||
...createFlamechartSetters(dispatch, FlamechartID.CHRONO, index),
|
||||
...chronoViewState,
|
||||
}
|
||||
},
|
||||
)
|
||||
const setters = useFlamechartSetters(FlamechartID.CHRONO, index)
|
||||
|
||||
return (
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
selectedNode={chronoViewState.selectedNode}
|
||||
setSelectedNode={setters.setSelectedNode}
|
||||
configSpaceViewportRect={chronoViewState.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
|
||||
>
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...chronoViewState}
|
||||
{...setters}
|
||||
/>
|
||||
</FlamechartSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export const getLeftHeavyFlamechart = memoizeByShallowEquality(
|
||||
({
|
||||
@@ -169,35 +169,44 @@ export const getLeftHeavyFlamechart = memoizeByShallowEquality(
|
||||
|
||||
const getLeftHeavyFlamechartRenderer = createMemoizedFlamechartRenderer()
|
||||
|
||||
export const LeftHeavyFlamechartView = createContainer(
|
||||
FlamechartView,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
|
||||
const {index, profile, leftHeavyViewState} = activeProfileState
|
||||
const {index, profile, leftHeavyViewState} = activeProfileState
|
||||
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
|
||||
const flamechart = getLeftHeavyFlamechart({
|
||||
profile,
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getLeftHeavyFlamechartRenderer({
|
||||
canvasContext,
|
||||
flamechart,
|
||||
})
|
||||
const flamechart = getLeftHeavyFlamechart({
|
||||
profile,
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getLeftHeavyFlamechartRenderer({
|
||||
canvasContext,
|
||||
flamechart,
|
||||
})
|
||||
|
||||
return {
|
||||
renderInverted: false,
|
||||
flamechart,
|
||||
flamechartRenderer,
|
||||
canvasContext,
|
||||
getCSSColorForFrame,
|
||||
...createFlamechartSetters(dispatch, FlamechartID.LEFT_HEAVY, index),
|
||||
...leftHeavyViewState,
|
||||
}
|
||||
},
|
||||
)
|
||||
const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)
|
||||
|
||||
return (
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
selectedNode={leftHeavyViewState.selectedNode}
|
||||
setSelectedNode={setters.setSelectedNode}
|
||||
configSpaceViewportRect={leftHeavyViewState.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
|
||||
>
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...leftHeavyViewState}
|
||||
{...setters}
|
||||
/>
|
||||
</FlamechartSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {h} from 'preact'
|
||||
import {h, Fragment} from 'preact'
|
||||
import {css} from 'aphrodite'
|
||||
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
@@ -14,6 +14,8 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
|
||||
import {Hovertip} from './hovertip'
|
||||
import {FlamechartViewProps} from './flamechart-view-container'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {ProfileSearchContext} from './search-view'
|
||||
import {FlamechartSearchView} from './flamechart-search-view'
|
||||
|
||||
export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
private configSpaceSize() {
|
||||
@@ -62,7 +64,7 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
|
||||
formatValue(weight: number) {
|
||||
const totalWeight = this.props.flamechart.getTotalWeight()
|
||||
const percent = 100 * weight / totalWeight
|
||||
const percent = (100 * weight) / totalWeight
|
||||
const formattedPercent = formatPercent(percent)
|
||||
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
|
||||
}
|
||||
@@ -86,7 +88,7 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
}
|
||||
|
||||
container: HTMLDivElement | null = null
|
||||
containerRef = (container?: Element) => {
|
||||
containerRef = (container: Element | null) => {
|
||||
this.container = (container as HTMLDivElement) || null
|
||||
}
|
||||
|
||||
@@ -101,20 +103,28 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
canvasContext={this.props.canvasContext}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
/>
|
||||
<FlamechartPanZoomView
|
||||
canvasContext={this.props.canvasContext}
|
||||
flamechart={this.props.flamechart}
|
||||
flamechartRenderer={this.props.flamechartRenderer}
|
||||
renderInverted={false}
|
||||
onNodeHover={this.onNodeHover}
|
||||
onNodeSelect={this.onNodeClick}
|
||||
selectedNode={this.props.selectedNode}
|
||||
transformViewport={this.transformViewport}
|
||||
configSpaceViewportRect={this.props.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
|
||||
/>
|
||||
<ProfileSearchContext.Consumer>
|
||||
{searchResults => (
|
||||
<Fragment>
|
||||
<FlamechartPanZoomView
|
||||
canvasContext={this.props.canvasContext}
|
||||
flamechart={this.props.flamechart}
|
||||
flamechartRenderer={this.props.flamechartRenderer}
|
||||
renderInverted={false}
|
||||
onNodeHover={this.onNodeHover}
|
||||
onNodeSelect={this.onNodeClick}
|
||||
selectedNode={this.props.selectedNode}
|
||||
transformViewport={this.transformViewport}
|
||||
configSpaceViewportRect={this.props.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
<FlamechartSearchView />
|
||||
</Fragment>
|
||||
)}
|
||||
</ProfileSearchContext.Consumer>
|
||||
{this.renderTooltip()}
|
||||
{this.props.selectedNode && (
|
||||
<FlamechartDetailView
|
||||
|
||||
@@ -12,15 +12,10 @@ import {StatelessComponent} from '../lib/typed-redux'
|
||||
export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
private clampViewportToFlamegraph(viewportRect: Rect) {
|
||||
const {flamechart, renderInverted} = this.props
|
||||
const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length)
|
||||
const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
|
||||
const size = viewportRect.size.withX(width)
|
||||
const origin = Vec2.clamp(
|
||||
viewportRect.origin,
|
||||
new Vec2(0, renderInverted ? 0 : -1),
|
||||
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
|
||||
)
|
||||
return new Rect(origin, viewportRect.size.withX(width))
|
||||
return flamechart.getClampedConfigSpaceViewportRect({
|
||||
configSpaceViewportRect: viewportRect,
|
||||
renderInverted,
|
||||
})
|
||||
}
|
||||
private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => {
|
||||
this.props.setConfigSpaceViewportRect(this.clampViewportToFlamegraph(configSpaceViewportRect))
|
||||
@@ -34,7 +29,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
}
|
||||
private formatValue(weight: number) {
|
||||
const totalWeight = this.props.flamechart.getTotalWeight()
|
||||
const percent = 100 * weight / totalWeight
|
||||
const percent = (100 * weight) / totalWeight
|
||||
const formattedPercent = formatPercent(percent)
|
||||
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
|
||||
}
|
||||
@@ -54,7 +49,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
)
|
||||
}
|
||||
container: HTMLDivElement | null = null
|
||||
containerRef = (container?: Element) => {
|
||||
containerRef = (container: Element | null) => {
|
||||
this.container = (container as HTMLDivElement) || null
|
||||
}
|
||||
private setNodeHover = (
|
||||
@@ -83,7 +78,8 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
canvasContext={this.props.canvasContext}
|
||||
renderInverted={this.props.renderInverted}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchResults={null}
|
||||
/>
|
||||
{this.renderTooltip()}
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,7 @@ export class Hovertip extends Component<HovertipProps, {}> {
|
||||
const width = containerSize.x
|
||||
const height = containerSize.y
|
||||
|
||||
const positionStyle: {
|
||||
left?: number
|
||||
right?: number
|
||||
top?: number
|
||||
bottom?: number
|
||||
} = {}
|
||||
const positionStyle: {[key: string]: number} = {}
|
||||
|
||||
const OFFSET_FROM_MOUSE = 7
|
||||
if (offset.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import {memoizeByShallowEquality} from '../lib/utils'
|
||||
import {Profile, Frame} from '../lib/profile'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {
|
||||
createMemoizedFlamechartRenderer,
|
||||
FlamechartViewContainerProps,
|
||||
createFlamechartSetters,
|
||||
} from './flamechart-view-container'
|
||||
import {createContainer, Dispatch} from '../lib/typed-redux'
|
||||
import {ApplicationState} from '../store'
|
||||
import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getProfileWithRecursionFlattened,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
|
||||
const getInvertedCallerProfile = memoizeByShallowEquality(
|
||||
({
|
||||
profile,
|
||||
frame,
|
||||
flattenRecursion,
|
||||
}: {
|
||||
profile: Profile
|
||||
frame: Frame
|
||||
flattenRecursion: boolean
|
||||
}): Profile => {
|
||||
let p = profile.getInvertedProfileForCallersOf(frame)
|
||||
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
|
||||
},
|
||||
)
|
||||
|
||||
const getInvertedCallerFlamegraph = memoizeByShallowEquality(
|
||||
({
|
||||
invertedCallerProfile,
|
||||
getColorBucketForFrame,
|
||||
}: {
|
||||
invertedCallerProfile: Profile
|
||||
getColorBucketForFrame: (frame: Frame) => number
|
||||
}): Flamechart => {
|
||||
return new Flamechart({
|
||||
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
|
||||
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
|
||||
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const getInvertedCallerFlamegraphRenderer = createMemoizedFlamechartRenderer({inverted: true})
|
||||
|
||||
export const InvertedCallerFlamegraphView = createContainer(
|
||||
FlamechartWrapper,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
let {profile, sandwichViewState, index} = activeProfileState
|
||||
let {flattenRecursion, glCanvas} = state
|
||||
if (!profile) throw new Error('profile missing')
|
||||
if (!glCanvas) throw new Error('glCanvas missing')
|
||||
const {callerCallee} = sandwichViewState
|
||||
if (!callerCallee) throw new Error('callerCallee missing')
|
||||
const {selectedFrame} = callerCallee
|
||||
|
||||
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
|
||||
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
|
||||
const flamechart = getInvertedCallerFlamegraph({
|
||||
invertedCallerProfile: getInvertedCallerProfile({
|
||||
profile,
|
||||
frame: selectedFrame,
|
||||
flattenRecursion,
|
||||
}),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getInvertedCallerFlamegraphRenderer({canvasContext, flamechart})
|
||||
|
||||
return {
|
||||
renderInverted: true,
|
||||
flamechart,
|
||||
flamechartRenderer,
|
||||
canvasContext,
|
||||
getCSSColorForFrame,
|
||||
...createFlamechartSetters(dispatch, FlamechartID.SANDWICH_INVERTED_CALLERS, index),
|
||||
// This overrides the setSelectedNode specified in createFlamechartSettesr
|
||||
setSelectedNode: () => {},
|
||||
...callerCallee.invertedCallerFlamegraph,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import {memoizeByShallowEquality, noop} from '../lib/utils'
|
||||
import {Profile, Frame} from '../lib/profile'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {
|
||||
createMemoizedFlamechartRenderer,
|
||||
FlamechartViewContainerProps,
|
||||
useFlamechartSetters,
|
||||
} from './flamechart-view-container'
|
||||
import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {useAppSelector} from '../store'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
|
||||
const getInvertedCallerProfile = memoizeByShallowEquality(
|
||||
({
|
||||
profile,
|
||||
frame,
|
||||
flattenRecursion,
|
||||
}: {
|
||||
profile: Profile
|
||||
frame: Frame
|
||||
flattenRecursion: boolean
|
||||
}): Profile => {
|
||||
let p = profile.getInvertedProfileForCallersOf(frame)
|
||||
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
|
||||
},
|
||||
)
|
||||
|
||||
const getInvertedCallerFlamegraph = memoizeByShallowEquality(
|
||||
({
|
||||
invertedCallerProfile,
|
||||
getColorBucketForFrame,
|
||||
}: {
|
||||
invertedCallerProfile: Profile
|
||||
getColorBucketForFrame: (frame: Frame) => number
|
||||
}): Flamechart => {
|
||||
return new Flamechart({
|
||||
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
|
||||
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
|
||||
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const getInvertedCallerFlamegraphRenderer = createMemoizedFlamechartRenderer({inverted: true})
|
||||
|
||||
export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
let {profile, sandwichViewState, index} = activeProfileState
|
||||
const flattenRecursion = useAppSelector(state => state.flattenRecursion, [])
|
||||
const glCanvas = useAppSelector(state => state.glCanvas, [])
|
||||
|
||||
if (!profile) throw new Error('profile missing')
|
||||
if (!glCanvas) throw new Error('glCanvas missing')
|
||||
const {callerCallee} = sandwichViewState
|
||||
if (!callerCallee) throw new Error('callerCallee missing')
|
||||
const {selectedFrame} = callerCallee
|
||||
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const canvasContext = getCanvasContext(glCanvas)
|
||||
|
||||
const flamechart = getInvertedCallerFlamegraph({
|
||||
invertedCallerProfile: getInvertedCallerProfile({
|
||||
profile,
|
||||
frame: selectedFrame,
|
||||
flattenRecursion,
|
||||
}),
|
||||
getColorBucketForFrame,
|
||||
})
|
||||
const flamechartRenderer = getInvertedCallerFlamegraphRenderer({canvasContext, flamechart})
|
||||
|
||||
return (
|
||||
<FlamechartWrapper
|
||||
renderInverted={true}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
import {Profile} from '../lib/profile'
|
||||
import {h, JSX, ComponentChild, Ref} from 'preact'
|
||||
import {useCallback, useState, useMemo, useEffect, useRef} from 'preact/hooks'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {Colors, ZIndex, Sizes} from './style'
|
||||
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
|
||||
import {sortBy} from '../lib/utils'
|
||||
|
||||
interface ProfileSelectRowProps {
|
||||
setProfileIndexToView: (profileIndex: number) => void
|
||||
setHoveredProfileIndex: (profileIndex: number) => void
|
||||
profile: Profile
|
||||
matchedRanges: [number, number][]
|
||||
hovered: boolean
|
||||
selected: boolean
|
||||
indexInProfileGroup: number
|
||||
indexInFilteredListView: number
|
||||
profileCount: number
|
||||
nodeRef?: Ref<HTMLDivElement>
|
||||
closeProfileSelect: () => void
|
||||
}
|
||||
|
||||
function highlightRanges(text: string, ranges: [number, number][]): JSX.Element {
|
||||
const spans: ComponentChild[] = []
|
||||
let last = 0
|
||||
for (let range of ranges) {
|
||||
spans.push(text.slice(last, range[0]))
|
||||
spans.push(<span className={css(style.highlighted)}>{text.slice(range[0], range[1])}</span>)
|
||||
last = range[1]
|
||||
}
|
||||
spans.push(text.slice(last))
|
||||
|
||||
return <span>{spans}</span>
|
||||
}
|
||||
|
||||
export function ProfileSelectRow({
|
||||
setProfileIndexToView,
|
||||
setHoveredProfileIndex,
|
||||
profile,
|
||||
selected,
|
||||
hovered,
|
||||
profileCount,
|
||||
nodeRef,
|
||||
closeProfileSelect,
|
||||
indexInProfileGroup,
|
||||
matchedRanges,
|
||||
indexInFilteredListView,
|
||||
}: ProfileSelectRowProps) {
|
||||
const onMouseUp = useCallback(() => {
|
||||
closeProfileSelect()
|
||||
setProfileIndexToView(indexInProfileGroup)
|
||||
}, [closeProfileSelect, setProfileIndexToView, indexInProfileGroup])
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(ev: Event) => {
|
||||
setHoveredProfileIndex(indexInProfileGroup)
|
||||
},
|
||||
[setHoveredProfileIndex, indexInProfileGroup],
|
||||
)
|
||||
|
||||
const name = profile.getName()
|
||||
|
||||
const maxDigits = 1 + Math.floor(Math.log10(profileCount))
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
const result = highlightRanges(name, matchedRanges)
|
||||
return result
|
||||
}, [name, matchedRanges])
|
||||
|
||||
// TODO(jlfwong): There's a really gnarly edge-case here where the highlighted
|
||||
// ranges are part of the text truncated by ellipsis. I'm just going to punt
|
||||
// on solving for that.
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseEnter={onMouseEnter}
|
||||
title={name}
|
||||
className={css(
|
||||
style.profileRow,
|
||||
indexInFilteredListView % 2 === 0 && style.profileRowEven,
|
||||
selected && style.profileRowSelected,
|
||||
hovered && style.profileRowHovered,
|
||||
)}
|
||||
>
|
||||
<span className={css(style.profileIndex)} style={{width: maxDigits + 'em'}}>
|
||||
{indexInProfileGroup + 1}:
|
||||
</span>{' '}
|
||||
{highlighted}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileSelectProps {
|
||||
setProfileIndexToView: (profileIndex: number) => void
|
||||
indexToView: number
|
||||
profiles: Profile[]
|
||||
closeProfileSelect: () => void
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
function stopPropagation(ev: Event) {
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
interface FilteredProfile {
|
||||
indexInProfileGroup: number
|
||||
profile: Profile
|
||||
matchedRanges: [number, number][]
|
||||
score: number
|
||||
}
|
||||
|
||||
function getSortedFilteredProfiles(profiles: Profile[], filterText: string): FilteredProfile[] {
|
||||
const filtered: FilteredProfile[] = []
|
||||
for (let i = 0; i < profiles.length; i++) {
|
||||
const profile = profiles[i]
|
||||
const match = fuzzyMatchStrings(profile.getName(), filterText)
|
||||
if (!match) continue
|
||||
filtered.push({
|
||||
indexInProfileGroup: i,
|
||||
profile,
|
||||
...match,
|
||||
})
|
||||
}
|
||||
sortBy(filtered, p => -p.score)
|
||||
return filtered
|
||||
}
|
||||
|
||||
export function ProfileSelect({
|
||||
profiles,
|
||||
closeProfileSelect,
|
||||
indexToView,
|
||||
visible,
|
||||
setProfileIndexToView,
|
||||
}: ProfileSelectProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
|
||||
const onFilterTextChange = useCallback(
|
||||
(ev: Event) => {
|
||||
const value = (ev.target as HTMLInputElement).value
|
||||
setFilterText(value)
|
||||
},
|
||||
[setFilterText],
|
||||
)
|
||||
|
||||
const focusFilterInput = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
if (visible) {
|
||||
node.select()
|
||||
} else {
|
||||
node.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
[visible],
|
||||
)
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return getSortedFilteredProfiles(profiles, filterText)
|
||||
}, [profiles, filterText])
|
||||
|
||||
const [hoveredProfileIndex, setHoveredProfileIndex] = useState<number | null>(0)
|
||||
|
||||
const selectedNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Whenever the profile select becomes visible...
|
||||
|
||||
// Clear any hovered element
|
||||
setHoveredProfileIndex(null)
|
||||
|
||||
// And scroll the selected profile into view, if possible
|
||||
if (selectedNodeRef.current !== null) {
|
||||
selectedNodeRef.current.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// TODO(jlfwong): Hi-jacking the behavior of enter and the arrow keys won't
|
||||
// work well for some composition methods (e.g. a Chinese character
|
||||
// composition keyboard input method).
|
||||
const onFilterKeyUp = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
// Prevent the key-press from propagating to other keyboard shortcut
|
||||
// handlers in other components.
|
||||
ev.stopPropagation()
|
||||
|
||||
let newHoveredIndexInFilteredList: number | null = null
|
||||
|
||||
switch (ev.key) {
|
||||
case 'Enter': {
|
||||
if (hoveredProfileIndex != null) {
|
||||
closeProfileSelect()
|
||||
setProfileIndexToView(hoveredProfileIndex)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
closeProfileSelect()
|
||||
break
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
ev.preventDefault()
|
||||
newHoveredIndexInFilteredList = 0
|
||||
if (hoveredProfileIndex != null) {
|
||||
const indexInFilteredList = filteredProfiles.findIndex(
|
||||
p => p.indexInProfileGroup === hoveredProfileIndex,
|
||||
)
|
||||
if (indexInFilteredList !== -1) {
|
||||
newHoveredIndexInFilteredList = indexInFilteredList + 1
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
ev.preventDefault()
|
||||
newHoveredIndexInFilteredList = filteredProfiles.length - 1
|
||||
if (hoveredProfileIndex != null) {
|
||||
const indexInFilteredList = filteredProfiles.findIndex(
|
||||
p => p.indexInProfileGroup === hoveredProfileIndex,
|
||||
)
|
||||
if (indexInFilteredList !== -1) {
|
||||
newHoveredIndexInFilteredList = indexInFilteredList - 1
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newHoveredIndexInFilteredList != null &&
|
||||
newHoveredIndexInFilteredList >= 0 &&
|
||||
newHoveredIndexInFilteredList < filteredProfiles.length
|
||||
) {
|
||||
const indexInProfileGroup =
|
||||
filteredProfiles[newHoveredIndexInFilteredList].indexInProfileGroup
|
||||
setHoveredProfileIndex(indexInProfileGroup)
|
||||
setPendingForcedScroll(true)
|
||||
}
|
||||
},
|
||||
[closeProfileSelect, setProfileIndexToView, hoveredProfileIndex, filteredProfiles],
|
||||
)
|
||||
|
||||
const [pendingForcedScroll, setPendingForcedScroll] = useState(false)
|
||||
useEffect(() => {
|
||||
// Whenever the list of filtered profiles changes, set the first element hovered.
|
||||
if (filteredProfiles.length > 0) {
|
||||
setHoveredProfileIndex(filteredProfiles[0].indexInProfileGroup)
|
||||
setPendingForcedScroll(true)
|
||||
}
|
||||
}, [setHoveredProfileIndex, filteredProfiles])
|
||||
|
||||
const hoveredNodeRef = useCallback(
|
||||
(hoveredNode: HTMLDivElement | null) => {
|
||||
if (pendingForcedScroll && hoveredNode) {
|
||||
hoveredNode.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
setPendingForcedScroll(false)
|
||||
}
|
||||
},
|
||||
[pendingForcedScroll, setPendingForcedScroll],
|
||||
)
|
||||
|
||||
const selectedHoveredRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
selectedNodeRef.current = node
|
||||
hoveredNodeRef(node)
|
||||
},
|
||||
[selectedNodeRef, hoveredNodeRef],
|
||||
)
|
||||
|
||||
// We allow ProfileSelect to be aware of its own visibility in order to retain
|
||||
// its scroll offset state between times when it's hidden & shown, and also to
|
||||
// scroll the selected node into view once it becomes shown again after the
|
||||
// selected profile has changed.
|
||||
return (
|
||||
<div className={css(style.profileSelectOuter)}>
|
||||
<div className={css(style.caret)} />
|
||||
<div className={css(style.profileSelectBox)}>
|
||||
{/* We stop event propagation for key events on the input to prevent
|
||||
this from triggering keyboard shortcuts. */}
|
||||
<div className={css(style.filterInputContainer)}>
|
||||
<input
|
||||
type="text"
|
||||
ref={focusFilterInput}
|
||||
placeholder={'Filter...'}
|
||||
value={filterText}
|
||||
onInput={onFilterTextChange}
|
||||
onKeyDown={onFilterKeyUp}
|
||||
onKeyUp={stopPropagation}
|
||||
onKeyPress={stopPropagation}
|
||||
/>
|
||||
</div>
|
||||
<div className={css(style.profileSelectScrolling)}>
|
||||
{filteredProfiles.map(({profile, matchedRanges, indexInProfileGroup}, indexInList) => {
|
||||
let ref: Ref<HTMLDivElement> | undefined = undefined
|
||||
const selected = indexInProfileGroup === indexToView
|
||||
const hovered = indexInProfileGroup === hoveredProfileIndex
|
||||
if (selected && hovered) {
|
||||
ref = selectedHoveredRef
|
||||
} else if (selected) {
|
||||
ref = selectedNodeRef
|
||||
} else if (hovered) {
|
||||
ref = hoveredNodeRef
|
||||
}
|
||||
return (
|
||||
<ProfileSelectRow
|
||||
setHoveredProfileIndex={setHoveredProfileIndex}
|
||||
indexInProfileGroup={indexInProfileGroup}
|
||||
indexInFilteredListView={indexInList}
|
||||
hovered={indexInProfileGroup == hoveredProfileIndex}
|
||||
selected={indexInProfileGroup === indexToView}
|
||||
profile={profile}
|
||||
profileCount={profiles.length}
|
||||
nodeRef={ref}
|
||||
matchedRanges={matchedRanges}
|
||||
setProfileIndexToView={setProfileIndexToView}
|
||||
closeProfileSelect={closeProfileSelect}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{filteredProfiles.length === 0 ? (
|
||||
<div className={css(style.profileRow)}>No results match filter "{filterText}"</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const paddingHeight = 10
|
||||
|
||||
const style = StyleSheet.create({
|
||||
filterInputContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 10,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
caret: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottom: '5px solid black',
|
||||
},
|
||||
highlighted: {
|
||||
background: Colors.PALE_DARK_BLUE,
|
||||
},
|
||||
padding: {
|
||||
height: paddingHeight,
|
||||
background: Colors.BLACK,
|
||||
},
|
||||
profileRow: {
|
||||
height: Sizes.FRAME_HEIGHT - 2,
|
||||
border: '1px solid transparent',
|
||||
textAlign: 'left',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
background: Colors.BLACK,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
profileRowHovered: {
|
||||
border: `1px solid ${Colors.DARK_BLUE}`,
|
||||
},
|
||||
profileRowSelected: {
|
||||
background: Colors.DARK_BLUE,
|
||||
},
|
||||
profileRowEven: {
|
||||
background: Colors.DARK_GRAY,
|
||||
},
|
||||
profileSelectScrolling: {
|
||||
maxHeight: `min(calc(100vh - ${Sizes.TOOLBAR_HEIGHT - 2 * paddingHeight}px), ${
|
||||
20 * Sizes.FRAME_HEIGHT
|
||||
}px)`,
|
||||
overflow: 'auto',
|
||||
},
|
||||
profileSelectBox: {
|
||||
width: '100%',
|
||||
paddingBottom: 10,
|
||||
background: Colors.BLACK,
|
||||
color: Colors.WHITE,
|
||||
},
|
||||
profileSelectOuter: {
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
margin: '0 auto',
|
||||
position: 'relative',
|
||||
zIndex: ZIndex.PROFILE_SELECT,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
profileIndex: {
|
||||
textAlign: 'right',
|
||||
display: 'inline-block',
|
||||
color: Colors.LIGHT_GRAY,
|
||||
},
|
||||
})
|
||||
+252
-178
@@ -1,15 +1,17 @@
|
||||
import {h, Component} from 'preact'
|
||||
import {h, Component, JSX, ComponentChild} from 'preact'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {Profile, Frame} from '../lib/profile'
|
||||
import {sortBy, formatPercent} from '../lib/utils'
|
||||
import {formatPercent} from '../lib/utils'
|
||||
import {FontSize, Colors, Sizes, commonStyle} from './style'
|
||||
import {ColorChit} from './color-chit'
|
||||
import {ScrollableListView, ListItem} from './scrollable-list-view'
|
||||
import {ListItem, ScrollableListView} from './scrollable-list-view'
|
||||
import {actions} from '../store/actions'
|
||||
import {Dispatch, createContainer} from '../lib/typed-redux'
|
||||
import {ApplicationState} from '../store'
|
||||
import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
|
||||
import {ActiveProfileState} from './application'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {useAppSelector, ActiveProfileState} from '../store'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {SandwichViewContext} from './sandwich-view'
|
||||
|
||||
export enum SortField {
|
||||
SYMBOL_NAME,
|
||||
@@ -31,14 +33,12 @@ interface HBarProps {
|
||||
perc: number
|
||||
}
|
||||
|
||||
class HBarDisplay extends Component<HBarProps, {}> {
|
||||
render() {
|
||||
return (
|
||||
<div className={css(style.hBarDisplay)}>
|
||||
<div className={css(style.hBarDisplayFilled)} style={{width: `${this.props.perc}%`}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function HBarDisplay(props: HBarProps) {
|
||||
return (
|
||||
<div className={css(style.hBarDisplay)}>
|
||||
<div className={css(style.hBarDisplayFilled)} style={{width: `${props.perc}%`}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortIconProps {
|
||||
@@ -67,156 +67,219 @@ class SortIcon extends Component<SortIconProps, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileTableRowViewProps {
|
||||
frame: Frame
|
||||
matchedRanges: [number, number][] | null
|
||||
index: number
|
||||
profile: Profile
|
||||
selectedFrame: Frame | null
|
||||
setSelectedFrame: (f: Frame) => void
|
||||
getCSSColorForFrame: (frame: Frame) => string
|
||||
}
|
||||
|
||||
function highlightRanges(
|
||||
text: string,
|
||||
ranges: [number, number][],
|
||||
highlightedClassName: string,
|
||||
): JSX.Element {
|
||||
const spans: ComponentChild[] = []
|
||||
let last = 0
|
||||
for (let range of ranges) {
|
||||
spans.push(text.slice(last, range[0]))
|
||||
spans.push(<span className={highlightedClassName}>{text.slice(range[0], range[1])}</span>)
|
||||
last = range[1]
|
||||
}
|
||||
spans.push(text.slice(last))
|
||||
|
||||
return <span>{spans}</span>
|
||||
}
|
||||
|
||||
const ProfileTableRowView = ({
|
||||
frame,
|
||||
matchedRanges,
|
||||
profile,
|
||||
index,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
}: ProfileTableRowViewProps) => {
|
||||
const totalWeight = frame.getTotalWeight()
|
||||
const selfWeight = frame.getSelfWeight()
|
||||
const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
|
||||
const selfPerc = (100.0 * selfWeight) / profile.getTotalNonIdleWeight()
|
||||
|
||||
const selected = frame === selectedFrame
|
||||
|
||||
// We intentionally use index rather than frame.key here as the tr key
|
||||
// in order to re-use rows when sorting rather than creating all new elements.
|
||||
return (
|
||||
<tr
|
||||
key={`${index}`}
|
||||
onClick={setSelectedFrame.bind(null, frame)}
|
||||
className={css(
|
||||
style.tableRow,
|
||||
index % 2 == 0 && style.tableRowEven,
|
||||
selected && style.tableRowSelected,
|
||||
)}
|
||||
>
|
||||
<td className={css(style.numericCell)}>
|
||||
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
|
||||
<HBarDisplay perc={totalPerc} />
|
||||
</td>
|
||||
<td className={css(style.numericCell)}>
|
||||
{profile.formatValue(selfWeight)} ({formatPercent(selfPerc)})
|
||||
<HBarDisplay perc={selfPerc} />
|
||||
</td>
|
||||
<td title={frame.file} className={css(style.textCell)}>
|
||||
<ColorChit color={getCSSColorForFrame(frame)} />
|
||||
{matchedRanges
|
||||
? highlightRanges(
|
||||
frame.name,
|
||||
matchedRanges,
|
||||
css(style.matched, selected && style.matchedSelected),
|
||||
)
|
||||
: frame.name}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileTableViewProps {
|
||||
profile: Profile
|
||||
profileIndex: number
|
||||
selectedFrame: Frame | null
|
||||
getCSSColorForFrame: (frame: Frame) => string
|
||||
sortMethod: SortMethod
|
||||
setSelectedFrame: (frame: Frame | null) => void
|
||||
setSortMethod: (sortMethod: SortMethod) => void
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
}
|
||||
|
||||
export class ProfileTableView extends Component<ProfileTableViewProps, void> {
|
||||
renderRow(frame: Frame, index: number) {
|
||||
const {profile, selectedFrame} = this.props
|
||||
export const ProfileTableView = memo(
|
||||
({
|
||||
profile,
|
||||
sortMethod,
|
||||
setSortMethod,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
searchQuery,
|
||||
searchIsActive,
|
||||
}: ProfileTableViewProps) => {
|
||||
const onSortClick = useCallback(
|
||||
(field: SortField, ev: MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
|
||||
const totalWeight = frame.getTotalWeight()
|
||||
const selfWeight = frame.getSelfWeight()
|
||||
const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
|
||||
const selfPerc = 100.0 * selfWeight / profile.getTotalNonIdleWeight()
|
||||
|
||||
const selected = frame === selectedFrame
|
||||
|
||||
// We intentionally use index rather than frame.key here as the tr key
|
||||
// in order to re-use rows when sorting rather than creating all new elements.
|
||||
return (
|
||||
<tr
|
||||
key={`${index}`}
|
||||
onClick={this.props.setSelectedFrame.bind(null, frame)}
|
||||
className={css(
|
||||
style.tableRow,
|
||||
index % 2 == 0 && style.tableRowEven,
|
||||
selected && style.tableRowSelected,
|
||||
)}
|
||||
>
|
||||
<td className={css(style.numericCell)}>
|
||||
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
|
||||
<HBarDisplay perc={totalPerc} />
|
||||
</td>
|
||||
<td className={css(style.numericCell)}>
|
||||
{profile.formatValue(selfWeight)} ({formatPercent(selfPerc)})
|
||||
<HBarDisplay perc={selfPerc} />
|
||||
</td>
|
||||
<td title={frame.file} className={css(style.textCell)}>
|
||||
<ColorChit color={this.props.getCSSColorForFrame(frame)} />
|
||||
{frame.name}
|
||||
</td>
|
||||
</tr>
|
||||
if (sortMethod.field == field) {
|
||||
// Toggle
|
||||
setSortMethod({
|
||||
field,
|
||||
direction:
|
||||
sortMethod.direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING,
|
||||
})
|
||||
} else {
|
||||
// Set a sane default
|
||||
switch (field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
setSortMethod({field, direction: SortDirection.ASCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[sortMethod, setSortMethod],
|
||||
)
|
||||
}
|
||||
|
||||
onSortClick = (field: SortField, ev: MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
const sandwichContext = useContext(SandwichViewContext)
|
||||
|
||||
const {sortMethod} = this.props
|
||||
const renderItems = useCallback(
|
||||
(firstIndex: number, lastIndex: number) => {
|
||||
if (!sandwichContext) return null
|
||||
|
||||
if (sortMethod.field == field) {
|
||||
// Toggle
|
||||
this.props.setSortMethod({
|
||||
field,
|
||||
direction:
|
||||
sortMethod.direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING,
|
||||
})
|
||||
} else {
|
||||
// Set a sane default
|
||||
switch (field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
this.props.setSortMethod({field, direction: SortDirection.ASCENDING})
|
||||
break
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
const frame = sandwichContext.rowList[i]
|
||||
const match = sandwichContext.getSearchMatchForFrame(frame)
|
||||
rows.push(
|
||||
ProfileTableRowView({
|
||||
frame,
|
||||
matchedRanges: match == null ? null : match.matchedRanges,
|
||||
index: i,
|
||||
profile: profile,
|
||||
selectedFrame: selectedFrame,
|
||||
setSelectedFrame: setSelectedFrame,
|
||||
getCSSColorForFrame: getCSSColorForFrame,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case SortField.SELF: {
|
||||
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (searchIsActive) {
|
||||
rows.push(
|
||||
<tr>
|
||||
<td className={css(style.emptyState)}>
|
||||
No symbol names match query "{searchQuery}".
|
||||
</td>
|
||||
</tr>,
|
||||
)
|
||||
} else {
|
||||
rows.push(
|
||||
<tr>
|
||||
<td className={css(style.emptyState)}>No symbols found.</td>
|
||||
</tr>,
|
||||
)
|
||||
}
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFrameList = (): Frame[] => {
|
||||
const {profile, sortMethod} = this.props
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
},
|
||||
[
|
||||
sandwichContext,
|
||||
profile,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
searchIsActive,
|
||||
searchQuery,
|
||||
],
|
||||
)
|
||||
|
||||
const frameList: Frame[] = []
|
||||
const listItems: ListItem[] = useMemo(
|
||||
() =>
|
||||
sandwichContext == null
|
||||
? []
|
||||
: sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})),
|
||||
[sandwichContext],
|
||||
)
|
||||
|
||||
profile.forEachFrame(f => frameList.push(f))
|
||||
|
||||
// TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't
|
||||
// seem to be a bottleneck, so we'll leave it alone.
|
||||
switch (sortMethod.field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
sortBy(frameList, f => f.name.toLowerCase())
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
sortBy(frameList, f => f.getSelfWeight())
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
sortBy(frameList, f => f.getTotalWeight())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sortMethod.direction === SortDirection.DESCENDING) {
|
||||
frameList.reverse()
|
||||
}
|
||||
|
||||
return frameList
|
||||
}
|
||||
|
||||
private listView: ScrollableListView | null = null
|
||||
private listViewRef = (listView: ScrollableListView | null) => {
|
||||
if (listView === this.listView) return
|
||||
this.listView = listView
|
||||
|
||||
const {selectedFrame} = this.props
|
||||
if (!selectedFrame || !listView) return
|
||||
const index = this.getFrameList().indexOf(selectedFrame)
|
||||
if (index === -1) return
|
||||
listView.scrollIndexIntoView(index)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {sortMethod} = this.props
|
||||
|
||||
const frameList = this.getFrameList()
|
||||
|
||||
const renderItems = (firstIndex: number, lastIndex: number) => {
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
rows.push(this.renderRow(frameList[i], i))
|
||||
}
|
||||
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
}
|
||||
|
||||
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
|
||||
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSymbolNameClick = useCallback(
|
||||
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
|
||||
[onSortClick],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css(commonStyle.vbox, style.profileTableView)}>
|
||||
<table className={css(style.tableView)}>
|
||||
<thead className={css(style.tableHeader)}>
|
||||
<tr>
|
||||
<th
|
||||
className={css(style.numericCell)}
|
||||
onClick={ev => this.onSortClick(SortField.TOTAL, ev)}
|
||||
>
|
||||
<th className={css(style.numericCell)} onClick={onTotalClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
|
||||
@@ -224,10 +287,7 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
|
||||
/>
|
||||
Total
|
||||
</th>
|
||||
<th
|
||||
className={css(style.numericCell)}
|
||||
onClick={ev => this.onSortClick(SortField.SELF, ev)}
|
||||
>
|
||||
<th className={css(style.numericCell)} onClick={onSelfClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.SELF ? sortMethod.direction : null
|
||||
@@ -235,10 +295,7 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
|
||||
/>
|
||||
Self
|
||||
</th>
|
||||
<th
|
||||
className={css(style.textCell)}
|
||||
onClick={ev => this.onSortClick(SortField.SYMBOL_NAME, ev)}
|
||||
>
|
||||
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
|
||||
@@ -250,16 +307,18 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
|
||||
</thead>
|
||||
</table>
|
||||
<ScrollableListView
|
||||
ref={this.listViewRef}
|
||||
axis={'y'}
|
||||
items={listItems}
|
||||
className={css(style.scrollView)}
|
||||
renderItems={renderItems}
|
||||
initialIndexInView={
|
||||
selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const style = StyleSheet.create({
|
||||
profileTableView: {
|
||||
@@ -269,6 +328,7 @@ const style = StyleSheet.create({
|
||||
scrollView: {
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexGrow: 1,
|
||||
},
|
||||
tableView: {
|
||||
width: '100%',
|
||||
@@ -327,40 +387,54 @@ const style = StyleSheet.create({
|
||||
background: Colors.GREEN,
|
||||
right: 0,
|
||||
},
|
||||
matched: {
|
||||
borderBottom: `2px solid ${Colors.BLACK}`,
|
||||
},
|
||||
matchedSelected: {
|
||||
borderColor: Colors.WHITE,
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
interface ProfileTableViewContainerProps {
|
||||
activeProfileState: ActiveProfileState
|
||||
}
|
||||
|
||||
export const ProfileTableViewContainer = createContainer(
|
||||
ProfileTableView,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: ProfileTableViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
const {profile, sandwichViewState, index} = activeProfileState
|
||||
if (!profile) throw new Error('profile missing')
|
||||
const {tableSortMethod} = state
|
||||
const {callerCallee} = sandwichViewState
|
||||
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
const {setTableSortMethod} = actions.sandwichView
|
||||
|
||||
const setSelectedFrame = (selectedFrame: Frame | null) => {
|
||||
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: index, args: selectedFrame}))
|
||||
}
|
||||
export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContainerProps) => {
|
||||
const {activeProfileState} = ownProps
|
||||
const {profile, sandwichViewState, index} = activeProfileState
|
||||
if (!profile) throw new Error('profile missing')
|
||||
const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
|
||||
const {callerCallee} = sandwichViewState
|
||||
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
|
||||
const setSortMethod = (sortMethod: SortMethod) => {
|
||||
dispatch(actions.sandwichView.setTableSortMethod(sortMethod))
|
||||
}
|
||||
const setSelectedFrame = useActionCreator(
|
||||
(selectedFrame: Frame | null) => {
|
||||
return actions.sandwichView.setSelectedFrame({profileIndex: index, args: selectedFrame})
|
||||
},
|
||||
[index],
|
||||
)
|
||||
const setSortMethod = useActionCreator(setTableSortMethod, [])
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
|
||||
return {
|
||||
profile,
|
||||
profileIndex: activeProfileState.index,
|
||||
selectedFrame,
|
||||
getCSSColorForFrame,
|
||||
sortMethod: tableSortMethod,
|
||||
setSelectedFrame,
|
||||
setSortMethod,
|
||||
}
|
||||
},
|
||||
)
|
||||
return (
|
||||
<ProfileTableView
|
||||
profile={profile}
|
||||
selectedFrame={selectedFrame}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
sortMethod={tableSortMethod}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
setSortMethod={setSortMethod}
|
||||
searchIsActive={searchIsActive}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {memo} from 'preact/compat'
|
||||
import {useContext, useMemo} from 'preact/hooks'
|
||||
import {SearchView} from './search-view'
|
||||
import {h} from 'preact'
|
||||
import {SandwichViewContext} from './sandwich-view'
|
||||
|
||||
export const SandwichSearchView = memo(() => {
|
||||
const sandwichViewContext = useContext(SandwichViewContext)
|
||||
|
||||
const rowList = sandwichViewContext != null ? sandwichViewContext.rowList : null
|
||||
const resultIndex =
|
||||
sandwichViewContext?.selectedFrame != null
|
||||
? sandwichViewContext.getIndexForFrame(sandwichViewContext.selectedFrame)
|
||||
: null
|
||||
const numResults = rowList != null ? rowList.length : null
|
||||
|
||||
const {selectPrev, selectNext} = useMemo(() => {
|
||||
if (rowList == null || numResults == null || numResults === 0 || sandwichViewContext == null) {
|
||||
return {selectPrev: () => {}, selectNext: () => {}}
|
||||
}
|
||||
|
||||
return {
|
||||
selectPrev: () => {
|
||||
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
|
||||
if (index < 0) index = numResults - 1
|
||||
sandwichViewContext.setSelectedFrame(rowList[index])
|
||||
},
|
||||
selectNext: () => {
|
||||
let index = resultIndex == null ? 0 : resultIndex + 1
|
||||
if (index >= numResults) index = 0
|
||||
sandwichViewContext.setSelectedFrame(rowList[index])
|
||||
},
|
||||
}
|
||||
}, [resultIndex, rowList, numResults, sandwichViewContext])
|
||||
|
||||
return (
|
||||
<SearchView
|
||||
resultIndex={resultIndex}
|
||||
numResults={numResults}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+106
-20
@@ -1,14 +1,20 @@
|
||||
import {Frame} from '../lib/profile'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {ProfileTableViewContainer} from './profile-table-view'
|
||||
import {h} from 'preact'
|
||||
import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view'
|
||||
import {h, JSX, createContext} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {commonStyle, Sizes, Colors, FontSize} from './style'
|
||||
import {actions} from '../store/actions'
|
||||
import {createContainer, Dispatch, StatelessComponent} from '../lib/typed-redux'
|
||||
import {ApplicationState} from '../store'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
|
||||
import {CalleeFlamegraphView} from './callee-flamegraph-view'
|
||||
import {ActiveProfileState} from './application'
|
||||
import {useDispatch} from '../lib/preact-redux'
|
||||
import {SandwichSearchView} from './sandwich-search-view'
|
||||
import {useAppSelector, ActiveProfileState} from '../store'
|
||||
import {sortBy} from '../lib/utils'
|
||||
import {ProfileSearchContext} from './search-view'
|
||||
import {FuzzyMatch} from '../lib/fuzzy-find'
|
||||
|
||||
interface SandwichViewProps {
|
||||
selectedFrame: Frame | null
|
||||
@@ -70,6 +76,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
|
||||
<div className={css(style.tableView)}>
|
||||
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
|
||||
<SandwichSearchView />
|
||||
</div>
|
||||
{flamegraphViews}
|
||||
</div>
|
||||
@@ -79,6 +86,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
|
||||
const style = StyleSheet.create({
|
||||
tableView: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
},
|
||||
panZoomViewWraper: {
|
||||
@@ -122,28 +130,106 @@ interface SandwichViewContainerProps {
|
||||
glCanvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
export const SandwichViewContainer = createContainer(
|
||||
SandwichView,
|
||||
(state: ApplicationState, dispatch: Dispatch, ownProps: SandwichViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
const {sandwichViewState, index} = activeProfileState
|
||||
const {callerCallee} = sandwichViewState
|
||||
interface SandwichViewContextData {
|
||||
rowList: Frame[]
|
||||
selectedFrame: Frame | null
|
||||
setSelectedFrame: (frame: Frame | null) => void
|
||||
getIndexForFrame: (frame: Frame) => number | null
|
||||
getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null
|
||||
}
|
||||
|
||||
const setSelectedFrame = (selectedFrame: Frame | null) => {
|
||||
export const SandwichViewContext = createContext<SandwichViewContextData | null>(null)
|
||||
|
||||
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
const {sandwichViewState, index} = activeProfileState
|
||||
const {callerCallee} = sandwichViewState
|
||||
|
||||
const dispatch = useDispatch()
|
||||
const setSelectedFrame = useCallback(
|
||||
(selectedFrame: Frame | null) => {
|
||||
dispatch(
|
||||
actions.sandwichView.setSelectedFrame({
|
||||
profileIndex: index,
|
||||
args: selectedFrame,
|
||||
}),
|
||||
)
|
||||
},
|
||||
[dispatch, index],
|
||||
)
|
||||
|
||||
const profile = activeProfileState.profile
|
||||
const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
|
||||
const profileSearchResults = useContext(ProfileSearchContext)
|
||||
|
||||
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
|
||||
|
||||
const rowList: Frame[] = useMemo(() => {
|
||||
const rowList: Frame[] = []
|
||||
|
||||
profile.forEachFrame(frame => {
|
||||
if (profileSearchResults && !profileSearchResults.getMatchForFrame(frame)) {
|
||||
return
|
||||
}
|
||||
rowList.push(frame)
|
||||
})
|
||||
|
||||
switch (tableSortMethod.field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
sortBy(rowList, f => f.name.toLowerCase())
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
sortBy(rowList, f => f.getSelfWeight())
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
sortBy(rowList, f => f.getTotalWeight())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (tableSortMethod.direction === SortDirection.DESCENDING) {
|
||||
rowList.reverse()
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfileState: activeProfileState,
|
||||
glCanvas,
|
||||
setSelectedFrame,
|
||||
selectedFrame: callerCallee ? callerCallee.selectedFrame : null,
|
||||
profileIndex: index,
|
||||
return rowList
|
||||
}, [profile, profileSearchResults, tableSortMethod])
|
||||
|
||||
const getIndexForFrame: (frame: Frame) => number | null = useMemo(() => {
|
||||
const indexByFrame = new Map<Frame, number>()
|
||||
for (let i = 0; i < rowList.length; i++) {
|
||||
indexByFrame.set(rowList[i], i)
|
||||
}
|
||||
},
|
||||
)
|
||||
return (frame: Frame) => {
|
||||
const index = indexByFrame.get(frame)
|
||||
return index == null ? null : index
|
||||
}
|
||||
}, [rowList])
|
||||
|
||||
const getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null = useMemo(() => {
|
||||
return (frame: Frame) => {
|
||||
if (profileSearchResults == null) return null
|
||||
return profileSearchResults.getMatchForFrame(frame)
|
||||
}
|
||||
}, [profileSearchResults])
|
||||
|
||||
const contextData: SandwichViewContextData = {
|
||||
rowList,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getIndexForFrame,
|
||||
getSearchMatchForFrame,
|
||||
}
|
||||
|
||||
return (
|
||||
<SandwichViewContext.Provider value={contextData}>
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
glCanvas={glCanvas}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
selectedFrame={selectedFrame}
|
||||
profileIndex={index}
|
||||
/>
|
||||
</SandwichViewContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,57 +1,79 @@
|
||||
// A simple implementation of an efficient scrolling list view which
|
||||
// renders only items within the viewport + a couple extra items.
|
||||
|
||||
import {h, Component} from 'preact'
|
||||
import {h, JSX} from 'preact'
|
||||
import {useState, useCallback, useRef, useMemo, useEffect} from 'preact/hooks'
|
||||
|
||||
export interface ListItem {
|
||||
size: number
|
||||
}
|
||||
|
||||
interface RangeResult {
|
||||
firstVisibleIndex: number
|
||||
lastVisibleIndex: number
|
||||
invisiblePrefixSize: number
|
||||
}
|
||||
|
||||
interface ScrollableListViewProps {
|
||||
items: ListItem[]
|
||||
axis: 'x' | 'y'
|
||||
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
|
||||
renderItems: (
|
||||
firstVisibleIndex: number,
|
||||
lastVisibleIndex: number,
|
||||
) => JSX.Element | JSX.Element[] | null
|
||||
className?: string
|
||||
initialIndexInView?: number | null
|
||||
}
|
||||
|
||||
interface ScrollableListViewState {
|
||||
firstVisibleIndex: number | null
|
||||
lastVisibleIndex: number | null
|
||||
invisiblePrefixSize: number | null
|
||||
viewportSize: number | null
|
||||
cachedTotalSize: number
|
||||
}
|
||||
export const ScrollableListView = ({
|
||||
items,
|
||||
axis,
|
||||
renderItems,
|
||||
className,
|
||||
initialIndexInView,
|
||||
}: ScrollableListViewProps) => {
|
||||
const [viewportSize, setViewportSize] = useState<number | null>(null)
|
||||
const [viewportScrollOffset, setViewportScrollOffset] = useState<number>(0)
|
||||
|
||||
export class ScrollableListView extends Component<
|
||||
ScrollableListViewProps,
|
||||
ScrollableListViewState
|
||||
> {
|
||||
constructor(props: ScrollableListViewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
firstVisibleIndex: null,
|
||||
lastVisibleIndex: null,
|
||||
invisiblePrefixSize: null,
|
||||
viewportSize: null,
|
||||
cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0),
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const widthOrHeight = axis === 'x' ? 'width' : 'height'
|
||||
const leftOrTop = axis === 'x' ? 'left' : 'top'
|
||||
const scrollLeftOrScrollTop = axis === 'x' ? 'scrollLeft' : 'scrollTop'
|
||||
|
||||
// This is kind of a weird hack, but I'm not sure what the better of doing something like this is.
|
||||
const offset = initialIndexInView
|
||||
? items.reduce((a, b, i) => (i < initialIndexInView ? a + b.size : a), 0)
|
||||
: 0
|
||||
const initialScroll = useRef<number | null>(offset)
|
||||
|
||||
const viewportCallback = useCallback(
|
||||
(viewport: HTMLDivElement | null) => {
|
||||
if (viewport) {
|
||||
requestAnimationFrame(() => {
|
||||
setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
|
||||
if (initialScroll.current != null) {
|
||||
viewport.scrollTo({[leftOrTop]: initialScroll.current})
|
||||
initialScroll.current = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setViewportSize(null)
|
||||
}
|
||||
viewportRef.current = viewport
|
||||
},
|
||||
[setViewportSize, widthOrHeight, leftOrTop],
|
||||
)
|
||||
|
||||
const rangeResult: RangeResult | null = useMemo(() => {
|
||||
if (viewportRef.current == null || viewportSize == null || viewportScrollOffset == null) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private viewport: HTMLDivElement | null = null
|
||||
private viewportRef = (viewport?: Element) => {
|
||||
this.viewport = (viewport as HTMLDivElement) || null
|
||||
}
|
||||
|
||||
private recomputeVisibleIndices(props: ScrollableListViewProps) {
|
||||
if (!this.viewport) return
|
||||
const {items} = props
|
||||
|
||||
const viewportSize = this.viewport.getBoundingClientRect().height
|
||||
|
||||
// We render items up to a quarter viewport height outside of the
|
||||
// viewport both above and below to prevent flickering.
|
||||
const minY = this.viewport.scrollTop - viewportSize / 4
|
||||
const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4
|
||||
const minY = viewportScrollOffset - viewportSize / 4
|
||||
const maxY = viewportScrollOffset + viewportSize + viewportSize / 4
|
||||
|
||||
let total = 0
|
||||
let invisiblePrefixSize = 0
|
||||
@@ -77,62 +99,54 @@ export class ScrollableListView extends Component<
|
||||
}
|
||||
|
||||
const lastVisibleIndex = Math.min(i, items.length - 1)
|
||||
this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex})
|
||||
}
|
||||
|
||||
private pendingScroll = 0
|
||||
public scrollIndexIntoView(index: number) {
|
||||
this.pendingScroll = this.props.items.reduce((sum, cur, i) => {
|
||||
if (i >= index) return sum
|
||||
return sum + cur.size
|
||||
}, 0)
|
||||
}
|
||||
private applyPendingScroll() {
|
||||
if (!this.viewport) return
|
||||
|
||||
const leftOrTop = this.props.axis === 'y' ? 'top' : 'left'
|
||||
this.viewport.scrollTo({
|
||||
[leftOrTop]: this.pendingScroll,
|
||||
})
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: ScrollableListViewProps) {
|
||||
if (this.props.items !== nextProps.items) {
|
||||
this.recomputeVisibleIndices(nextProps)
|
||||
return {
|
||||
firstVisibleIndex,
|
||||
lastVisibleIndex,
|
||||
invisiblePrefixSize,
|
||||
}
|
||||
}
|
||||
}, [viewportSize, viewportScrollOffset, items])
|
||||
|
||||
componentDidMount() {
|
||||
this.applyPendingScroll()
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
}
|
||||
const totalSize = useMemo(() => items.reduce((a, b) => a + b.size, 0), [items])
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
}
|
||||
const onViewportScroll = useCallback(() => {
|
||||
if (viewportRef.current != null) {
|
||||
setViewportScrollOffset(viewportRef.current[scrollLeftOrScrollTop])
|
||||
}
|
||||
}, [scrollLeftOrScrollTop])
|
||||
|
||||
onWindowResize = () => {
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
}
|
||||
useEffect(() => {
|
||||
const resizeListener = () => {
|
||||
if (viewportRef.current != null) {
|
||||
setViewportSize(viewportRef.current.getBoundingClientRect()[widthOrHeight])
|
||||
}
|
||||
}
|
||||
|
||||
onViewportScroll = (ev: UIEvent) => {
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
}
|
||||
window.addEventListener('resize', resizeListener)
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeListener)
|
||||
}
|
||||
}, [widthOrHeight])
|
||||
|
||||
render() {
|
||||
const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state
|
||||
const visibleItems = useMemo(() => {
|
||||
return rangeResult
|
||||
? renderItems(rangeResult.firstVisibleIndex, rangeResult.lastVisibleIndex)
|
||||
: null
|
||||
}, [renderItems, rangeResult])
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
|
||||
<div style={{height: cachedTotalSize}}>
|
||||
<div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
|
||||
{firstVisibleIndex != null &&
|
||||
lastVisibleIndex != null &&
|
||||
this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
|
||||
</div>
|
||||
<div style={{height: totalSize}}>
|
||||
<div style={{transform: `translateY(${rangeResult?.invisiblePrefixSize || 0}px)`}}>
|
||||
{visibleItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize])
|
||||
|
||||
return (
|
||||
<div className={className} ref={viewportCallback} onScroll={onViewportScroll}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {h, createContext, ComponentChildren, Fragment} from 'preact'
|
||||
import {useCallback, useRef, useEffect, useMemo} from 'preact/hooks'
|
||||
import {memo} from 'preact/compat'
|
||||
import {Sizes, Colors, FontSize} from './style'
|
||||
import {ProfileSearchResults} from '../lib/profile-search'
|
||||
import {Profile} from '../lib/profile'
|
||||
import {useActiveProfileState, useAppSelector} from '../store'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {actions} from '../store/actions'
|
||||
|
||||
function stopPropagation(ev: Event) {
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
export const ProfileSearchContext = createContext<ProfileSearchResults | null>(null)
|
||||
|
||||
export const ProfileSearchContextProvider = ({children}: {children: ComponentChildren}) => {
|
||||
const activeProfileState = useActiveProfileState()
|
||||
const profile: Profile | null = activeProfileState ? activeProfileState.profile : null
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!profile || !searchIsActive || searchQuery.length === 0) {
|
||||
return null
|
||||
}
|
||||
return new ProfileSearchResults(profile, searchQuery)
|
||||
}, [searchIsActive, searchQuery, profile])
|
||||
|
||||
return (
|
||||
<ProfileSearchContext.Provider value={searchResults}>{children}</ProfileSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const {setSearchQuery: setSearchQueryAction, setSearchIsActive: setSearchIsActiveAction} = actions
|
||||
|
||||
interface SearchViewProps {
|
||||
resultIndex: number | null
|
||||
numResults: number | null
|
||||
selectNext: () => void
|
||||
selectPrev: () => void
|
||||
}
|
||||
|
||||
export const SearchView = memo(
|
||||
({numResults, resultIndex, selectNext, selectPrev}: SearchViewProps) => {
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const setSearchQuery = useActionCreator(setSearchQueryAction, [])
|
||||
const setSearchIsActive = useActionCreator(setSearchIsActiveAction, [])
|
||||
|
||||
const onInput = useCallback(
|
||||
(ev: Event) => {
|
||||
const value = (ev.target as HTMLInputElement).value
|
||||
setSearchQuery(value)
|
||||
},
|
||||
[setSearchQuery],
|
||||
)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
|
||||
|
||||
const selectPrevOrNextResult = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (ev.shiftKey) {
|
||||
selectPrev()
|
||||
} else {
|
||||
selectNext()
|
||||
}
|
||||
},
|
||||
[selectPrev, selectNext],
|
||||
)
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
ev.stopPropagation()
|
||||
|
||||
// Hitting Esc should close the search box
|
||||
if (ev.key === 'Escape') {
|
||||
setSearchIsActive(false)
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter') {
|
||||
selectPrevOrNextResult(ev)
|
||||
}
|
||||
|
||||
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
|
||||
if (inputRef.current) {
|
||||
// If the input is already focused, select all
|
||||
inputRef.current.select()
|
||||
}
|
||||
|
||||
// It seems like when an input is focused, the browser find menu pops
|
||||
// up without this line. It seems like it's not sufficient to only
|
||||
// preventDefault in the window keydown handler.
|
||||
ev.preventDefault()
|
||||
}
|
||||
},
|
||||
[setSearchIsActive, selectPrevOrNextResult],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowKeyDown = (ev: KeyboardEvent) => {
|
||||
// Cmd+F or Ctrl+F open the search box
|
||||
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
|
||||
// Prevent the browser's search menu from appearing
|
||||
ev.preventDefault()
|
||||
|
||||
if (inputRef.current) {
|
||||
// If the search box is already open, then re-select it immediately.
|
||||
inputRef.current.select()
|
||||
} else {
|
||||
// Otherwise, focus the search, then focus the input on the next
|
||||
// frame, when the search box should have mounted.
|
||||
setSearchIsActive(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onWindowKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown)
|
||||
}
|
||||
}, [setSearchIsActive])
|
||||
|
||||
if (!searchIsActive) return null
|
||||
|
||||
return (
|
||||
<div className={css(style.searchView)}>
|
||||
<span className={css(style.icon)}>🔍</span>
|
||||
<span className={css(style.inputContainer)}>
|
||||
<input
|
||||
className={css(style.input)}
|
||||
value={searchQuery}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={stopPropagation}
|
||||
onKeyPress={stopPropagation}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</span>
|
||||
{numResults != null && (
|
||||
<Fragment>
|
||||
<span className={css(style.resultCount)}>
|
||||
{resultIndex == null ? '?' : resultIndex + 1}/{numResults}
|
||||
</span>
|
||||
<button className={css(style.icon, style.button)} onClick={selectPrev}>
|
||||
⬅️
|
||||
</button>
|
||||
<button className={css(style.icon, style.button)} onClick={selectNext}>
|
||||
➡️
|
||||
</button>
|
||||
</Fragment>
|
||||
)}
|
||||
<svg
|
||||
className={css(style.icon)}
|
||||
onClick={close}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.99999 4.16217L11.6427 10.8048M11.6427 4.16217L4.99999 10.8048"
|
||||
stroke="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const style = StyleSheet.create({
|
||||
searchView: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 10,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
width: 16 * 13,
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.BLACK,
|
||||
borderStyle: 'solid',
|
||||
fontSize: FontSize.LABEL,
|
||||
boxSizing: 'border-box',
|
||||
background: Colors.DARK_GRAY,
|
||||
color: Colors.WHITE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
fontSize: FontSize.LABEL,
|
||||
lineHeight: `${Sizes.TOOLBAR_HEIGHT}px`,
|
||||
color: Colors.WHITE,
|
||||
':focus': {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
},
|
||||
'::selection': {
|
||||
color: Colors.WHITE,
|
||||
background: Colors.DARK_BLUE,
|
||||
},
|
||||
},
|
||||
resultCount: {
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
icon: {
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
height: '100%',
|
||||
margin: '0px 2px 0px 2px',
|
||||
fontSize: FontSize.LABEL,
|
||||
},
|
||||
button: {
|
||||
display: 'inline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
+5
-2
@@ -22,12 +22,14 @@ export enum Colors {
|
||||
PALE_DARK_BLUE = '#8EB7ED',
|
||||
GREEN = '#6FCF97',
|
||||
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
|
||||
YELLOW = '#FEDC62',
|
||||
ORANGE = '#FFAC02',
|
||||
}
|
||||
|
||||
export enum Sizes {
|
||||
MINIMAP_HEIGHT = 100,
|
||||
DETAIL_VIEW_HEIGHT = 150,
|
||||
TOOLTIP_WIDTH_MAX = 300,
|
||||
TOOLTIP_WIDTH_MAX = 900,
|
||||
TOOLTIP_HEIGHT_MAX = 80,
|
||||
SEPARATOR_HEIGHT = 2,
|
||||
FRAME_HEIGHT = 20,
|
||||
@@ -40,7 +42,8 @@ export enum Duration {
|
||||
}
|
||||
|
||||
export enum ZIndex {
|
||||
HOVERTIP = 1,
|
||||
PROFILE_SELECT = 1,
|
||||
HOVERTIP = 2,
|
||||
}
|
||||
|
||||
export const commonStyle = StyleSheet.create({
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import {ApplicationProps} from './application'
|
||||
import {ViewMode} from '../store'
|
||||
import {h, JSX, Fragment} from 'preact'
|
||||
import {useCallback, useState, useEffect} from 'preact/hooks'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {Sizes, Colors, FontFamily, FontSize, Duration} from './style'
|
||||
import {ProfileSelect} from './profile-select'
|
||||
import {ProfileGroupState} from '../store/profiles-state'
|
||||
import {Profile} from '../lib/profile'
|
||||
import {objectsHaveShallowEquality} from '../lib/utils'
|
||||
|
||||
interface ToolbarProps extends ApplicationProps {
|
||||
browseForFile(): void
|
||||
saveFile(): void
|
||||
}
|
||||
|
||||
function useSetViewMode(setViewMode: (viewMode: ViewMode) => void, viewMode: ViewMode) {
|
||||
return useCallback(() => setViewMode(viewMode), [setViewMode, viewMode])
|
||||
}
|
||||
|
||||
function ToolbarLeftContent(props: ToolbarProps) {
|
||||
const setChronoFlameChart = useSetViewMode(props.setViewMode, ViewMode.CHRONO_FLAME_CHART)
|
||||
const setLeftHeavyFlameGraph = useSetViewMode(props.setViewMode, ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
||||
const setSandwichView = useSetViewMode(props.setViewMode, ViewMode.SANDWICH_VIEW)
|
||||
|
||||
if (!props.activeProfileState) return null
|
||||
|
||||
return (
|
||||
<div className={css(style.toolbarLeft)}>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={setChronoFlameChart}
|
||||
>
|
||||
<span className={css(style.emoji)}>🕰</span>Time Order
|
||||
</div>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={setLeftHeavyFlameGraph}
|
||||
>
|
||||
<span className={css(style.emoji)}>⬅️</span>Left Heavy
|
||||
</div>
|
||||
<div
|
||||
className={css(
|
||||
style.toolbarTab,
|
||||
props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
|
||||
)}
|
||||
onClick={setSandwichView}
|
||||
>
|
||||
<span className={css(style.emoji)}>🥪</span>Sandwich
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getCachedProfileList = (() => {
|
||||
// TODO(jlfwong): It would be nice to just implement this as useMemo, but if
|
||||
// we do that using profileGroup or profileGroup.profiles as the cache key,
|
||||
// then it will invalidate whenever *anything* changes, because
|
||||
// profileGroup.profiles is ProfileState[], which contains component state
|
||||
// information for each tab for each profile. So whenever any property in any
|
||||
// persisted view state changes for *any* view in *any* profile, the profiles
|
||||
// list will get re-generated.
|
||||
let cachedProfileList: Profile[] | null = null
|
||||
|
||||
return (profileGroup: ProfileGroupState): Profile[] | null => {
|
||||
let nextProfileList = profileGroup?.profiles.map(p => p.profile) || null
|
||||
|
||||
if (
|
||||
cachedProfileList === null ||
|
||||
(nextProfileList != null && !objectsHaveShallowEquality(cachedProfileList, nextProfileList))
|
||||
) {
|
||||
cachedProfileList = nextProfileList
|
||||
}
|
||||
|
||||
return cachedProfileList
|
||||
}
|
||||
})()
|
||||
|
||||
function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
|
||||
const {activeProfileState, profileGroup} = props
|
||||
const profiles = getCachedProfileList(profileGroup)
|
||||
const [profileSelectShown, setProfileSelectShown] = useState(false)
|
||||
|
||||
const openProfileSelect = useCallback(() => {
|
||||
setProfileSelectShown(true)
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
const closeProfileSelect = useCallback(() => {
|
||||
setProfileSelectShown(false)
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowKeyPress = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 't') {
|
||||
ev.preventDefault()
|
||||
setProfileSelectShown(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keypress', onWindowKeyPress)
|
||||
return () => {
|
||||
window.removeEventListener('keypress', onWindowKeyPress)
|
||||
}
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowKeyPress = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 't') {
|
||||
ev.preventDefault()
|
||||
setProfileSelectShown(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keypress', onWindowKeyPress)
|
||||
return () => {
|
||||
window.removeEventListener('keypress', onWindowKeyPress)
|
||||
}
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
if (activeProfileState && profileGroup && profiles) {
|
||||
if (profileGroup.profiles.length === 1) {
|
||||
return <Fragment>{activeProfileState.profile.getName()}</Fragment>
|
||||
} else {
|
||||
return (
|
||||
<div className={css(style.toolbarCenter)} onMouseLeave={closeProfileSelect}>
|
||||
<span onMouseOver={openProfileSelect}>
|
||||
{activeProfileState.profile.getName()}{' '}
|
||||
<span className={css(style.toolbarProfileIndex)}>
|
||||
({activeProfileState.index + 1}/{profileGroup.profiles.length})
|
||||
</span>
|
||||
</span>
|
||||
<div style={{display: profileSelectShown ? 'block' : 'none'}}>
|
||||
<ProfileSelect
|
||||
setProfileIndexToView={props.setProfileIndexToView}
|
||||
indexToView={profileGroup.indexToView}
|
||||
profiles={profiles}
|
||||
closeProfileSelect={closeProfileSelect}
|
||||
visible={profileSelectShown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return <Fragment>{'🔬speedscope'}</Fragment>
|
||||
}
|
||||
|
||||
function ToolbarRightContent(props: ToolbarProps) {
|
||||
const importFile = (
|
||||
<div className={css(style.toolbarTab)} onClick={props.browseForFile}>
|
||||
<span className={css(style.emoji)}>⤵️</span>Import
|
||||
</div>
|
||||
)
|
||||
const help = (
|
||||
<div className={css(style.toolbarTab)}>
|
||||
<a
|
||||
href="https://github.com/jlfwong/speedscope#usage"
|
||||
className={css(style.noLinkStyle)}
|
||||
target="_blank"
|
||||
>
|
||||
<span className={css(style.emoji)}>❓</span>Help
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css(style.toolbarRight)}>
|
||||
{props.activeProfileState && (
|
||||
<div className={css(style.toolbarTab)} onClick={props.saveFile}>
|
||||
<span className={css(style.emoji)}>⤴️</span>Export
|
||||
</div>
|
||||
)}
|
||||
{importFile}
|
||||
{help}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
return (
|
||||
<div className={css(style.toolbar)}>
|
||||
<ToolbarLeftContent {...props} />
|
||||
<ToolbarCenterContent {...props} />
|
||||
<ToolbarRightContent {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
toolbar: {
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
flexShrink: 0,
|
||||
background: Colors.BLACK,
|
||||
color: Colors.WHITE,
|
||||
textAlign: 'center',
|
||||
fontFamily: FontFamily.MONOSPACE,
|
||||
fontSize: FontSize.TITLE,
|
||||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||||
userSelect: 'none',
|
||||
},
|
||||
toolbarLeft: {
|
||||
position: 'absolute',
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
top: 0,
|
||||
left: 0,
|
||||
marginRight: 2,
|
||||
textAlign: 'left',
|
||||
},
|
||||
toolbarCenter: {
|
||||
paddingTop: 1,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
},
|
||||
toolbarRight: {
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
marginRight: 2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
toolbarProfileIndex: {
|
||||
color: Colors.LIGHT_GRAY,
|
||||
},
|
||||
toolbarTab: {
|
||||
background: Colors.DARK_GRAY,
|
||||
marginTop: Sizes.SEPARATOR_HEIGHT,
|
||||
height: Sizes.TOOLBAR_TAB_HEIGHT,
|
||||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 8,
|
||||
display: 'inline-block',
|
||||
marginLeft: 2,
|
||||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||||
':hover': {
|
||||
background: Colors.GRAY,
|
||||
},
|
||||
},
|
||||
toolbarTabActive: {
|
||||
background: Colors.BRIGHT_BLUE,
|
||||
':hover': {
|
||||
background: Colors.BRIGHT_BLUE,
|
||||
},
|
||||
},
|
||||
emoji: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: '0px',
|
||||
marginRight: '0.3em',
|
||||
},
|
||||
noLinkStyle: {
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user