|
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> |
|
); |
|
}); |
|
|