Compare commits

...
15 Commits
Author SHA1 Message Date
Jamie Wong 8a4f38a8cb 1.10.0 2020-09-29 18:51:49 -07:00
Jamie Wong 9361a6baf2 Switch from Travis CI to GitHub Actions for test runs (#316)
Switch from Travis CI to GitHub Actions for test runs
2020-09-29 16:39:18 -07:00
Jamie Wong ede9c74d50 Remove accidentally added/retained dependencies on react and react-redux (#315)
speedscope no longer relies upon react-redux, and never depended upon react. Let's clean these up.
2020-09-29 16:20:27 -07:00
Jamie Wong f758130455 Add support for imports of UTF-16 encoded text w/ Byte Order Mark (BOM) (#314)
Before this PR, we blindly assumed that all text imported into speedscope was UTF-8 encoded. This, unsurprisingly, is not always true. After this PR, we support text that's UTF-16 encoded, with either the little-endian or big-endian byte-order-mark.

Fixed #291
2020-09-29 15:40:49 -07:00
Jamie Wong f3a1c09c9b Add support for Safari profiles (#313)
Closes #294 

This adds import for Safari/webkit profiler. Well, for Safari 13.1 for sure, I haven't done any work to check if there's been changes to the syntax.

It seems to work OK, and is already a huge improvement over profiling in Safari (which doesn't even have a flame graph, let alone something like left heavy). Sadly, the sampler resolution is only 1kHz, which is not super useful for a lot of profiling work. I made a ticket on webkit bug tracker to ask for 10kHz/configurable sampling rate: https://bugs.webkit.org/show_bug.cgi?id=214866

Another thing that's missing is that I cut out all the idle time. We could also insert layout/paint samples into the timeline by parsing `events`. But I'll leave that for another time.

<img width="1280" alt="Captura de pantalla 2020-07-28 a las 11 02 06" src="https://user-images.githubusercontent.com/183747/88643560-20c16700-d0c2-11ea-9c73-d9159e68fab9.png">
2020-09-29 14:26:01 -07:00
Sebastian Wahl 069c0194a6 #168 Fix browser not opening on Windows when using the CLI (#307) 2020-09-14 11:53:36 -07:00
Jamie Wong 64fe369c42 1.9.0 2020-08-05 01:02:17 -07:00
Jamie Wong f55c53f699 Small followup tweaks to #305 2020-08-05 00:50:02 -07:00
E-Liang Tan a0b3fe8420 Add patch to fix accumulated negative deltas (#305)
## Context

Hi! I'm working on an experimental React [concurrent mode profiler](https://react-scheduling-profiler.vercel.app) in partnership with the React core team, and we're using a [custom build of Speedscope](https://github.com/taneliang/speedscope/compare/master...taneliang:fork-for-scheduling-profiler) that exposes Speedscope's internals to support our custom flamechart rendering. Specifically, Speedscope is used to import and process Chrome profiles, which are then fed to our rendering code that draws everything to a canvas.

Here's a screenshot of our app for context. The stuff above the thick gray bar is React data (some React Fiber lanes, React events, and other user timing marks), and a flamechart is drawn below.

![image](https://user-images.githubusercontent.com/12784593/89261576-e2e3b600-d660-11ea-9b90-6c6991d061d6.png)

## Problem

Early on, we had [an issue](https://github.com/MLH-Fellowship/scheduling-profiler-prototype/issues/42) where our flamechart was not aligned with the React data. The discrepancy between the flamechart frames and our React data grew over the time of the profile.

We tracked down the cause to https://github.com/jlfwong/speedscope/pull/80, which resolves https://github.com/jlfwong/speedscope/issues/70. It seems like zeroing out those negative time deltas resulted in the accumulation of errors over the time of these profiles, which resulted in the very visible misalignment in our profiler.

I am confident that the React data's timestamps are correct because they are obtained from User Timing marks, which have absolute timestamps and are thus independent of any `timeDelta` stuff. This would mean that Speedscope is likely displaying incorrect timestamps for Chrome profiles.

## Solution

This PR takes a different approach to solving the negative `timeDelta` problem: we add a `lastElapsed` variable as a sort of backstop, preventing `elapsed` from traveling backwards in time, while still ensuring that `elapsed` is always accurate.

We've been using this patch in our custom build for about a month now and it seems to work well.
2020-08-05 00:47:49 -07:00
Jamie Wong 9452aeae82 Provide prev/next buttons to cycle through search results, make search results more visually prominent (#304)
This PR addresses two key pieces of feedback provided on search in #38 

1. Make the search results more visually prominent
2. Make it easier to find the matches by having some way of jumping to next

For the visual prominence facet, I switched from yellow outlines to orange backgrounds.

|Before|After|
|-|-|
|![image](https://user-images.githubusercontent.com/150329/89276105-14746700-d5f8-11ea-9c9d-1dfdfc3bd6d7.png)|![image](https://user-images.githubusercontent.com/150329/89276070-045c8780-d5f8-11ea-8664-9da0af569cec.png)|

For the easy identification portion, I added prev/next buttons to each view, which can also be operated by hitting Enter for next or Shift+Enter for previous.

![Kapture 2020-08-04 at 2 16 57](https://user-images.githubusercontent.com/150329/89276542-a1b7bb80-d5f8-11ea-8642-a172a6561734.gif)
2020-08-04 02:20:26 -07:00
Gabriele N. Tornetta b26cdb5be4 Add link to Austin to README (#303)
Add a link to Austin's Speedscope section of the README to Speedscope's README.
2020-07-30 11:03:12 -07:00
Jamie Wong 1c5bdba36e Increase contrast for matching research results by fading text for unmatched frames (#298)
Before:
![image](https://user-images.githubusercontent.com/150329/88493052-ee99f300-cf63-11ea-9522-8de032e920ac.png)

After:
![image](https://user-images.githubusercontent.com/150329/88493062-f9548800-cf63-11ea-9e7e-5c87a1dba836.png)

Works towards #38
2020-07-26 17:21:38 -07:00
Jamie Wong c3b35d7b0f 1.8.0 2020-07-19 21:27:00 -07:00
Jamie Wong dfaefe54fd Implement search highlighting in time order & left heavy views (#297)
This implements the next step towards full featured search in speedscope: visual highlighting of matching search results in the time ordered & left heavy views. This doesn't yet add the ability to click prev/next to select the next matching element in the editor, but I'm still planning on doing something like that. I haven't figured out yet what I want the user experience to be like for that.

![speedscope-flamegraph-search](https://user-images.githubusercontent.com/150329/87898991-9ebba900-ca04-11ea-9bd9-31ad8d4c6d2a.gif)

This works towards fixing #38
2020-07-19 21:20:14 -07:00
Jamie Wong 7514f4c0c9 Fix performance issues for the caller/callee flamegraphs in the sandwich view (#296)
This fixes two unrelated problems which together caused performance issues in the sandwich view & made hover tooltips appear to be broken.

The first issue was caused by continuously priming the `requestAnimationFrame` loop when it should be a no-op, and the second issue was caused by using different cache keys when trying to access a memoized value in the caller & callee flamegraph components. This resulted in thrash, and especially bad performance because the cache miss was resulting in us re-allocating the WebGL framebuffer on every frame, which is unsurprisingly quite slow.

Fixes #212 
Fixes #155 
Fixes #74 (though this was maybe already fixed)
2020-07-18 22:37:15 -07:00
44 changed files with 1710 additions and 447 deletions
+43
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
language: node_js
node_js:
- '10'
- '12'
- '13'
- 'node'
+32
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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()
+35 -101
View File
@@ -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
View File
@@ -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"`;
+9 -9
View File
@@ -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"`;
+8
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromSafari', async () => {
await checkProfileSnapshot('./sample/profiles/Safari/13.1/simple.html-recording.json')
})
+120
View File
@@ -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
View File
@@ -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
View File
@@ -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
})()
+69
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
}
}
+116
View File
@@ -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
View File
@@ -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
}
-4
View File
@@ -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
+31
View File
@@ -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,
}
}, [])
}
+19 -33
View File
@@ -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 -12
View File
@@ -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
+1 -1
View File
@@ -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}
/>
)
})
+123 -64
View File
@@ -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) {
+151
View File
@@ -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}
/>
)
})
+38 -17
View File
@@ -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>
)
})
+25 -15
View File
@@ -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
+6 -10
View File
@@ -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}
/>
)
})
+24 -52
View File
@@ -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%',
+44
View File
@@ -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
View File
@@ -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>
)
})
+4 -2
View File
@@ -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
View File
@@ -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',
},
},
})
+2
View File
@@ -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 {