SPB Git

Repository Public

files-browser.tsx 14.8 KB 221 lines tsx

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
Files main
Menu

Theme