Skip to content

Commit 4c7c2eb

Browse files
authored
feat: rename/delete/add image (#2967)
1 parent a6ded8c commit 4c7c2eb

File tree

6 files changed

+830
-21
lines changed

6 files changed

+830
-21
lines changed

apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-image-operations.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import type { EditorEngine } from '@/components/store/editor/engine';
12
import type { CodeFileSystem } from '@onlook/file-system';
23
import { useDirectory } from '@onlook/file-system/hooks';
34
import { sanitizeFilename } from '@onlook/utility';
45
import { isImageFile } from '@onlook/utility/src/file';
56
import path from 'path';
67
import { useMemo, useState } from 'react';
8+
import { updateImageReferences } from '../utils/image-references';
79

8-
export const useImageOperations = (projectId: string, branchId: string, activeFolder: string, codeEditor?: CodeFileSystem) => {
10+
export const useImageOperations = (projectId: string, branchId: string, activeFolder: string, codeEditor?: CodeFileSystem, editorEngine?: EditorEngine) => {
911
const [isUploading, setIsUploading] = useState(false);
1012

1113
// Get directory entries
@@ -56,12 +58,78 @@ export const useImageOperations = (projectId: string, branchId: string, activeFo
5658
}
5759
};
5860

61+
// Handle file rename
62+
const handleRename = async (oldPath: string, newName: string) => {
63+
if (!codeEditor) throw new Error('Code editor not available');
64+
65+
const directory = path.dirname(oldPath);
66+
const sanitizedName = sanitizeFilename(newName);
67+
const newPath = path.join(directory, sanitizedName);
68+
69+
// Find all JS/TS files in the project
70+
const allFiles = await codeEditor.listFiles('**/*');
71+
const jsFiles = allFiles.filter(f => {
72+
const ext = path.extname(f);
73+
// Only process JS/TS/JSX/TSX files, skip test files and build dirs
74+
return ['.js', '.jsx', '.ts', '.tsx'].includes(ext) &&
75+
!f.includes('node_modules') &&
76+
!f.includes('.next') &&
77+
!f.includes('dist') &&
78+
!f.endsWith('.test.ts') &&
79+
!f.endsWith('.test.tsx');
80+
});
81+
82+
// Update references in parallel
83+
const updatePromises: Promise<void>[] = [];
84+
const oldFileName = path.basename(oldPath);
85+
86+
for (const file of jsFiles) {
87+
const filePath = path.join('/', file);
88+
updatePromises.push(
89+
(async () => {
90+
try {
91+
const content = await codeEditor.readFile(filePath);
92+
if (typeof content !== 'string' || !content.includes(oldFileName)) {
93+
return;
94+
}
95+
96+
const updatedContent = await updateImageReferences(content, oldPath, newPath);
97+
if (updatedContent !== content) {
98+
await codeEditor.writeFile(filePath, updatedContent);
99+
}
100+
} catch (error) {
101+
console.warn(`Failed to update references in ${filePath}:`, error);
102+
}
103+
})()
104+
);
105+
}
106+
107+
// Wait for all updates to complete
108+
await Promise.all(updatePromises);
109+
110+
// Finally, rename the actual image file
111+
await codeEditor.moveFile(oldPath, newPath);
112+
113+
// Refresh all frame views after a slight delay to show updated image references
114+
setTimeout(() => {
115+
editorEngine?.frames.reloadAllViews();
116+
}, 500);
117+
};
118+
119+
// Handle file delete
120+
const handleDelete = async (filePath: string) => {
121+
if (!codeEditor) throw new Error('Code editor not available');
122+
await codeEditor.deleteFile(filePath);
123+
};
124+
59125
return {
60126
folders,
61127
images,
62128
loading,
63129
error,
64130
isUploading,
65131
handleUpload,
132+
handleRename,
133+
handleDelete,
66134
};
67135
};

apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-grid.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ interface ImageGridProps {
1212
branchId: string;
1313
search: string;
1414
onUpload: (files: FileList) => Promise<void>;
15+
onRename: (oldPath: string, newName: string) => Promise<void>;
16+
onDelete: (filePath: string) => Promise<void>;
17+
onAddToChat: (imagePath: string) => void;
1518
}
1619

17-
export const ImageGrid = ({ images, projectId, branchId, search, onUpload }: ImageGridProps) => {
20+
export const ImageGrid = ({ images, projectId, branchId, search, onUpload, onRename, onDelete, onAddToChat }: ImageGridProps) => {
1821
const {
1922
handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging,
2023
onImageDragStart, onImageDragEnd, onImageMouseDown, onImageMouseUp
@@ -41,6 +44,9 @@ export const ImageGrid = ({ images, projectId, branchId, search, onUpload }: Ima
4144
onImageDragEnd={onImageDragEnd}
4245
onImageMouseDown={onImageMouseDown}
4346
onImageMouseUp={onImageMouseUp}
47+
onRename={onRename}
48+
onDelete={onDelete}
49+
onAddToChat={onAddToChat}
4450
/>
4551
))}
4652
</div>

apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-item.tsx

Lines changed: 188 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,28 @@
22

33
import { useFile } from '@onlook/file-system/hooks';
44
import type { ImageContentData } from '@onlook/models';
5+
import {
6+
AlertDialog,
7+
AlertDialogAction,
8+
AlertDialogCancel,
9+
AlertDialogContent,
10+
AlertDialogDescription,
11+
AlertDialogFooter,
12+
AlertDialogHeader,
13+
AlertDialogTitle
14+
} from '@onlook/ui/alert-dialog';
15+
import { Button } from '@onlook/ui/button';
16+
import {
17+
DropdownMenu,
18+
DropdownMenuContent,
19+
DropdownMenuItem,
20+
DropdownMenuTrigger
21+
} from '@onlook/ui/dropdown-menu';
522
import { Icons } from '@onlook/ui/icons';
23+
import { Input } from '@onlook/ui/input';
24+
import { getMimeType } from '@onlook/utility';
625
import { useEffect, useState } from 'react';
26+
import { toast } from 'sonner';
727

828
interface ImageItemProps {
929
image: {
@@ -17,12 +37,19 @@ interface ImageItemProps {
1737
onImageDragEnd: () => void;
1838
onImageMouseDown: () => void;
1939
onImageMouseUp: () => void;
40+
onRename: (oldPath: string, newName: string) => Promise<void>;
41+
onDelete: (filePath: string) => Promise<void>;
42+
onAddToChat: (imagePath: string) => void;
2043
}
2144

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

2754
// Convert content to data URL for display
2855
useEffect(() => {
@@ -56,6 +83,13 @@ export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImag
5683
};
5784
}, [content, image.mimeType, image.name]);
5885

86+
// Close dropdown when entering rename mode or showing delete dialog
87+
useEffect(() => {
88+
if (isRenaming || showDeleteDialog) {
89+
setDropdownOpen(false);
90+
}
91+
}, [isRenaming, showDeleteDialog]);
92+
5993
if (loading) {
6094
return (
6195
<div className="aspect-square bg-background-secondary rounded-md border border-border-primary flex items-center justify-center">
@@ -81,30 +115,166 @@ export const ImageItem = ({ image, projectId, branchId, onImageDragStart, onImag
81115
const imageContentData: ImageContentData = {
82116
fileName: image.name,
83117
content: content as string,
84-
mimeType: imageUrl,
118+
mimeType: getMimeType(image.name),
85119
originPath: image.path,
86120
};
87121
onImageDragStart(e, imageContentData);
88122
};
89123

124+
const handleRename = async () => {
125+
if (newName.trim() && newName !== image.name) {
126+
try {
127+
await onRename(image.path, newName.trim());
128+
setIsRenaming(false);
129+
} catch (error) {
130+
toast.error('Failed to rename file', {
131+
description: error instanceof Error ? error.message : 'Unknown error',
132+
});
133+
console.error('Failed to rename file:', error);
134+
setNewName(image.name); // Reset on error
135+
}
136+
} else {
137+
setIsRenaming(false);
138+
}
139+
};
140+
141+
const handleDelete = async () => {
142+
try {
143+
await onDelete(image.path);
144+
setShowDeleteDialog(false);
145+
} catch (error) {
146+
toast.error('Failed to delete file', {
147+
description: error instanceof Error ? error.message : 'Unknown error',
148+
});
149+
console.error('Failed to delete file:', error);
150+
}
151+
};
152+
153+
const handleAddToChat = () => {
154+
onAddToChat(image.path);
155+
};
156+
157+
const handleKeyDown = (e: React.KeyboardEvent) => {
158+
if (e.key === 'Enter') {
159+
void handleRename();
160+
} else if (e.key === 'Escape') {
161+
setNewName(image.name);
162+
setIsRenaming(false);
163+
}
164+
};
165+
90166
return (
91-
<div className="aspect-square bg-background-secondary rounded-md border border-border-primary overflow-hidden cursor-pointer hover:border-border-onlook transition-colors"
92-
onDragStart={handleDragStart}
93-
onDragEnd={onImageDragEnd}
94-
onMouseDown={onImageMouseDown}
95-
onMouseUp={onImageMouseUp}
96-
>
97-
<img
98-
src={imageUrl}
99-
alt={image.name}
100-
className="w-full h-full object-cover"
101-
loading="lazy"
102-
/>
103-
<div className="p-1 bg-background-primary/80 backdrop-blur-sm">
104-
<div className="text-xs text-foreground-primary truncate" title={image.name}>
105-
{image.name}
106-
</div>
167+
<div className="group">
168+
<div
169+
className="aspect-square bg-background-secondary rounded-md border border-border-primary overflow-hidden cursor-pointer hover:border-border-onlook transition-colors relative"
170+
onDragStart={handleDragStart}
171+
onDragEnd={onImageDragEnd}
172+
onMouseDown={onImageMouseDown}
173+
onMouseUp={onImageMouseUp}
174+
>
175+
<img
176+
src={imageUrl}
177+
alt={image.name}
178+
className="w-full h-full object-cover"
179+
loading="lazy"
180+
/>
181+
182+
{/* Action menu */}
183+
{!isRenaming && (
184+
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
185+
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
186+
<DropdownMenuTrigger asChild>
187+
<Button
188+
size="icon"
189+
variant="secondary"
190+
className="h-6 w-6 bg-background-secondary/90 hover:bg-background-onlook"
191+
onClick={(e) => {
192+
e.preventDefault();
193+
e.stopPropagation();
194+
}}
195+
>
196+
<Icons.DotsHorizontal className="h-3 w-3" />
197+
</Button>
198+
</DropdownMenuTrigger>
199+
<DropdownMenuContent align="end" className="w-40">
200+
<DropdownMenuItem
201+
onClick={(e) => {
202+
e.preventDefault();
203+
e.stopPropagation();
204+
handleAddToChat();
205+
}}
206+
className="flex items-center gap-2"
207+
>
208+
<Icons.Plus className="h-3 w-3" />
209+
Add to Chat
210+
</DropdownMenuItem>
211+
<DropdownMenuItem
212+
onClick={(e) => {
213+
e.preventDefault();
214+
e.stopPropagation();
215+
setIsRenaming(true);
216+
}}
217+
className="flex items-center gap-2"
218+
>
219+
<Icons.Edit className="h-3 w-3" />
220+
Rename
221+
</DropdownMenuItem>
222+
<DropdownMenuItem
223+
onClick={(e) => {
224+
e.preventDefault();
225+
e.stopPropagation();
226+
setShowDeleteDialog(true);
227+
}}
228+
className="flex items-center gap-2 text-red-500 hover:text-red-600 focus:text-red-600"
229+
>
230+
<Icons.Trash className="h-3 w-3" />
231+
Delete
232+
</DropdownMenuItem>
233+
</DropdownMenuContent>
234+
</DropdownMenu>
235+
</div>
236+
)}
237+
</div>
238+
239+
{/* Name section with rename functionality */}
240+
<div className="mt-1 px-1">
241+
{isRenaming ? (
242+
<Input
243+
value={newName}
244+
onChange={(e) => setNewName(e.target.value)}
245+
onKeyDown={handleKeyDown}
246+
onBlur={() => void handleRename()}
247+
className="h-6 text-xs p-1 border-0 bg-transparent focus-visible:ring-1 focus-visible:ring-ring"
248+
autoFocus
249+
onClick={(e) => e.stopPropagation()}
250+
/>
251+
) : (
252+
<div className="text-xs text-foreground-primary truncate" title={image.name}>
253+
{image.name}
254+
</div>
255+
)}
107256
</div>
257+
258+
{/* Delete confirmation dialog */}
259+
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
260+
<AlertDialogContent>
261+
<AlertDialogHeader>
262+
<AlertDialogTitle>Delete Image</AlertDialogTitle>
263+
<AlertDialogDescription>
264+
Are you sure you want to delete {image.name}? This action cannot be undone.
265+
</AlertDialogDescription>
266+
</AlertDialogHeader>
267+
<AlertDialogFooter>
268+
<AlertDialogCancel>Cancel</AlertDialogCancel>
269+
<AlertDialogAction
270+
onClick={() => void handleDelete()}
271+
className="bg-destructive text-white hover:bg-destructive/90"
272+
>
273+
Delete
274+
</AlertDialogAction>
275+
</AlertDialogFooter>
276+
</AlertDialogContent>
277+
</AlertDialog>
108278
</div>
109279
);
110280
};

0 commit comments

Comments
 (0)