dir-browser.tsx
1 'use client' 2 3 import { useEffect, useState, useMemo } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { SearchInput } from '@/components/ui/search-input' 6 7 interface DirEntry { 8 name: string 9 path: string 10 } 11 12 interface DirApiResponse { 13 dirs: DirEntry[] 14 currentPath: string 15 parentPath: string | null 16 } 17 18 interface DirBrowserProps { 19 value: string | null 20 file?: string | null 21 onChange: (dir: string, file?: string | null) => void 22 onClear: () => void 23 } 24 25 type Mode = 'native' | 'browse' 26 27 export function DirBrowser({ value, file, onChange, onClear }: DirBrowserProps) { 28 const [mode, setMode] = useState<Mode>('native') 29 const [picking, setPicking] = useState<'file' | 'folder' | null>(null) 30 31 // Browse mode state 32 const [browsePath, setBrowsePath] = useState('~/Dev') 33 const [dirs, setDirs] = useState<DirEntry[]>([]) 34 const [currentPath, setCurrentPath] = useState('') 35 const [parentPath, setParentPath] = useState<string | null>(null) 36 const [loading, setLoading] = useState(false) 37 const [pathInput, setPathInput] = useState('') 38 const [search, setSearch] = useState('') 39 40 // Reset search and mark loading when navigating in browse mode 41 const [prevBrowsePath, setPrevBrowsePath] = useState(browsePath) 42 const [prevMode, setPrevMode] = useState(mode) 43 if (browsePath !== prevBrowsePath || mode !== prevMode) { 44 setPrevBrowsePath(browsePath) 45 setPrevMode(mode) 46 if (mode === 'browse') { 47 setSearch('') 48 setLoading(true) 49 } 50 } 51 52 useEffect(() => { 53 if (mode !== 'browse') return 54 api<DirApiResponse>('GET', `/dirs?path=${encodeURIComponent(browsePath)}`) 55 .then((data) => { 56 setDirs(data.dirs || []) 57 setCurrentPath(data.currentPath || browsePath) 58 setParentPath(data.parentPath || null) 59 setPathInput(data.currentPath || browsePath) 60 }) 61 .catch(() => { setDirs([]) }) 62 .finally(() => setLoading(false)) 63 }, [browsePath, mode]) 64 65 const filteredDirs = useMemo(() => { 66 if (!search) return dirs 67 const q = search.toLowerCase() 68 return dirs.filter((d) => d.name.toLowerCase().includes(q)) 69 }, [dirs, search]) 70 71 const navigateTo = (path: string) => setBrowsePath(path) 72 73 const handlePathSubmit = (e: React.KeyboardEvent) => { 74 if (e.key === 'Enter' && pathInput.trim()) { 75 navigateTo(pathInput.trim()) 76 } 77 } 78 79 const handlePick = async (pickMode: 'file' | 'folder') => { 80 setPicking(pickMode) 81 try { 82 const data = await api<{ directory: string | null; file: string | null }>('POST', '/dirs/pick', { mode: pickMode }) 83 if (data.directory) { 84 onChange(data.directory, data.file) 85 } 86 } catch { /* cancelled or error */ } 87 setPicking(null) 88 } 89 90 // Breadcrumbs for browse mode 91 const homedir = currentPath.match(/^\/Users\/[^/]+/)?.[0] || '' 92 const breadcrumbs: Array<{ label: string; path: string }> = [] 93 if (currentPath && homedir) { 94 const relative = currentPath.slice(homedir.length) 95 const parts = relative.split('/').filter(Boolean) 96 breadcrumbs.push({ label: '~', path: homedir }) 97 let acc = homedir 98 for (const p of parts) { 99 acc = `${acc}/${p}` 100 breadcrumbs.push({ label: p, path: acc }) 101 } 102 } else if (currentPath) { 103 const parts = currentPath.split('/').filter(Boolean) 104 breadcrumbs.push({ label: '/', path: '/' }) 105 let acc = '' 106 for (const p of parts) { 107 acc = `${acc}/${p}` 108 breadcrumbs.push({ label: p, path: acc }) 109 } 110 } 111 112 // Selected state 113 if (value) { 114 const displayDir = value.replace(/^\/Users\/\w+/, '~') 115 const displayFile = file ? file.split('/').pop() : null 116 return ( 117 <div className="flex items-center gap-2"> 118 <div className="flex-1 min-w-0 px-4 py-3 rounded-[14px] border border-accent-bright/20 bg-accent-soft overflow-hidden"> 119 <div className="text-accent-bright text-[14px] font-mono truncate">{displayDir}</div> 120 {displayFile && ( 121 <div className="text-accent-bright/60 text-[12px] font-mono truncate mt-0.5"> 122 {displayFile} 123 </div> 124 )} 125 </div> 126 <button 127 onClick={onClear} 128 className="shrink-0 px-3 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text-3 text-[13px] cursor-pointer hover:bg-surface-2 transition-colors" 129 style={{ fontFamily: 'inherit' }} 130 > 131 Clear 132 </button> 133 </div> 134 ) 135 } 136 137 return ( 138 <div className="space-y-3"> 139 {mode === 'native' ? ( 140 <> 141 {/* Native picker buttons */} 142 <div className="flex gap-3"> 143 <button 144 onClick={() => handlePick('folder')} 145 disabled={picking !== null} 146 className="flex-1 flex items-center justify-center gap-2.5 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text-2 text-[14px] font-600 cursor-pointer hover:bg-surface-2 hover:border-white/[0.12] transition-all disabled:opacity-40" 147 style={{ fontFamily: 'inherit' }} 148 > 149 {picking === 'folder' ? ( 150 <span className="text-text-3">Opening...</span> 151 ) : ( 152 <> 153 <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-text-3"> 154 <path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2Z" /> 155 </svg> 156 Choose Folder 157 </> 158 )} 159 </button> 160 <button 161 onClick={() => handlePick('file')} 162 disabled={picking !== null} 163 className="flex-1 flex items-center justify-center gap-2.5 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text-2 text-[14px] font-600 cursor-pointer hover:bg-surface-2 hover:border-white/[0.12] transition-all disabled:opacity-40" 164 style={{ fontFamily: 'inherit' }} 165 > 166 {picking === 'file' ? ( 167 <span className="text-text-3">Opening...</span> 168 ) : ( 169 <> 170 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3"> 171 <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> 172 <polyline points="14 2 14 8 20 8" /> 173 </svg> 174 Choose File 175 </> 176 )} 177 </button> 178 </div> 179 <button 180 onClick={() => setMode('browse')} 181 className="text-[12px] text-text-3/60 hover:text-text-3 transition-colors cursor-pointer bg-transparent border-none p-0" 182 > 183 Or browse directories manually 184 </button> 185 </> 186 ) : ( 187 <> 188 {/* Path input */} 189 <input 190 type="text" 191 value={pathInput} 192 onChange={(e) => setPathInput(e.target.value)} 193 onKeyDown={handlePathSubmit} 194 placeholder="Type a path and press Enter..." 195 className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] font-mono outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow" 196 /> 197 198 {/* Breadcrumb bar */} 199 <div className="flex items-center gap-1 px-1 overflow-x-auto scrollbar-none"> 200 {parentPath && ( 201 <button 202 onClick={() => navigateTo(parentPath)} 203 className="shrink-0 w-7 h-7 rounded-[8px] border border-white/[0.06] bg-surface text-text-3 text-[13px] cursor-pointer hover:bg-surface-2 hover:text-text-2 transition-colors flex items-center justify-center" 204 > 205 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 206 <polyline points="15 18 9 12 15 6" /> 207 </svg> 208 </button> 209 )} 210 {breadcrumbs.map((bc, i) => ( 211 <span key={bc.path} className="flex items-center shrink-0"> 212 {i > 0 && <span className="text-text-3/60 text-[12px] mx-0.5">/</span>} 213 <button 214 onClick={() => navigateTo(bc.path)} 215 className={`px-2 py-1 rounded-[6px] text-[12px] font-600 cursor-pointer transition-colors 216 ${i === breadcrumbs.length - 1 217 ? 'text-text bg-white/[0.04]' 218 : 'text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`} 219 > 220 {bc.label} 221 </button> 222 </span> 223 ))} 224 </div> 225 226 {/* Search filter */} 227 {dirs.length > 5 && ( 228 <SearchInput 229 size="sm" 230 value={search} 231 onChange={(e) => setSearch(e.target.value)} 232 onClear={() => setSearch('')} 233 placeholder="Filter directories..." 234 /> 235 )} 236 237 {/* Directory list */} 238 <div className="max-h-[200px] overflow-y-auto rounded-[14px] border border-white/[0.06] bg-surface divide-y divide-white/[0.04]"> 239 {loading ? ( 240 <div className="py-8 text-center text-[13px] text-text-3/50">Loading...</div> 241 ) : filteredDirs.length === 0 ? ( 242 <div className="py-8 text-center text-[13px] text-text-3/50"> 243 {search ? 'No matching directories' : 'No subdirectories'} 244 </div> 245 ) : ( 246 filteredDirs.map((d) => ( 247 <button 248 key={d.path} 249 onClick={() => navigateTo(d.path)} 250 className="w-full flex items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-white/[0.03] group" 251 > 252 <svg className="shrink-0 text-text-3/70 group-hover:text-accent-bright/60 transition-colors" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 253 <path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2Z" /> 254 </svg> 255 <span className="text-[13px] font-600 text-text-2 group-hover:text-text truncate">{d.name}</span> 256 <svg className="shrink-0 ml-auto text-text-3/50 group-hover:text-text-3/70 transition-colors" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 257 <polyline points="9 18 15 12 9 6" /> 258 </svg> 259 </button> 260 )) 261 )} 262 </div> 263 264 {/* Actions */} 265 <div className="flex gap-3"> 266 <button 267 onClick={() => onChange(currentPath, null)} 268 className="flex-1 py-3 rounded-[14px] border border-accent-bright/20 bg-accent-soft text-accent-bright text-[14px] font-600 cursor-pointer hover:brightness-110 transition-all" 269 style={{ fontFamily: 'inherit' }} 270 > 271 Select This Directory 272 </button> 273 </div> 274 <button 275 onClick={() => setMode('native')} 276 className="text-[12px] text-text-3/60 hover:text-text-3 transition-colors cursor-pointer bg-transparent border-none p-0" 277 > 278 Or use system file picker 279 </button> 280 </> 281 )} 282 </div> 283 ) 284 }