Browse Source

ref(replays): Refactor Replay Details Breadcrumbs list to use react-virtualized (#46103)

A few style changes along the way:
- The "portrait" aka "topbar" layout now shows tags. Previously Tags
were hidden completely in this layout
- I dropped the rounded corners and margin around the breadcrumb items,
they sit flush against each other and against the border.
- Before we showed 4 loading `<Placeholder>` rows, now we show a gray
Placeholder over the whole section.
- Without the margin/rounded corners & margin having individual items
doesn't make sense. Now it matches with the other tabs better;
console/network/etc.
- Once the replayRecord is downloaded you'll see the "Start Recording"
crumb, which seems to be replaced once everything else is done. But this
is a good placeholder.
- Moved BreadcrumbItem into components, it's used in Timeline,
UrlWalker, and the Breadcrumb List.
- The list item added a wrapper around it, for reading from
useReplayContext and some other hooks.

Tested that things continue to work:
- auto scroll breadcrumbs when the video is playing
- text overflow inside breadcrumbs
    - was able to remove an extra `<div>` in each crumb item


| Window Orientation | Before | After |
| --- | --- | --- |
| Landscape (the usual) | <img width="585" alt="widescreen list -
before"
src="https://user-images.githubusercontent.com/187460/226489835-2f08d42e-8743-4f45-b1ac-7458cbc4a60e.png">
| <img width="585" alt="widescreen list - after"
src="https://user-images.githubusercontent.com/187460/226489833-f327701a-379e-4d5c-8d8f-98ed43bb5044.png">
|
| Portrait | <img width="496" alt="tall list - before"
src="https://user-images.githubusercontent.com/187460/226489837-dfcc6c84-2eec-4ed3-bb04-6a4428e4bfe3.png">
| <img width="507" alt="tall list - after"
src="https://user-images.githubusercontent.com/187460/226489840-4f9993f8-8ced-4274-92df-fc1af7bea129.png">
|

Fixes https://github.com/getsentry/sentry/issues/45842
Fixes https://github.com/getsentry/sentry/issues/44165
Ryan Albrecht 2 years ago
parent
commit
05c53516bd

+ 24 - 46
static/app/views/replays/detail/breadcrumbs/breadcrumbItem.tsx → static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

@@ -1,37 +1,37 @@
-import {memo, useCallback} from 'react';
+import {CSSProperties, memo, useCallback} from 'react';
 import styled from '@emotion/styled';
 
 import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
 import {PanelItem} from 'sentry/components/panels';
 import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
 import {Tooltip} from 'sentry/components/tooltip';
-import {SVGIconProps} from 'sentry/icons/svgIcon';
 import {space} from 'sentry/styles/space';
 import type {Crumb} from 'sentry/types/breadcrumbs';
+import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
 import TimestampButton from 'sentry/views/replays/detail/timestampButton';
 
 type MouseCallback = (crumb: Crumb, e: React.MouseEvent<HTMLElement>) => void;
 
 interface Props {
   crumb: Crumb;
+  isCurrent: boolean;
   isHovered: boolean;
-  isSelected: boolean;
   onClick: null | MouseCallback;
   startTimestampMs: number;
-  allowHover?: boolean;
   onMouseEnter?: MouseCallback;
   onMouseLeave?: MouseCallback;
+  style?: CSSProperties;
 }
 
 function BreadcrumbItem({
   crumb,
+  isCurrent,
   isHovered,
-  isSelected,
-  startTimestampMs,
-  allowHover = true,
+  onClick,
   onMouseEnter,
   onMouseLeave,
-  onClick,
+  startTimestampMs,
+  style,
 }: Props) {
   const {title, description} = getDetails(crumb);
 
@@ -52,16 +52,16 @@ function BreadcrumbItem({
 
   return (
     <CrumbItem
+      aria-current={isCurrent}
       as={onClick ? 'button' : 'span'}
+      isCurrent={isCurrent}
+      isHovered={isHovered}
+      onClick={handleClick}
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
-      onClick={handleClick}
-      isHovered={isHovered}
-      isSelected={isSelected}
-      aria-current={isSelected}
-      allowHover={allowHover}
+      style={style}
     >
-      <IconWrapper color={crumb.color}>
+      <IconWrapper color={crumb.color} hasOccurred>
         <BreadcrumbIcon type={crumb.type} />
       </IconWrapper>
       <CrumbDetails>
@@ -75,9 +75,9 @@ function BreadcrumbItem({
           ) : null}
         </TitleContainer>
 
-        <Tooltip title={description} showOnlyOnOverflow>
-          <Description>{description}</Description>
-        </Tooltip>
+        <Description title={description} showOnlyOnOverflow>
+          {description}
+        </Description>
       </CrumbDetails>
     </CrumbItem>
   );
@@ -103,7 +103,7 @@ const Title = styled('span')`
   line-height: ${p => p.theme.text.lineHeightBody};
 `;
 
-const Description = styled('span')`
+const Description = styled(Tooltip)`
   ${p => p.theme.overflowEllipsis};
   font-size: 0.7rem;
   font-variant-numeric: tabular-nums;
@@ -112,9 +112,8 @@ const Description = styled('span')`
 `;
 
 type CrumbItemProps = {
+  isCurrent: boolean;
   isHovered: boolean;
-  isSelected: boolean;
-  allowHover?: boolean;
 };
 
 const CrumbItem = styled(PanelItem)<CrumbItemProps>`
@@ -130,15 +129,13 @@ const CrumbItem = styled(PanelItem)<CrumbItemProps>`
   text-align: left;
   border: none;
   position: relative;
-  ${p => p.isSelected && `background-color: ${p.theme.purple100};`}
+  ${p => p.isCurrent && `background-color: ${p.theme.purple100};`}
   ${p => p.isHovered && `background-color: ${p.theme.surface200};`}
   border-radius: ${p => p.theme.borderRadius};
 
-  ${p =>
-    p.allowHover &&
-    ` &:hover {
-    background-color: ${p.theme.surface200};
-  }`}
+  &:hover {
+    background-color: ${p => p.theme.surface200};
+  }
 
   /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
   &::after {
@@ -165,23 +162,4 @@ const CrumbItem = styled(PanelItem)<CrumbItemProps>`
   }
 `;
 
-/**
- * Taken `from events/interfaces/.../breadcrumbs/types`
- */
-const IconWrapper = styled('div')<Required<Pick<SVGIconProps, 'color'>>>`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 24px;
-  height: 24px;
-  border-radius: 50%;
-  color: ${p => p.theme.white};
-  background: ${p => p.theme[p.color] ?? p.color};
-  box-shadow: ${p => p.theme.dropShadowLight};
-  position: relative;
-  z-index: ${p => p.theme.zIndex.initial};
-`;
-
-const MemoizedBreadcrumbItem = memo(BreadcrumbItem);
-
-export default MemoizedBreadcrumbItem;
+export default memo(BreadcrumbItem);

+ 5 - 5
static/app/components/replays/breadcrumbs/replayTimelineEvents.tsx

@@ -1,6 +1,7 @@
 import {css, Theme, useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
+import BreadcrumbItem from 'sentry/components/replays/breadcrumbs/breadcrumbItem';
 import * as Timeline from 'sentry/components/replays/breadcrumbs/timeline';
 import {getCrumbsByColumn} from 'sentry/components/replays/utils';
 import {Tooltip} from 'sentry/components/tooltip';
@@ -8,7 +9,6 @@ import {space} from 'sentry/styles/space';
 import {Crumb} from 'sentry/types/breadcrumbs';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
 import type {Color} from 'sentry/utils/theme';
-import BreadcrumbItem from 'sentry/views/replays/detail/breadcrumbs/breadcrumbItem';
 
 const NODE_SIZES = [8, 12, 16];
 
@@ -79,14 +79,14 @@ function Event({
 
   const buttons = crumbs.map(crumb => (
     <BreadcrumbItem
-      key={crumb.id}
       crumb={crumb}
-      startTimestampMs={startTimestampMs}
+      isCurrent={false}
       isHovered={false}
-      isSelected={false}
+      key={crumb.id}
+      onClick={handleClick}
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
-      onClick={handleClick}
+      startTimestampMs={startTimestampMs}
     />
   ));
   const title = <TooltipWrapper>{buttons}</TooltipWrapper>;

+ 4 - 4
static/app/components/replays/walker/splitCrumbs.tsx

@@ -3,13 +3,13 @@ import first from 'lodash/first';
 import last from 'lodash/last';
 
 import {Hovercard} from 'sentry/components/hovercard';
+import BreadcrumbItem from 'sentry/components/replays/breadcrumbs/breadcrumbItem';
 import TextOverflow from 'sentry/components/textOverflow';
 import {Tooltip} from 'sentry/components/tooltip';
 import {tn} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {BreadcrumbTypeNavigation, Crumb} from 'sentry/types/breadcrumbs';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
-import BreadcrumbItem from 'sentry/views/replays/detail/breadcrumbs/breadcrumbItem';
 
 type MaybeOnClickHandler = null | ((crumb: Crumb) => void);
 
@@ -105,12 +105,12 @@ function SummarySegment({
         <li key={crumb.id || i}>
           <BreadcrumbItem
             crumb={crumb}
-            startTimestampMs={startTimestampMs}
+            isCurrent={false}
             isHovered={false}
-            isSelected={false}
+            onClick={handleOnClick}
             onMouseEnter={handleMouseEnter}
             onMouseLeave={handleMouseLeave}
-            onClick={handleOnClick}
+            startTimestampMs={startTimestampMs}
           />
         </li>
       ))}

+ 0 - 75
static/app/utils/replays/hooks/useCurrentItemScroller.tsx

@@ -1,75 +0,0 @@
-import type {RefObject} from 'react';
-import {useEffect, useState} from 'react';
-
-const defer = (fn: () => void) => setTimeout(fn, 0);
-
-export function useCurrentItemScroller(containerRef: RefObject<HTMLElement>) {
-  const [isAutoScrollDisabled, setIsAutoScrollDisabled] = useState(false);
-
-  useEffect(() => {
-    const containerEl = containerRef.current;
-    let observer: MutationObserver | undefined;
-
-    if (containerEl) {
-      const isContainerScrollable = () =>
-        containerEl.scrollHeight > containerEl.offsetHeight;
-
-      observer = new MutationObserver(mutationList => {
-        for (const mutation of mutationList) {
-          if (
-            mutation.type === 'attributes' &&
-            mutation.attributeName === 'aria-current' &&
-            mutation.target.nodeType === 1 // Element nodeType
-          ) {
-            const element = mutation.target as HTMLElement;
-            const isCurrent = element?.ariaCurrent === 'true';
-            if (isCurrent && isContainerScrollable() && !isAutoScrollDisabled) {
-              let offset: number;
-
-              // If possible scroll to the middle of the container instead of to the top
-              if (element.clientHeight < containerEl.clientHeight) {
-                offset =
-                  element.offsetTop -
-                  (containerEl.clientHeight / 2 - element.clientHeight / 2);
-              } else {
-                // Align it to the top as per default if the element is higher than the container
-                offset = element.offsetTop;
-              }
-              // Deferring the scroll helps prevent it from not being executed
-              // in certain situations. (jumping to a time with the scrubber)
-              defer(() => {
-                containerEl?.scrollTo({
-                  behavior: 'smooth',
-                  top: offset,
-                });
-              });
-            }
-          }
-        }
-      });
-
-      observer.observe(containerRef.current, {
-        attributes: true,
-        childList: false,
-        subtree: true,
-      });
-    }
-
-    const handleMouseEnter = () => {
-      setIsAutoScrollDisabled(true);
-    };
-
-    const handleMouseLeave = () => {
-      setIsAutoScrollDisabled(false);
-    };
-
-    containerEl?.addEventListener('mouseenter', handleMouseEnter);
-    containerEl?.addEventListener('mouseleave', handleMouseLeave);
-
-    return () => {
-      observer?.disconnect();
-      containerEl?.removeEventListener('mouseenter', handleMouseEnter);
-      containerEl?.removeEventListener('mouseleave', handleMouseLeave);
-    };
-  }, [containerRef, isAutoScrollDisabled]);
-}

+ 68 - 0
static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx

@@ -0,0 +1,68 @@
+import {CSSProperties, memo, useCallback} from 'react';
+
+import BreadcrumbItem from 'sentry/components/replays/breadcrumbs/breadcrumbItem';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
+import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
+
+interface Props {
+  breadcrumb: Crumb;
+  breadcrumbs: Crumb[];
+  startTimestampMs: number;
+  style: CSSProperties;
+}
+
+function BreadcrumbRow({breadcrumb, breadcrumbs, startTimestampMs, style}: Props) {
+  const {currentTime, currentHoverTime} = useReplayContext();
+
+  const {handleMouseEnter, handleMouseLeave, handleClick} =
+    useCrumbHandlers(startTimestampMs);
+
+  const onClickTimestamp = useCallback(
+    () => handleClick(breadcrumb),
+    [handleClick, breadcrumb]
+  );
+  const onMouseEnter = useCallback(
+    () => handleMouseEnter(breadcrumb),
+    [handleMouseEnter, breadcrumb]
+  );
+  const onMouseLeave = useCallback(
+    () => handleMouseLeave(breadcrumb),
+    [handleMouseLeave, breadcrumb]
+  );
+
+  const current = getPrevReplayEvent({
+    items: breadcrumbs,
+    targetTimestampMs: startTimestampMs + currentTime,
+    allowEqual: true,
+    allowExact: true,
+  });
+
+  const hovered = currentHoverTime
+    ? getPrevReplayEvent({
+        items: breadcrumbs,
+        targetTimestampMs: startTimestampMs + currentHoverTime,
+        allowEqual: true,
+        allowExact: true,
+      })
+    : undefined;
+
+  const isCurrent = breadcrumb.id === current?.id;
+  const isHovered = breadcrumb.id === hovered?.id;
+
+  return (
+    <BreadcrumbItem
+      crumb={breadcrumb}
+      isCurrent={isCurrent}
+      isHovered={isHovered}
+      onClick={onClickTimestamp}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      startTimestampMs={startTimestampMs}
+      style={style}
+    />
+  );
+}
+
+export default memo(BreadcrumbRow);

+ 81 - 99
static/app/views/replays/detail/breadcrumbs/index.tsx

@@ -1,125 +1,107 @@
-import {useRef} from 'react';
+import {memo, useMemo, useRef} from 'react';
+import {
+  AutoSizer,
+  CellMeasurer,
+  List as ReactVirtualizedList,
+  ListRowProps,
+} from 'react-virtualized';
 import styled from '@emotion/styled';
 
-import {
-  Panel as BasePanel,
-  PanelHeader as BasePanelHeader,
-} from 'sentry/components/panels';
 import Placeholder from 'sentry/components/placeholder';
-import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
-import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
-import {useCurrentItemScroller} from 'sentry/utils/replays/hooks/useCurrentItemScroller';
-import BreadcrumbItem from 'sentry/views/replays/detail/breadcrumbs/breadcrumbItem';
-import FluidPanel from 'sentry/views/replays/detail/layout/fluidPanel';
-
-function CrumbPlaceholder({number}: {number: number}) {
-  return (
-    <BreadcrumbContainer>
-      {[...Array(number)].map((_, i) => (
-        <PlaceholderMargin key={i} height="53px" />
-      ))}
-    </BreadcrumbContainer>
-  );
-}
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import BreadcrumbRow from 'sentry/views/replays/detail/breadcrumbs/breadcrumbRow';
+import useScrollToCurrentItem from 'sentry/views/replays/detail/breadcrumbs/useScrollToCurrentItem';
+import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
+import NoRowRenderer from 'sentry/views/replays/detail/noRowRenderer';
+import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';
 
 type Props = {
-  showTitle: boolean;
+  breadcrumbs: undefined | Crumb[];
+  startTimestampMs: number;
 };
 
-function Breadcrumbs({showTitle = true}: Props) {
-  const {currentHoverTime, currentTime, replay} = useReplayContext();
-
-  const replayRecord = replay?.getReplay();
-  const allCrumbs = replay?.getRawCrumbs();
-
-  const crumbListContainerRef = useRef<HTMLDivElement>(null);
-  useCurrentItemScroller(crumbListContainerRef);
-
-  const startTimestampMs = replayRecord?.started_at.getTime() || 0;
-  const {handleMouseEnter, handleMouseLeave, handleClick} =
-    useCrumbHandlers(startTimestampMs);
-
-  const isLoaded = Boolean(replayRecord);
+function Breadcrumbs({breadcrumbs, startTimestampMs}: Props) {
+  const items = useMemo(
+    () =>
+      (breadcrumbs || []).filter(crumb => !['console'].includes(crumb.category || '')),
+    [breadcrumbs]
+  );
 
-  const crumbs =
-    allCrumbs?.filter(crumb => !['console'].includes(crumb.category || '')) || [];
+  const listRef = useRef<ReactVirtualizedList>(null);
+  const {cache, updateList} = useVirtualizedList({
+    cellMeasurer: {
+      fixedWidth: true,
+      minHeight: 53,
+    },
+    ref: listRef,
+    deps: [items],
+  });
 
-  const currentUserAction = getPrevReplayEvent({
-    items: crumbs,
-    targetTimestampMs: startTimestampMs + currentTime,
-    allowExact: true,
+  useScrollToCurrentItem({
+    breadcrumbs,
+    ref: listRef,
+    startTimestampMs,
   });
 
-  const closestUserAction =
-    currentHoverTime !== undefined
-      ? getPrevReplayEvent({
-          items: crumbs,
-          targetTimestampMs: startTimestampMs + (currentHoverTime ?? 0),
-          allowExact: true,
-        })
-      : undefined;
+  const renderRow = ({index, key, style, parent}: ListRowProps) => {
+    const item = items[index];
 
-  const content = isLoaded ? (
-    <BreadcrumbContainer>
-      {crumbs.map(crumb => (
-        <BreadcrumbItem
-          key={crumb.id}
-          crumb={crumb}
+    return (
+      <CellMeasurer
+        cache={cache}
+        columnIndex={0}
+        key={key}
+        parent={parent}
+        rowIndex={index}
+      >
+        <BreadcrumbRow
+          breadcrumb={item}
+          breadcrumbs={items}
           startTimestampMs={startTimestampMs}
-          isHovered={closestUserAction?.id === crumb.id}
-          isSelected={currentUserAction?.id === crumb.id}
-          onMouseEnter={handleMouseEnter}
-          onMouseLeave={handleMouseLeave}
-          onClick={handleClick}
-          // We are controlling the hover state ourselves with `isHovered` prop
-          allowHover={false}
+          style={style}
         />
-      ))}
-    </BreadcrumbContainer>
-  ) : (
-    <CrumbPlaceholder number={4} />
-  );
+      </CellMeasurer>
+    );
+  };
 
   return (
-    <Panel>
-      <FluidPanel
-        bodyRef={crumbListContainerRef}
-        title={showTitle ? <PanelHeader>{t('Breadcrumbs')}</PanelHeader> : undefined}
-      >
-        {content}
-      </FluidPanel>
-    </Panel>
+    <FluidHeight>
+      <BreadcrumbContainer>
+        {breadcrumbs ? (
+          <AutoSizer onResize={updateList}>
+            {({height, width}) => (
+              <ReactVirtualizedList
+                deferredMeasurementCache={cache}
+                height={height}
+                noRowsRenderer={() => (
+                  <NoRowRenderer unfilteredItems={breadcrumbs} clearSearchTerm={() => {}}>
+                    {t('No breadcrumbs recorded')}
+                  </NoRowRenderer>
+                )}
+                overscanRowCount={5}
+                ref={listRef}
+                rowCount={items.length}
+                rowHeight={cache.rowHeight}
+                rowRenderer={renderRow}
+                width={width}
+              />
+            )}
+          </AutoSizer>
+        ) : (
+          <Placeholder height="100%" />
+        )}
+      </BreadcrumbContainer>
+    </FluidHeight>
   );
 }
 
 const BreadcrumbContainer = styled('div')`
-  padding: ${space(0.5)};
-`;
-
-const Panel = styled(BasePanel)`
-  width: 100%;
+  position: relative;
   height: 100%;
   overflow: hidden;
-  margin-bottom: 0;
-`;
-
-const PanelHeader = styled(BasePanelHeader)`
-  background-color: ${p => p.theme.background};
-  border-bottom: 1px solid ${p => p.theme.innerBorder};
-  font-size: ${p => p.theme.fontSizeSmall};
-  color: ${p => p.theme.gray500};
-  text-transform: capitalize;
-  padding: ${space(1)} ${space(1.5)} ${space(1)};
-  font-weight: 600;
-`;
-
-const PlaceholderMargin = styled(Placeholder)`
-  margin-bottom: ${space(1)};
-  width: auto;
+  border: 1px solid ${p => p.theme.border};
   border-radius: ${p => p.theme.borderRadius};
 `;
 
-export default Breadcrumbs;
+export default memo(Breadcrumbs);

+ 34 - 0
static/app/views/replays/detail/breadcrumbs/useScrollToCurrentItem.tsx

@@ -0,0 +1,34 @@
+import {RefObject, useEffect, useMemo} from 'react';
+import type {List as ReactVirtualizedList} from 'react-virtualized';
+
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
+
+type Opts = {
+  breadcrumbs: undefined | Crumb[];
+  ref: RefObject<ReactVirtualizedList>;
+  startTimestampMs: number;
+};
+function useScrollToCurrentItem({breadcrumbs, ref, startTimestampMs}: Opts) {
+  const {currentTime} = useReplayContext();
+  const current = useMemo(
+    () =>
+      getPrevReplayEvent({
+        items: breadcrumbs || [],
+        targetTimestampMs: startTimestampMs + currentTime,
+        allowEqual: true,
+        allowExact: true,
+      }),
+    [breadcrumbs, currentTime, startTimestampMs]
+  );
+
+  useEffect(() => {
+    if (ref.current && current) {
+      const index = breadcrumbs?.findIndex(crumb => crumb.id === current.id);
+      ref.current?.scrollToRow(index);
+    }
+  }, [breadcrumbs, current, ref]);
+}
+
+export default useScrollToCurrentItem;

+ 1 - 21
static/app/views/replays/detail/domMutations/domMutationRow.tsx

@@ -7,11 +7,11 @@ import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/brea
 import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {relativeTimeInMs} from 'sentry/components/replays/utils';
-import {SVGIconProps} from 'sentry/icons/svgIcon';
 import {space} from 'sentry/styles/space';
 import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
 import type {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
 import TimestampButton from 'sentry/views/replays/detail/timestampButton';
 
 type Props = {
@@ -156,26 +156,6 @@ const Row = styled('div')`
   flex-direction: row;
 `;
 
-/**
- * Taken `from events/interfaces/.../breadcrumbs/types`
- */
-const IconWrapper = styled('div')<
-  {hasOccurred: boolean} & Required<Pick<SVGIconProps, 'color'>>
->`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 24px;
-  min-width: 24px;
-  height: 24px;
-  border-radius: 50%;
-  color: ${p => p.theme.white};
-  background: ${p => (p.hasOccurred ? p.theme[p.color] ?? p.color : p.theme.purple200)};
-
-  /* Make sure the icon is above the line through the back */
-  z-index: ${p => p.theme.zIndex.initial};
-`;
-
 const Title = styled('span')<{hasOccurred?: boolean}>`
   color: ${p => (p.hasOccurred ? p.theme.gray400 : p.theme.gray300)};
   font-weight: bold;

+ 26 - 0
static/app/views/replays/detail/iconWrapper.tsx

@@ -0,0 +1,26 @@
+import styled from '@emotion/styled';
+
+import {SVGIconProps} from 'sentry/icons/svgIcon';
+
+/**
+ * Taken `from events/interfaces/.../breadcrumbs/types`
+ */
+const IconWrapper = styled('div')<
+  {hasOccurred: boolean} & Required<Pick<SVGIconProps, 'color'>>
+>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  min-width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  color: ${p => p.theme.white};
+  background: ${p => (p.hasOccurred ? p.theme[p.color] ?? p.color : p.theme.purple200)};
+  position: relative;
+
+  /* Make sure the icon is above the line through the back */
+  z-index: ${p => p.theme.zIndex.initial};
+`;
+
+export default IconWrapper;

+ 0 - 0
static/app/views/replays/detail/focusArea.tsx → static/app/views/replays/detail/layout/focusArea.tsx


Some files were not shown because too many files changed in this diff