Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,30 @@ export const useImageOperations = (projectId: string, branchId: string, activeFo
}
};

// Handle file rename
const handleRename = async (oldPath: string, newName: string) => {
if (!codeEditor) throw new Error('Code editor not available');

const directory = path.dirname(oldPath);
const sanitizedName = sanitizeFilename(newName);
const newPath = path.join(directory, sanitizedName);
await codeEditor.moveFile(oldPath, newPath);
};

// Handle file delete
const handleDelete = async (filePath: string) => {
if (!codeEditor) throw new Error('Code editor not available');
await codeEditor.deleteFile(filePath);
};

return {
folders,
images,
loading,
error,
isUploading,
handleUpload,
handleRename,
handleDelete,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ interface ImageGridProps {
branchId: string;
search: string;
onUpload: (files: FileList) => Promise<void>;
onRename: (oldPath: string, newName: string) => Promise<void>;
onDelete: (filePath: string) => Promise<void>;
onAddToChat: (imagePath: string) => void;
}

export const ImageGrid = ({ images, projectId, branchId, search, onUpload }: ImageGridProps) => {
export const ImageGrid = ({ images, projectId, branchId, search, onUpload, onRename, onDelete, onAddToChat }: ImageGridProps) => {
const {
handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging,
onImageDragStart, onImageDragEnd, onImageMouseDown, onImageMouseUp
Expand All @@ -41,6 +44,9 @@ export const ImageGrid = ({ images, projectId, branchId, search, onUpload }: Ima
onImageDragEnd={onImageDragEnd}
onImageMouseDown={onImageMouseDown}
onImageMouseUp={onImageMouseUp}
onRename={onRename}
onDelete={onDelete}
onAddToChat={onAddToChat}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,26 @@

import { useFile } from '@onlook/file-system/hooks';
import type { ImageContentData } from '@onlook/models';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@onlook/ui/alert-dialog';
import { Button } from '@onlook/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@onlook/ui/dropdown-menu';
import { Icons } from '@onlook/ui/icons';
import { Input } from '@onlook/ui/input';
import { getMimeType } from '@onlook/utility';
import { useEffect, useState } from 'react';

interface ImageItemProps {
Expand All @@ -17,12 +36,19 @@ interface ImageItemProps {
onImageDragEnd: () => void;
onImageMouseDown: () => void;
onImageMouseUp: () => void;
onRename: (oldPath: string, newName: string) => Promise<void>;
onDelete: (filePath: string) => Promise<void>;
onAddToChat: (imagePath: string) => void;
}

export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImageDragEnd, onImageMouseDown, onImageMouseUp }: ImageItemProps) => {
export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImageDragEnd, onImageMouseDown, onImageMouseUp, onRename, onDelete, onAddToChat }: ImageItemProps) => {
const { content, loading } = useFile(projectId, branchId, image.path);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isDisabled, setIsDisabled] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(image.name);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);

// Convert content to data URL for display
useEffect(() => {
Expand Down Expand Up @@ -56,6 +82,13 @@ export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImag
};
}, [content, image.mimeType, image.name]);

// Close dropdown when entering rename mode or showing delete dialog
useEffect(() => {
if (isRenaming || showDeleteDialog) {
setDropdownOpen(false);
}
}, [isRenaming, showDeleteDialog]);

if (loading) {
return (
<div className="aspect-square bg-background-secondary rounded-md border border-border-primary flex items-center justify-center">
Expand All @@ -81,30 +114,158 @@ export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImag
const imageContentData: ImageContentData = {
fileName: image.name,
content: content as string,
mimeType: imageUrl,
mimeType: getMimeType(image.name),
originPath: image.path,
};
onImageDragStart(e, imageContentData);
};

const handleRename = async () => {
if (newName.trim() && newName !== image.name) {
try {
await onRename(image.path, newName.trim());
} catch (error) {
console.error('Failed to rename file:', error);
setNewName(image.name); // Reset on error
}
}
setIsRenaming(false);
};

const handleDelete = async () => {
try {
await onDelete(image.path);
setShowDeleteDialog(false);
} catch (error) {
console.error('Failed to delete file:', error);
}
};

const handleAddToChat = () => {
onAddToChat(image.path);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
void handleRename();
} else if (e.key === 'Escape') {
setNewName(image.name);
setIsRenaming(false);
}
};

return (
<div className="aspect-square bg-background-secondary rounded-md border border-border-primary overflow-hidden cursor-pointer hover:border-border-onlook transition-colors"
onDragStart={handleDragStart}
onDragEnd={onImageDragEnd}
onMouseDown={onImageMouseDown}
onMouseUp={onImageMouseUp}
>
<img
src={imageUrl}
alt={image.name}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="p-1 bg-background-primary/80 backdrop-blur-sm">
<div className="text-xs text-foreground-primary truncate" title={image.name}>
{image.name}
</div>
<div className="group">
<div
className="aspect-square bg-background-secondary rounded-md border border-border-primary overflow-hidden cursor-pointer hover:border-border-onlook transition-colors relative"
onDragStart={handleDragStart}
onDragEnd={onImageDragEnd}
onMouseDown={onImageMouseDown}
onMouseUp={onImageMouseUp}
>
<img
src={imageUrl}
alt={image.name}
className="w-full h-full object-cover"
loading="lazy"
/>

{/* Action menu */}
{!isRenaming && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="secondary"
className="h-6 w-6 bg-background-secondary/90 hover:bg-background-onlook"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icons.DotsHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToChat();
}}
className="flex items-center gap-2"
>
<Icons.Plus className="h-3 w-3" />
Add to Chat
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsRenaming(true);
}}
className="flex items-center gap-2"
>
<Icons.Edit className="h-3 w-3" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2 text-red-500 hover:text-red-600 focus:text-red-600"
>
<Icons.Trash className="h-3 w-3" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>

