suna/apps/mobile/components/FileAttachment.tsx

495 lines
18 KiB
TypeScript
Raw Normal View History

2025-06-26 05:41:40 +08:00
import { useImageContent } from '@/hooks/useImageContent';
import { useTheme } from '@/hooks/useThemeColor';
import { FileType, getEstimatedFileSize, getFileType } from '@/utils/file-parser';
import { File, FileAudio, FileCode, FileImage, FileText, FileVideo } from 'lucide-react-native';
import React from 'react';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2025-07-03 05:32:11 +08:00
import { getRendererForExtension, hasRenderer } from './file-renderers';
2025-06-26 05:41:40 +08:00
interface FileAttachmentProps {
filepath: string;
sandboxId?: string;
onPress?: (path: string) => void;
showPreview?: boolean;
layout?: 'inline' | 'grid';
isUploading?: boolean;
uploadError?: string;
2025-06-27 03:14:39 +08:00
uploadedBlob?: Blob; // For optimistic caching
2025-06-27 04:45:38 +08:00
localUri?: string; // For React Native local file URIs
2025-06-26 05:41:40 +08:00
}
const getFileIcon = (type: FileType) => {
const iconProps = { size: 20, strokeWidth: 2 };
switch (type) {
case 'image': return <FileImage {...iconProps} />;
case 'video': return <FileVideo {...iconProps} />;
case 'audio': return <FileAudio {...iconProps} />;
case 'code': return <FileCode {...iconProps} />;
case 'text': return <FileText {...iconProps} />;
default: return <File {...iconProps} />;
}
};
const getTypeLabel = (type: FileType, extension?: string): string => {
if (type === 'code' && extension) {
return extension.toUpperCase();
}
const labels: Record<FileType, string> = {
image: 'Image',
video: 'Video',
audio: 'Audio',
pdf: 'PDF',
text: 'Text',
code: 'Code',
other: 'File'
};
return labels[type];
};
export const FileAttachment: React.FC<FileAttachmentProps> = ({
filepath,
sandboxId,
onPress,
showPreview = true,
layout = 'inline',
isUploading = false,
2025-06-27 03:14:39 +08:00
uploadError,
2025-06-27 04:45:38 +08:00
uploadedBlob,
localUri
2025-06-26 05:41:40 +08:00
}) => {
const theme = useTheme();
const filename = filepath.split('/').pop() || 'file';
const extension = filename.split('.').pop()?.toLowerCase() || '';
const fileType = getFileType(filename);
const fileSize = getEstimatedFileSize(filepath, fileType);
const typeLabel = getTypeLabel(fileType, extension);
const isImage = fileType === 'image';
const isGrid = layout === 'grid';
2025-07-03 05:32:11 +08:00
const hasPreviewRenderer = hasRenderer(extension);
2025-06-26 05:41:40 +08:00
// Debug logging
2025-07-03 05:32:11 +08:00
console.log(`[FileAttachment] ${filename} - sandboxId: ${sandboxId || 'none'}, localUri: ${localUri ? 'present' : 'none'}, hasRenderer: ${hasPreviewRenderer}`);
2025-06-26 05:41:40 +08:00
// Use authenticated image loading for images
const {
data: imageUrl,
isLoading: imageLoading,
2025-06-27 03:14:39 +08:00
error: imageError,
isProcessing
} = useImageContent(
isImage && sandboxId ? sandboxId : undefined,
isImage ? filepath : undefined,
uploadedBlob
);
2025-06-26 05:41:40 +08:00
2025-06-27 04:45:38 +08:00
// For local files without sandboxId, use localUri directly
const localImageUri = !sandboxId && isImage && localUri ? localUri : null;
2025-06-28 18:16:29 +08:00
// Log image state
const imageSource = localImageUri ? 'LOCAL' : imageUrl ? (imageUrl.startsWith('data:') ? 'CACHED' : 'SERVER') : 'NONE';
console.log(`[FileAttachment] ${filename} will use: ${imageSource}${imageLoading ? ' (upgrading)' : ''}`);
2025-06-26 05:41:40 +08:00
const handlePress = () => {
onPress?.(filepath);
};
const containerStyle = [
styles.container,
{
backgroundColor: theme.sidebar,
borderColor: theme.border,
},
isGrid ? styles.gridContainer : styles.inlineContainer
];
2025-07-03 05:32:11 +08:00
// FILE RENDERER PREVIEW LOGIC (HTML, Markdown, etc.)
if (hasPreviewRenderer && showPreview && isGrid) {
const RendererComponent = getRendererForExtension(extension);
if (RendererComponent) {
console.log(`[FileAttachment] RENDERING WITH ${extension.toUpperCase()} RENDERER - ${filename}`);
return (
<View
style={{
backgroundColor: theme.sidebar,
borderColor: theme.border,
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
width: '100%',
aspectRatio: 1.5,
maxHeight: 250,
minHeight: 150,
}}
>
<RendererComponent
filepath={filepath}
sandboxId={sandboxId}
filename={filename}
uploadedBlob={uploadedBlob}
onExternalOpen={handlePress}
/>
</View>
);
}
}
2025-06-26 05:41:40 +08:00
// IMAGES ALWAYS SHOW AS PREVIEWS
if (isImage && showPreview) {
// Dynamic height based on layout with aspect ratio considerations
const maxHeight = isGrid ? 200 : 54;
const minHeight = isGrid ? 120 : 54;
2025-06-27 04:45:38 +08:00
// For local files (no sandboxId), use blob URL directly
if (!sandboxId && localImageUri) {
2025-06-28 18:16:29 +08:00
console.log(`[FileAttachment] RENDERING LOCAL IMAGE - ${filename}`);
console.log(`[FileAttachment] - localImageUri: ${localImageUri}`);
console.log(`[FileAttachment] - layout: ${isGrid ? 'grid' : 'inline'}`);
2025-06-27 04:45:38 +08:00
if (isGrid) {
return (
<View
style={{
backgroundColor: theme.sidebar,
borderColor: theme.border,
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
width: '100%',
aspectRatio: 1.5,
maxHeight: 250,
minHeight: 150,
}}
>
<TouchableOpacity
style={styles.imageGridTouchable}
onPress={handlePress}
activeOpacity={0.8}
>
<Image
source={{ uri: localImageUri }}
style={styles.imageGridPreview}
resizeMode="cover"
2025-06-28 18:16:29 +08:00
onLoad={() => {
console.log(`[FileAttachment] LOCAL IMAGE LOADED (grid) - ${filename}`);
}}
onError={(error) => {
console.log(`[FileAttachment] LOCAL IMAGE ERROR (grid) - ${filename}:`, error.nativeEvent.error);
}}
2025-06-27 04:45:38 +08:00
/>
</TouchableOpacity>
</View>
);
} else {
return (
<TouchableOpacity
style={[
styles.container,
{
backgroundColor: theme.sidebar,
borderColor: theme.border,
},
styles.imageInlineContainer
]}
onPress={handlePress}
activeOpacity={0.8}
>
<Image
source={{ uri: localImageUri }}
style={styles.imageInlinePreview}
resizeMode="contain"
2025-06-28 18:16:29 +08:00
onLoad={() => {
console.log(`[FileAttachment] LOCAL IMAGE LOADED (inline) - ${filename}`);
}}
onError={(error) => {
console.log(`[FileAttachment] LOCAL IMAGE ERROR (inline) - ${filename}:`, error.nativeEvent.error);
}}
2025-06-27 04:45:38 +08:00
/>
</TouchableOpacity>
);
}
}
2025-06-28 18:16:29 +08:00
// Loading state (only for server images AND only if we have no image data at all)
if (imageLoading && sandboxId && !imageUrl) {
console.log(`[FileAttachment] RENDERING LOADING STATE - ${filename}`);
console.log(`[FileAttachment] - sandboxId: ${sandboxId}`);
console.log(`[FileAttachment] - isProcessing: ${isProcessing}`);
console.log(`[FileAttachment] - imageUrl: ${imageUrl ? 'present' : 'none'} (no loading if we have cached data)`);
2025-06-26 05:41:40 +08:00
return (
<TouchableOpacity
style={[
containerStyle,
{
height: minHeight,
justifyContent: 'center',
alignItems: 'center'
}
]}
onPress={handlePress}
activeOpacity={0.8}
>
<ActivityIndicator size="small" color={theme.mutedForeground} />
2025-06-27 03:14:39 +08:00
{isProcessing && (
<Text style={[styles.metadataText, { color: theme.mutedForeground, marginTop: 4 }]}>
Processing...
</Text>
)}
2025-06-26 05:41:40 +08:00
</TouchableOpacity>
);
}
2025-06-27 03:14:39 +08:00
// Error state (but not if processing)
if (imageError && !isProcessing) {
2025-06-28 18:16:29 +08:00
console.log(`[FileAttachment] RENDERING ERROR STATE - ${filename}`);
console.log(`[FileAttachment] - error: ${imageError.message || 'unknown error'}`);
console.log(`[FileAttachment] - isProcessing: ${isProcessing}`);
2025-06-26 05:41:40 +08:00
return (
<TouchableOpacity
style={[containerStyle, { height: minHeight }]}
onPress={handlePress}
activeOpacity={0.8}
>
<View style={[styles.iconContainer, { backgroundColor: theme.background }]}>
{React.cloneElement(getFileIcon(fileType), {
color: theme.destructive
})}
</View>
<View style={styles.fileInfo}>
<Text style={[styles.filename, { color: theme.foreground }]} numberOfLines={1}>
{filename}
</Text>
<Text style={[styles.errorText, { color: theme.destructive }]}>
Failed to load
</Text>
</View>
</TouchableOpacity>
);
}
2025-06-28 18:16:29 +08:00
// Success: Show image preview with proper aspect ratio (cached or server data)
if (imageUrl) {
console.log(`[FileAttachment] HAVE IMAGE DATA - ${filename}`);
console.log(`[FileAttachment] - imageUrl source: ${imageUrl.startsWith('data:') ? 'CACHED_BLOB' : 'SERVER'}`);
console.log(`[FileAttachment] - isLoading: ${imageLoading} (upgrading in background)`);
}
if (imageUrl && isGrid) {
console.log(`[FileAttachment] RENDERING SERVER IMAGE (grid) - ${filename}`);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
console.log(`[FileAttachment] - sandboxId: ${sandboxId}`);
2025-06-26 05:41:40 +08:00
return (
<View
style={{
backgroundColor: theme.sidebar,
borderColor: theme.border,
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
width: '100%',
aspectRatio: 1.5,
maxHeight: 250,
minHeight: 150,
}}
>
<TouchableOpacity
style={styles.imageGridTouchable}
onPress={handlePress}
activeOpacity={0.8}
>
<Image
2025-06-27 03:14:39 +08:00
source={imageUrl ? { uri: imageUrl } : undefined}
2025-06-26 05:41:40 +08:00
style={styles.imageGridPreview}
resizeMode="cover"
2025-06-28 18:16:29 +08:00
onLoad={() => {
console.log(`[FileAttachment] SERVER IMAGE LOADED (grid) - ${filename}`);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
}}
2025-06-26 05:41:40 +08:00
onError={(error) => {
2025-06-28 18:16:29 +08:00
console.log(`[FileAttachment] SERVER IMAGE ERROR (grid) - ${filename}:`, error.nativeEvent.error);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
console.log(`[FileAttachment] - sandboxId: ${sandboxId}`);
2025-06-26 05:41:40 +08:00
}}
/>
</TouchableOpacity>
</View>
);
2025-06-28 18:16:29 +08:00
} else if (imageUrl) {
console.log(`[FileAttachment] RENDERING SERVER IMAGE (inline) - ${filename}`);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
console.log(`[FileAttachment] - sandboxId: ${sandboxId}`);
2025-06-26 05:41:40 +08:00
return (
<TouchableOpacity
2025-06-27 03:14:39 +08:00
style={[
styles.container,
{
backgroundColor: theme.sidebar,
borderColor: theme.border,
},
styles.imageInlineContainer
]}
2025-06-26 05:41:40 +08:00
onPress={handlePress}
activeOpacity={0.8}
>
<Image
2025-06-27 03:14:39 +08:00
source={imageUrl ? { uri: imageUrl } : undefined}
2025-06-26 05:41:40 +08:00
style={styles.imageInlinePreview}
resizeMode="contain"
2025-06-28 18:16:29 +08:00
onLoad={() => {
console.log(`[FileAttachment] SERVER IMAGE LOADED (inline) - ${filename}`);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
}}
2025-06-26 05:41:40 +08:00
onError={(error) => {
2025-06-28 18:16:29 +08:00
console.log(`[FileAttachment] SERVER IMAGE ERROR (inline) - ${filename}:`, error.nativeEvent.error);
console.log(`[FileAttachment] - imageUrl: ${imageUrl.substring(0, 50)}`);
console.log(`[FileAttachment] - sandboxId: ${sandboxId}`);
2025-06-26 05:41:40 +08:00
}}
/>
</TouchableOpacity>
);
}
}
2025-07-03 05:32:11 +08:00
// Regular file display (non-images, non-renderer files)
2025-06-26 05:41:40 +08:00
return (
<TouchableOpacity
style={[containerStyle, { opacity: isUploading ? 0.7 : 1 }]}
onPress={handlePress}
activeOpacity={0.8}
>
<View style={[styles.iconContainer, { backgroundColor: theme.background }]}>
{isUploading ? (
<ActivityIndicator size="small" color={theme.mutedForeground} />
) : (
React.cloneElement(getFileIcon(fileType), {
color: uploadError ? theme.destructive : theme.mutedForeground
})
)}
</View>
<View style={styles.fileInfo}>
<Text
style={[styles.filename, { color: uploadError ? theme.destructive : theme.foreground }]}
numberOfLines={1}
>
{filename}
</Text>
<View style={styles.metadata}>
{uploadError ? (
<Text style={[styles.metadataText, { color: theme.destructive }]}>
Upload failed
</Text>
) : isUploading ? (
<Text style={[styles.metadataText, { color: theme.mutedForeground }]}>
</Text>
) : (
<>
<Text style={[styles.metadataText, { color: theme.mutedForeground }]}>
{typeLabel}
</Text>
<Text style={[styles.metadataText, { color: theme.mutedForeground }]}>
{fileSize}
</Text>
</>
)}
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
flexDirection: 'row',
alignItems: 'center',
},
inlineContainer: {
height: 54,
2025-06-27 03:14:39 +08:00
minWidth: 170,
maxWidth: 300,
2025-06-26 05:41:40 +08:00
paddingRight: 12,
},
gridContainer: {
width: '100%',
2025-06-27 04:45:38 +08:00
minWidth: 170,
2025-06-26 05:41:40 +08:00
},
imageInlineContainer: {
height: 54,
2025-06-27 03:14:39 +08:00
width: 54,
minWidth: 54,
maxWidth: 54,
2025-06-26 05:41:40 +08:00
padding: 0,
2025-06-27 03:14:39 +08:00
justifyContent: 'center',
alignItems: 'center',
2025-06-26 05:41:40 +08:00
},
iconContainer: {
width: 54,
height: 54,
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
},
fileInfo: {
flex: 1,
paddingLeft: 12,
justifyContent: 'center',
minWidth: 0,
},
filename: {
fontSize: 14,
fontWeight: '500',
marginBottom: 2,
},
metadata: {
flexDirection: 'row',
alignItems: 'center',
},
metadataText: {
fontSize: 12,
marginRight: 4,
},
imageInlinePreview: {
2025-06-27 03:14:39 +08:00
width: 54,
2025-06-26 05:41:40 +08:00
height: 54,
},
imageGridPreview: {
width: '100%',
height: '100%',
},
imageOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 8,
},
imageFilename: {
fontSize: 12,
fontWeight: '500',
},
loadingText: {
fontSize: 12,
marginTop: 4,
},
errorText: {
fontSize: 12,
},
imageGridTouchable: {
flex: 1,
width: '100%',
height: '100%',
position: 'relative',
},
});