diff options
Diffstat (limited to 'src/components/Sidebar')
| -rw-r--r-- | src/components/Sidebar/FileTree.tsx | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/src/components/Sidebar/FileTree.tsx b/src/components/Sidebar/FileTree.tsx new file mode 100644 index 0000000..03e05e7 --- /dev/null +++ b/src/components/Sidebar/FileTree.tsx @@ -0,0 +1,227 @@ +import { useState, useCallback } from 'react' +import type { FileEntry } from '@/types' +import { useTabsStore } from '@/stores/useTabsStore' +import { useContextMenuStore } from '@/stores/useContextMenuStore' + +const extColors: Record<string, string> = { + ts: '#3178c6', + tsx: '#3178c6', + js: '#f7df1e', + jsx: '#f7df1e', + rs: '#dea584', + json: '#5eb7e0', + md: '#e8e8ed', + html: '#e34c4c', + css: '#42a5f5', + py: '#3572a5', +} + +const ROW_HEIGHT = 32 +const INDENT = 16 +const GUIDE_COLOR = 'var(--color-panel-border)' +const GUIDE_HOVER_COLOR = 'var(--color-text-muted)' + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + <svg + width="16" height="16" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="transition-transform duration-150 shrink-0 text-text-muted" + style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }} + > + <polyline points="9 18 15 12 9 6" /> + </svg> + ) +} + +function FileTypeIcon({ name, isDir, expanded }: { name: string; isDir: boolean; expanded: boolean }) { + if (isDir) { + return ( + <svg + width="16" height="16" viewBox="0 0 24 24" + fill={expanded ? 'currentColor' : '#5c5e64'} + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + className={`shrink-0 transition-colors duration-150 ${expanded ? 'text-accent' : 'text-text-muted'}`} + > + {expanded ? ( + <path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1M5 19l3-10h15l-3 10z" /> + ) : ( + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + )} + </svg> + ) + } + + const ext = name.split('.').pop()?.toLowerCase() ?? '' + const color = extColors[ext] ?? '#5c5e64' + + return ( + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <polyline points="14,2 14,8 20,8" /> + </svg> + ) +} + +interface GuideLinesProps { + depth: number + branchDepths: number[] + isLast: boolean + parentHovered: boolean +} + +function GuideLines({ depth, branchDepths, isLast, parentHovered }: GuideLinesProps) { + const color = parentHovered ? GUIDE_HOVER_COLOR : GUIDE_COLOR + const mid = ROW_HEIGHT / 2 + const leftBase = 12 + + return ( + <div className="absolute inset-0 pointer-events-none"> + {branchDepths.map((bd) => { + const l = leftBase + bd * INDENT + return ( + <div + key={bd} + className="absolute top-0 bottom-0" + style={{ left: `${l}px`, width: '1px', backgroundColor: color }} + /> + ) + })} + <div + className="absolute" + style={{ + left: `${leftBase + depth * INDENT}px`, + width: '1px', + top: 0, + bottom: isLast ? `calc(100% - ${mid}px)` : 0, + backgroundColor: color, + }} + /> + <div + className="absolute" + style={{ + left: `${leftBase + depth * INDENT}px`, + width: '7px', + height: '1px', + top: `${mid}px`, + backgroundColor: color, + }} + /> + </div> + ) +} + +interface FileTreeItemProps { + entry: FileEntry + depth: number + isLast: boolean + branchDepths: number[] +} + +function FileTreeItem({ entry, depth, isLast, branchDepths }: FileTreeItemProps) { + const [expanded, setExpanded] = useState(depth < 1) + const [parentHovered, setParentHovered] = useState(false) + const openFile = useTabsStore((s) => s.openFile) + const activeTabId = useTabsStore((s) => s.activeTabId) + const openContextMenu = useContextMenuStore((s) => s.open) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + openContextMenu(e.clientX, e.clientY, [ + { id: 'open', label: entry.is_dir ? 'Expand' : 'Open', icon: entry.is_dir ? 'folder' : 'file', action: () => entry.is_dir ? setExpanded(!expanded) : openFile(entry.path) }, + { id: 'sep1', label: '', separator: true, action: () => {} }, + { id: 'copyPath', label: 'Copy Path', icon: 'link', action: () => navigator.clipboard.writeText(entry.path) }, + ]) + }, [entry, expanded, openFile, openContextMenu]) + + if (entry.is_dir) { + return ( + <div> + <button + className="relative w-full flex items-center text-ui-sm transition-colors duration-fast hover:bg-panel-hover focus-visible:outline-none" + style={{ height: `${ROW_HEIGHT}px` }} + tabIndex={0} + aria-expanded={expanded ? 'true' : 'false'} + aria-label={`${entry.name} (folder)`} + onMouseEnter={() => setParentHovered(true)} + onMouseLeave={() => setParentHovered(false)} + onClick={() => setExpanded(!expanded)} + onContextMenu={handleContextMenu} + > + <GuideLines depth={depth} branchDepths={branchDepths} isLast={isLast} parentHovered={parentHovered} /> + <div className="flex items-center gap-1 relative pl-2" style={{ paddingLeft: `${12 + depth * INDENT + 18}px` }}> + <ChevronIcon expanded={expanded} /> + <FileTypeIcon name={entry.name} isDir expanded={expanded} /> + <span className="truncate text-text-primary">{entry.name}</span> + </div> + </button> + <div + className="overflow-hidden transition-all duration-normal" + style={{ maxHeight: expanded ? '9999px' : '0px', opacity: expanded ? 1 : 0 }} + > + {entry.children && ( + <FileTree + entries={entry.children} + depth={depth + 1} + parentBranchDepths={isLast ? branchDepths : [...branchDepths, depth]} + /> + )} + </div> + </div> + ) + } + + const isActive = entry.path === activeTabId + + return ( + <button + className={`relative w-full flex items-center text-ui-sm transition-colors duration-fast hover:bg-panel-hover focus-visible:outline-none ${isActive ? 'bg-panel-active' : ''}`} + style={{ height: `${ROW_HEIGHT}px` }} + tabIndex={0} + aria-selected={isActive ? 'true' : 'false'} + aria-label={entry.name} + onClick={() => openFile(entry.path)} + onContextMenu={handleContextMenu} + > + <GuideLines depth={depth} branchDepths={branchDepths} isLast={isLast} parentHovered={false} /> + <div + className="flex items-center gap-1.5 relative pl-2" + style={{ paddingLeft: `${12 + depth * INDENT + 24}px` }} + > + <FileTypeIcon name={entry.name} isDir={false} expanded={false} /> + <span + className="truncate" + style={{ color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)' }} + > + {entry.name} + </span> + </div> + </button> + ) +} + +interface FileTreeProps { + entries: FileEntry[] + depth?: number + parentBranchDepths?: number[] +} + +export default function FileTree({ entries, depth = 0, parentBranchDepths = [] }: FileTreeProps) { + return ( + <div> + {entries.map((entry, idx) => ( + <FileTreeItem + key={entry.path} + entry={entry} + depth={depth} + isLast={idx === entries.length - 1} + branchDepths={parentBranchDepths} + /> + ))} + </div> + ) +} |