"use client"; import { useState, useEffect, useRef, Fragment, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { File, Folder, FolderOpen, Upload, Download, ChevronRight, Home, ChevronLeft, Loader, AlertTriangle, } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { FileRenderer, getFileTypeFromExtension } from "@/components/file-renderers"; import { listSandboxFiles, getSandboxFileContent, type FileInfo, Project } from "@/lib/api"; import { toast } from "sonner"; import { createClient } from "@/lib/supabase/client"; // Define API_URL const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; interface FileViewerModalProps { open: boolean; onOpenChange: (open: boolean) => void; sandboxId: string; initialFilePath?: string | null; project?: Project; } export function FileViewerModal({ open, onOpenChange, sandboxId, initialFilePath, project }: FileViewerModalProps) { // File navigation state const [currentPath, setCurrentPath] = useState("/workspace"); const [files, setFiles] = useState([]); const [isLoadingFiles, setIsLoadingFiles] = useState(false); // File content state const [selectedFilePath, setSelectedFilePath] = useState(null); const [rawContent, setRawContent] = useState(null); const [textContentForRenderer, setTextContentForRenderer] = useState(null); const [blobUrlForRenderer, setBlobUrlForRenderer] = useState(null); const [isLoadingContent, setIsLoadingContent] = useState(false); const [contentError, setContentError] = useState(null); // Add a ref to track current loading operation const loadingFileRef = useRef(null); // Utility state const [isUploading, setIsUploading] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const fileInputRef = useRef(null); // State to track if initial path has been processed const [initialPathProcessed, setInitialPathProcessed] = useState(false); // Project state const [projectWithSandbox, setProjectWithSandbox] = useState(project); // Setup project with sandbox URL if not provided directly useEffect(() => { if (project) { setProjectWithSandbox(project); } }, [project, sandboxId]); // Function to ensure a path starts with /workspace - Defined early const normalizePath = useCallback((path: unknown): string => { // Explicitly check if the path is a non-empty string if (typeof path !== 'string' || !path) { console.warn(`[FILE VIEWER] normalizePath received non-string or empty value:`, path, `Returning '/workspace'`); return '/workspace'; } // Now we know path is a string return path.startsWith('/workspace') ? path : `/workspace/${path.replace(/^\//, '')}`; }, []); // Helper function to clear the selected file const clearSelectedFile = useCallback(() => { setSelectedFilePath(null); setRawContent(null); setTextContentForRenderer(null); // Clear derived text content setBlobUrlForRenderer(null); // Clear derived blob URL setContentError(null); setIsLoadingContent(false); loadingFileRef.current = null; // Clear the loading ref }, []); // Helper function to navigate to a folder - COMPLETELY FIXED const navigateToFolder = useCallback((folder: FileInfo) => { if (!folder.is_dir) return; // Ensure the path is properly normalized const normalizedPath = normalizePath(folder.path); // Log before and after states for debugging console.log(`[FILE VIEWER] Navigating to folder: ${folder.path} → ${normalizedPath}`); console.log(`[FILE VIEWER] Current path before navigation: ${currentPath}`); // Clear selected file when navigating clearSelectedFile(); // Update path state - must happen after clearing selection setCurrentPath(normalizedPath); }, [normalizePath, clearSelectedFile, currentPath]); // Navigate to a specific path in the breadcrumb const navigateToBreadcrumb = useCallback((path: string) => { const normalizedPath = normalizePath(path); console.log(`[FILE VIEWER] Navigating to breadcrumb path: ${path} → ${normalizedPath}`); clearSelectedFile(); setCurrentPath(normalizedPath); }, [normalizePath, clearSelectedFile]); // Helper function to navigate to home const navigateHome = useCallback(() => { console.log('[FILE VIEWER] Navigating home from:', currentPath); clearSelectedFile(); setCurrentPath('/workspace'); }, [clearSelectedFile, currentPath]); // Function to generate breadcrumb segments from a path const getBreadcrumbSegments = useCallback((path: string) => { // Ensure we're working with a normalized path const normalizedPath = normalizePath(path); // Remove /workspace prefix and split by / const cleanPath = normalizedPath.replace(/^\/workspace\/?/, ''); if (!cleanPath) return []; const parts = cleanPath.split('/').filter(Boolean); let currentPath = '/workspace'; return parts.map((part, index) => { currentPath = `${currentPath}/${part}`; return { name: part, path: currentPath, isLast: index === parts.length - 1 }; }); }, [normalizePath]); // Core file opening function - Refined const openFile = useCallback(async (file: FileInfo) => { if (file.is_dir) { navigateToFolder(file); return; } // Skip if already selected and content exists if (selectedFilePath === file.path && rawContent) { console.log(`[FILE VIEWER] File already loaded: ${file.path}`); return; } console.log(`[FILE VIEWER] Opening file: ${file.path}`); // Clear previous state FIRST clearSelectedFile(); // Set loading state and selected file path immediately setIsLoadingContent(true); setSelectedFilePath(file.path); // Set the loading ref to track current operation loadingFileRef.current = file.path; try { // Fetch content const content = await getSandboxFileContent(sandboxId, file.path); console.log(`[FILE VIEWER] Received content for ${file.path} (${typeof content})`); // Critical check: Ensure the file we just loaded is still the one selected if (loadingFileRef.current !== file.path) { console.log(`[FILE VIEWER] Selection changed during loading, aborting. Loading: ${loadingFileRef.current}, Expected: ${file.path}`); setIsLoadingContent(false); // Still need to stop loading indicator return; // Abort state update } // Store raw content setRawContent(content); // Determine how to prepare content for the renderer if (typeof content === 'string') { console.log(`[FILE VIEWER] Setting text content directly for renderer.`); setTextContentForRenderer(content); setBlobUrlForRenderer(null); // Ensure no blob URL is set } else if (content instanceof Blob) { console.log(`[FILE VIEWER] Content is a Blob. Will generate URL if needed.`); // Let the useEffect handle URL generation setTextContentForRenderer(null); // Clear any previous text content } else { console.warn("[FILE VIEWER] Unexpected content type received."); setContentError("Received unexpected content type."); } setIsLoadingContent(false); } catch (error) { console.error(`[FILE VIEWER] Error loading file:`, error); // Only update error if this file is still the one being loaded if (loadingFileRef.current === file.path) { setContentError(`Failed to load file: ${error instanceof Error ? error.message : String(error)}`); setIsLoadingContent(false); setRawContent(null); // Clear raw content on error } } finally { // Clear the loading ref if it matches the current operation if (loadingFileRef.current === file.path) { loadingFileRef.current = null; } } }, [sandboxId, selectedFilePath, rawContent, navigateToFolder, clearSelectedFile]); // Effect to manage blob URL for renderer useEffect(() => { let objectUrl: string | null = null; // Create a URL if rawContent is a Blob if (rawContent instanceof Blob) { // Determine if it *should* be text - might still render via blob URL if conversion fails const fileType = selectedFilePath ? getFileTypeFromExtension(selectedFilePath) : 'binary'; const shouldBeText = ['text', 'code', 'markdown'].includes(fileType); // Attempt to read as text first if it should be text if (shouldBeText) { rawContent.text() .then(text => { // Check if selection is still valid *before* setting state if (loadingFileRef.current === null && selectedFilePath && rawContent instanceof Blob) { console.log(`[FILE VIEWER] Successfully read Blob as text, length: ${text.length}`); setTextContentForRenderer(text); setBlobUrlForRenderer(null); // Clear any blob URL if text is successful } else { console.log("[FILE VIEWER] Selection changed or no longer a blob while reading text, discarding result."); } }) .catch(err => { console.warn("[FILE VIEWER] Failed to read Blob as text, falling back to blob URL:", err); // If reading as text fails, fall back to creating a blob URL if (loadingFileRef.current === null && selectedFilePath && rawContent instanceof Blob) { objectUrl = URL.createObjectURL(rawContent); console.log(`[FILE VIEWER] Created blob URL (fallback): ${objectUrl}`); setBlobUrlForRenderer(objectUrl); setTextContentForRenderer(null); // Ensure text content is cleared } else { console.log("[FILE VIEWER] Selection changed or no longer a blob during text read fallback, discarding result."); } }); } else { // For binary types, directly create the blob URL objectUrl = URL.createObjectURL(rawContent); console.log(`[FILE VIEWER] Created blob URL for binary type: ${objectUrl}`); setBlobUrlForRenderer(objectUrl); setTextContentForRenderer(null); } } else { // If rawContent is not a Blob, ensure URL state is null setBlobUrlForRenderer(null); } // Cleanup function to revoke the URL return () => { if (objectUrl) { console.log(`[FILE VIEWER] Revoking blob URL: ${objectUrl}`); URL.revokeObjectURL(objectUrl); } }; }, [rawContent, selectedFilePath]); // Re-run when rawContent or selectedFilePath changes // Handle file download - Define after helpers const handleDownload = useCallback(async () => { if (!selectedFilePath || isDownloading) return; setIsDownloading(true); try { // Use cached content if available if (rawContent) { const blob = rawContent instanceof Blob ? rawContent : new Blob([rawContent], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = selectedFilePath.split('/').pop() || 'file'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Clean up the URL toast.success("File downloaded"); } else { // Fetch directly if not cached const content = await getSandboxFileContent(sandboxId, selectedFilePath); const blob = content instanceof Blob ? content : new Blob([String(content)]); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = selectedFilePath.split('/').pop() || 'file'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Clean up the URL toast.success("File downloaded"); } } catch (error) { console.error("Download failed:", error); toast.error("Failed to download file"); } finally { setIsDownloading(false); } }, [selectedFilePath, isDownloading, rawContent, sandboxId]); // Handle file upload - Define after helpers const handleUpload = useCallback(() => { if (fileInputRef.current) { fileInputRef.current.click(); } }, []); // Process uploaded file - Define after helpers const processUpload = useCallback(async (event: React.ChangeEvent) => { if (!event.target.files || event.target.files.length === 0) return; const file = event.target.files[0]; setIsUploading(true); try { const formData = new FormData(); formData.append('file', file); formData.append('path', `${currentPath}/${file.name}`); const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); if (!session?.access_token) { throw new Error('No access token available'); } const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.access_token}`, }, body: formData }); if (!response.ok) { const error = await response.text(); throw new Error(error || 'Upload failed'); } // Reload the file list const filesData = await listSandboxFiles(sandboxId, currentPath); setFiles(filesData); toast.success(`Uploaded: ${file.name}`); } catch (error) { console.error("Upload failed:", error); toast.error(`Upload failed: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsUploading(false); if (event.target) event.target.value = ''; } }, [currentPath, sandboxId]); // Handle modal closing - clean up resources const handleOpenChange = useCallback((open: boolean) => { if (!open) { console.log('[FILE VIEWER] handleOpenChange: Modal closing, resetting state.'); clearSelectedFile(); setCurrentPath('/workspace'); // Reset path to root setFiles([]); setInitialPathProcessed(false); // Reset the processed flag } onOpenChange(open); }, [onOpenChange, clearSelectedFile]); // --- useEffect Hooks --- // // Load files when modal opens or path changes - Refined useEffect(() => { if (!open || !sandboxId) { return; // Don't load if modal is closed or no sandbox ID } const loadFiles = async () => { setIsLoadingFiles(true); console.log(`[FILE VIEWER] useEffect[currentPath]: Triggered. Loading files for path: ${currentPath}`); try { const filesData = await listSandboxFiles(sandboxId, currentPath); console.log(`[FILE VIEWER] useEffect[currentPath]: API returned ${filesData.length} files.`); setFiles(filesData); } catch (error) { console.error("Failed to load files:", error); toast.error("Failed to load files"); setFiles([]); } finally { setIsLoadingFiles(false); } }; loadFiles(); // Dependency: Only re-run when open, sandboxId, or currentPath changes }, [open, sandboxId, currentPath]); // Handle initial file path - Runs ONLY ONCE on open if initialFilePath is provided useEffect(() => { // Only run if modal is open, initial path is provided, AND it hasn't been processed yet if (open && initialFilePath && !initialPathProcessed) { console.log(`[FILE VIEWER] useEffect[initialFilePath]: Processing initial path: ${initialFilePath}`); // Normalize the initial path const fullPath = normalizePath(initialFilePath); const lastSlashIndex = fullPath.lastIndexOf('/'); const directoryPath = lastSlashIndex > 0 ? fullPath.substring(0, lastSlashIndex) : '/workspace'; const fileName = lastSlashIndex >= 0 ? fullPath.substring(lastSlashIndex + 1) : ''; console.log(`[FILE VIEWER] useEffect[initialFilePath]: Normalized Path: ${fullPath}, Directory: ${directoryPath}, File: ${fileName}`); // Set the current path to the target directory // This will trigger the other useEffect to load files for this directory if (currentPath !== directoryPath) { console.log(`[FILE VIEWER] useEffect[initialFilePath]: Setting current path to ${directoryPath}`); setCurrentPath(directoryPath); } // Mark the initial path as processed so this doesn't run again setInitialPathProcessed(true); // We don't need to open the file here; the file loading useEffect // combined with the logic below will handle it once files are loaded. } else if (!open) { // Reset the processed flag when the modal closes console.log('[FILE VIEWER] useEffect[initialFilePath]: Modal closed, resetting initialPathProcessed flag.'); setInitialPathProcessed(false); } }, [open, initialFilePath, initialPathProcessed, normalizePath, currentPath]); // Dependencies carefully chosen // Effect to open the initial file *after* the correct directory files are loaded useEffect(() => { // Only run if initial path was processed, files are loaded, and no file is currently selected if (initialPathProcessed && !isLoadingFiles && files.length > 0 && !selectedFilePath && initialFilePath) { console.log('[FILE VIEWER] useEffect[openInitialFile]: Checking for initial file now that files are loaded.'); const fullPath = normalizePath(initialFilePath); const lastSlashIndex = fullPath.lastIndexOf('/'); const targetFileName = lastSlashIndex >= 0 ? fullPath.substring(lastSlashIndex + 1) : ''; if (targetFileName) { console.log(`[FILE VIEWER] useEffect[openInitialFile]: Looking for file: ${targetFileName} in current directory: ${currentPath}`); const targetFile = files.find(f => f.name === targetFileName && f.path === fullPath); if (targetFile && !targetFile.is_dir) { console.log(`[FILE VIEWER] useEffect[openInitialFile]: Found initial file, opening: ${targetFile.path}`); openFile(targetFile); } else { console.log(`[FILE VIEWER] useEffect[openInitialFile]: Initial file ${targetFileName} not found in loaded files or is a directory.`); } } } }, [initialPathProcessed, isLoadingFiles, files, selectedFilePath, initialFilePath, normalizePath, currentPath, openFile]); // Depends on files being loaded // --- Render --- // return ( Workspace Files {/* Navigation Bar */}
{currentPath !== '/workspace' && ( <> {getBreadcrumbSegments(currentPath).map((segment, index) => ( ))} )} {selectedFilePath && ( <>
{selectedFilePath.split('/').pop()}
)}
{selectedFilePath && ( )} {!selectedFilePath && ( )}
{/* Content Area */}
{selectedFilePath ? ( /* File Viewer */
{isLoadingContent ? (

Loading file...

) : contentError ? (

Error Loading File

{contentError}

) : (
)}
) : ( /* File Explorer */
{isLoadingFiles ? (
) : files.length === 0 ? (

Directory is empty

) : (
{files.map(file => ( ))}
)}
)}
); }