import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react'; import { api, ApiError } 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 { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; interface Props { projectId: string; sessionId: 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, sessionId }: 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); // New-file-from-pasted-text modal. Queues a pending_changes create via // BooCoder; it then shows in the CoderPane DiffPanel for explicit apply. const [newFileOpen, setNewFileOpen] = useState(false); const [newFilePath, setNewFilePath] = useState(''); const [newFileContent, setNewFileContent] = useState(''); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const openNewFile = useCallback(() => { setNewFilePath(''); setNewFileContent(''); setCreateError(null); setNewFileOpen(true); }, []); const submitNewFile = useCallback(async () => { const path = newFilePath.trim(); if (!path || creating) return; setCreating(true); setCreateError(null); try { await api.coder.createPendingFile(sessionId, path, newFileContent); setNewFileOpen(false); setNewFilePath(''); setNewFileContent(''); } catch (err) { // 422 WriteGuardError surfaces via ApiError.message (the route's { error }). setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file'); } finally { setCreating(false); } }, [sessionId, newFilePath, newFileContent, creating]); // 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)} /> )} New file from pasted text Queues a new file as a pending change. Review and apply it from the Coder pane.
setNewFilePath(e.target.value)} placeholder="src/example.ts" />