Repository: jlfwong/speedscope (ts)

Issue:
Add dropdown for thread selection
Instead of having to go through each thread with left and right buttons, it would be nice to be able to skip to a specific thread with a drop down menu.   Also you could put thread span count and start time in the drop down, so we can more easily differentiate between different threads.

I would put the dropdown where the thread name is.

Pull request description:
Introduce a profile selector dropdown
This adds much better UI for selecting different profiles within a single import.

![Kapture 2020-05-30 at 21 34 06](https://user-images.githubusercontent.com/150329/83344564-595ce400-a2bd-11ea-8306-e5d8f647b65e.gif)

You can now hover over the middle of the toolbar or hit `t` on your keyboard to bring up the profile selector. From there, you can use fuzzy-find to switch to the profile you want, and hit "enter" to select it. The up and down arrow keys can be used while the profile selector filter input is focused to move through the list of profiles.

I think the "next" and "prev" buttons are now totally useless, so I removed them.

Fixes #167 

Patch:
diff --git a/README.md b/README.md
index 4cf0897..56b22ee 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
 * `Cmd+O`/`Ctrl+O` to open a new profile
 * `n`: Go to next profile/thread if one is available
 * `p`: Go to previous profile/thread if one is available
+* `t`: Open the profile/thread selector if available
 
 ## Contributing
 
diff --git a/src/lib/fuzzy-find.ts b/src/lib/fuzzy-find.ts
new file mode 100644
index 0000000..acbc994
--- /dev/null
+++ b/src/lib/fuzzy-find.ts
@@ -0,0 +1,246 @@
+/**
+ * This file contains an implementation of fuzzy string matching.
+ */
+
+export interface FuzzyMatch {
+  // List of [start, end] indices in the haystack string that match the needle string
+  matchedRanges: [number, number][]
+
+  // The score of the match for relative ranking. Higher scores indicate
+  // "better" matches.
+  score: number
+}
+
+export function fuzzyMatchStrings(text: string, pattern: string): FuzzyMatch | null {
+  return fzfFuzzyMatchV1(text, pattern)
+}
+
+// The implementation here is based on FuzzyMatchV1, as described here:
+// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L8-L15
+//
+// This is a hand-port to better understand what the code is doing and for added
+// clarity.
+//
+// Capitalized letters only match capitalized letters, but lower-case letters
+// match both.
+//
+// Note: fzf includes a normalization table for homoglyphs. I'm going to ignore that too
+// https://github.com/junegunn/fzf/blob/master/src/algo/normalize.go
+const charCodeLowerA = 'a'.charCodeAt(0)
+const charCodeLowerZ = 'z'.charCodeAt(0)
+const charCodeUpperA = 'A'.charCodeAt(0)
+const charCodeUpperZ = 'Z'.charCodeAt(0)
+const charCodeDigit0 = '0'.charCodeAt(0)
+const charCodeDigit9 = '9'.charCodeAt(0)
+
+enum fzfCharClass {
+  charNonWord,
+  charLower,
+  charUpper,
+  charNumber,
+}
+
+function fzfCharClassOf(char: string): fzfCharClass {
+  const code = char.charCodeAt(0)
+  if (charCodeLowerA <= code && code <= charCodeLowerZ) {
+    return fzfCharClass.charLower
+  } else if (charCodeUpperA <= code && code <= charCodeUpperZ) {
+    return fzfCharClass.charUpper
+  } else if (charCodeDigit0 <= code && code <= charCodeDigit9) {
+    return fzfCharClass.charNumber
+  }
+  return fzfCharClass.charNonWord
+}
+
+function charsMatch(textChar: string, patternChar: string): boolean {
+  if (textChar === patternChar) return true
+
+  const patternCharCode = patternChar.charCodeAt(0)
+  if (charCodeLowerA <= patternCharCode && patternCharCode <= charCodeLowerZ) {
+    return textChar.charCodeAt(0) === patternCharCode - charCodeLowerA + charCodeUpperA
+  }
+  return false
+}
+
+function fzfFuzzyMatchV1(text: string, pattern: string): FuzzyMatch | null {
+  if (pattern.length == 0) {
+    return {matchedRanges: [], score: 0}
+  }
+
+  // I removed the fzfAsciiFuzzyIndex code because it's not actually clear to
+  // me that it's a very helpful optimization.
+
+  let pidx = 0
+  let sidx = -1
+  let eidx = -1
+
+  let lenRunes = text.length
+  let lenPattern = pattern.length
+
+  // Forward pass: scan over the text pattern, identifying the earliest start
+  // and the latest end to consider.
+  for (let index = 0; index < lenRunes; index++) {
+    let char = text[index]
+    let pchar = pattern[pidx]
+    if (charsMatch(char, pchar)) {
+      if (sidx < 0) {
+        sidx = index
+      }
+      pidx++
+      if (pidx == lenPattern) {
+        // We found the last character in the pattern! eidx is exclusive, so
+        // we'll set it to the current index + 1.
+        eidx = index + 1
+        break
+      }
+    }
+  }
+
+  if (eidx == -1) {
+    // We couldn't find all the characters in the pattern. No match.
+    return null
+  }
+
+  // Assuming we found all the characters in the pattern, perform the backwards
+  // pass.
+  pidx--
+  for (let index = eidx - 1; index >= sidx; index--) {
+    const char = text[index]
+    const pchar = pattern[pidx]
+    if (charsMatch(char, pchar)) {
+      pidx--
+      if (pidx < 0) {
+        // We found the first character of the pattern, scanning
+        // backwards. This *may* have narrowed the match further.
+        // For example, for the following inputs:
+        //
+        //    text = "xxx a b c abc xxx"
+        // pattern = "abc"
+        //
+        // For the forward pass, you get:
+        //
+        //    "xxx a b c abc xxx"
+        //    start^        ^end
+        //
+        // But after the backward pass, we can narrow this to:
+        //
+        //    "xxx a b c abc xxx"
+        //          start^  ^end
+        sidx = index
+        return fzfCalculateScore(text, pattern, sidx, eidx)
+      }
+    }
+  }
+
+  // This should be unreachable.
+  throw new Error('Implementation error. This must be a bug in fzfFuzzyMatchV1')
+}
+
+const fzfScoreMatch = 16
+const fzfScoreGapStart = -3
+const fzfScoreGapExtension = -1
+const fzfBonusBoundary = fzfScoreMatch / 2
+const fzfBonusNonWord = fzfScoreMatch / 2
+const fzfBonusCamel123 = fzfBonusBoundary + fzfScoreGapExtension
+const fzfBonusConsecutive = -(fzfScoreGapStart + fzfScoreGapExtension)
+const fzfBonusFirstCharMultiplier = 2
+
+function bonusFor(prevClass: fzfCharClass, curClass: fzfCharClass): number {
+  if (prevClass === fzfCharClass.charNonWord && curClass !== fzfCharClass.charNonWord) {
+    // Prefer matching at word boundaries
+    //
+    // This should prefer "a c" over "abc" for a pattern of "ac".
+    return fzfBonusBoundary
+  }
+
+  if (
+    (prevClass === fzfCharClass.charLower && curClass == fzfCharClass.charUpper) ||
+    (prevClass !== fzfCharClass.charNumber && curClass == fzfCharClass.charNumber)
+  ) {
+    // Prefer matching at the transition point between lower & upper for camelCase,
+    // and from transition from letter to number for identifiers like letter123.
+    //
+    // This should prefer "OutNode" over "phone" for a pattern of "n",
+    // and "abc123" over "x211" for a pattern of "1".
+    return fzfBonusCamel123
+  }
+
+  if (curClass === fzfCharClass.charNonWord) {
+    return fzfBonusNonWord
+  }
+  return 0
+}
+
+function fzfCalculateScore(text: string, pattern: string, sidx: number, eidx: number): FuzzyMatch {
+  let pidx = 0
+  let score = 0
+  let inGap = false
+  let consecutive = 0
+  let firstBonus = 0
+  let pos: number[] = new Array(pattern.length)
+  let prevClass = fzfCharClass.charNonWord
+
+  if (sidx > 0) {
+    prevClass = fzfCharClassOf(text[sidx - 1])
+  }
+  for (let idx = sidx; idx < eidx; idx++) {
+    let char = text[idx]
+    let curClass = fzfCharClassOf(char)
+    if (charsMatch(char, pattern[pidx])) {
+      pos[pidx] = idx
+      score += fzfScoreMatch
+      let bonus = bonusFor(prevClass, curClass)
+      if (consecutive == 0) {
+        firstBonus = bonus
+      } else {
+        // Break consecutive chunk
+        if (bonus === fzfBonusBoundary) {
+          firstBonus = bonus
+        }
+        bonus = Math.max(bonus, firstBonus, fzfBonusConsecutive)
+      }
+      if (pidx === 0) {
+        score += bonus * fzfBonusFirstCharMultiplier
+      } else {
+        score += bonus
+      }
+      inGap = false
+      consecutive++
+      pidx++
+    } else {
+      if (inGap) {
+        // Penalize gaps (this bonus is negative)
+        score += fzfScoreGapExtension
+      } else {
+        // Penalize the beginning of gaps more harshly
+        score += fzfScoreGapStart
+      }
+      inGap = true
+      consecutive = 0
+      firstBonus = 0
+    }
+    prevClass = curClass
+  }
+
+  if (pidx !== pattern.length) {
+    throw new Error(
+      'fzfCalculateScore should only be called when pattern is found between sidx and eidx',
+    )
+  }
+
+  let matchedRanges: [number, number][] = [[pos[0], pos[0] + 1]]
+  for (let i = 1; i < pos.length; i++) {
+    const curPos = pos[i]
+    const curRange = matchedRanges[matchedRanges.length - 1]
+    if (curRange[1] === curPos) {
+      curRange[1] = curPos + 1
+    } else {
+      matchedRanges.push([curPos, curPos + 1])
+    }
+  }
+
+  return {
+    score,
+    matchedRanges,
+  }
+}
diff --git a/src/views/profile-select.tsx b/src/views/profile-select.tsx
new file mode 100644
index 0000000..8f58ea0
--- /dev/null
+++ b/src/views/profile-select.tsx
@@ -0,0 +1,410 @@
+import {Profile} from '../lib/profile'
+import {h, JSX, ComponentChild, Ref} from 'preact'
+import {useCallback, useState, useMemo, useEffect, useRef} from 'preact/hooks'
+import {StyleSheet, css} from 'aphrodite'
+import {Colors, ZIndex, Sizes} from './style'
+import {fuzzyMatchStrings} from '../lib/fuzzy-find'
+import {sortBy} from '../lib/utils'
+
+interface ProfileSelectRowProps {
+  setProfileIndexToView: (profileIndex: number) => void
+  setHoveredProfileIndex: (profileIndex: number) => void
+  profile: Profile
+  matchedRanges: [number, number][]
+  hovered: boolean
+  selected: boolean
+  indexInProfileGroup: number
+  indexInFilteredListView: number
+  profileCount: number
+  nodeRef?: Ref<HTMLDivElement>
+  closeProfileSelect: () => void
+}
+
+function highlightRanges(text: string, ranges: [number, number][]): JSX.Element {
+  const spans: ComponentChild[] = []
+  let last = 0
+  for (let range of ranges) {
+    spans.push(text.slice(last, range[0]))
+    spans.push(<span className={css(style.highlighted)}>{text.slice(range[0], range[1])}</span>)
+    last = range[1]
+  }
+  spans.push(text.slice(last))
+
+  return <span>{spans}</span>
+}
+
+export function ProfileSelectRow({
+  setProfileIndexToView,
+  setHoveredProfileIndex,
+  profile,
+  selected,
+  hovered,
+  profileCount,
+  nodeRef,
+  closeProfileSelect,
+  indexInProfileGroup,
+  matchedRanges,
+  indexInFilteredListView,
+}: ProfileSelectRowProps) {
+  const onMouseUp = useCallback(() => {
+    closeProfileSelect()
+    setProfileIndexToView(indexInProfileGroup)
+  }, [closeProfileSelect, setProfileIndexToView, indexInProfileGroup])
+
+  const onMouseEnter = useCallback(
+    (ev: Event) => {
+      setHoveredProfileIndex(indexInProfileGroup)
+    },
+    [setHoveredProfileIndex, indexInProfileGroup],
+  )
+
+  const name = profile.getName()
+
+  const maxDigits = 1 + Math.floor(Math.log10(profileCount))
+
+  const highlighted = useMemo(() => {
+    const result = highlightRanges(name, matchedRanges)
+    return result
+  }, [name, matchedRanges])
+
+  // TODO(jlfwong): There's a really gnarly edge-case here where the highlighted
+  // ranges are part of the text truncated by ellipsis. I'm just going to punt
+  // on solving for that.
+  return (
+    <div
+      ref={nodeRef}
+      onMouseUp={onMouseUp}
+      onMouseEnter={onMouseEnter}
+      title={name}
+      className={css(
+        style.profileRow,
+        indexInFilteredListView % 2 === 0 && style.profileRowEven,
+        selected && style.profileRowSelected,
+        hovered && style.profileRowHovered,
+      )}
+    >
+      <span className={css(style.profileIndex)} style={{width: maxDigits + 'em'}}>
+        {indexInProfileGroup + 1}:
+      </span>{' '}
+      {highlighted}
+    </div>
+  )
+}
+
+interface ProfileSelectProps {
+  setProfileIndexToView: (profileIndex: number) => void
+  indexToView: number
+  profiles: Profile[]
+  closeProfileSelect: () => void
+  visible: boolean
+}
+
+function stopPropagation(ev: Event) {
+  ev.stopPropagation()
+}
+
+interface FilteredProfile {
+  indexInProfileGroup: number
+  profile: Profile
+  matchedRanges: [number, number][]
+  score: number
+}
+
+function getSortedFilteredProfiles(profiles: Profile[], filterText: string): FilteredProfile[] {
+  const filtered: FilteredProfile[] = []
+  for (let i = 0; i < profiles.length; i++) {
+    const profile = profiles[i]
+    const match = fuzzyMatchStrings(profile.getName(), filterText)
+    if (!match) continue
+    filtered.push({
+      indexInProfileGroup: i,
+      profile,
+      ...match,
+    })
+  }
+  sortBy(filtered, p => -p.score)
+  return filtered
+}
+
+export function ProfileSelect({
+  profiles,
+  closeProfileSelect,
+  indexToView,
+  visible,
+  setProfileIndexToView,
+}: ProfileSelectProps) {
+  const [filterText, setFilterText] = useState('')
+
+  const onFilterTextChange = useCallback(
+    (ev: Event) => {
+      const value = (ev.target as HTMLInputElement).value
+      setFilterText(value)
+    },
+    [setFilterText],
+  )
+
+  const focusFilterInput = useCallback(
+    (node: HTMLInputElement | null) => {
+      if (node) {
+        if (visible) {
+          node.select()
+        } else {
+          node.blur()
+        }
+      }
+    },
+    [visible],
+  )
+
+  const filteredProfiles = useMemo(() => {
+    return getSortedFilteredProfiles(profiles, filterText)
+  }, [profiles, filterText])
+
+  const [hoveredProfileIndex, setHoveredProfileIndex] = useState<number | null>(0)
+
+  const selectedNodeRef = useRef<HTMLDivElement | null>(null)
+  useEffect(() => {
+    if (visible) {
+      // Whenever the profile select becomes visible...
+
+      // Clear any hovered element
+      setHoveredProfileIndex(null)
+
+      // And scroll the selected profile into view, if possible
+      if (selectedNodeRef.current !== null) {
+        selectedNodeRef.current.scrollIntoView({
+          behavior: 'auto',
+          block: 'nearest',
+          inline: 'nearest',
+        })
+      }
+    }
+  }, [visible])
+
+  // TODO(jlfwong): Hi-jacking the behavior of enter and the arrow keys won't
+  // work well for some composition methods (e.g. a Chinese character
+  // composition keyboard input method).
+  const onFilterKeyUp = useCallback(
+    (ev: KeyboardEvent) => {
+      // Prevent the key-press from propagating to other keyboard shortcut
+      // handlers in other components.
+      ev.stopPropagation()
+
+      let newHoveredIndexInFilteredList: number | null = null
+
+      switch (ev.key) {
+        case 'Enter': {
+          if (hoveredProfileIndex != null) {
+            closeProfileSelect()
+            setProfileIndexToView(hoveredProfileIndex)
+          }
+          break
+        }
+        case 'Escape': {
+          closeProfileSelect()
+          break
+        }
+        case 'ArrowDown': {
+          ev.preventDefault()
+          newHoveredIndexInFilteredList = 0
+          if (hoveredProfileIndex != null) {
+            const indexInFilteredList = filteredProfiles.findIndex(
+              p => p.indexInProfileGroup === hoveredProfileIndex,
+            )
+            if (indexInFilteredList !== -1) {
+              newHoveredIndexInFilteredList = indexInFilteredList + 1
+            }
+          }
+          break
+        }
+        case 'ArrowUp': {
+          ev.preventDefault()
+          newHoveredIndexInFilteredList = filteredProfiles.length - 1
+          if (hoveredProfileIndex != null) {
+            const indexInFilteredList = filteredProfiles.findIndex(
+              p => p.indexInProfileGroup === hoveredProfileIndex,
+            )
+            if (indexInFilteredList !== -1) {
+              newHoveredIndexInFilteredList = indexInFilteredList - 1
+            }
+          }
+          break
+        }
+      }
+
+      if (
+        newHoveredIndexInFilteredList != null &&
+        newHoveredIndexInFilteredList >= 0 &&
+        newHoveredIndexInFilteredList < filteredProfiles.length
+      ) {
+        const indexInProfileGroup =
+          filteredProfiles[newHoveredIndexInFilteredList].indexInProfileGroup
+        setHoveredProfileIndex(indexInProfileGroup)
+        setPendingForcedScroll(true)
+      }
+    },
+    [closeProfileSelect, setProfileIndexToView, hoveredProfileIndex, filteredProfiles],
+  )
+
+  const [pendingForcedScroll, setPendingForcedScroll] = useState(false)
+  useEffect(() => {
+    // Whenever the list of filtered profiles changes, set the first element hovered.
+    if (filteredProfiles.length > 0) {
+      setHoveredProfileIndex(filteredProfiles[0].indexInProfileGroup)
+      setPendingForcedScroll(true)
+    }
+  }, [setHoveredProfileIndex, filteredProfiles])
+
+  const hoveredNodeRef = useCallback(
+    (hoveredNode: HTMLDivElement | null) => {
+      if (pendingForcedScroll && hoveredNode) {
+        hoveredNode.scrollIntoView({
+          behavior: 'auto',
+          block: 'nearest',
+          inline: 'nearest',
+        })
+        setPendingForcedScroll(false)
+      }
+    },
+    [pendingForcedScroll, setPendingForcedScroll],
+  )
+
+  const selectedHoveredRef = useCallback(
+    (node: HTMLDivElement | null) => {
+      selectedNodeRef.current = node
+      hoveredNodeRef(node)
+    },
+    [selectedNodeRef, hoveredNodeRef],
+  )
+
+  // We allow ProfileSelect to be aware of its own visibility in order to retain
+  // its scroll offset state between times when it's hidden & shown, and also to
+  // scroll the selected node into view once it becomes shown again after the
+  // selected profile has changed.
+  return (
+    <div className={css(style.profileSelectOuter)}>
+      <div className={css(style.caret)} />
+      <div className={css(style.profileSelectBox)}>
+        {/* We stop event propagation for key events on the input to prevent
+            this from triggering keyboard shortcuts. */}
+        <div className={css(style.filterInputContainer)}>
+          <input
+            type="text"
+            ref={focusFilterInput}
+            placeholder={'Filter...'}
+            value={filterText}
+            onInput={onFilterTextChange}
+            onKeyDown={onFilterKeyUp}
+            onKeyUp={stopPropagation}
+            onKeyPress={stopPropagation}
+          />
+        </div>
+        <div className={css(style.profileSelectScrolling)}>
+          {filteredProfiles.map(({profile, matchedRanges, indexInProfileGroup}, indexInList) => {
+            let ref: Ref<HTMLDivElement> | undefined = undefined
+            const selected = indexInProfileGroup === indexToView
+            const hovered = indexInProfileGroup === hoveredProfileIndex
+            if (selected && hovered) {
+              ref = selectedHoveredRef
+            } else if (selected) {
+              ref = selectedNodeRef
+            } else if (hovered) {
+              ref = hoveredNodeRef
+            }
+            return (
+              <ProfileSelectRow
+                setHoveredProfileIndex={setHoveredProfileIndex}
+                indexInProfileGroup={indexInProfileGroup}
+                indexInFilteredListView={indexInList}
+                hovered={indexInProfileGroup == hoveredProfileIndex}
+                selected={indexInProfileGroup === indexToView}
+                profile={profile}
+                profileCount={profiles.length}
+                nodeRef={ref}
+                matchedRanges={matchedRanges}
+                setProfileIndexToView={setProfileIndexToView}
+                closeProfileSelect={closeProfileSelect}
+              />
+            )
+          })}
+          {filteredProfiles.length === 0 ? (
+            <div className={css(style.profileRow)}>No results match filter "{filterText}"</div>
+          ) : null}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+const paddingHeight = 10
+
+const style = StyleSheet.create({
+  filterInputContainer: {
+    display: 'flex',
+    flexDirection: 'column',
+    padding: 10,
+    alignItems: 'stretch',
+  },
+  caret: {
+    width: 0,
+    height: 0,
+    borderLeft: '5px solid transparent',
+    borderRight: '5px solid transparent',
+    borderBottom: '5px solid black',
+  },
+  highlighted: {
+    background: Colors.PALE_DARK_BLUE,
+  },
+  padding: {
+    height: paddingHeight,
+    background: Colors.BLACK,
+  },
+  profileRow: {
+    height: Sizes.FRAME_HEIGHT - 2,
+    border: '1px solid transparent',
+    textAlign: 'left',
+    paddingLeft: 10,
+    paddingRight: 10,
+    background: Colors.BLACK,
+    overflow: 'hidden',
+    whiteSpace: 'nowrap',
+    textOverflow: 'ellipsis',
+    cursor: 'pointer',
+  },
+  profileRowHovered: {
+    border: `1px solid ${Colors.DARK_BLUE}`,
+  },
+  profileRowSelected: {
+    background: Colors.DARK_BLUE,
+  },
+  profileRowEven: {
+    background: Colors.DARK_GRAY,
+  },
+  profileSelectScrolling: {
+    maxHeight: `min(calc(100vh - ${Sizes.TOOLBAR_HEIGHT - 2 * paddingHeight}px), ${
+      20 * Sizes.FRAME_HEIGHT
+    }px)`,
+    overflow: 'auto',
+  },
+  profileSelectBox: {
+    width: '100%',
+    paddingBottom: 10,
+    background: Colors.BLACK,
+    color: Colors.WHITE,
+  },
+  profileSelectOuter: {
+    width: '100%',
+    maxWidth: 480,
+    margin: '0 auto',
+    position: 'relative',
+    zIndex: ZIndex.PROFILE_SELECT,
+    alignItems: 'center',
+    display: 'flex',
+    flexDirection: 'column',
+  },
+  profileIndex: {
+    textAlign: 'right',
+    display: 'inline-block',
+    color: Colors.LIGHT_GRAY,
+  },
+})
diff --git a/src/views/style.ts b/src/views/style.ts
index c212689..dca459c 100644
--- a/src/views/style.ts
+++ b/src/views/style.ts
@@ -40,7 +40,8 @@ export enum Duration {
 }
 
 export enum ZIndex {
-  HOVERTIP = 1,
+  PROFILE_SELECT = 1,
+  HOVERTIP = 2,
 }
 
 export const commonStyle = StyleSheet.create({
diff --git a/src/views/toolbar.tsx b/src/views/toolbar.tsx
index e60e73f..b9efe5b 100644
--- a/src/views/toolbar.tsx
+++ b/src/views/toolbar.tsx
@@ -1,9 +1,13 @@
 import {ApplicationProps} from './application'
 import {ViewMode} from '../store'
 import {h, JSX, Fragment} from 'preact'
-import {useCallback} from 'preact/hooks'
+import {useCallback, useState, useEffect} from 'preact/hooks'
 import {StyleSheet, css} from 'aphrodite'
 import {Sizes, Colors, FontFamily, FontSize, Duration} from './style'
+import {ProfileSelect} from './profile-select'
+import {ProfileGroupState} from '../store/profiles-state'
+import {Profile} from '../lib/profile'
+import {objectsHaveShallowEquality} from '../lib/utils'
 
 interface ToolbarProps extends ApplicationProps {
   browseForFile(): void
@@ -54,44 +58,77 @@ function ToolbarLeftContent(props: ToolbarProps) {
   )
 }
 
+const getCachedProfileList = (() => {
+  // TODO(jlfwong): It would be nice to just implement this as useMemo, but if
+  // we do that using profileGroup or profileGroup.profiles as the cache key,
+  // then it will invalidate whenever *anything* changes, because
+  // profileGroup.profiles is ProfileState[], which contains component state
+  // information for each tab for each profile. So whenever any property in any
+  // persisted view state changes for *any* view in *any* profile, the profiles
+  // list will get re-generated.
+  let cachedProfileList: Profile[] | null = null
+
+  return (profileGroup: ProfileGroupState): Profile[] | null => {
+    let nextProfileList = profileGroup?.profiles.map(p => p.profile) || null
+
+    if (
+      cachedProfileList === null ||
+      (nextProfileList != null && !objectsHaveShallowEquality(cachedProfileList, nextProfileList))
+    ) {
+      cachedProfileList = nextProfileList
+    }
+
+    return cachedProfileList
+  }
+})()
+
 function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
   const {activeProfileState, profileGroup} = props
-  if (activeProfileState && profileGroup) {
-    const {index} = activeProfileState
+  const profiles = getCachedProfileList(profileGroup)
+  const [profileSelectShown, setProfileSelectShown] = useState(false)
+
+  const openProfileSelect = useCallback(() => {
+    setProfileSelectShown(true)
+  }, [setProfileSelectShown])
+
+  const closeProfileSelect = useCallback(() => {
+    setProfileSelectShown(false)
+  }, [setProfileSelectShown])
+
+  useEffect(() => {
+    const onWindowKeyPress = (ev: KeyboardEvent) => {
+      if (ev.key === 't') {
+        ev.preventDefault()
+        setProfileSelectShown(true)
+      }
+    }
+    window.addEventListener('keypress', onWindowKeyPress)
+    return () => {
+      window.removeEventListener('keypress', onWindowKeyPress)
+    }
+  }, [setProfileSelectShown])
+
+  if (activeProfileState && profileGroup && profiles) {
     if (profileGroup.profiles.length === 1) {
       return <Fragment>{activeProfileState.profile.getName()}</Fragment>
     } else {
-      function makeNavButton(content: string, disabled: boolean, onClick: () => void) {
-        return (
-          <button
-            disabled={disabled}
-            onClick={onClick}
-            className={css(
-              style.emoji,
-              style.toolbarProfileNavButton,
-              disabled && style.toolbarProfileNavButtonDisabled,
-            )}
-          >
-            {content}
-          </button>
-        )
-      }
-
-      const prevButton = makeNavButton('⬅️', index === 0, () =>
-        props.setProfileIndexToView(index - 1),
-      )
-      const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () =>
-        props.setProfileIndexToView(index + 1),
-      )
-
       return (
-        <div className={css(style.toolbarCenter)}>
-          {prevButton}
-          {activeProfileState.profile.getName()}{' '}
-          <span className={css(style.toolbarProfileIndex)}>
-            ({activeProfileState.index + 1}/{profileGroup.profiles.length})
+        <div className={css(style.toolbarCenter)} onMouseLeave={closeProfileSelect}>
+          <span onMouseOver={openProfileSelect}>
+            {activeProfileState.profile.getName()}{' '}
+            <span className={css(style.toolbarProfileIndex)}>
+              ({activeProfileState.index + 1}/{profileGroup.profiles.length})
+            </span>
           </span>
-          {nextButton}
+          <div style={{display: profileSelectShown ? 'block' : 'none'}}>
+            <ProfileSelect
+              setProfileIndexToView={props.setProfileIndexToView}
+              indexToView={profileGroup.indexToView}
+              profiles={profiles}
+              closeProfileSelect={closeProfileSelect}
+              visible={profileSelectShown}
+            />
+          </div>
         </div>
       )
     }
@@ -177,26 +214,6 @@ const style = StyleSheet.create({
   toolbarProfileIndex: {
     color: Colors.LIGHT_GRAY,
   },
-  toolbarProfileNavButton: {
-    opacity: 0.8,
-    fontSize: FontSize.TITLE,
-    lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
-    ':hover': {
-      opacity: 1.0,
-    },
-    background: 'none',
-    border: 'none',
-    padding: 0,
-    marginLeft: '0.3em',
-    marginRight: '0.3em',
-    transition: `all ${Duration.HOVER_CHANGE} ease-in`,
-  },
-  toolbarProfileNavButtonDisabled: {
-    opacity: 0.5,
-    ':hover': {
-      opacity: 0.5,
-    },
-  },
   toolbarTab: {
     background: Colors.DARK_GRAY,
     marginTop: Sizes.SEPARATOR_HEIGHT,


Test patch:
diff --git a/src/lib/fuzzy-find.test.ts b/src/lib/fuzzy-find.test.ts
new file mode 100644
index 0000000..fea49b2
--- /dev/null
+++ b/src/lib/fuzzy-find.test.ts
@@ -0,0 +1,81 @@
+import {fuzzyMatchStrings} from './fuzzy-find'
+import {sortBy} from './utils'
+
+function assertMatches(texts: string[], pattern: string, expectedResults: string[]) {
+  const results: {score: number; highlighted: string}[] = []
+
+  for (let text of texts) {
+    const match = fuzzyMatchStrings(text, pattern)
+    if (match == null) {
+      continue
+    }
+
+    let highlighted = ''
+    let last = 0
+    for (let range of match.matchedRanges) {
+      highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
+      last = range[1]
+    }
+    highlighted += text.slice(last)
+
+    results.push({score: match.score, highlighted})
+  }
+
+  // Sort scores in descending order
+  sortBy(results, r => -r.score)
+  expect(results.map(r => r.highlighted)).toEqual(expectedResults)
+}
+
+function assertMatch(text: string, pattern: string, expected: string) {
+  assertMatches([text], pattern, [expected])
+}
+
+function assertNoMatch(text: string, pattern: string) {
+  assertMatches([text], pattern, [])
+}
+
+describe('fuzzyMatchStrings', () => {
+  test('no match', () => {
+    assertNoMatch('a', 'b')
+    assertNoMatch('aa', 'ab')
+    assertNoMatch('a', 'aa')
+    assertNoMatch('ca', 'ac')
+  })
+
+  test('full text match', () => {
+    assertMatch('hello', 'hello', '[hello]')
+    assertMatch('multiple words', 'multiple words', '[multiple words]')
+  })
+
+  test('case sensitivity', () => {
+    assertMatch('HELLO', 'hello', '[HELLO]')
+    assertMatch('Hello', 'hello', '[Hello]')
+    assertNoMatch('hello', 'Hello')
+    assertNoMatch('hello', 'HELLO')
+  })
+
+  test('multiple occurrences', () => {
+    assertMatch('hello hello', 'hello', '[hello] hello')
+    assertMatch('hellohello', 'hello', '[hello]hello')
+  })
+
+  test('prefer earlier matches', () => {
+    assertMatches(['cab', 'ab'], 'ab', ['[ab]', 'c[ab]'])
+  })
+
+  test('prefer shorter matches', () => {
+    assertMatches(['abbc', 'abc', 'abbbc'], 'ac', ['[a]b[c]', '[a]bb[c]', '[a]bbb[c]'])
+  })
+
+  test('prefer word boundaries', () => {
+    assertMatches(['abc', 'a c'], 'ac', ['[a] [c]', '[a]b[c]'])
+  })
+
+  test('prefer camelCase matches', () => {
+    assertMatches(['downtown', 'OutNode'], 'n', ['Out[N]ode', 'dow[n]town'])
+  })
+
+  test('prefer number prefix matches', () => {
+    assertMatches(['211', 'a123'], '1', ['a[1]23', '2[1]1'])
+  })
+})