Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4f38a8cb | ||
|
|
9361a6baf2 | ||
|
|
ede9c74d50 | ||
|
|
f758130455 | ||
|
|
f3a1c09c9b | ||
|
|
069c0194a6 | ||
|
|
64fe369c42 | ||
|
|
f55c53f699 | ||
|
|
a0b3fe8420 | ||
|
|
9452aeae82 | ||
|
|
b26cdb5be4 | ||
|
|
1c5bdba36e | ||
|
|
c3b35d7b0f | ||
|
|
dfaefe54fd | ||
|
|
7514f4c0c9 |
@@ -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,6 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '10'
|
||||
- '12'
|
||||
- '13'
|
||||
- 'node'
|
||||
@@ -1,5 +1,37 @@
|
||||
## 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
|
||||
|
||||
@@ -36,6 +36,7 @@ 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))
|
||||
@@ -44,6 +45,7 @@ speedscope is designed to ingest profiles from a variety of different profilers
|
||||
- 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
|
||||
@@ -116,6 +118,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
|
||||
* `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
+35
-101
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "speedscope",
|
||||
"version": "1.5.3",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3423,27 +3423,6 @@
|
||||
"parse-json": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"coveralls": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/coveralls/-/coveralls-3.0.1.tgz",
|
||||
"integrity": "sha512-FAzXwiDOYLGDWH+zgoIA+8GbWv50hlx+kpEJyvzLKOdnIBv9uWoVl4DhqGgyUHpiRjAlF8KYZSipWXYtllWH6Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"js-yaml": "^3.6.1",
|
||||
"lcov-parse": "^0.0.10",
|
||||
"log-driver": "^1.2.5",
|
||||
"minimist": "^1.2.0",
|
||||
"request": "^2.79.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"create-ecdh": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz",
|
||||
@@ -5793,15 +5772,6 @@
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"hosted-git-info": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz",
|
||||
@@ -6249,6 +6219,11 @@
|
||||
"integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=",
|
||||
"dev": true
|
||||
},
|
||||
"is-docker": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz",
|
||||
"integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw=="
|
||||
},
|
||||
"is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
@@ -6397,7 +6372,8 @@
|
||||
"is-wsl": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
|
||||
"integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0="
|
||||
"integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=",
|
||||
"dev": true
|
||||
},
|
||||
"isarray": {
|
||||
"version": "1.0.0",
|
||||
@@ -7076,7 +7052,8 @@
|
||||
"js-tokens": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
|
||||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
|
||||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
|
||||
"dev": true
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.13.1",
|
||||
@@ -7283,12 +7260,6 @@
|
||||
"integrity": "sha1-iAy4qrJWAmOC4C9T7AiWgqdMW2o=",
|
||||
"dev": true
|
||||
},
|
||||
"lcov-parse": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz",
|
||||
"integrity": "sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM=",
|
||||
"dev": true
|
||||
},
|
||||
"left-pad": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
|
||||
@@ -7395,12 +7366,6 @@
|
||||
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
|
||||
"dev": true
|
||||
},
|
||||
"log-driver": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz",
|
||||
"integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==",
|
||||
"dev": true
|
||||
},
|
||||
"log-symbols": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",
|
||||
@@ -7420,6 +7385,7 @@
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
|
||||
"integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0"
|
||||
}
|
||||
@@ -7885,7 +7851,8 @@
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
|
||||
"dev": true
|
||||
},
|
||||
"object-copy": {
|
||||
"version": "0.1.0",
|
||||
@@ -8139,10 +8106,30 @@
|
||||
"mimic-fn": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"open": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.2.0.tgz",
|
||||
"integrity": "sha512-4HeyhxCvBTI5uBePsAdi55C5fmqnWZ2e2MlmvWi5KW5tdH5rxoiv/aMtbeVxKZc3eWkT1GymMnLG8XC4Rq4TDQ==",
|
||||
"requires": {
|
||||
"is-docker": "^2.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"requires": {
|
||||
"is-docker": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"opn": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/opn/-/opn-5.3.0.tgz",
|
||||
"integrity": "sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-wsl": "^1.1.0"
|
||||
}
|
||||
@@ -9244,26 +9231,6 @@
|
||||
"sisteransi": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"prop-types": {
|
||||
"version": "15.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
|
||||
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"protobufjs": {
|
||||
"version": "6.8.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
|
||||
@@ -9512,44 +9479,11 @@
|
||||
"integrity": "sha1-CMbgSgFo9utiHCKrbLEVG9n0pk0=",
|
||||
"dev": true
|
||||
},
|
||||
"react": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz",
|
||||
"integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
|
||||
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
|
||||
},
|
||||
"react-redux": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz",
|
||||
"integrity": "sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-is": "^16.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==",
|
||||
"dev": true
|
||||
},
|
||||
"read-pkg": {
|
||||
"version": "3.0.0",
|
||||
|
||||
+3
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "speedscope",
|
||||
"version": "1.7.0",
|
||||
"version": "1.10.0",
|
||||
"description": "",
|
||||
"repository": "jlfwong/speedscope",
|
||||
"main": "index.js",
|
||||
@@ -13,7 +13,7 @@
|
||||
"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",
|
||||
"coverage": "npm run jest -- --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "./scripts/ci.sh",
|
||||
"serve": "parcel assets/index.html --open --no-autoinstall"
|
||||
@@ -38,7 +38,6 @@
|
||||
"@typescript-eslint/parser": "2.33.0",
|
||||
"acorn": "7.2.0",
|
||||
"aphrodite": "2.1.0",
|
||||
"coveralls": "3.0.1",
|
||||
"eslint": "6.0.0",
|
||||
"eslint-plugin-prettier": "2.6.0",
|
||||
"eslint-plugin-react-hooks": "4.0.2",
|
||||
@@ -50,7 +49,6 @@
|
||||
"preact": "10.4.1",
|
||||
"prettier": "2.0.4",
|
||||
"protobufjs": "6.8.8",
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"ts-jest": "24.3.0",
|
||||
"typescript": "3.9.2",
|
||||
@@ -79,7 +77,6 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"opn": "5.3.0",
|
||||
"react": "^16.13.1"
|
||||
"open": "7.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -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"`;
|
||||
|
||||
@@ -633,7 +633,7 @@ Object {
|
||||
"line": 0,
|
||||
"name": "(anonymous)",
|
||||
"selfWeight": 0,
|
||||
"totalWeight": 2591,
|
||||
"totalWeight": 2524,
|
||||
},
|
||||
Frame {
|
||||
"col": -1,
|
||||
@@ -641,8 +641,8 @@ Object {
|
||||
"key": "Worker::-1:-1",
|
||||
"line": -1,
|
||||
"name": "Worker",
|
||||
"selfWeight": 873,
|
||||
"totalWeight": 2591,
|
||||
"selfWeight": 806,
|
||||
"totalWeight": 2524,
|
||||
},
|
||||
Frame {
|
||||
"col": 29,
|
||||
@@ -731,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",
|
||||
@@ -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"`;
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+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])
|
||||
}
|
||||
|
||||
+8
-2
@@ -16,7 +16,7 @@
|
||||
// Because we're not going to use this in our actual build, it's okay for this
|
||||
// to be inefficient.
|
||||
|
||||
(function () {
|
||||
;(function () {
|
||||
const nodeVersion = process.versions.node
|
||||
const versionParts = nodeVersion.split('.')
|
||||
const majorVersion = parseInt(versionParts[0], 10)
|
||||
@@ -48,4 +48,10 @@
|
||||
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
|
||||
})()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+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) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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 (intentinoally) unmet peer dependencies.
|
||||
* 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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -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()))
|
||||
|
||||
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()
|
||||
|
||||
@@ -511,6 +520,7 @@ export class StackListProfileBuilder extends Profile {
|
||||
this.totalWeight,
|
||||
this.weights.reduce((a, b) => a + b, 0),
|
||||
)
|
||||
this.sortGroupedCallTree()
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -651,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ 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
|
||||
|
||||
@@ -11,6 +11,10 @@ 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,
|
||||
@@ -101,3 +105,30 @@ export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArg
|
||||
/* 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,
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {h} from 'preact'
|
||||
import {Application, ActiveProfileState} from './application'
|
||||
import {getProfileToView, getCanvasContext} from '../store/getters'
|
||||
import {Application} from './application'
|
||||
import {getCanvasContext} from '../store/getters'
|
||||
import {actions} from '../store/actions'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useAppSelector} from '../store'
|
||||
import {useAppSelector, useActiveProfileState} from '../store'
|
||||
import {ProfileSearchContextProvider} from './search-view'
|
||||
|
||||
const {
|
||||
setLoading,
|
||||
@@ -24,36 +25,21 @@ export const ApplicationContainer = memo(() => {
|
||||
[],
|
||||
)
|
||||
|
||||
const activeProfileState: ActiveProfileState | null = 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,
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Application
|
||||
activeProfileState={activeProfileState}
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,16 +2,14 @@ import {h} from 'preact'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
|
||||
|
||||
import {Profile, ProfileGroup} from '../lib/profile'
|
||||
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'
|
||||
@@ -131,14 +129,6 @@ export class GLCanvas extends StatelessComponent<GLCanvasProps> {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -78,9 +78,9 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,7 +54,9 @@ 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, {}> {
|
||||
@@ -163,27 +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.textBaseline = 'alphabetic'
|
||||
ctx.fillStyle = Colors.DARK_GRAY
|
||||
|
||||
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
|
||||
const minConfigSpaceWidthToRender = (
|
||||
@@ -192,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
|
||||
@@ -226,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) {
|
||||
@@ -249,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))
|
||||
@@ -271,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()
|
||||
}
|
||||
@@ -396,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 = () => {
|
||||
@@ -686,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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -14,10 +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
|
||||
@@ -126,16 +127,26 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
|
||||
flamechart,
|
||||
})
|
||||
|
||||
const setters = useFlamechartSetters(FlamechartID.CHRONO, index)
|
||||
|
||||
return (
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
|
||||
{...chronoViewState}
|
||||
/>
|
||||
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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -177,15 +188,25 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
|
||||
flamechart,
|
||||
})
|
||||
|
||||
const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)
|
||||
|
||||
return (
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
|
||||
{...leftHeavyViewState}
|
||||
/>
|
||||
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() {
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getProfileWithRecursionFlattened,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
@@ -65,8 +64,6 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
|
||||
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)
|
||||
@@ -90,9 +87,9 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
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 {ListItem, ScrollableListView} from './scrollable-list-view'
|
||||
import {actions} from '../store/actions'
|
||||
import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
|
||||
import {ActiveProfileState} from './application'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {useAppSelector} from '../store'
|
||||
import {useAppSelector, ActiveProfileState} from '../store'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback, useMemo} from 'preact/hooks'
|
||||
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {SandwichViewContext} from './sandwich-view'
|
||||
|
||||
export enum SortField {
|
||||
SYMBOL_NAME,
|
||||
@@ -68,13 +67,9 @@ class SortIcon extends Component<SortIconProps, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileTableRowInfo {
|
||||
interface ProfileTableRowViewProps {
|
||||
frame: Frame
|
||||
matchedRanges: [number, number][] | null
|
||||
}
|
||||
|
||||
interface ProfileTableRowViewProps {
|
||||
info: ProfileTableRowInfo
|
||||
index: number
|
||||
profile: Profile
|
||||
selectedFrame: Frame | null
|
||||
@@ -100,15 +95,14 @@ function highlightRanges(
|
||||
}
|
||||
|
||||
const ProfileTableRowView = ({
|
||||
info,
|
||||
frame,
|
||||
matchedRanges,
|
||||
profile,
|
||||
index,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
}: ProfileTableRowViewProps) => {
|
||||
const {frame, matchedRanges} = info
|
||||
|
||||
const totalWeight = frame.getTotalWeight()
|
||||
const selfWeight = frame.getSelfWeight()
|
||||
const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
|
||||
@@ -206,48 +200,21 @@ export const ProfileTableView = memo(
|
||||
[sortMethod, setSortMethod],
|
||||
)
|
||||
|
||||
const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => {
|
||||
const rowList: ProfileTableRowInfo[] = []
|
||||
|
||||
profile.forEachFrame(frame => {
|
||||
let matchedRanges: [number, number][] | null = null
|
||||
if (searchIsActive) {
|
||||
const match = fuzzyMatchStrings(frame.name, searchQuery)
|
||||
if (match == null) return
|
||||
matchedRanges = match.matchedRanges
|
||||
}
|
||||
rowList.push({frame, matchedRanges})
|
||||
})
|
||||
|
||||
switch (sortMethod.field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
sortBy(rowList, f => f.frame.name.toLowerCase())
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
sortBy(rowList, f => f.frame.getSelfWeight())
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
sortBy(rowList, f => f.frame.getTotalWeight())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sortMethod.direction === SortDirection.DESCENDING) {
|
||||
rowList.reverse()
|
||||
}
|
||||
|
||||
return rowList
|
||||
}, [profile, sortMethod, searchQuery, searchIsActive])
|
||||
const sandwichContext = useContext(SandwichViewContext)
|
||||
|
||||
const renderItems = useCallback(
|
||||
(firstIndex: number, lastIndex: number) => {
|
||||
if (!sandwichContext) return null
|
||||
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
const frame = sandwichContext.rowList[i]
|
||||
const match = sandwichContext.getSearchMatchForFrame(frame)
|
||||
rows.push(
|
||||
ProfileTableRowView({
|
||||
info: rowList[i],
|
||||
frame,
|
||||
matchedRanges: match == null ? null : match.matchedRanges,
|
||||
index: i,
|
||||
profile: profile,
|
||||
selectedFrame: selectedFrame,
|
||||
@@ -278,7 +245,7 @@ export const ProfileTableView = memo(
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
},
|
||||
[
|
||||
rowList,
|
||||
sandwichContext,
|
||||
profile,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
@@ -288,9 +255,13 @@ export const ProfileTableView = memo(
|
||||
],
|
||||
)
|
||||
|
||||
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
|
||||
rowList,
|
||||
])
|
||||
const listItems: ListItem[] = useMemo(
|
||||
() =>
|
||||
sandwichContext == null
|
||||
? []
|
||||
: sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})),
|
||||
[sandwichContext],
|
||||
)
|
||||
|
||||
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
|
||||
onSortClick,
|
||||
@@ -341,7 +312,7 @@ export const ProfileTableView = memo(
|
||||
className={css(style.scrollView)}
|
||||
renderItems={renderItems}
|
||||
initialIndexInView={
|
||||
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
|
||||
selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -357,6 +328,7 @@ const style = StyleSheet.create({
|
||||
scrollView: {
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexGrow: 1,
|
||||
},
|
||||
tableView: {
|
||||
width: '100%',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+92
-36
@@ -1,18 +1,20 @@
|
||||
import {Frame} from '../lib/profile'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {ProfileTableViewContainer} from './profile-table-view'
|
||||
import {h, JSX} from 'preact'
|
||||
import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view'
|
||||
import {h, JSX, createContext} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback} from 'preact/hooks'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {commonStyle, Sizes, Colors, FontSize} from './style'
|
||||
import {actions} from '../store/actions'
|
||||
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, useActionCreator} from '../lib/preact-redux'
|
||||
import {SearchView} from './search-view'
|
||||
import {useAppSelector} from '../store'
|
||||
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
|
||||
@@ -20,10 +22,6 @@ interface SandwichViewProps {
|
||||
activeProfileState: ActiveProfileState
|
||||
setSelectedFrame: (selectedFrame: Frame | null) => void
|
||||
glCanvas: HTMLCanvasElement
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
setSearchQuery: (query: string | null) => void
|
||||
setSearchIsActive: (active: boolean) => void
|
||||
}
|
||||
|
||||
class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
@@ -45,13 +43,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedFrame,
|
||||
searchIsActive,
|
||||
setSearchIsActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
} = this.props
|
||||
const {selectedFrame} = this.props
|
||||
let flamegraphViews: JSX.Element | null = null
|
||||
|
||||
if (selectedFrame) {
|
||||
@@ -84,12 +76,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
|
||||
<div className={css(style.tableView)}>
|
||||
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
|
||||
<SearchView
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchIsActive={searchIsActive}
|
||||
setSearchIsActive={setSearchIsActive}
|
||||
/>
|
||||
<SandwichSearchView />
|
||||
</div>
|
||||
{flamegraphViews}
|
||||
</div>
|
||||
@@ -143,7 +130,15 @@ interface SandwichViewContainerProps {
|
||||
glCanvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
const {setSearchQuery, setSearchIsActive} = actions
|
||||
interface SandwichViewContextData {
|
||||
rowList: Frame[]
|
||||
selectedFrame: Frame | null
|
||||
setSelectedFrame: (frame: Frame | null) => void
|
||||
getIndexForFrame: (frame: Frame) => number | null
|
||||
getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null
|
||||
}
|
||||
|
||||
export const SandwichViewContext = createContext<SandwichViewContextData | null>(null)
|
||||
|
||||
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
@@ -163,17 +158,78 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
|
||||
[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 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 (
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
glCanvas={glCanvas}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
|
||||
profileIndex={index}
|
||||
searchQuery={useAppSelector(state => state.searchQuery, [])}
|
||||
setSearchQuery={useActionCreator(setSearchQuery, [])}
|
||||
searchIsActive={useAppSelector(state => state.searchIsActive, [])}
|
||||
setSearchIsActive={useActionCreator(setSearchIsActive, [])}
|
||||
/>
|
||||
<SandwichViewContext.Provider value={contextData}>
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
glCanvas={glCanvas}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
selectedFrame={selectedFrame}
|
||||
profileIndex={index}
|
||||
/>
|
||||
</SandwichViewContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,10 @@ interface RangeResult {
|
||||
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
|
||||
}
|
||||
@@ -50,7 +53,6 @@ export const ScrollableListView = ({
|
||||
requestAnimationFrame(() => {
|
||||
setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
|
||||
if (initialScroll.current != null) {
|
||||
console.log('executing initial scroll to ', initialScroll.current)
|
||||
viewport.scrollTo({[leftOrTop]: initialScroll.current})
|
||||
initialScroll.current = null
|
||||
}
|
||||
|
||||
+128
-37
@@ -1,23 +1,54 @@
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {h} from 'preact'
|
||||
import {useCallback, useRef, useEffect} from 'preact/hooks'
|
||||
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()
|
||||
}
|
||||
|
||||
interface SearchViewProps {
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
export const ProfileSearchContext = createContext<ProfileSearchResults | null>(null)
|
||||
|
||||
setSearchQuery: (query: string | null) => void
|
||||
setSearchIsActive: (active: boolean) => void
|
||||
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(
|
||||
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
|
||||
({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
|
||||
@@ -28,6 +59,19 @@ export const SearchView = memo(
|
||||
|
||||
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()
|
||||
@@ -36,8 +80,24 @@ export const SearchView = memo(
|
||||
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],
|
||||
[setSearchIsActive, selectPrevOrNextResult],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,10 +108,17 @@ export const SearchView = memo(
|
||||
ev.preventDefault()
|
||||
|
||||
if (inputRef.current) {
|
||||
// If the search box is already open, then re-select it.
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,33 +129,37 @@ export const SearchView = memo(
|
||||
}
|
||||
}, [setSearchIsActive])
|
||||
|
||||
const focusInput = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
requestAnimationFrame(() => {
|
||||
node.select()
|
||||
})
|
||||
}
|
||||
inputRef.current = node
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
|
||||
|
||||
if (!searchIsActive) return null
|
||||
|
||||
return (
|
||||
<div className={css(style.searchView)}>
|
||||
<span className={css(style.icon)}>🔍</span>
|
||||
<input
|
||||
className={css(style.input)}
|
||||
value={searchQuery}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={stopPropagation}
|
||||
onKeyPress={stopPropagation}
|
||||
ref={focusInput}
|
||||
/>
|
||||
|
||||
<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"
|
||||
@@ -112,7 +183,7 @@ const style = StyleSheet.create({
|
||||
top: 0,
|
||||
right: 10,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
width: 150,
|
||||
width: 16 * 13,
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.BLACK,
|
||||
borderStyle: 'solid',
|
||||
@@ -121,12 +192,19 @@ const style = StyleSheet.create({
|
||||
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,
|
||||
flex: 1,
|
||||
lineHeight: `${Sizes.TOOLBAR_HEIGHT}px`,
|
||||
color: Colors.WHITE,
|
||||
':focus': {
|
||||
border: 'none',
|
||||
@@ -137,10 +215,23 @@ const style = StyleSheet.create({
|
||||
background: Colors.DARK_BLUE,
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
display: 'inline-block',
|
||||
resultCount: {
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: '0px',
|
||||
margin: '0 2px 0 4px',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ export enum Colors {
|
||||
PALE_DARK_BLUE = '#8EB7ED',
|
||||
GREEN = '#6FCF97',
|
||||
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
|
||||
YELLOW = '#FEDC62',
|
||||
ORANGE = '#FFAC02',
|
||||
}
|
||||
|
||||
export enum Sizes {
|
||||
|
||||
Reference in New Issue
Block a user