| 'use client' |
|
|
| import { useState, useEffect } from 'react' |
| import { Folder, File, FolderOpen, RefreshCw, Code2, FileText, Image, Package } from 'lucide-react' |
|
|
| interface FileItem { |
| path: string |
| size: number |
| } |
|
|
| interface WorkspaceData { |
| workspace: string |
| files: FileItem[] |
| total: number |
| } |
|
|
| const getFileIcon = (filename: string) => { |
| const ext = filename.split('.').pop()?.toLowerCase() |
| if (['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs'].includes(ext || '')) return Code2 |
| if (['json', 'yaml', 'yml', 'toml'].includes(ext || '')) return Package |
| if (['md', 'txt', 'rst'].includes(ext || '')) return FileText |
| if (['png', 'jpg', 'svg', 'webp'].includes(ext || '')) return Image |
| return File |
| } |
|
|
| const getFileColor = (filename: string) => { |
| const ext = filename.split('.').pop()?.toLowerCase() |
| if (['ts', 'tsx'].includes(ext || '')) return '#3b82f6' |
| if (['py'].includes(ext || '')) return '#f59e0b' |
| if (['js', 'jsx'].includes(ext || '')) return '#eab308' |
| if (['go'].includes(ext || '')) return '#06b6d4' |
| if (['rs'].includes(ext || '')) return '#f97316' |
| if (['json', 'yaml', 'yml'].includes(ext || '')) return '#a78bfa' |
| if (['md'].includes(ext || '')) return '#6b7280' |
| return '#9ca3af' |
| } |
|
|
| export default function FileExplorer() { |
| const [workspace, setWorkspace] = useState<WorkspaceData | null>(null) |
| const [loading, setLoading] = useState(false) |
| const [selectedFile, setSelectedFile] = useState<string | null>(null) |
| const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()) |
|
|
| const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' |
|
|
| const fetchWorkspace = async () => { |
| setLoading(true) |
| try { |
| const resp = await fetch(`${apiUrl}/api/v1/files/workspace`) |
| if (resp.ok) { |
| const data = await resp.json() |
| setWorkspace(data) |
| } |
| } catch (e) { |
| console.error('Failed to fetch workspace', e) |
| } finally { |
| setLoading(false) |
| } |
| } |
|
|
| useEffect(() => { |
| fetchWorkspace() |
| }, []) |
|
|
| |
| const buildTree = (files: FileItem[]) => { |
| const tree: Record<string, any> = {} |
| files.forEach(({ path, size }) => { |
| const parts = path.split('/') |
| let current = tree |
| parts.forEach((part, i) => { |
| if (i === parts.length - 1) { |
| current[part] = { _file: true, path, size } |
| } else { |
| if (!current[part]) current[part] = {} |
| current = current[part] |
| } |
| }) |
| }) |
| return tree |
| } |
|
|
| const toggleDir = (path: string) => { |
| setExpandedDirs(prev => { |
| const next = new Set(prev) |
| if (next.has(path)) next.delete(path) |
| else next.add(path) |
| return next |
| }) |
| } |
|
|
| const renderTree = (node: Record<string, any>, prefix: string = '', depth: number = 0) => { |
| return Object.entries(node).map(([name, value]) => { |
| const fullPath = prefix ? `${prefix}/${name}` : name |
| const isFile = value?._file === true |
| const Icon = isFile ? getFileIcon(name) : (expandedDirs.has(fullPath) ? FolderOpen : Folder) |
| const color = isFile ? getFileColor(name) : '#f59e0b' |
|
|
| return ( |
| <div key={fullPath}> |
| <div |
| className={`flex items-center gap-1.5 py-0.5 px-2 rounded cursor-pointer transition-all text-xs group`} |
| style={{ |
| paddingLeft: `${8 + depth * 12}px`, |
| background: selectedFile === fullPath ? 'rgba(99,102,241,0.15)' : 'transparent', |
| color: selectedFile === fullPath ? 'var(--text-primary)' : 'var(--text-secondary)', |
| }} |
| onClick={() => { |
| if (isFile) setSelectedFile(fullPath) |
| else toggleDir(fullPath) |
| }} |
| onMouseEnter={e => { if (selectedFile !== fullPath) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.04)' }} |
| onMouseLeave={e => { if (selectedFile !== fullPath) (e.currentTarget as HTMLElement).style.background = 'transparent' }} |
| > |
| <Icon size={11} style={{ color, flexShrink: 0 }} /> |
| <span className="truncate flex-1">{name}</span> |
| {isFile && value.size && ( |
| <span className="text-[9px] opacity-40 flex-shrink-0"> |
| {value.size > 1024 ? `${(value.size / 1024).toFixed(1)}k` : `${value.size}b`} |
| </span> |
| )} |
| </div> |
| {!isFile && expandedDirs.has(fullPath) && renderTree(value, fullPath, depth + 1)} |
| </div> |
| ) |
| }) |
| } |
|
|
| const tree = workspace ? buildTree(workspace.files) : {} |
|
|
| return ( |
| <div className="flex flex-col h-full" style={{ background: 'var(--bg-1)' }}> |
| {/* Header */} |
| <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0" |
| style={{ borderColor: 'var(--border)', background: 'var(--bg-2)' }}> |
| <div className="flex items-center gap-2"> |
| <Folder size={13} className="text-yellow-400" /> |
| <span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}> |
| File Explorer |
| </span> |
| {workspace && ( |
| <span className="text-[10px] px-1.5 py-0.5 rounded font-mono" |
| style={{ background: 'var(--bg-3)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}> |
| {workspace.total} files |
| </span> |
| )} |
| </div> |
| <button |
| onClick={fetchWorkspace} |
| disabled={loading} |
| className="p-1.5 rounded-lg transition-all hover:opacity-80" |
| style={{ background: 'var(--bg-3)', border: '1px solid var(--border)' }}> |
| <RefreshCw size={11} className={`${loading ? 'animate-spin' : ''}`} style={{ color: 'var(--text-muted)' }} /> |
| </button> |
| </div> |
| |
| {/* File Tree */} |
| <div className="flex-1 overflow-y-auto py-2"> |
| {loading ? ( |
| <div className="flex items-center justify-center h-20"> |
| <RefreshCw size={14} className="animate-spin text-indigo-400" /> |
| </div> |
| ) : workspace && workspace.files.length > 0 ? ( |
| <div>{renderTree(tree)}</div> |
| ) : ( |
| <div className="flex flex-col items-center justify-center h-20 gap-2"> |
| <Folder size={20} className="opacity-30" style={{ color: 'var(--text-muted)' }} /> |
| <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Workspace empty</p> |
| <p className="text-[10px] text-center px-4" style={{ color: 'var(--text-muted)' }}> |
| Ask God Agent to create a project |
| </p> |
| </div> |
| )} |
| </div> |
| |
| {/* Selected file info */} |
| {selectedFile && ( |
| <div className="px-3 py-2 border-t shrink-0" style={{ borderColor: 'var(--border)', background: 'var(--bg-2)' }}> |
| <p className="text-[10px] truncate font-mono" style={{ color: 'var(--text-muted)' }}> |
| 📄 {selectedFile} |
| </p> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|