| import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; |
| import { AnimatePresence, motion, type Variants } from 'framer-motion'; |
| import { memo, useEffect, useRef, useState } from 'react'; |
| import type { FileMap } from '~/lib/stores/files'; |
| import { classNames } from '~/utils/classNames'; |
| import { WORK_DIR } from '~/utils/constants'; |
| import { cubicEasingFn } from '~/utils/easings'; |
| import { renderLogger } from '~/utils/logger'; |
| import FileTree from './FileTree'; |
|
|
| const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`); |
|
|
| interface FileBreadcrumbProps { |
| files?: FileMap; |
| pathSegments?: string[]; |
| onFileSelect?: (filePath: string) => void; |
| } |
|
|
| const contextMenuVariants = { |
| open: { |
| y: 0, |
| opacity: 1, |
| transition: { |
| duration: 0.15, |
| ease: cubicEasingFn, |
| }, |
| }, |
| close: { |
| y: 6, |
| opacity: 0, |
| transition: { |
| duration: 0.15, |
| ease: cubicEasingFn, |
| }, |
| }, |
| } satisfies Variants; |
|
|
| export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => { |
| renderLogger.trace('FileBreadcrumb'); |
|
|
| const [activeIndex, setActiveIndex] = useState<number | null>(null); |
|
|
| const contextMenuRef = useRef<HTMLDivElement | null>(null); |
| const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]); |
|
|
| const handleSegmentClick = (index: number) => { |
| setActiveIndex((prevIndex) => (prevIndex === index ? null : index)); |
| }; |
|
|
| useEffect(() => { |
| const handleOutsideClick = (event: MouseEvent) => { |
| if ( |
| activeIndex !== null && |
| !contextMenuRef.current?.contains(event.target as Node) && |
| !segmentRefs.current.some((ref) => ref?.contains(event.target as Node)) |
| ) { |
| setActiveIndex(null); |
| } |
| }; |
|
|
| document.addEventListener('mousedown', handleOutsideClick); |
|
|
| return () => { |
| document.removeEventListener('mousedown', handleOutsideClick); |
| }; |
| }, [activeIndex]); |
|
|
| if (files === undefined || pathSegments.length === 0) { |
| return null; |
| } |
|
|
| return ( |
| <div className="flex"> |
| {pathSegments.map((segment, index) => { |
| const isLast = index === pathSegments.length - 1; |
| |
| const path = pathSegments.slice(0, index).join('/'); |
| |
| if (!WORK_DIR_REGEX.test(path)) { |
| return null; |
| } |
| |
| const isActive = activeIndex === index; |
| |
| return ( |
| <div key={index} className="relative flex items-center"> |
| <DropdownMenu.Root open={isActive} modal={false}> |
| <DropdownMenu.Trigger asChild> |
| <span |
| ref={(ref) => { |
| segmentRefs.current[index] = ref; |
| }} |
| className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', { |
| 'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive, |
| 'text-bolt-elements-textPrimary underline': isActive, |
| 'pr-4': isLast, |
| })} |
| onClick={() => handleSegmentClick(index)} |
| > |
| {isLast && <div className="i-ph:file-duotone" />} |
| {segment} |
| </span> |
| </DropdownMenu.Trigger> |
| {index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />} |
| <AnimatePresence> |
| {isActive && ( |
| <DropdownMenu.Portal> |
| <DropdownMenu.Content |
| className="z-file-tree-breadcrumb" |
| asChild |
| align="start" |
| side="bottom" |
| avoidCollisions={false} |
| > |
| <motion.div |
| ref={contextMenuRef} |
| initial="close" |
| animate="open" |
| exit="close" |
| variants={contextMenuVariants} |
| > |
| <div className="rounded-lg overflow-hidden"> |
| <div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg"> |
| <FileTree |
| files={files} |
| hideRoot |
| rootFolder={path} |
| collapsed |
| allowFolderSelection |
| selectedFile={`${path}/${segment}`} |
| onFileSelect={(filePath) => { |
| setActiveIndex(null); |
| onFileSelect?.(filePath); |
| }} |
| /> |
| </div> |
| </div> |
| <DropdownMenu.Arrow className="fill-bolt-elements-borderColor" /> |
| </motion.div> |
| </DropdownMenu.Content> |
| </DropdownMenu.Portal> |
| )} |
| </AnimatePresence> |
| </DropdownMenu.Root> |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| }); |
|
|