import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import * as qs from 'query-string'; import Link from 'sentry/components/links/link'; import { IconChevron, IconCircle, IconCode, IconExpand, IconFile, IconGrid, IconNumber, } from 'sentry/icons'; import type {SVGIconProps} from 'sentry/icons/svgIcon'; import {space} from 'sentry/styles/space'; import {fzf} from 'sentry/utils/profiling/fzf/fzf'; import {useLocation} from 'sentry/utils/useLocation'; class StoryTreeNode { public name: string; public path: string; public visible = true; public expanded = false; public children: Record = {}; public result: ReturnType | null = null; constructor(name: string, path: string) { this.name = name; this.path = path; } find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined { for (const {node} of this) { if (node && predicate(node)) { return node; } } return undefined; } // Iterator that yields all files in the tree, excluding folders *[Symbol.iterator]() { function* recurse( node: StoryTreeNode, path: StoryTreeNode[] ): Generator<{node: StoryTreeNode; path: StoryTreeNode[]}> { yield {node, path}; for (const child of Object.values(node.children)) { yield* recurse(child, path.concat(node)); } } yield* recurse(this, []); } } function folderOrSearchScoreFirst(a: StoryTreeNode, b: StoryTreeNode) { if (a.visible && !b.visible) { return -1; } if (!a.visible && b.visible) { return 1; } if (a.result && b.result) { if (a.result.score === b.result.score) { return a.name.localeCompare(b.name); } return b.result.score - a.result.score; } const aIsFolder = Object.keys(a.children).length > 0; const bIsFolder = Object.keys(b.children).length > 0; if (aIsFolder && !bIsFolder) { return -1; } if (!aIsFolder && bIsFolder) { return 1; } return a.name.localeCompare(b.name); } function normalizeFilename(filename: string) { // Do not uppercase the first three characters of the filename if (filename.startsWith('use')) { return filename.replace('.stories.tsx', ''); } // capitalizes the filename return filename.charAt(0).toUpperCase() + filename.slice(1).replace('.stories.tsx', ''); } export function useStoryTree(files: string[], query: string) { const location = useLocation(); const initialName = useRef(location.query.name); const tree = useMemo(() => { const root = new StoryTreeNode('root', ''); for (const file of files) { const parts = file.split('/'); let parent = root; for (const part of parts) { if (!(part in parent.children)) { parent.children[part] = new StoryTreeNode(part, file); } parent = parent.children[part]!; } } // If the user navigates to a story, expand to its location in the tree if (initialName.current) { for (const {node, path} of root) { if (node.path === initialName.current) { for (const p of path) { p.expanded = true; } initialName.current = null; break; } } } return root; }, [files]); const nodes = useMemo(() => { // Skip the top level app folder as it's where the entire project is at const root = tree.find(node => node.name === 'app') ?? tree; if (!query) { if (initialName.current) { return Object.values(root.children); } // If there is no initial query and no story is selected, the sidebar // tree is collapsed to the root node. for (const {node} of root) { node.visible = true; node.expanded = false; node.result = null; } return Object.values(root.children); } for (const {node} of root) { node.visible = false; node.expanded = false; node.result = null; } // Fzf requires the input to be lowercase as it normalizes the search candidates to lowercase const lowerCaseQuery = query.toLowerCase(); for (const {node, path} of root) { // index files are useless when trying to match by name, so we'll special // case them and match by their full path as it'll contain a more // relevant path that we can match against. const name = node.name.startsWith('index.') ? [node.name, ...path.map(p => p.name)].join('.') : node.name; const match = fzf(name, lowerCaseQuery, false); node.result = match; if (match.score > 0) { node.visible = true; if (Object.keys(node.children).length > 0) { node.expanded = true; for (const child of Object.values(node.children)) { child.visible = true; } } // @TODO (JonasBadalic): We can trip this when we find a visible node if we reverse iterate for (const p of path) { p.visible = true; p.expanded = true; // The entire path needs to contain max score of its child results so that // the entire path to it can be sorted by this score. The side effect of this is that results from the same // tree path with a lower score will be placed higher in the tree if that same path has a higher score anywhere // in the tree. This isn't ideal, but given that it favors the most relevant results, it makes it a good starting point. p.result = match.score > (p.result?.score ?? 0) ? match : p.result; } } } return Object.values(root.children); }, [tree, query]); return nodes; } interface Props extends React.HTMLAttributes { nodes: StoryTreeNode[]; } // @TODO (JonasBadalic): Implement treeview pattern navigation // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ export function StoryTree({nodes, ...htmlProps}: Props) { return ( ); } function Folder(props: {node: StoryTreeNode}) { const [expanded, setExpanded] = useState(props.node.expanded); if (props.node.expanded !== expanded) { setExpanded(props.node.expanded); } if (!props.node.visible) { return null; } return (
  • { props.node.expanded = !props.node.expanded; if (props.node.expanded) { for (const child of Object.values(props.node.children)) { child.visible = true; } } setExpanded(props.node.expanded); }} > {normalizeFilename(props.node.name)} {expanded && Object.keys(props.node.children).length > 0 && ( {Object.values(props.node.children) .sort(folderOrSearchScoreFirst) .map(child => { if (!child.visible) { return null; } return Object.keys(child.children).length === 0 ? ( ) : ( ); })} )}
  • ); } function File(props: {node: StoryTreeNode}) { const location = useLocation(); const query = qs.stringify({...location.query, name: props.node.path}); const category = props.node.path.split('/').at(1) ?? 'default'; return (
  • {/* @TODO (JonasBadalic): Do we need to show the file extension? */} {normalizeFilename(props.node.name)}
  • ); } function StoryIcon(props: { category: 'components' | 'icons' | 'styles' | 'utils' | 'views' | string | {}; }) { const iconProps: SVGIconProps = {size: 'xs'}; switch (props.category) { case 'components': return ; case 'icons': return ; case 'styles': return ; case 'utils': return ; case 'views': return ; default: return ; } } const StoryList = styled('ul')` list-style-type: none; padding-left: 10px; &:first-child { padding-left: 0; } `; const FolderName = styled('div')` display: flex; align-items: center; gap: ${space(0.75)}; padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)}; cursor: pointer; position: relative; &:before { background: ${p => p.theme.surface100}; content: ''; inset: 0px 0px 0px -100%; position: absolute; z-index: -1; opacity: 0; } &:hover { &:before { opacity: 1; } } `; const FolderLink = styled(Link, { shouldForwardProp: prop => prop !== 'active', })<{active: boolean}>` display: flex; align-items: center; margin-left: ${space(0.5)}; gap: ${space(0.75)}; color: ${p => p.theme.textColor}; padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)}; position: relative; &:before { background: ${p => p.theme.surface100}; content: ''; inset: 0px 0px 0px -100%; position: absolute; z-index: -1; opacity: ${p => (p.active ? 1 : 0)}; } &:hover { color: ${p => p.theme.textColor}; &:before { opacity: 1; } } svg { flex-shrink: 0; } `;