{/* Name section with rename functionality */}
<div className="mt-1 px-1">
{isRenaming ? (
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => void handleRename()}
className="h-6 text-xs p-1 border-0 bg-transparent focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className="text-xs text-foreground-primary truncate" title={image.name}>
{image.name}
</div>
)}
</div>

{/* Delete confirmation dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Image</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {image.name}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => void handleDelete()}
className="bg-destructive text-white hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { useEditorEngine } from '@/components/store/editor';
import { MessageContextType, type FileMessageContext } from '@onlook/models/chat';
import { Icons } from '@onlook/ui/icons';
import { toast } from '@onlook/ui/sonner';
import { observer } from 'mobx-react-lite';
import { BreadcrumbNavigation } from './breadcrumb-navigation';
import { FolderList } from './folder-list';
Expand Down Expand Up @@ -37,11 +39,56 @@ export const ImagesTab = observer(() => {
error,
isUploading,
handleUpload,
handleRename,
handleDelete,
} = useImageOperations(projectId, branchId, activeFolder, branchData?.codeEditor);

// Filter images based on search
const images = filterImages(allImages);

// Handler functions with error handling and feedback
const handleRenameWithFeedback = async (oldPath: string, newName: string) => {
try {
await handleRename(oldPath, newName);
toast.success('Image renamed successfully');
} catch (error) {
console.error('Failed to rename image:', error);
toast.error(`Failed to rename image: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
};

const handleDeleteWithFeedback = async (filePath: string) => {
try {
await handleDelete(filePath);
toast.success('Image deleted successfully');
} catch (error) {
console.error('Failed to delete image:', error);
toast.error(`Failed to delete image: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
};

const handleAddToChat = async (imagePath: string) => {
try {
// Convert the image path to file context for chat
const fileName = imagePath.split('/').pop() || imagePath;
const fileContext: FileMessageContext = {
type: MessageContextType.FILE,
content: '', // File content will be loaded by the chat system
displayName: fileName,
path: imagePath,
branchId: branchId,
};

editorEngine.chat.context.addContexts([fileContext]);
toast.success('Image added to chat');
} catch (error) {
console.error('Failed to add image to chat:', error);
toast.error('Failed to add image to chat');
}
};

if (loading) {
return (
<div className="w-full h-full flex items-center justify-center gap-2">
Expand Down Expand Up @@ -84,6 +131,9 @@ export const ImagesTab = observer(() => {
branchId={branchId}
search={search}
onUpload={handleUpload}
onRename={handleRenameWithFeedback}
onDelete={handleDeleteWithFeedback}
onAddToChat={handleAddToChat}
/>
</div>
);
Expand Down