import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react'; import { api } from '@/api/client'; import type { FileEntry } from '@/api/types'; import { inferLanguage } from '@/lib/attachments'; import { sessionEvents } from '@/hooks/sessionEvents'; import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { Input } from '@/components/ui/input'; interface Props { projectId: string; } const STORAGE_KEY = 'boocode.rightrail'; function basename(path: string): string { if (!path) return ''; const parts = path.split('/'); return parts[parts.length - 1] ?? path; } function joinPath(parent: string, name: string): string { if (!parent || parent === '.' || parent === '') return name; return `${parent}/${name}`; } export function RightRail({ projectId }: Props) { const [open, setOpen] = useState(() => { try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; } }); const [filter, setFilter] = useState(''); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [cache, setCache] = useState>(new Map()); const [fullFileList, setFullFileList] = useState(null); const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null); useEffect(() => { // best-effort; ignore failure because localStorage may be unavailable (quota, private mode) try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {} }, [open]); useEffect(() => { let cancelled = false; api.projects.files(projectId).then((r) => { if (!cancelled) setFullFileList(r.files); }).catch(() => {}); return () => { cancelled = true; }; }, [projectId]); const loadDir = useCallback(async (dirPath: string) => { const apiPath = dirPath === '' ? '.' : dirPath; try { const result = await api.projects.listDir(projectId, apiPath); setCache((prev) => { const next = new Map(prev); next.set(dirPath, result.entries); return next; }); } catch { /* ignore */ } }, [projectId]); useEffect(() => { if (!open) return; if (!cache.has('')) void loadDir(''); }, [open, cache, loadDir]); function toggleDir(dirPath: string) { setExpandedDirs((prev) => { const next = new Set(prev); if (next.has(dirPath)) { next.delete(dirPath); } else { next.add(dirPath); if (!cache.has(dirPath)) void loadDir(dirPath); } return next; }); } async function openFile(path: string) { try { const result = await api.projects.viewFile(projectId, path); setViewerFile({ path, content: result.content }); } catch { /* ignore */ } } // Filter results const trimmed = filter.trim().toLowerCase(); const filterActive = trimmed.length > 0; interface FilterResult { path: string; name: string; } const filterResults = useMemo(() => { if (!filterActive) return []; if (fullFileList) { const filenameMatches: string[] = []; const pathOnly: string[] = []; for (const p of fullFileList) { const lp = p.toLowerCase(); if (!lp.includes(trimmed)) continue; if (basename(p).toLowerCase().includes(trimmed)) filenameMatches.push(p); else pathOnly.push(p); } filenameMatches.sort((a, b) => a.localeCompare(b)); pathOnly.sort((a, b) => a.localeCompare(b)); return [...filenameMatches, ...pathOnly].slice(0, 50).map((p) => ({ path: p, name: basename(p) })); } return []; }, [filterActive, trimmed, fullFileList]); // Listen for open_file_in_browser events useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type !== 'open_file_in_browser') return; if (!open) setOpen(true); void openFile(event.path); }); }, [open, projectId]); if (!open) { return ( ); } const rootEntries = cache.get('') ?? []; return ( <> {viewerFile && ( setViewerFile(null)} onNavigate={(path) => void openFile(path)} /> )} ); } interface TreeLevelProps { parentPath: string; entries: FileEntry[]; cache: Map; expanded: Set; depth: number; onToggleDir: (dirPath: string) => void; onSelectFile: (path: string) => void; } function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) { const sorted = useMemo(() => { const copy = [...entries]; copy.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); return copy; }, [entries]); return (
    {sorted.map((entry) => { const fullPath = joinPath(parentPath, entry.name); const isExpanded = entry.kind === 'dir' && expanded.has(fullPath); return (
  • { if (entry.kind === 'dir') onToggleDir(fullPath); else onSelectFile(fullPath); }} > {entry.kind === 'dir' ? ( isExpanded ? : ) : ( )} {entry.kind === 'dir' ? ( ) : ( )} {entry.name}
    {entry.kind === 'dir' && isExpanded && cache.has(fullPath) && ( )}
  • ); })}
); }