Files
lemma/app/src/components/files/FolderSelector.tsx

267 lines
6.5 KiB
TypeScript

import React, { useRef, useLayoutEffect, useState } from 'react';
import { Box } from '@mantine/core';
import { Tree, type NodeApi } from 'react-arborist';
import {
IconFolder,
IconFolderOpen,
IconChevronRight,
} from '@tabler/icons-react';
import useResizeObserver from '@react-hook/resize-observer';
import { filterToFolders } from '../../utils/fileTreeUtils';
import type { FileNode } from '@/types/models';
interface FolderSelectorProps {
files: FileNode[];
selectedPath: string;
onSelect: (path: string) => void;
}
interface Size {
width: number;
height: number;
}
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
const [size, setSize] = useState<Size>();
useLayoutEffect(() => {
if (target.current) {
setSize(target.current.getBoundingClientRect());
}
}, [target]);
useResizeObserver(target, (entry) => setSize(entry.contentRect));
return size;
};
// Node component for rendering folders
function FolderNode({
node,
style,
selectedPath,
onSelect,
}: {
node: NodeApi<FileNode>;
style: React.CSSProperties;
selectedPath: string;
onSelect: (path: string) => void;
}) {
const isSelected = node.data.path === selectedPath;
const hasChildren = node.children && node.children.length > 0;
const handleClick = () => {
onSelect(node.data.path);
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
node.toggle();
};
return (
<div
style={{
...style,
paddingLeft: `${node.level * 16 + 8}px`,
paddingRight: '8px',
paddingTop: '4px',
paddingBottom: '4px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
backgroundColor: isSelected
? 'var(--mantine-color-blue-filled)'
: 'transparent',
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
borderRadius: '4px',
transition: 'background-color 0.1s ease, color 0.1s ease',
}}
onClick={handleClick}
title={node.data.name}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor =
'var(--mantine-color-default-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
{/* Chevron for folders with children */}
{hasChildren && (
<IconChevronRight
size={14}
onClick={handleChevronClick}
style={{
marginRight: '4px',
transform: node.isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
flexShrink: 0,
}}
/>
)}
{/* Spacer for items without chevron */}
{!hasChildren && <div style={{ width: '18px', flexShrink: 0 }} />}
{/* Folder icon */}
{node.isOpen ? (
<IconFolderOpen
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
) : (
<IconFolder
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
)}
{/* Name */}
<span
style={{
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
{node.data.name}
</span>
</div>
);
}
// Root node component
function RootNode({
isSelected,
onSelect,
}: {
isSelected: boolean;
onSelect: () => void;
}) {
return (
<div
style={{
paddingLeft: '8px',
paddingRight: '8px',
paddingTop: '4px',
paddingBottom: '4px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
backgroundColor: isSelected
? 'var(--mantine-color-blue-filled)'
: 'transparent',
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
borderRadius: '4px',
transition: 'background-color 0.1s ease, color 0.1s ease',
marginBottom: '4px',
}}
onClick={onSelect}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor =
'var(--mantine-color-default-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<div style={{ width: '18px', flexShrink: 0 }} />
<IconFolder
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
<span
style={{
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
/ (root)
</span>
</div>
);
}
export const FolderSelector: React.FC<FolderSelectorProps> = ({
files,
selectedPath,
onSelect,
}) => {
const target = useRef<HTMLDivElement>(null);
const size = useSize(target);
// Filter to only folders
const folders = filterToFolders(files);
// Calculate tree height: root node (32px) + folders
const rootNodeHeight = 32;
const treeHeight = size ? size.height - rootNodeHeight : 0;
return (
<Box
ref={target}
style={{
maxHeight: '300px',
height: '300px',
overflowY: 'auto',
padding: '8px',
}}
>
{/* Root option */}
<RootNode isSelected={selectedPath === ''} onSelect={() => onSelect('')} />
{/* Folder tree */}
{size && folders.length > 0 && (
<Tree
data={folders}
openByDefault={false}
width={size.width - 16}
height={treeHeight}
indent={24}
rowHeight={28}
idAccessor="id"
disableDrag={() => true}
disableDrop={() => true}
>
{(props) => (
<FolderNode {...props} selectedPath={selectedPath} onSelect={onSelect} />
)}
</Tree>
)}
</Box>
);
};
export default FolderSelector;