suna/frontend/src/components/thread/file-attachment.tsx

491 lines
18 KiB
TypeScript

import React from 'react';
import Image from 'next/image';
import {
FileText, FileImage, FileCode, FilePlus, FileSpreadsheet, FileVideo,
FileAudio, FileType, Database, Archive, File, ExternalLink,
Download, Loader2
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AttachmentGroup } from './attachment-group';
import { HtmlRenderer } from './preview-renderers/html-renderer';
import { MarkdownRenderer } from './preview-renderers/markdown-renderer';
import { CsvRenderer } from './preview-renderers/csv-renderer';
import { useFileContent } from '@/hooks/use-file-content';
import { useImageContent } from '@/hooks/use-image-content';
import { useAuth } from '@/components/AuthProvider';
import { Project } from '@/lib/api';
// Define basic file types
export type FileType =
| 'image' | 'code' | 'text' | 'pdf'
| 'audio' | 'video' | 'spreadsheet'
| 'archive' | 'database' | 'markdown'
| 'csv'
| 'other';
// Simple extension-based file type detection
function getFileType(filename: string): FileType {
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
if (['js', 'jsx', 'ts', 'tsx', 'html', 'css', 'json', 'py', 'java', 'c', 'cpp'].includes(ext)) return 'code';
if (['txt', 'log', 'env'].includes(ext)) return 'text';
if (['md', 'markdown'].includes(ext)) return 'markdown';
if (ext === 'pdf') return 'pdf';
if (['mp3', 'wav', 'ogg', 'flac'].includes(ext)) return 'audio';
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return 'video';
if (['csv', 'tsv'].includes(ext)) return 'csv';
if (['xls', 'xlsx'].includes(ext)) return 'spreadsheet';
if (['zip', 'rar', 'tar', 'gz'].includes(ext)) return 'archive';
if (['db', 'sqlite', 'sql'].includes(ext)) return 'database';
return 'other';
}
// Get appropriate icon for file type
function getFileIcon(type: FileType): React.ElementType {
const icons: Record<FileType, React.ElementType> = {
image: FileImage,
code: FileCode,
text: FileText,
markdown: FileText,
pdf: FileType,
audio: FileAudio,
video: FileVideo,
spreadsheet: FileSpreadsheet,
csv: FileSpreadsheet,
archive: Archive,
database: Database,
other: File
};
return icons[type];
}
// Generate a human-readable display name for file type
function getTypeLabel(type: FileType, extension?: string): string {
if (type === 'code' && extension) {
return extension.toUpperCase();
}
const labels: Record<FileType, string> = {
image: 'Image',
code: 'Code',
text: 'Text',
markdown: 'Markdown',
pdf: 'PDF',
audio: 'Audio',
video: 'Video',
spreadsheet: 'Spreadsheet',
csv: 'CSV',
archive: 'Archive',
database: 'Database',
other: 'File'
};
return labels[type];
}
// Generate realistic file size based on file path and type
function getFileSize(filepath: string, type: FileType): string {
// Base size calculation
const base = (filepath.length * 5) % 800 + 200;
// Type-specific multipliers
const multipliers: Record<FileType, number> = {
image: 5.0,
video: 20.0,
audio: 10.0,
code: 0.5,
text: 0.3,
markdown: 0.3,
pdf: 8.0,
spreadsheet: 3.0,
csv: 2.0,
archive: 5.0,
database: 4.0,
other: 1.0
};
const size = base * multipliers[type];
if (size < 1024) return `${Math.round(size)} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
// Get the API URL for file content
function getFileUrl(sandboxId: string | undefined, path: string): string {
if (!sandboxId) return path;
// Check if the path already starts with /workspace
if (!path.startsWith('/workspace')) {
// Prepend /workspace to the path if it doesn't already have it
path = `/workspace/${path.startsWith('/') ? path.substring(1) : path}`;
}
// Handle any potential Unicode escape sequences
try {
// Replace escaped Unicode sequences with actual characters
path = path.replace(/\\u([0-9a-fA-F]{4})/g, (_, hexCode) => {
return String.fromCharCode(parseInt(hexCode, 16));
});
} catch (e) {
console.error('Error processing Unicode escapes in path:', e);
}
const url = new URL(`${process.env.NEXT_PUBLIC_BACKEND_URL}/sandboxes/${sandboxId}/files/content`);
// Properly encode the path parameter for UTF-8 support
url.searchParams.append('path', path);
return url.toString();
}
interface FileAttachmentProps {
filepath: string;
onClick?: (path: string) => void;
className?: string;
sandboxId?: string;
showPreview?: boolean;
localPreviewUrl?: string;
customStyle?: React.CSSProperties;
/**
* Controls whether HTML, Markdown, and CSV files show their content preview.
* - true: files are shown as regular file attachments (default)
* - false: HTML, MD, and CSV files show rendered content in grid layout
*/
collapsed?: boolean;
project?: Project;
}
// Cache fetched content between mounts to avoid duplicate fetches
const contentCache = new Map<string, string>();
const errorCache = new Set<string>();
export function FileAttachment({
filepath,
onClick,
className,
sandboxId,
showPreview = true,
localPreviewUrl,
customStyle,
collapsed = true,
project
}: FileAttachmentProps) {
// Authentication
const { session } = useAuth();
// Simplified state management
const [hasError, setHasError] = React.useState(false);
// Basic file info
const filename = filepath.split('/').pop() || 'file';
const extension = filename.split('.').pop()?.toLowerCase() || '';
const fileType = getFileType(filename);
const fileUrl = localPreviewUrl || (sandboxId ? getFileUrl(sandboxId, filepath) : filepath);
const typeLabel = getTypeLabel(fileType, extension);
const fileSize = getFileSize(filepath, fileType);
const IconComponent = getFileIcon(fileType);
// Display flags
const isImage = fileType === 'image';
const isHtmlOrMd = extension === 'html' || extension === 'htm' || extension === 'md' || extension === 'markdown';
const isCsv = extension === 'csv' || extension === 'tsv';
const isGridLayout = customStyle?.gridColumn === '1 / -1' || Boolean(customStyle && ('--attachment-height' in customStyle));
const shouldShowPreview = (isHtmlOrMd || isCsv) && showPreview && collapsed === false;
// Use the React Query hook to fetch file content
const {
data: fileContent,
isLoading: fileContentLoading,
error: fileContentError
} = useFileContent(
shouldShowPreview ? sandboxId : undefined,
shouldShowPreview ? filepath : undefined
);
// Use the React Query hook to fetch image content with authentication
const {
data: imageUrl,
isLoading: imageLoading,
error: imageError
} = useImageContent(
isImage && showPreview && sandboxId ? sandboxId : undefined,
isImage && showPreview ? filepath : undefined
);
// Set error state based on query errors
React.useEffect(() => {
if (fileContentError || imageError) {
setHasError(true);
}
}, [fileContentError, imageError]);
const handleClick = () => {
if (onClick) {
onClick(filepath);
}
};
// Images are displayed with their natural aspect ratio
if (isImage && showPreview) {
// Use custom height for images if provided through CSS variable
const imageHeight = isGridLayout
? customStyle['--attachment-height'] as string
: '54px';
// Show loading state for images
if (imageLoading && sandboxId) {
return (
<button
onClick={handleClick}
className={cn(
"group relative min-h-[54px] min-w-fit rounded-xl cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"p-0 overflow-hidden",
"flex items-center justify-center",
isGridLayout ? "w-full" : "inline-block",
className
)}
style={{
maxWidth: "100%",
height: isGridLayout ? imageHeight : 'auto',
...customStyle
}}
title={filename}
>
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</button>
);
}
return (
<button
onClick={handleClick}
className={cn(
"group relative min-h-[54px] rounded-xl cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"p-0 overflow-hidden", // No padding, content touches borders
"flex items-center justify-center", // Center the image
isGridLayout ? "w-full" : "inline-block", // Full width in grid
className
)}
style={{
maxWidth: "100%", // Ensure doesn't exceed container width
height: isGridLayout ? imageHeight : 'auto',
...customStyle
}}
title={filename}
>
<img
src={sandboxId && session?.access_token ? imageUrl : fileUrl}
alt={filename}
className={cn(
"max-h-full", // Respect parent height constraint
isGridLayout ? "w-full h-full object-cover" : "w-auto" // Full width & height in grid with object-cover
)}
style={{
height: imageHeight,
objectPosition: "center",
objectFit: isGridLayout ? "cover" : "contain"
}}
onLoad={() => { }}
onError={(e) => {
console.error('Image load error:', e);
setHasError(true);
// If the image failed to load and we have a localPreviewUrl that's a blob URL, try using it directly
if (localPreviewUrl && typeof localPreviewUrl === 'string' && localPreviewUrl.startsWith('blob:')) {
(e.target as HTMLImageElement).src = localPreviewUrl;
}
}}
/>
</button>
);
}
const rendererMap = {
'html': HtmlRenderer,
'htm': HtmlRenderer,
'md': MarkdownRenderer,
'markdown': MarkdownRenderer,
'csv': CsvRenderer,
'tsv': CsvRenderer
};
// HTML/MD/CSV preview when not collapsed and in grid layout
if (shouldShowPreview && isGridLayout) {
// Determine the renderer component
const Renderer = rendererMap[extension as keyof typeof rendererMap];
return (
<div
className={cn(
"group relative rounded-xl w-full",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"overflow-hidden",
"h-[300px]", // Fixed height for previews
"pt-10", // Room for header
className
)}
style={{
gridColumn: "1 / -1", // Make it take full width in grid
width: "100%", // Ensure full width
...customStyle
}}
onClick={hasError ? handleClick : undefined} // Make clickable if error
>
{/* Content area */}
<div className="h-full w-full relative">
{/* Only show content if we have it and no errors */}
{!hasError && fileContent && (
<>
{Renderer ? (
<Renderer
content={fileContent}
previewUrl={fileUrl}
className="h-full w-full"
project={project}
/>
) : (
<div className="p-4 text-muted-foreground">
No preview available for this file type
</div>
)}
</>
)}
{/* Error state */}
{hasError && (
<div className="h-full w-full flex flex-col items-center justify-center p-4">
<div className="text-red-500 mb-2">Error loading content</div>
<div className="text-muted-foreground text-sm text-center mb-2">
{fileUrl && (
<div className="text-xs max-w-full overflow-hidden truncate opacity-70">
Path may need /workspace prefix
</div>
)}
</div>
<button
onClick={handleClick}
className="px-3 py-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-sm"
>
Open in viewer
</button>
</div>
)}
{/* Loading state */}
{fileContentLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</div>
)}
{/* Empty content state - show when not loading and no content yet */}
{!fileContent && !fileContentLoading && !hasError && (
<div className="h-full w-full flex flex-col items-center justify-center p-4 pointer-events-none">
<div className="text-muted-foreground text-sm mb-2">
Preview available
</div>
<div className="text-muted-foreground text-xs text-center">
Click header to open externally
</div>
</div>
)}
</div>
{/* Header with filename */}
<div className="absolute top-0 left-0 right-0 bg-black/5 dark:bg-white/5 p-2 z-10 flex items-center justify-between">
<div className="text-sm font-medium truncate">{filename}</div>
{onClick && (
<button
onClick={handleClick}
className="p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink size={14} />
</button>
)}
</div>
</div>
);
}
// Regular files with details
const safeStyle = { ...customStyle };
delete safeStyle.height;
delete safeStyle['--attachment-height'];
return (
<button
onClick={handleClick}
className={cn(
"group flex rounded-xl transition-all duration-200 min-h-[54px] h-[54px] w-full overflow-hidden cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-sidebar",
"text-left",
"pr-7", // Right padding for X button
className
)}
style={safeStyle}
title={filename}
>
<div className="relative min-w-[54px] h-full aspect-square flex-shrink-0 bg-black/5 dark:bg-white/5">
<div className="flex items-center justify-center h-full w-full">
<IconComponent className="h-5 w-5 text-black/60 dark:text-white/60" />
</div>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center p-2 pl-3">
<div className="text-sm font-medium text-foreground truncate">
{filename}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span className="text-black/60 dark:text-white/60">{typeLabel}</span>
<span className="text-black/40 dark:text-white/40">·</span>
<span className="text-black/60 dark:text-white/60">{fileSize}</span>
</div>
</div>
</button>
);
}
interface FileAttachmentGridProps {
attachments: string[];
onFileClick?: (path: string) => void;
className?: string;
sandboxId?: string;
showPreviews?: boolean;
collapsed?: boolean;
project?: Project;
}
export function FileAttachmentGrid({
attachments,
onFileClick,
className,
sandboxId,
showPreviews = true,
collapsed = false,
project
}: FileAttachmentGridProps) {
if (!attachments || attachments.length === 0) return null;
return (
<AttachmentGroup
files={attachments}
onFileClick={onFileClick}
className={className}
sandboxId={sandboxId}
showPreviews={showPreviews}
layout="grid"
gridImageHeight={150} // Use larger height for grid layout
collapsed={collapsed}
project={project}
/>
);
}