237 lines
6.5 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
IconButton,
Collapse,
Input,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FiFolder } from 'react-icons/fi';
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
isExpanded?: boolean;
}
interface FolderBrowserProps {
folders: string[];
selectedFolder: string;
onFolderSelect: (folder: string) => void;
}
const FolderBrowser: React.FC<FolderBrowserProps> = ({
folders,
selectedFolder,
onFolderSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [folderTree, setFolderTree] = useState<FolderNode[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Convert flat folder list to tree structure
useEffect(() => {
const tree: FolderNode[] = [];
const folderMap = new Map<string, FolderNode>();
// Add root node
const rootNode: FolderNode = { name: '(root)', path: '', children: [] };
tree.push(rootNode);
folderMap.set('', rootNode);
// Process each folder path
folders.forEach(folder => {
if (folder === '') return; // Skip root as it's already added
const parts = folder.split('/');
let currentPath = '';
parts.forEach((part) => {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!folderMap.has(currentPath)) {
const newNode: FolderNode = {
name: part,
path: currentPath,
children: [],
};
folderMap.set(currentPath, newNode);
if (parentPath === '') {
// Top level folder
tree.push(newNode);
} else {
// Subfolder
const parent = folderMap.get(parentPath);
if (parent) {
parent.children.push(newNode);
}
}
}
});
});
setFolderTree(tree);
}, [folders]);
const toggleFolder = (path: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedFolders(newExpanded);
};
const isExpanded = (path: string) => expandedFolders.has(path);
const renderFolderNode = (node: FolderNode, level: number = 0): React.ReactNode => {
const hasChildren = node.children.length > 0;
const expanded = isExpanded(node.path);
const isSelected = selectedFolder === node.path;
// Filter children based on search query
const filteredChildren = node.children.filter(child =>
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
);
// If searching and this node doesn't match, only show if it has matching children
if (searchQuery && !node.name.toLowerCase().includes(searchQuery.toLowerCase()) && filteredChildren.length === 0) {
return null;
}
return (
<Box key={node.path}>
<HStack
spacing={2}
py={1}
px={2}
cursor="pointer"
bg={isSelected ? 'blue.600' : 'transparent'}
_hover={{ bg: isSelected ? 'blue.600' : 'gray.700' }}
borderRadius="md"
onClick={() => onFolderSelect(node.path)}
minH="32px"
>
<Box w={`${level * 20}px`} />
{hasChildren ? (
<IconButton
size="sm"
variant="ghost"
icon={expanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFolder(node.path);
}}
aria-label={expanded ? 'Collapse folder' : 'Expand folder'}
color="gray.300"
_hover={{ bg: 'gray.700', color: 'white' }}
_active={{ bg: 'gray.600' }}
/>
) : (
<Box w="32px" />
)}
<Box color={hasChildren ? 'yellow.400' : 'gray.400'}>
<FiFolder />
</Box>
<Text
fontSize="sm"
fontWeight={isSelected ? 'bold' : 'normal'}
color={isSelected ? 'white' : 'gray.200'}
flex={1}
noOfLines={1}
>
{node.name}
</Text>
</HStack>
{hasChildren && (
<Collapse in={expanded}>
<VStack align="stretch" spacing={0}>
{filteredChildren.map(child => renderFolderNode(child, level + 1))}
</VStack>
</Collapse>
)}
</Box>
);
};
const filteredTree = searchQuery
? folderTree.filter(node =>
node.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
node.path.toLowerCase().includes(searchQuery.toLowerCase()) ||
node.children.some(child =>
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
)
)
: folderTree;
return (
<Box>
<InputGroup mb={3}>
<InputLeftElement>
<FiFolder />
</InputLeftElement>
<Input
placeholder="Search folders..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg="gray.800"
borderColor="gray.700"
_focus={{ borderColor: 'blue.500' }}
/>
</InputGroup>
<Box
maxH="300px"
overflowY="auto"
border="1px solid"
borderColor="gray.700"
borderRadius="md"
bg="gray.900"
sx={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: 'gray.600',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: 'gray.500',
},
}}
>
<VStack align="stretch" spacing={0}>
{filteredTree.map(node => renderFolderNode(node))}
</VStack>
</Box>
{searchQuery && filteredTree.length === 0 && (
<Text color="gray.500" textAlign="center" py={4}>
No folders match your search
</Text>
)}
</Box>
);
};
export default FolderBrowser;