diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-image-operations.tsx b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-image-operations.tsx index 1c73a77746..21373bdd0f 100644 --- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-image-operations.tsx +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/hooks/use-image-operations.tsx @@ -1,11 +1,13 @@ +import type { EditorEngine } from '@/components/store/editor/engine'; import type { CodeFileSystem } from '@onlook/file-system'; import { useDirectory } from '@onlook/file-system/hooks'; import { sanitizeFilename } from '@onlook/utility'; import { isImageFile } from '@onlook/utility/src/file'; import path from 'path'; import { useMemo, useState } from 'react'; +import { updateImageReferences } from '../utils/image-references'; -export const useImageOperations = (projectId: string, branchId: string, activeFolder: string, codeEditor?: CodeFileSystem) => { +export const useImageOperations = (projectId: string, branchId: string, activeFolder: string, codeEditor?: CodeFileSystem, editorEngine?: EditorEngine) => { const [isUploading, setIsUploading] = useState(false); // Get directory entries @@ -56,6 +58,70 @@ 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); + + // Find all JS/TS files in the project + const allFiles = await codeEditor.listFiles('**/*'); + const jsFiles = allFiles.filter(f => { + const ext = path.extname(f); + // Only process JS/TS/JSX/TSX files, skip test files and build dirs + return ['.js', '.jsx', '.ts', '.tsx'].includes(ext) && + !f.includes('node_modules') && + !f.includes('.next') && + !f.includes('dist') && + !f.endsWith('.test.ts') && + !f.endsWith('.test.tsx'); + }); + + // Update references in parallel + const updatePromises: Promise[] = []; + const oldFileName = path.basename(oldPath); + + for (const file of jsFiles) { + const filePath = path.join('/', file); + updatePromises.push( + (async () => { + try { + const content = await codeEditor.readFile(filePath); + if (typeof content !== 'string' || !content.includes(oldFileName)) { + return; + } + + const updatedContent = await updateImageReferences(content, oldPath, newPath); + if (updatedContent !== content) { + await codeEditor.writeFile(filePath, updatedContent); + } + } catch (error) { + console.warn(`Failed to update references in ${filePath}:`, error); + } + })() + ); + } + + // Wait for all updates to complete + await Promise.all(updatePromises); + + // Finally, rename the actual image file + await codeEditor.moveFile(oldPath, newPath); + + // Refresh all frame views after a slight delay to show updated image references + setTimeout(() => { + editorEngine?.frames.reloadAllViews(); + }, 500); + }; + + // Handle file delete + const handleDelete = async (filePath: string) => { + if (!codeEditor) throw new Error('Code editor not available'); + await codeEditor.deleteFile(filePath); + }; + return { folders, images, @@ -63,5 +129,7 @@ export const useImageOperations = (projectId: string, branchId: string, activeFo error, isUploading, handleUpload, + handleRename, + handleDelete, }; }; \ No newline at end of file diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-grid.tsx b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-grid.tsx index 943993fd48..79015b1a76 100644 --- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-grid.tsx +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-grid.tsx @@ -12,9 +12,12 @@ interface ImageGridProps { branchId: string; search: string; onUpload: (files: FileList) => Promise; + onRename: (oldPath: string, newName: string) => Promise; + onDelete: (filePath: string) => Promise; + 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 @@ -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} /> ))} diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-item.tsx b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-item.tsx index 9cf7e3371f..d015993bba 100644 --- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-item.tsx +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/image-item.tsx @@ -2,8 +2,28 @@ 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'; +import { toast } from 'sonner'; interface ImageItemProps { image: { @@ -17,12 +37,19 @@ interface ImageItemProps { onImageDragEnd: () => void; onImageMouseDown: () => void; onImageMouseUp: () => void; + onRename: (oldPath: string, newName: string) => Promise; + onDelete: (filePath: string) => Promise; + 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(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(() => { @@ -56,6 +83,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 (
@@ -81,30 +115,166 @@ 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()); + setIsRenaming(false); + } catch (error) { + toast.error('Failed to rename file', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + console.error('Failed to rename file:', error); + setNewName(image.name); // Reset on error + } + } else { + setIsRenaming(false); + } + }; + + const handleDelete = async () => { + try { + await onDelete(image.path); + setShowDeleteDialog(false); + } catch (error) { + toast.error('Failed to delete file', { + description: error instanceof Error ? error.message : 'Unknown 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 ( -
- {image.name} -
-
- {image.name} -
+
+
+ {image.name} + + {/* Action menu */} + {!isRenaming && ( +
+ + + + + + { + e.preventDefault(); + e.stopPropagation(); + handleAddToChat(); + }} + className="flex items-center gap-2" + > + + Add to Chat + + { + e.preventDefault(); + e.stopPropagation(); + setIsRenaming(true); + }} + className="flex items-center gap-2" + > + + Rename + + { + e.preventDefault(); + e.stopPropagation(); + setShowDeleteDialog(true); + }} + className="flex items-center gap-2 text-red-500 hover:text-red-600 focus:text-red-600" + > + + Delete + + + +
+ )} +
+ + {/* Name section with rename functionality */} +
+ {isRenaming ? ( + 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()} + /> + ) : ( +
+ {image.name} +
+ )}
+ + {/* Delete confirmation dialog */} + + + + Delete Image + + Are you sure you want to delete {image.name}? This action cannot be undone. + + + + Cancel + void handleDelete()} + className="bg-destructive text-white hover:bg-destructive/90" + > + Delete + + + +
); }; \ No newline at end of file diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/index.tsx b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/index.tsx index a096958d17..2c45fa10a0 100644 --- a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/index.tsx @@ -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'; @@ -37,11 +39,56 @@ export const ImagesTab = observer(() => { error, isUploading, handleUpload, - } = useImageOperations(projectId, branchId, activeFolder, branchData?.codeEditor); + handleRename, + handleDelete, + } = useImageOperations(projectId, branchId, activeFolder, branchData?.codeEditor, editorEngine); // 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 (
@@ -84,6 +131,9 @@ export const ImagesTab = observer(() => { branchId={branchId} search={search} onUpload={handleUpload} + onRename={handleRenameWithFeedback} + onDelete={handleDeleteWithFeedback} + onAddToChat={handleAddToChat} />
); diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.test.ts b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.test.ts new file mode 100644 index 0000000000..3f0f1b407e --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'bun:test'; +import { updateImageReferences } from './image-references'; + +describe('updateImageReferences', () => { + it('should update src attribute in img tag', async () => { + const content = ` +export function Component() { + return test; +} +`; + const result = await updateImageReferences( + content, + '/images/old-image.jpg', + '/images/new-image.jpg' + ); + expect(result).toContain('src="/images/new-image.jpg"'); + expect(result).not.toContain('old-image.jpg'); + }); + + it('should update backgroundImage in style prop', async () => { + const content = ` +export function Component() { + return
; +} +`; + const result = await updateImageReferences( + content, + '/assets/hero.jpg', + '/assets/new-hero.jpg' + ); + expect(result).toContain("url('/assets/new-hero.jpg')"); + expect(result).not.toContain("'/assets/hero.jpg'"); + }); + + it('should update multiple references in the same file', async () => { + const content = ` +export function Component() { + return ( +
+ + Logo +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/images/logo.png', + '/images/new-logo.png' + ); + expect(result).toContain('src="/images/new-logo.png"'); + expect(result).not.toContain('"/images/logo.png"'); + }); + + it('should not modify content if no references found', async () => { + const content = ` +export function Component() { + return
No images here
; +} +`; + const result = await updateImageReferences( + content, + '/images/old-image.jpg', + '/images/new-image.jpg' + ); + expect(result).toBe(content); + }); + + it('should handle Image component from next/image', async () => { + const content = ` +import Image from 'next/image'; + +export function Component() { + return Sunset; +} +`; + const result = await updateImageReferences( + content, + '/photos/sunset.jpg', + '/photos/sunrise.jpg' + ); + expect(result).toContain('src="/photos/sunrise.jpg"'); + expect(result).not.toContain('sunset.jpg'); + }); + + it('should update only the filename when using just filename', async () => { + const content = ` +export function Component() { + return test; +} +`; + const result = await updateImageReferences( + content, + 'photo.jpg', + 'new-photo.jpg' + ); + expect(result).toContain('src="new-photo.jpg"'); + expect(result).not.toContain('"photo.jpg"'); + }); + + it('should update className with image references', async () => { + const content = ` +export function Component() { + return
; +} +`; + const result = await updateImageReferences( + content, + '/images/bg.png', + '/images/new-bg.png' + ); + expect(result).toContain('/images/new-bg.png'); + expect(result).not.toContain('/images/bg.png'); + }); + + it('should update template literal className', async () => { + const content = ` +export function Component() { + const imagePath = '/images/hero.jpg'; + return
; +} +`; + const result = await updateImageReferences( + content, + '/images/hero.jpg', + '/images/new-hero.jpg' + ); + // Note: Variable declarations aren't updated, only JSX attributes + expect(result).toContain("imagePath = '/images/hero.jpg'"); + }); + + it('should handle relative paths', async () => { + const content = ` +export function Component() { + return Logo; +} +`; + const result = await updateImageReferences( + content, + './assets/logo.svg', + './assets/new-logo.svg' + ); + expect(result).toContain('src="./assets/new-logo.svg"'); + expect(result).not.toContain('src="./assets/logo.svg"'); + }); + + it('should handle absolute paths with different extensions', async () => { + const content = ` +export function Component() { + return ( +
+ + Photo +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/public/images/photo.webp', + '/public/images/updated-photo.webp' + ); + expect(result).toContain('src="/public/images/updated-photo.webp"'); + expect(result).not.toContain('src="/public/images/photo.webp"'); + }); + + it('should handle paths with special characters', async () => { + const content = ` +export function Component() { + return test; +} +`; + const result = await updateImageReferences( + content, + '/images/my-photo (1).jpg', + '/images/my-photo (2).jpg' + ); + expect(result).toContain('src="/images/my-photo (2).jpg"'); + expect(result).not.toContain('my-photo (1).jpg'); + }); + + it('should update multiple different images independently', async () => { + const content = ` +export function Component() { + return ( +
+ + +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/images/logo.png', + '/images/new-logo.png' + ); + expect(result).toContain('src="/images/new-logo.png"'); + expect(result).toContain('src="/images/banner.jpg"'); + expect(result).not.toContain('src="/images/logo.png"'); + }); + + it('should handle nested JSX elements', async () => { + const content = ` +export function Component() { + return ( +
+
+
+ +
+
+
+ ); +} +`; + const result = await updateImageReferences( + content, + '/icons/star.png', + '/icons/new-star.png' + ); + expect(result).toContain('src="/icons/new-star.png"'); + expect(result).toContain("url('/bg/pattern.svg')"); + expect(result).not.toContain('src="/icons/star.png"'); + }); + + it('should preserve formatting and whitespace', async () => { + const content = ` +export function Component() { + return ( + Photo + ); +} +`; + const result = await updateImageReferences( + content, + '/images/photo.jpg', + '/images/new-photo.jpg' + ); + expect(result).toContain('src="/images/new-photo.jpg"'); + expect(result).toContain('alt="Photo"'); + expect(result).toContain('width={500}'); + expect(result).not.toContain('src="/images/photo.jpg"'); + }); + + it('should handle images in object properties', async () => { + const content = ` +export function Component() { + return ( +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/images/hero.png', + '/images/new-hero.png' + ); + expect(result).toContain("url('/images/new-hero.png')"); + expect(result).toContain('backgroundSize'); + expect(result).not.toContain("url('/images/hero.png')"); + }); + + it('should handle URL-encoded paths', async () => { + const content = ` +export function Component() { + return test; +} +`; + const result = await updateImageReferences( + content, + '/images/my%20photo.jpg', + '/images/new%20photo.jpg' + ); + expect(result).toContain('src="/images/new%20photo.jpg"'); + expect(result).not.toContain('my%20photo.jpg'); + }); + + it('should handle images in array map', async () => { + const content = ` +export function Gallery() { + const images = ['/gallery/img1.jpg', '/gallery/img2.jpg']; + return ( +
+ {images.map((src) => )} + Featured +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/gallery/img1.jpg', + '/gallery/new-img1.jpg' + ); + // JSX src attribute should be updated + expect(result).toContain('src="/gallery/new-img1.jpg"'); + expect(result).toContain('/gallery/img2.jpg'); + // Note: Array literals aren't updated, only JSX attributes + }); + + it('should handle conditional rendering with images', async () => { + const content = ` +export function Component({ isActive }) { + return ( + Status + ); +} +`; + const result = await updateImageReferences( + content, + '/icons/active.svg', + '/icons/new-active.svg' + ); + // Note: Ternary expressions aren't currently updated, only direct string literals + // This is a known limitation - ternary values would need separate handling + expect(result).toContain('/icons/inactive.svg'); + }); + + it('should not replace similar filenames', async () => { + const content = ` +export function Component() { + return ( +
+ + +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/images/logo.png', + '/images/new-logo.png' + ); + expect(result).toContain('src="/images/new-logo.png"'); + expect(result).toContain('src="/images/logo-dark.png"'); + expect(result).not.toContain('"/images/logo.png"'); + }); + + it('should handle mixed quotes in same file', async () => { + const content = ` +export function Component() { + return ( +
+ + +
+ ); +} +`; + const result = await updateImageReferences( + content, + '/images/photo1.jpg', + '/images/new-photo1.jpg' + ); + expect(result).toContain('src="/images/new-photo1.jpg"'); + expect(result).toContain("src='/images/photo2.jpg'"); + expect(result).not.toContain('src="/images/photo1.jpg"'); + }); +}); diff --git a/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.ts b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.ts new file mode 100644 index 0000000000..678a6a67a1 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/left-panel/image-tab/utils/image-references.ts @@ -0,0 +1,141 @@ +import { getAstFromContent, getContentFromAst, t, traverse } from '@onlook/parser'; +import path from 'path'; + +/** + * Update all image references in a file from oldPath to newPath + * Uses AST manipulation to update src attributes, className (including Tailwind), and style props + */ +export async function updateImageReferences( + content: string, + oldImagePath: string, + newImagePath: string +): Promise { + const ast = getAstFromContent(content); + if (!ast) { + return content; + } + + const oldFileName = path.basename(oldImagePath); + const newFileName = path.basename(newImagePath); + + let hasChanges = false; + + traverse(ast, { + JSXOpeningElement(nodePath) { + const { node } = nodePath; + + for (const attr of node.attributes) { + if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) { + continue; + } + + const attrName = attr.name.name; + + // Update src attribute + if (attrName === 'src' && t.isStringLiteral(attr.value)) { + const srcValue = attr.value.value; + if (srcValue.includes(oldImagePath)) { + attr.value.value = srcValue.replace(oldImagePath, newImagePath); + hasChanges = true; + } else if (srcValue.includes(oldFileName)) { + attr.value.value = srcValue.replace(oldFileName, newFileName); + hasChanges = true; + } + } + + // Update className (handles Tailwind and other class-based images) + if (attrName === 'className' && t.isStringLiteral(attr.value)) { + const className = attr.value.value; + // Check if contains image filename before processing + if (className.includes(oldFileName) || className.includes(oldImagePath)) { + const updated = replaceImageInString(className, oldImagePath, newImagePath, oldFileName, newFileName); + if (updated !== className) { + attr.value.value = updated; + hasChanges = true; + } + } + } + + // Update className in template literals + if (attrName === 'className' && t.isJSXExpressionContainer(attr.value)) { + const expression = attr.value.expression; + if (t.isTemplateLiteral(expression)) { + for (const quasi of expression.quasis) { + const rawValue = quasi.value.raw; + if (rawValue.includes(oldImagePath)) { + quasi.value.raw = rawValue.replace(oldImagePath, newImagePath); + quasi.value.cooked = quasi.value.raw; + hasChanges = true; + } else if (rawValue.includes(oldFileName)) { + quasi.value.raw = rawValue.replace(oldFileName, newFileName); + quasi.value.cooked = quasi.value.raw; + hasChanges = true; + } + } + } + } + + // Update style prop backgroundImage + if (attrName === 'style' && t.isJSXExpressionContainer(attr.value)) { + const expression = attr.value.expression; + if (t.isObjectExpression(expression)) { + for (const prop of expression.properties) { + if ( + t.isObjectProperty(prop) && + t.isIdentifier(prop.key) && + prop.key.name === 'backgroundImage' && + t.isStringLiteral(prop.value) + ) { + const bgValue = prop.value.value; + if (bgValue.includes(oldImagePath)) { + prop.value.value = bgValue.replace(oldImagePath, newImagePath); + hasChanges = true; + } else if (bgValue.includes(oldFileName)) { + prop.value.value = bgValue.replace(oldFileName, newFileName); + hasChanges = true; + } + } + } + } + } + } + }, + }); + + if (!hasChanges) { + return content; + } + + return await getContentFromAst(ast, content); +} + +/** + * Replace image paths in a string (used for className updates) + * Handles both full paths and filenames, avoiding double replacement + */ +function replaceImageInString( + str: string, + oldPath: string, + newPath: string, + oldFilename: string, + newFilename: string +): string { + let result = str; + + // Replace full path first if it exists + if (result.includes(oldPath)) { + result = result.replace(new RegExp(escapeRegExp(oldPath), 'g'), newPath); + } else if (result.includes(oldFilename)) { + // Only replace filename if full path wasn't found (to avoid double replacement) + result = result.replace(new RegExp(escapeRegExp(oldFilename), 'g'), newFilename); + } + + return result; +} + +/** + * Escape special regex characters in a string + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}