1
- import { useState } from "react" ;
1
+ import { useState , useEffect } from "react" ;
2
2
import { benchifyFileSchema } from "@/lib/schemas" ;
3
3
import { z } from "zod" ;
4
4
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' ;
5
5
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism' ;
6
6
import { cn } from "@/lib/utils" ;
7
7
import { ScrollArea } from "@/components/ui/scroll-area" ;
8
+ import { Collapsible , CollapsibleContent , CollapsibleTrigger } from "@/components/ui/collapsible" ;
9
+ import {
10
+ ChevronDown ,
11
+ ChevronRight ,
12
+ Folder ,
13
+ FileText ,
14
+ FileCode ,
15
+ Settings ,
16
+ Palette ,
17
+ Globe ,
18
+ Package
19
+ } from "lucide-react" ;
8
20
9
21
interface CodeEditorProps {
10
22
files : z . infer < typeof benchifyFileSchema > ;
11
23
}
12
24
25
+ interface FileNode {
26
+ name : string ;
27
+ path : string ;
28
+ type : 'file' | 'folder' ;
29
+ children ?: FileNode [ ] ;
30
+ content ?: string ;
31
+ }
32
+
13
33
export function CodeEditor ( { files = [ ] } : CodeEditorProps ) {
14
- const [ selectedFileIndex , setSelectedFileIndex ] = useState ( 0 ) ;
34
+ const [ selectedFilePath , setSelectedFilePath ] = useState < string > ( '' ) ;
35
+ const [ openFolders , setOpenFolders ] = useState < Set < string > > ( new Set ( ) ) ;
36
+
37
+ // Build file tree structure (pure function, no side effects)
38
+ const buildFileTree = ( files : Array < { path : string ; content : string } > ) : { tree : FileNode [ ] , allFolders : string [ ] } => {
39
+ const root : FileNode [ ] = [ ] ;
40
+ const folderMap = new Map < string , FileNode > ( ) ;
41
+ const allFolders : string [ ] = [ ] ;
42
+
43
+ // Sort files to ensure consistent ordering
44
+ const sortedFiles = [ ...files ] . sort ( ( a , b ) => a . path . localeCompare ( b . path ) ) ;
45
+
46
+ sortedFiles . forEach ( file => {
47
+ const parts = file . path . split ( '/' ) ;
48
+ let currentPath = '' ;
49
+ let currentLevel = root ;
50
+
51
+ for ( let i = 0 ; i < parts . length ; i ++ ) {
52
+ const part = parts [ i ] ;
53
+ const isLast = i === parts . length - 1 ;
54
+ currentPath = currentPath ? `${ currentPath } /${ part } ` : part ;
55
+
56
+ if ( isLast ) {
57
+ // It's a file
58
+ currentLevel . push ( {
59
+ name : part ,
60
+ path : file . path ,
61
+ type : 'file' ,
62
+ content : file . content
63
+ } ) ;
64
+ } else {
65
+ // It's a folder
66
+ let folder = folderMap . get ( currentPath ) ;
67
+ if ( ! folder ) {
68
+ folder = {
69
+ name : part ,
70
+ path : currentPath ,
71
+ type : 'folder' ,
72
+ children : [ ]
73
+ } ;
74
+ folderMap . set ( currentPath , folder ) ;
75
+ currentLevel . push ( folder ) ;
76
+ allFolders . push ( currentPath ) ;
77
+ }
78
+ currentLevel = folder . children ! ;
79
+ }
80
+ }
81
+ } ) ;
82
+
83
+ return { tree : root , allFolders } ;
84
+ } ;
85
+
86
+ const { tree : fileTree , allFolders } = buildFileTree ( files ) ;
87
+ const selectedFile = files . find ( f => f . path === selectedFilePath ) ;
88
+
89
+ // Open all folders by default (only once when files change)
90
+ useEffect ( ( ) => {
91
+ if ( allFolders . length > 0 ) {
92
+ setOpenFolders ( new Set ( allFolders ) ) ;
93
+ }
94
+ } , [ files . length ] ) ; // Only trigger when files array changes
15
95
16
- // Selected file data
17
- const selectedFile = files [ selectedFileIndex ] ;
96
+ // Auto-select first file if none selected (only once when files change)
97
+ useEffect ( ( ) => {
98
+ if ( ! selectedFilePath && files . length > 0 ) {
99
+ setSelectedFilePath ( files [ 0 ] . path ) ;
100
+ }
101
+ } , [ files . length , selectedFilePath ] ) ; // Dependency on selectedFilePath prevents loops
102
+
103
+ // Get file icon based on extension
104
+ const getFileIcon = ( path : string ) => {
105
+ if ( path . endsWith ( '.tsx' ) || path . endsWith ( '.jsx' ) ) return < FileCode className = "h-4 w-4 text-blue-500" /> ;
106
+ if ( path . endsWith ( '.ts' ) || path . endsWith ( '.js' ) ) return < FileCode className = "h-4 w-4 text-yellow-500" /> ;
107
+ if ( path . endsWith ( '.css' ) ) return < Palette className = "h-4 w-4 text-pink-500" /> ;
108
+ if ( path . endsWith ( '.html' ) ) return < Globe className = "h-4 w-4 text-orange-500" /> ;
109
+ if ( path . endsWith ( '.json' ) || path . includes ( 'config' ) ) return < Settings className = "h-4 w-4 text-gray-500" /> ;
110
+ if ( path . includes ( 'package.json' ) ) return < Package className = "h-4 w-4 text-green-500" /> ;
111
+ return < FileText className = "h-4 w-4 text-gray-400" /> ;
112
+ } ;
18
113
19
114
// Determine file language for syntax highlighting
20
115
const getLanguage = ( path : string ) => {
@@ -30,32 +125,74 @@ export function CodeEditor({ files = [] }: CodeEditorProps) {
30
125
return 'text' ;
31
126
} ;
32
127
33
- // Extract filename from path
34
- const getFileName = ( path : string ) => {
35
- const parts = path . split ( '/' ) ;
36
- return parts [ parts . length - 1 ] ;
128
+ const toggleFolder = ( folderPath : string ) => {
129
+ setOpenFolders ( prev => {
130
+ const newSet = new Set ( prev ) ;
131
+ if ( newSet . has ( folderPath ) ) {
132
+ newSet . delete ( folderPath ) ;
133
+ } else {
134
+ newSet . add ( folderPath ) ;
135
+ }
136
+ return newSet ;
137
+ } ) ;
138
+ } ;
139
+
140
+ const renderFileTree = ( nodes : FileNode [ ] , depth = 0 ) => {
141
+ return nodes . map ( node => {
142
+ if ( node . type === 'folder' ) {
143
+ const isOpen = openFolders . has ( node . path ) ;
144
+
145
+ return (
146
+ < Collapsible key = { node . path } open = { isOpen } onOpenChange = { ( ) => toggleFolder ( node . path ) } >
147
+ < CollapsibleTrigger className = "flex items-center w-full text-left py-1 px-2 hover:bg-muted/50 text-sm" >
148
+ < div className = "flex items-center gap-2" style = { { paddingLeft : `${ depth * 12 } px` } } >
149
+ { isOpen ? (
150
+ < ChevronDown className = "h-4 w-4 text-muted-foreground" />
151
+ ) : (
152
+ < ChevronRight className = "h-4 w-4 text-muted-foreground" />
153
+ ) }
154
+ < Folder className = "h-4 w-4 text-blue-400" />
155
+ < span className = "font-medium" > { node . name } </ span >
156
+ </ div >
157
+ </ CollapsibleTrigger >
158
+ < CollapsibleContent >
159
+ { node . children && renderFileTree ( node . children , depth + 1 ) }
160
+ </ CollapsibleContent >
161
+ </ Collapsible >
162
+ ) ;
163
+ } else {
164
+ const isSelected = selectedFilePath === node . path ;
165
+
166
+ return (
167
+ < button
168
+ key = { node . path }
169
+ onClick = { ( ) => setSelectedFilePath ( node . path ) }
170
+ className = { cn (
171
+ "flex items-center w-full text-left py-1.5 px-2 hover:bg-muted/50 text-sm transition-colors" ,
172
+ isSelected && "bg-primary/10 text-primary"
173
+ ) }
174
+ >
175
+ < div className = "flex items-center gap-2" style = { { paddingLeft : `${ depth * 12 + 20 } px` } } >
176
+ { getFileIcon ( node . path ) }
177
+ < span className = "truncate" > { node . name } </ span >
178
+ </ div >
179
+ </ button >
180
+ ) ;
181
+ }
182
+ } ) ;
37
183
} ;
38
184
39
185
return (
40
- < div className = "grid grid-cols-[180px_1fr ] h-[700px] gap-4" >
41
- { /* File sidebar */ }
186
+ < div className = "grid grid-cols-[320px_1fr ] h-[700px] gap-4" >
187
+ { /* File sidebar - now wider */ }
42
188
< div className = "border rounded-md overflow-hidden bg-card min-w-0 h-full" >
43
- < div className = "p-2 border-b bg-muted/50 font-medium text-sm" > Files</ div >
44
- < ScrollArea className = "h-[calc(100%-33px)]" >
45
- < div className = "py-1" >
46
- { files . map ( ( file , index ) => (
47
- < button
48
- key = { file . path }
49
- onClick = { ( ) => setSelectedFileIndex ( index ) }
50
- className = { cn (
51
- "w-full text-left px-2 py-1.5 text-xs hover:bg-muted/50" ,
52
- selectedFileIndex === index && "bg-primary/10 text-primary font-medium"
53
- ) }
54
- title = { file . path }
55
- >
56
- < span className = "block truncate" > { file . path } </ span >
57
- </ button >
58
- ) ) }
189
+ < div className = "p-3 border-b bg-muted/50 font-semibold text-sm flex items-center gap-2" >
190
+ < Folder className = "h-4 w-4" />
191
+ Files
192
+ </ div >
193
+ < ScrollArea className = "h-[calc(100%-41px)]" >
194
+ < div className = "py-2" >
195
+ { renderFileTree ( fileTree ) }
59
196
</ div >
60
197
</ ScrollArea >
61
198
</ div >
@@ -64,8 +201,9 @@ export function CodeEditor({ files = [] }: CodeEditorProps) {
64
201
< div className = "border rounded-md overflow-hidden h-full min-w-0 flex-1" >
65
202
{ selectedFile ? (
66
203
< div className = "flex flex-col h-full" >
67
- < div className = "p-2 border-b bg-muted/50 font-medium flex items-center" >
68
- < span className = "text-sm truncate" > { getFileName ( selectedFile . path ) } </ span >
204
+ < div className = "p-3 border-b bg-muted/50 font-medium flex items-center gap-2" >
205
+ { getFileIcon ( selectedFile . path ) }
206
+ < span className = "text-sm truncate" > { selectedFile . path } </ span >
69
207
</ div >
70
208
< SyntaxHighlighter
71
209
language = { getLanguage ( selectedFile . path ) }
@@ -104,7 +242,10 @@ export function CodeEditor({ files = [] }: CodeEditorProps) {
104
242
</ div >
105
243
) : (
106
244
< div className = "flex items-center justify-center h-full text-muted-foreground" >
107
- No file selected
245
+ < div className = "text-center" >
246
+ < FileText className = "h-12 w-12 mx-auto mb-4 opacity-50" />
247
+ < p > Select a file to view its contents</ p >
248
+ </ div >
108
249
</ div >
109
250
) }
110
251
</ div >
0 commit comments