/ src / components / shared / dir-browser.tsx
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  }