Browse Source

feat(profiling): Update flamegraph thread selector (#33913)

Update the thread selector on flamegraphs to use the new updated designs.
Tony Xiao 2 years ago
parent
commit
813a5d7209

+ 5 - 5
static/app/components/profiling/flamegraph.tsx

@@ -66,6 +66,11 @@ function Flamegraph(props: FlamegraphProps): ReactElement {
   return (
     <Fragment>
       <FlamegraphToolbar>
+        <ThreadMenuSelector
+          profileGroup={props.profiles}
+          activeProfileIndex={flamegraph.profileIndex}
+          onProfileIndexChange={setActiveProfileIndex}
+        />
         <FlamegraphViewSelectMenu
           view={view}
           sorting={sorting}
@@ -76,11 +81,6 @@ function Flamegraph(props: FlamegraphProps): ReactElement {
             dispatch({type: 'set view', payload: v});
           }}
         />
-        <ThreadMenuSelector
-          profileGroup={props.profiles}
-          activeProfileIndex={flamegraph.profileIndex}
-          onProfileIndexChange={setActiveProfileIndex}
-        />
         <FlamegraphOptionsMenu canvasPoolManager={canvasPoolManager} />
       </FlamegraphToolbar>
 

+ 3 - 4
static/app/components/profiling/flamegraphToolbar.tsx

@@ -1,5 +1,7 @@
 import styled from '@emotion/styled';
 
+import space from 'sentry/styles/space';
+
 interface FlamegraphToolbarProps {
   children: React.ReactNode;
 }
@@ -8,8 +10,5 @@ export const FlamegraphToolbar = styled('div')<FlamegraphToolbarProps>`
   display: flex;
   justify-content: space-between;
   align-items: center;
-
-  > div {
-    flex: 1;
-  }
+  margin: ${space(1)} ${space(4)};
 `;

+ 65 - 203
static/app/components/profiling/threadSelector.tsx

@@ -1,45 +1,12 @@
-import * as React from 'react';
-import {css} from '@emotion/react';
-import styled from '@emotion/styled';
-import Fuse from 'fuse.js';
+import {useCallback, useMemo} from 'react';
 
-import AutoComplete from 'sentry/components/autoComplete';
-import Button from 'sentry/components/button';
-import Input from 'sentry/components/forms/controls/input';
-import {IconCheckmark} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
+import CompactSelect from 'sentry/components/forms/compactSelect';
+import {ControlProps, GeneralSelectValue} from 'sentry/components/forms/selectControl';
+import {IconList} from 'sentry/icons';
+import {SelectValue} from 'sentry/types';
+import {defined} from 'sentry/utils';
 import {ProfileGroup} from 'sentry/utils/profiling/profile/importProfile';
 import {Profile} from 'sentry/utils/profiling/profile/profile';
-import useOnClickOutside from 'sentry/utils/useOnClickOutside';
-
-const sortProfiles = (profiles: ReadonlyArray<Profile>): ProfileGroup['profiles'] => {
-  return [...profiles].sort((a, b) => {
-    if (!b.duration) {
-      return -1;
-    }
-    if (!a.duration) {
-      return 1;
-    }
-
-    if (a.name.startsWith('(tid') && b.name.startsWith('(tid')) {
-      return -1;
-    }
-    if (a.name.startsWith('(tid')) {
-      return -1;
-    }
-    if (b.name.startsWith('(tid')) {
-      return -1;
-    }
-    if (a.name.includes('main')) {
-      return -1;
-    }
-    if (b.name.includes('main')) {
-      return 1;
-    }
-    return a.name > b.name ? -1 : 1;
-  });
-};
 
 interface ThreadSelectorProps {
   activeProfileIndex: ProfileGroup['activeProfileIndex'];
@@ -47,180 +14,75 @@ interface ThreadSelectorProps {
   profileGroup: ProfileGroup;
 }
 
-function ThreadMenuSelector(props: ThreadSelectorProps): React.ReactElement | null {
-  const containerRef = React.useRef<HTMLDivElement>(null);
-  const [open, setOpen] = React.useState<boolean>(false);
-
-  const handleSelectItem = React.useCallback(
-    (p: Fuse.FuseResult<Profile & {index: number}>) => {
-      const index = props.profileGroup.profiles.findIndex(
-        profile => profile.name === p.item.name
-      );
-      if (index === -1) {
-        return;
+function ThreadMenuSelector<OptionType extends GeneralSelectValue = GeneralSelectValue>({
+  activeProfileIndex,
+  onProfileIndexChange,
+  profileGroup,
+}: ThreadSelectorProps) {
+  const options: SelectValue<number>[] = useMemo(() => {
+    return profileGroup.profiles
+      .map((profile, i) => ({
+        name: profile.name,
+        duration: profile.duration,
+        index: i,
+      }))
+      .sort(compareProfiles)
+      .map(item => ({label: item.name, value: item.index}));
+  }, [profileGroup]);
+
+  const handleChange: NonNullable<ControlProps<OptionType>['onChange']> = useCallback(
+    opt => {
+      if (defined(opt)) {
+        onProfileIndexChange(opt.value);
       }
-      props.onProfileIndexChange(index);
     },
-    [props.onProfileIndexChange, props.profileGroup]
+    [onProfileIndexChange]
   );
 
-  React.useEffect(() => {
-    const tagNamesToPreventStealingFocusFrom = new Set<Element['tagName']>([
-      'INPUT',
-      'TEXTAREA',
-    ]);
-    function handleKeyDown(evt) {
-      if (tagNamesToPreventStealingFocusFrom.has(evt.target.tagName)) {
-        return;
-      }
-      if (evt.key === 't' && !open) {
-        evt.preventDefault();
-        setOpen(true);
-      }
-      if (evt.key === 'Escape' && open) {
-        setOpen(false);
-      }
-    }
-
-    document.addEventListener('keydown', handleKeyDown);
-    return () => document.removeEventListener('keydown', handleKeyDown);
-  }, [open]);
-
-  useOnClickOutside(containerRef, () => {
-    setOpen(false);
-  });
-
   return (
-    <div>
-      <CurrentThreadButton size="zero" borderless onClick={() => setOpen(true)}>
-        {props.profileGroup.profiles[props.activeProfileIndex]?.name ??
-          t('Select Thread')}
-      </CurrentThreadButton>
-      <AutoComplete
-        // @ts-ignore the type is typed as any
-        onSelect={handleSelectItem}
-        closeOnSelect={false}
-        isOpen={open}
-      >
-        {({getInputProps, getItemProps, isOpen, inputValue, highlightedIndex}) => {
-          const sortedProfiles = sortProfiles(props.profileGroup.profiles);
-          const filteredProfiles = inputValue
-            ? new Fuse(sortedProfiles, {includeMatches: true, keys: ['name']}).search(
-                inputValue
-              )
-            : sortedProfiles.map(p => ({item: p, matches: [], score: 1}));
-
-          return isOpen ? (
-            <ThreadSelectorContainer ref={containerRef}>
-              <Input
-                type="search"
-                autoFocus
-                {...getInputProps({
-                  onKeyDown: evt => {
-                    if (evt.key === 'Escape') {
-                      setOpen(false);
-                    }
-                  },
-                })}
-              />
-              <DropdownBox>
-                {filteredProfiles.length > 0 ? (
-                  filteredProfiles.map((profile, index) => {
-                    const activeProfile =
-                      props.profileGroup.profiles[props.activeProfileIndex];
-
-                    return (
-                      <SearchResult
-                        key={index}
-                        highlighted={index === highlightedIndex}
-                        ref={ref =>
-                          index === highlightedIndex
-                            ? ref?.scrollIntoView({block: 'nearest'})
-                            : null
-                        }
-                        {...getItemProps({
-                          // @ts-ignore the type is typed as any
-                          item: profile,
-                          index,
-                        })}
-                      >
-                        {activeProfile.name === profile.item.name ? (
-                          <IconCheckmark size="sm" style={{marginRight: space(1)}} />
-                        ) : null}
-                        {profile.item.name}
-                      </SearchResult>
-                    );
-                  })
-                ) : (
-                  <EmptyItem>{t('No results found')}</EmptyItem>
-                )}
-              </DropdownBox>
-            </ThreadSelectorContainer>
-          ) : null;
-        }}
-      </AutoComplete>
-    </div>
+    <CompactSelect
+      triggerProps={{
+        icon: <IconList size="xs" />,
+        size: 'xsmall',
+      }}
+      options={options}
+      value={activeProfileIndex}
+      onChange={handleChange}
+      isSearchable
+    />
   );
 }
 
-const SearchResult = styled('li')<{highlighted: boolean}>`
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  color: ${p => p.theme.textColor};
-  padding: ${space(1)} ${space(2)};
-
-  ${p =>
-    p.highlighted &&
-    css`
-      color: ${p.theme.purple300};
-      background: ${p.theme.backgroundSecondary};
-    `};
+type ProfileLight = {
+  duration: Profile['duration'];
+  index: number;
+  name: Profile['name'];
+};
 
-  &:not(:first-child) {
-    border-top: 1px solid ${p => p.theme.innerBorder};
+function compareProfiles(a: ProfileLight, b: ProfileLight): number {
+  if (!b.duration) {
+    return -1;
+  }
+  if (!a.duration) {
+    return 1;
   }
-`;
-
-const EmptyItem = styled('li')`
-  text-align: center;
-  padding: 16px;
-  opacity: 0.5;
-`;
-
-const DropdownBox = styled('ul')`
-  list-style-type: none;
-  padding: 0;
-  background: ${p => p.theme.background};
-  border: 1px solid ${p => p.theme.border};
-  box-shadow: ${p => p.theme.dropShadowHeavy};
-  right: 0;
-  width: 100%;
-  max-height: 60vh;
-  border-radius: 5px;
-  position: absolute;
-  overflow-y: auto;
-  top: 40px;
-`;
-
-const ThreadSelectorContainer = styled('div')`
-  z-index: ${p => p.theme.zIndex.dropdown};
-  border-radius: ${p => p.theme.borderRadius};
-  position: absolute;
-  max-width: 540px;
-  overflow: auto;
-  display: flex;
-  flex-direction: column;
-  width: 50%;
-  height: 80vh;
-  left: 50%;
-  transform: translate(-50%, 0);
-  top: 60px;
-`;
 
-const CurrentThreadButton = styled(Button)`
-  margin: 0 auto;
-  display: block;
-`;
+  if (a.name.startsWith('(tid') && b.name.startsWith('(tid')) {
+    return -1;
+  }
+  if (a.name.startsWith('(tid')) {
+    return -1;
+  }
+  if (b.name.startsWith('(tid')) {
+    return -1;
+  }
+  if (a.name.includes('main')) {
+    return -1;
+  }
+  if (b.name.includes('main')) {
+    return 1;
+  }
+  return a.name > b.name ? -1 : 1;
+}
 
 export {ThreadMenuSelector};