aboutsummaryrefslogtreecommitdiff
path: root/src/components/Sidebar
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
commit09f964451d7d92e9891430ec4595c1276d486aab (patch)
tree28da4483f5c28924a8c47fceb648b1baebe88224 /src/components/Sidebar
downloadyace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz
yace-09f964451d7d92e9891430ec4595c1276d486aab.zip
feat: uploadHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components/Sidebar')
-rw-r--r--src/components/Sidebar/FileTree.tsx227
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>
+ )
+}