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 { useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; 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 { isMobile } = useViewport(); const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer(); 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); // Combined open state: on mobile use the global drawer state (toggled by // the Session header's FolderTree button); on desktop use the persistent // internal state. const isOpen = isMobile ? drawerOpen : open; const closeRail = useCallback(() => { if (isMobile) setDrawerOpen(false); else setOpen(false); }, [isMobile, setDrawerOpen]); const openRail = useCallback(() => { if (isMobile) setDrawerOpen(true); else setOpen(true); }, [isMobile, setDrawerOpen]); 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 (!isOpen) return; if (!cache.has('')) void loadDir(''); }, [isOpen, 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 (!isOpen) openRail(); void openFile(event.path); }); }, [isOpen, openRail, projectId]); // Desktop closed state: render the floating chevron handle. Mobile never // shows the handle — the toggle lives in the Session header on mobile. if (!isMobile && !open) { return ( ); } const rootEntries = cache.get('') ?? []; // Mobile: render as fixed-position right-side drawer (always mounted so // the transform transition can animate in/out). Desktop: inline aside. const asideCls = isMobile ? cn( 'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden', 'transition-transform duration-200 ease-out', drawerOpen ? 'translate-x-0' : 'translate-x-full', ) : 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden'; 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) && ( )}
  • ); })}
); }