From 09f964451d7d92e9891430ec4595c1276d486aab Mon Sep 17 00:00:00 2001
From: zwlucas <lucas.fariamo08@gmail.com>
Date: Fri, 5 Jun 2026 17:52:54 -0300
Subject: feat: upload

Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
---
 src/components/Sidebar/FileTree.tsx | 227 ++++++++++++++++++++++++++++++++++++
 1 file changed, 227 insertions(+)
 create mode 100644 src/components/Sidebar/FileTree.tsx

(limited to 'src/components/Sidebar/FileTree.tsx')

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>
+  )
+}
-- 
cgit v1.2.3

