suna/frontend/src/components/thread/file-viewer-modal.tsx

527 lines
18 KiB
TypeScript
Raw Normal View History

2025-04-16 13:01:57 +08:00
"use client";
import { useState, useEffect, useRef } 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,
2025-04-16 15:16:38 +08:00
ArrowLeft,
Save
2025-04-16 13:01:57 +08:00
} from "lucide-react";
import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api";
import { toast } from "sonner";
import { createClient } from "@/lib/supabase/client";
2025-04-16 15:16:38 +08:00
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
2025-04-16 16:19:18 +08:00
import { FileRenderer } from "@/components/file-renderers";
2025-04-16 13:01:57 +08:00
// Define API_URL
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
interface FileViewerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sandboxId: string;
}
export function FileViewerModal({
open,
onOpenChange,
sandboxId
}: FileViewerModalProps) {
const [workspaceFiles, setWorkspaceFiles] = useState<FileInfo[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [binaryFileUrl, setBinaryFileUrl] = useState<string | null>(null);
const [fileType, setFileType] = useState<'text' | 'image' | 'pdf' | 'binary'>('text');
const [isLoadingContent, setIsLoadingContent] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Navigation state
const [currentPath, setCurrentPath] = useState<string>("/workspace");
const [pathHistory, setPathHistory] = useState<string[]>(["/workspace"]);
const [historyIndex, setHistoryIndex] = useState<number>(0);
// Load files when the modal opens or sandbox ID changes
useEffect(() => {
if (open && sandboxId) {
loadFilesAtPath(currentPath);
}
}, [open, sandboxId, currentPath]);
// Function to load files from a specific path
const loadFilesAtPath = async (path: string) => {
if (!sandboxId) return;
setIsLoadingFiles(true);
try {
const files = await listSandboxFiles(sandboxId, path);
setWorkspaceFiles(files);
} catch (error) {
console.error(`Failed to load files at ${path}:`, error);
toast.error("Failed to load files");
} finally {
setIsLoadingFiles(false);
}
};
// Navigate to a folder
const navigateToFolder = (folderPath: string) => {
// Update current path
setCurrentPath(folderPath);
// Add to navigation history, discarding any forward history if we're not at the end
if (historyIndex < pathHistory.length - 1) {
setPathHistory(prevHistory => [...prevHistory.slice(0, historyIndex + 1), folderPath]);
setHistoryIndex(historyIndex + 1);
} else {
setPathHistory(prevHistory => [...prevHistory, folderPath]);
setHistoryIndex(pathHistory.length);
}
// Reset file selection and content
setSelectedFile(null);
setFileContent(null);
setBinaryFileUrl(null);
};
// Go back in history
const goBack = () => {
if (historyIndex > 0) {
setHistoryIndex(historyIndex - 1);
setCurrentPath(pathHistory[historyIndex - 1]);
}
};
// Go to home directory
const goHome = () => {
setCurrentPath("/workspace");
// Reset file selection and content
setSelectedFile(null);
setFileContent(null);
setBinaryFileUrl(null);
};
// Determine file type based on extension
const getFileType = (filename: string): 'text' | 'image' | 'pdf' | 'binary' => {
const extension = filename.split('.').pop()?.toLowerCase() || '';
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
if (imageExtensions.includes(extension)) {
return 'image';
}
if (extension === 'pdf') {
return 'pdf';
}
const textExtensions = [
'txt', 'md', 'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'json', 'py',
'java', 'c', 'cpp', 'h', 'cs', 'php', 'rb', 'go', 'rs', 'sh', 'yml',
'yaml', 'toml', 'xml', 'csv', 'sql'
];
if (textExtensions.includes(extension)) {
return 'text';
}
return 'binary';
};
// Handle file or folder click
const handleFileClick = async (file: FileInfo) => {
if (file.is_dir) {
// If it's a directory, navigate to it
navigateToFolder(file.path);
return;
}
// Otherwise handle as regular file
setSelectedFile(file.path);
setIsLoadingContent(true);
setFileContent(null);
setBinaryFileUrl(null);
try {
// Determine file type based on extension
const fileType = getFileType(file.path);
setFileType(fileType);
const content = await getSandboxFileContent(sandboxId, file.path);
// Force certain file types to be treated as text
if (fileType === 'text') {
// For text files (including markdown), always try to render as text
if (typeof content === 'string') {
setFileContent(content);
} else if (content instanceof Blob) {
// If we got a Blob for a text file, convert it to text
const text = await content.text();
setFileContent(text);
}
} else if (fileType === 'image' || fileType === 'pdf') {
// For images and PDFs, create a blob URL to render them
if (content instanceof Blob) {
const url = URL.createObjectURL(content);
setBinaryFileUrl(url);
} else if (typeof content === 'string') {
try {
// For base64 content or binary text, create a blob
const blob = new Blob([content]);
const url = URL.createObjectURL(blob);
setBinaryFileUrl(url);
} catch (e) {
console.error("Failed to create blob URL:", e);
setFileType('text');
setFileContent(content);
}
}
} else {
// For other binary files
if (content instanceof Blob) {
const url = URL.createObjectURL(content);
setBinaryFileUrl(url);
} else if (typeof content === 'string') {
setFileContent("[Binary file]");
try {
const blob = new Blob([content]);
const url = URL.createObjectURL(blob);
setBinaryFileUrl(url);
} catch (e) {
console.error("Failed to create blob URL:", e);
setFileContent(content);
}
}
}
} catch (error) {
console.error("Failed to load file content:", error);
toast.error("Failed to load file content");
setFileContent(null);
setBinaryFileUrl(null);
} finally {
setIsLoadingContent(false);
}
};
// Clean up blob URLs on unmount or when they're no longer needed
useEffect(() => {
return () => {
if (binaryFileUrl) {
URL.revokeObjectURL(binaryFileUrl);
}
};
}, [binaryFileUrl]);
// Handle file upload
const handleFileUpload = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Process the file upload - upload to current directory
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
try {
setIsLoadingFiles(true);
const file = event.target.files[0];
if (file.size > 50 * 1024 * 1024) { // 50MB limit
toast.error("File size exceeds 50MB limit");
return;
}
// Create a FormData object
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');
}
// Upload using FormData - no need for any encoding/decoding
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
// Important: Do NOT set Content-Type header here, let the browser set it with the boundary
},
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
toast.success(`File uploaded: ${file.name}`);
// Refresh file list for current path
loadFilesAtPath(currentPath);
} catch (error) {
console.error("File upload failed:", error);
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
} finally {
setIsLoadingFiles(false);
// Reset the input
event.target.value = '';
}
};
// Render breadcrumb navigation
const renderBreadcrumbs = () => {
const parts = currentPath.split('/').filter(Boolean);
const isInWorkspace = parts[0] === 'workspace';
const pathParts = isInWorkspace ? parts.slice(1) : parts;
return (
2025-04-16 15:16:38 +08:00
<div className="flex items-center overflow-x-auto whitespace-nowrap text-sm gap-1">
2025-04-16 13:01:57 +08:00
<Button
variant="ghost"
size="sm"
2025-04-16 15:16:38 +08:00
className="h-7 px-2.5 text-sm font-medium hover:bg-accent min-w-fit"
2025-04-16 13:01:57 +08:00
onClick={goHome}
>
workspace
</Button>
{pathParts.map((part, index) => {
const pathUpToHere = isInWorkspace
? `/workspace/${pathParts.slice(0, index + 1).join('/')}`
: `/${pathParts.slice(0, index + 1).join('/')}`;
return (
2025-04-16 15:16:38 +08:00
<div key={index} className="flex items-center min-w-fit">
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground opacity-50" />
2025-04-16 13:01:57 +08:00
<Button
variant="ghost"
size="sm"
2025-04-16 15:16:38 +08:00
className="h-7 px-2.5 text-sm font-medium hover:bg-accent"
2025-04-16 13:01:57 +08:00
onClick={() => navigateToFolder(pathUpToHere)}
>
{part}
</Button>
</div>
);
})}
2025-04-16 15:50:32 +08:00
{/* Show selected file name in breadcrumb */}
{selectedFile && (
<div className="flex items-center min-w-fit">
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground opacity-50" />
<div className="flex items-center gap-1 h-7 px-2.5 text-sm font-medium bg-accent/30 rounded-md">
<File className="h-3.5 w-3.5 text-muted-foreground" />
<span>{selectedFile.split('/').pop()}</span>
</div>
</div>
)}
2025-04-16 13:01:57 +08:00
</div>
);
};
2025-04-16 15:16:38 +08:00
// Function to download file content
const handleDownload = async () => {
if (!selectedFile) return;
try {
let content: string | Blob;
let filename = selectedFile.split('/').pop() || 'download';
if (fileType === 'text' && fileContent) {
// For text files, use the text content
content = new Blob([fileContent], { type: 'text/plain' });
} else if (binaryFileUrl) {
// For binary files, fetch the content from the URL
const response = await fetch(binaryFileUrl);
content = await response.blob();
} else {
throw new Error('No content available for download');
}
// Create download link
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('File downloaded successfully');
} catch (error) {
console.error('Download failed:', error);
toast.error('Failed to download file');
}
};
2025-04-16 13:01:57 +08:00
return (
<Dialog open={open} onOpenChange={onOpenChange}>
2025-04-16 16:19:18 +08:00
<DialogContent
className="sm:max-w-[90vw] md:max-w-[1200px] w-[95vw] h-[90vh] max-h-[900px] flex flex-col p-0 gap-0 overflow-hidden"
>
2025-04-16 15:16:38 +08:00
<DialogHeader className="px-6 py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<DialogTitle className="text-lg font-semibold">Workspace Files</DialogTitle>
2025-04-16 13:01:57 +08:00
</DialogHeader>
2025-04-16 15:16:38 +08:00
<div className="flex flex-col sm:flex-row h-full overflow-hidden divide-x divide-border">
2025-04-16 13:01:57 +08:00
{/* File browser sidebar */}
2025-04-16 16:19:18 +08:00
<div className="w-full sm:w-[280px] lg:w-[320px] flex flex-col h-full bg-muted/5">
2025-04-16 15:16:38 +08:00
{/* Breadcrumb navigation */}
<div className="px-3 py-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{renderBreadcrumbs()}
</div>
{/* File tree */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-0.5">
{isLoadingFiles ? (
<div className="p-4 space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
) : workspaceFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[300px] text-muted-foreground p-6">
<Folder className="h-12 w-12 mb-4 opacity-40" />
<p className="text-sm font-medium text-center">This folder is empty</p>
<p className="text-sm text-center text-muted-foreground mt-1">Upload files or create new ones to get started</p>
</div>
) : (
workspaceFiles.map((file, index) => (
2025-04-16 15:50:32 +08:00
<div
2025-04-16 15:16:38 +08:00
key={file.path}
2025-04-16 15:50:32 +08:00
className="relative group"
2025-04-16 15:16:38 +08:00
>
2025-04-16 15:50:32 +08:00
<Button
variant={selectedFile === file.path ? "secondary" : "ghost"}
size="sm"
className={`w-full justify-start h-9 text-sm font-normal transition-colors ${
selectedFile === file.path
? "bg-accent/50 hover:bg-accent/60"
: "hover:bg-accent/30"
}`}
onClick={() => handleFileClick(file)}
>
{file.is_dir ? (
selectedFile === file.path ? (
<FolderOpen className="h-4 w-4 mr-2 flex-shrink-0 text-foreground" />
) : (
<Folder className="h-4 w-4 mr-2 flex-shrink-0 text-muted-foreground" />
)
2025-04-16 15:16:38 +08:00
) : (
2025-04-16 15:50:32 +08:00
<File className="h-4 w-4 mr-2 flex-shrink-0 text-muted-foreground" />
)}
<span className="truncate">{file.name}</span>
</Button>
{/* Show download button on hover for files */}
{!file.is_dir && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setSelectedFile(file.path);
handleDownload();
}}
title="Download file"
>
<Download className="h-3.5 w-3.5" />
</Button>
2025-04-16 15:16:38 +08:00
)}
2025-04-16 15:50:32 +08:00
</div>
2025-04-16 15:16:38 +08:00
))
)}
</div>
</ScrollArea>
{/* Navigation controls */}
<div className="px-2 py-2 border-t bg-muted/5 flex items-center justify-between">
2025-04-16 13:01:57 +08:00
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
2025-04-16 15:16:38 +08:00
className="h-8 w-8 hover:bg-accent"
2025-04-16 13:01:57 +08:00
onClick={goBack}
disabled={historyIndex === 0}
title="Go back"
>
2025-04-16 15:16:38 +08:00
<ArrowLeft className="h-4 w-4" />
2025-04-16 13:01:57 +08:00
</Button>
<Button
variant="ghost"
size="icon"
2025-04-16 15:16:38 +08:00
className="h-8 w-8 hover:bg-accent"
2025-04-16 13:01:57 +08:00
onClick={goHome}
title="Home directory"
>
2025-04-16 15:16:38 +08:00
<Home className="h-4 w-4" />
2025-04-16 13:01:57 +08:00
</Button>
</div>
2025-04-16 15:16:38 +08:00
<div>
2025-04-16 13:01:57 +08:00
<Button
variant="ghost"
size="icon"
2025-04-16 15:16:38 +08:00
className="h-8 w-8 hover:bg-accent"
2025-04-16 13:01:57 +08:00
onClick={handleFileUpload}
title="Upload file"
>
2025-04-16 15:16:38 +08:00
<Upload className="h-4 w-4" />
2025-04-16 13:01:57 +08:00
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={processFileUpload}
/>
</div>
</div>
</div>
{/* File content pane */}
2025-04-16 15:16:38 +08:00
<div className="w-full flex-1 flex flex-col h-full bg-muted/5">
{/* File content */}
<div className="flex-1 overflow-hidden">
2025-04-16 16:19:18 +08:00
{isLoadingContent ? (
<div className="p-4 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
2025-04-16 15:16:38 +08:00
</div>
2025-04-16 16:19:18 +08:00
) : !selectedFile ? (
<div className="flex flex-col items-center justify-center min-h-[400px] text-muted-foreground">
<File className="h-16 w-16 mb-4 opacity-30" />
<p className="text-sm font-medium">Select a file to view its contents</p>
<p className="text-sm text-muted-foreground mt-1">Choose a file from the sidebar to preview or edit</p>
</div>
) : (
<FileRenderer
content={fileContent}
binaryUrl={binaryFileUrl}
fileName={selectedFile}
className="h-full"
/>
)}
2025-04-16 13:01:57 +08:00
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}