Repository Public
1'use client';2import { useEffect, useMemo, useRef, useState } from 'react';3import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query';4import { useVirtualizer } from '@tanstack/react-virtual';5import type { FileEntry, SearchHit } from '@cr/shared';6import { ChevronDown, ChevronRight, Ellipsis, File, FilePlus, Folder, FolderOpen, FolderPlus, RefreshCw, Search, Upload, X } from 'lucide-react';7import { api, asList, downloadUrl, errorMessage, qs } from '@/lib/api';8import { qk } from '@/lib/queries';9import { fmtBytes, fmtRelative } from '@/lib/format';10import { IconButton } from '@/components/ui/button';11import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown';12import { EmptyState, ErrorState, Spinner } from '@/components/ui/misc';13import { Input } from '@/components/ui/input';14import { cn } from '@/lib/cn';15import { FileOpDialogs, useUploads, type FileOp } from './file-ops';1617interface Row {18 entry: FileEntry;19 depth: number;20 expanded: boolean;21 loading: boolean;22}23const listFiles = async (projectId: string, path: string) => asList<FileEntry>(await api.get(`/projects/${projectId}/files${qs({ path })}`));2425/**26 * File browser. `mode="tree"`: lazy tree (a directory is listed when first expanded).27 * `mode="drill"`: one directory at a time (phones). Rows are virtualized in both modes.28 */29export function FilesBrowser({ projectId, mode, onOpenFile, activePath, wide }: { projectId: string; mode: 'tree' | 'drill'; onOpenFile: (path: string) => void; activePath?: string | null; wide?: boolean }) {30 const qc = useQueryClient();31 const [expanded, setExpanded] = useState<Set<string>>(new Set());32 const [cwd, setCwd] = useState('');33 const [op, setOp] = useState<FileOp | null>(null);34 const [query, setQuery] = useState('');35 const [searchMode, setSearchMode] = useState<'name' | 'content'>('name');36 const [debounced, setDebounced] = useState('');37 const [dragOver, setDragOver] = useState(false);38 const picker = useRef<HTMLInputElement>(null);39 const scroller = useRef<HTMLDivElement>(null);40 const uploads = useUploads(projectId);41 const now = Date.now();4243 useEffect(() => {44 setExpanded(new Set());45 setCwd('');46 setQuery('');47 }, [projectId]);48 useEffect(() => {49 const t = setTimeout(() => setDebounced(query.trim()), 250);50 return () => clearTimeout(t);51 }, [query]);5253 const dirs = mode === 'tree' ? ['', ...expanded] : [cwd];54 const results = useQueries({ queries: dirs.map((d) => ({ queryKey: qk.files(projectId, d), queryFn: () => listFiles(projectId, d) })) });55 const byDir = new Map(dirs.map((d, i) => [d, results[i]]));56 const root = byDir.get(mode === 'tree' ? '' : cwd);57 const rows = useMemo(() => {58 const out: Row[] = [];59 const add = (dir: string, depth: number) => {60 for (const e of byDir.get(dir)?.data ?? []) {61 const isOpen = mode === 'tree' && e.type === 'dir' && expanded.has(e.path);62 out.push({ entry: e, depth, expanded: isOpen, loading: isOpen && !!byDir.get(e.path)?.isPending });63 if (isOpen) add(e.path, depth + 1);64 }65 };66 add(mode === 'tree' ? '' : cwd, 0);67 return out;68 // eslint-disable-next-line react-hooks/exhaustive-deps -- byDir is derived from results69 }, [results.map((r) => r.dataUpdatedAt).join(','), expanded, cwd, mode]);7071 const search = useQuery({72 queryKey: ['file-search', projectId, debounced, searchMode],73 queryFn: async () => asList<SearchHit>(await api.get(`/projects/${projectId}/search${qs({ q: debounced, mode: searchMode })}`)),74 enabled: debounced.length >= 2,75 });76 const searching = debounced.length >= 2;77 const rowH = mode === 'drill' ? 68 : 28;78 const virt = useVirtualizer({ count: searching ? 0 : rows.length, getScrollElement: () => scroller.current, estimateSize: () => rowH, overscan: 12 });7980 const toggle = (e: FileEntry) => {81 if (e.type !== 'dir') return onOpenFile(e.path);82 if (mode === 'drill') return setCwd(e.path);83 setExpanded((s) => {84 const n = new Set(s);85 if (n.has(e.path)) n.delete(e.path);86 else n.add(e.path);87 return n;88 });89 };90 const targetDir = mode === 'drill' ? cwd : '';91 const crumbs = cwd ? cwd.split('/') : [];9293 return (94 <div95 className={cn('relative flex h-full min-h-0 flex-col', dragOver && 'outline outline-2 -outline-offset-2 outline-accent')}96 data-testid="files-browser"97 onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDragOver(true); } }}98 onDragLeave={() => setDragOver(false)}99 onDrop={(e) => { e.preventDefault(); setDragOver(false); if (e.dataTransfer.files.length) void uploads.upload(e.dataTransfer.files, targetDir); }}100 >101 <div className="flex shrink-0 items-center gap-1 border-b border-line px-2 py-1.5">102 <div className="relative min-w-0 flex-1">103 <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted pointer-coarse:left-3 pointer-coarse:size-4" aria-hidden />104 <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mode === 'drill' ? 'Search' : searchMode === 'name' ? 'Search names' : 'Search contents'} aria-label="Search files" className="h-7 pl-7 pr-7 text-xs pointer-coarse:h-11 pointer-coarse:rounded-full pointer-coarse:pl-9" data-testid="file-search" />105 {query && (106 <button aria-label="Clear search" onClick={() => setQuery('')} className="absolute right-1 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center text-muted hover:text-text">107 <X className="size-3" />108 </button>109 )}110 </div>111 <button aria-pressed={searchMode === 'content'} onClick={() => setSearchMode((m) => (m === 'name' ? 'content' : 'name'))} title="Toggle between file-name and content search" className={cn('h-7 shrink-0 rounded-sm border px-1.5 text-[11px] pointer-coarse:h-11 pointer-coarse:px-3', searchMode === 'content' ? 'border-accent/50 bg-accent/10 text-accent' : 'border-line-2 text-muted hover:text-text-2')}>112 {searchMode === 'content' ? 'Content' : 'Name'}113 </button>114 <IconButton label="New file" size="sm" onClick={() => setOp({ type: 'new-file', dir: targetDir })}><FilePlus className="size-3.5" /></IconButton>115 <IconButton label="New folder" size="sm" onClick={() => setOp({ type: 'new-folder', dir: targetDir })}><FolderPlus className="size-3.5" /></IconButton>116 <IconButton label="Upload files" size="sm" onClick={() => picker.current?.click()}><Upload className="size-3.5" /></IconButton>117 <IconButton label="Refresh" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ['files', projectId] })}><RefreshCw className="size-3.5" /></IconButton>118 <input ref={picker} type="file" multiple hidden onChange={(e) => { if (e.target.files?.length) void uploads.upload(e.target.files, targetDir); e.target.value = ''; }} />119 </div>120 {mode === 'drill' && !searching && (121 <nav aria-label="Breadcrumb" className="no-scrollbar flex shrink-0 items-center gap-1 overflow-x-auto border-b border-line px-3 py-1 text-xs">122 <button onClick={() => setCwd('')} className={cn('min-h-9 shrink-0', cwd ? 'text-accent' : 'text-text')}>root</button>123 {crumbs.map((c, i) => (124 <span key={i} className="flex shrink-0 items-center gap-1">125 <ChevronRight className="size-3 text-muted" />126 <button onClick={() => setCwd(crumbs.slice(0, i + 1).join('/'))} className={cn('min-h-9', i === crumbs.length - 1 ? 'text-text' : 'text-accent')}>{c}</button>127 </span>128 ))}129 </nav>130 )}131 {uploads.items.length > 0 && (132 <div className="shrink-0 border-b border-line px-3 py-2 text-xs" data-testid="uploads">133 {uploads.items.map((u) => (134 <div key={u.id} className="flex items-center gap-2 py-0.5">135 <span className="min-w-0 flex-1 truncate">{u.name}</span>136 {u.error ? <span className="flex items-center gap-2 text-error">{u.error}{u.replace && <button onClick={u.replace} className="rounded-sm border border-line-2 px-1.5 py-0.5 text-text-2 hover:text-text">Replace</button>}</span> : u.done ? <span className="text-success">Uploaded</span> : (137 <span className="flex items-center gap-2"><progress value={u.fraction} max={1} className="h-1 w-20" aria-label={`Uploading ${u.name}`} /><span className="tnum w-9 text-right text-muted">{Math.round(u.fraction * 100)} %</span></span>138 )}139 </div>140 ))}141 {uploads.items.every((u) => u.done) && <button onClick={uploads.clear} className="mt-1 text-[11px] text-muted hover:text-text">Dismiss</button>}142 </div>143 )}144 <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto overscroll-contain" role={searching ? undefined : 'tree'} aria-label="Project files">145 {searching ? (146 search.isPending ? <div className="flex justify-center p-6"><Spinner /></div>147 : search.isError ? <ErrorState error={search.error} onRetry={() => search.refetch()} />148 : search.data.length === 0 ? <EmptyState title="No matches" description={`Nothing matches “${debounced}” (${searchMode} search).`} />149 : (150 <ul className="py-1" data-testid="search-results">151 {search.data.map((h, i) => (152 <li key={`${h.path}:${h.line}:${i}`}>153 <button onClick={() => onOpenFile(h.path)} className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-surface-2 pointer-coarse:py-2.5">154 <span className="truncate font-mono text-xs text-text">{h.path}{h.line != null && <span className="text-muted">:{h.line}</span>}</span>155 {h.preview && <span className="truncate font-mono text-[11px] text-muted">{h.preview}</span>}156 </button>157 </li>158 ))}159 </ul>160 )161 ) : root?.isPending ? <div className="flex justify-center p-6"><Spinner /></div>162 : root?.isError ? <ErrorState error={root.error} onRetry={() => root.refetch()} />163 : rows.length === 0 ? <EmptyState title="Empty directory" description="Create a file or drop files here to upload." />164 : (165 <div style={{ height: virt.getTotalSize(), position: 'relative' }}>166 {virt.getVirtualItems().map((v) => {167 const r = rows[v.index];168 if (!r) return null;169 const e = r.entry;170 const isDir = e.type === 'dir';171 return (172 <div173 key={e.path}174 role="treeitem"175 aria-expanded={isDir && mode === 'tree' ? r.expanded : undefined}176 aria-selected={activePath === e.path}177 style={{ position: 'absolute', top: v.start, height: v.size, left: 0, right: 0 }}178 className={cn('group flex items-center pr-1', mode === 'drill' ? 'px-3 py-1' : 'hover:bg-surface-2', mode === 'tree' && activePath === e.path && 'bg-surface-2')}179 >180 <button onClick={() => toggle(e)} data-testid="file-row" data-path={e.path} style={{ paddingLeft: mode === 'drill' ? 14 : 8 + r.depth * 14 }} className={cn('flex h-full min-w-0 flex-1 items-center text-left', mode === 'drill' ? 'card pressable gap-3 pr-3' : 'gap-1.5')}>181 {mode === 'tree' && (isDir ? (r.loading ? <Spinner className="size-3" /> : r.expanded ? <ChevronDown className="size-3 shrink-0 text-muted" /> : <ChevronRight className="size-3 shrink-0 text-muted" />) : <span className="w-3 shrink-0" />)}182 {mode === 'drill' ? (183 <span className={cn('flex size-9 shrink-0 items-center justify-center rounded-[12px]', isDir ? 'bg-accent/10 text-accent' : 'bg-surface-2 text-text-2')}>{isDir ? <Folder className="size-[18px]" /> : <File className="size-[18px]" />}</span>184 ) : isDir ? (r.expanded ? <FolderOpen className="size-3.5 shrink-0 text-accent" /> : <Folder className="size-3.5 shrink-0 text-accent" />) : <File className="size-3.5 shrink-0 text-muted" />}185 <span className="min-w-0 flex-1">186 <span className={cn('block truncate', mode === 'drill' ? 'text-[15px] font-semibold text-text' : 'text-xs', mode === 'tree' && (activePath === e.path ? 'text-text' : 'text-text-2'))}>{e.name}</span>187 {mode === 'drill' && <span className="tnum block text-[13px] text-muted">{isDir ? 'Folder' : fmtBytes(e.size)} · {fmtRelative(e.mtimeMs, now)}</span>}188 </span>189 {mode === 'tree' && wide && !isDir && <span className="tnum shrink-0 text-[11px] text-muted">{fmtBytes(e.size)}</span>}190 {mode === 'tree' && wide && <span className="tnum w-20 shrink-0 text-right text-[11px] text-muted">{fmtRelative(e.mtimeMs, now)}</span>}191 {mode === 'drill' && isDir && <ChevronRight className="size-4 shrink-0 text-muted" />}192 </button>193 <DropdownMenu>194 <DropdownMenuTrigger asChild>195 <button aria-label={`Actions for ${e.name}`} className="inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-muted opacity-0 hover:bg-hover hover:text-text focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100 pointer-coarse:size-11 pointer-coarse:opacity-100">196 <Ellipsis className="size-3.5" />197 </button>198 </DropdownMenuTrigger>199 <DropdownMenuContent>200 {isDir && <DropdownMenuItem onSelect={() => setOp({ type: 'new-file', dir: e.path })}>New file here</DropdownMenuItem>}201 {isDir && <DropdownMenuItem onSelect={() => setOp({ type: 'new-folder', dir: e.path })}>New folder here</DropdownMenuItem>}202 {!isDir && <DropdownMenuItem asChild><a href={downloadUrl(projectId, e.path)} download>Download</a></DropdownMenuItem>}203 <DropdownMenuItem onSelect={() => setOp({ type: 'rename', path: e.path })}>Rename</DropdownMenuItem>204 <DropdownMenuItem onSelect={() => setOp({ type: 'move', path: e.path })}>Move…</DropdownMenuItem>205 <DropdownMenuSeparator />206 <DropdownMenuItem danger onSelect={() => setOp({ type: 'delete', path: e.path, isDir })}>Delete…</DropdownMenuItem>207 </DropdownMenuContent>208 </DropdownMenu>209 </div>210 );211 })}212 </div>213 )}214 </div>215 {dragOver && <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-bg/80 text-sm text-accent">Drop to upload into {targetDir || 'the project root'}</div>}216 <FileOpDialogs projectId={projectId} op={op} onClose={() => setOp(null)} onCreated={onOpenFile} />217 </div>218 );219}220export { errorMessage };221