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>
|
|
|
|
);
|
|
|
|
}
|