From f02e10c9ace54c912eb4f5405ef1e02b8fa06f83 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Mon, 10 Mar 2025 11:12:25 +0000 Subject: [PATCH] fix: remove rename, creations and deletions now persist across reloads removed rename files until a better solution is found and made file/folder create/delete be persistent across reloads --- app/components/workbench/FileTree.tsx | 129 +++++------- app/lib/stores/files.ts | 293 +++++++++++++++++++++++++- app/lib/stores/workbench.ts | 282 ++++++++++++------------- 3 files changed, 474 insertions(+), 230 deletions(-) diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx index d1087361..cb12dd99 100644 --- a/app/components/workbench/FileTree.tsx +++ b/app/components/workbench/FileTree.tsx @@ -286,11 +286,23 @@ function FileContextMenu({ }: FolderContextMenuProps & { fullPath: string }) { const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); const [isDragging, setIsDragging] = useState(false); const depth = useMemo(() => fullPath.split('/').length, [fullPath]); const fileName = useMemo(() => path.basename(fullPath), [fullPath]); + // Add this to determine if the path is a file or folder + const isFolder = useMemo(() => { + const files = workbenchStore.files.get(); + const fileEntry = files[fullPath]; + + return !fileEntry || fileEntry.type === 'folder'; + }, [fullPath]); + + // Get the parent directory for files + const targetPath = useMemo(() => { + return isFolder ? fullPath : path.dirname(fullPath); + }, [fullPath, isFolder]); + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -309,20 +321,25 @@ function FileContextMenu({ e.stopPropagation(); const items = Array.from(e.dataTransfer.items); - const imageFiles = items.filter((item) => item.type.startsWith('image/')); + const files = items.filter((item) => item.kind === 'file'); - for (const item of imageFiles) { + for (const item of files) { const file = item.getAsFile(); if (file) { try { const filePath = path.join(fullPath, file.name); - const success = await workbenchStore.createNewFile(filePath, file); + + // Convert file to binary data (Uint8Array) + const arrayBuffer = await file.arrayBuffer(); + const binaryContent = new Uint8Array(arrayBuffer); + + const success = await workbenchStore.createFile(filePath, binaryContent); if (success) { - toast.success(`Image ${file.name} uploaded successfully`); + toast.success(`File ${file.name} uploaded successfully`); } else { - toast.error(`Failed to upload image ${file.name}`); + toast.error(`Failed to upload file ${file.name}`); } } catch (error) { toast.error(`Error uploading ${file.name}`); @@ -337,8 +354,11 @@ function FileContextMenu({ ); const handleCreateFile = async (fileName: string) => { - const newFilePath = path.join(fullPath, fileName); - const success = await workbenchStore.createNewFile(newFilePath); + // Use targetPath instead of fullPath + const newFilePath = path.join(targetPath, fileName); + + // Change from createNewFile to createFile + const success = await workbenchStore.createFile(newFilePath, ''); if (success) { toast.success('File created successfully'); @@ -350,8 +370,11 @@ function FileContextMenu({ }; const handleCreateFolder = async (folderName: string) => { - const newFolderPath = path.join(fullPath, folderName); - const success = await workbenchStore.createNewFolder(newFolderPath); + // Use targetPath instead of fullPath + const newFolderPath = path.join(targetPath, folderName); + + // Change from createNewFolder to createFolder + const success = await workbenchStore.createFolder(newFolderPath); if (success) { toast.success('Folder created successfully'); @@ -362,57 +385,31 @@ function FileContextMenu({ setIsCreatingFolder(false); }; + // Add delete handler function const handleDelete = async () => { try { - if (confirm(`Are you sure you want to delete ${fileName}?`)) { - const isDirectory = path.extname(fullPath) === ''; - - const success = isDirectory - ? await workbenchStore.deleteFolder(fullPath) - : await workbenchStore.deleteFile(fullPath); - - if (success) { - toast.success('Deleted successfully'); - } else { - toast.error('Failed to delete'); - } + // Confirm deletion with the user + if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) { + return; } - } catch (error) { - toast.error('Error during delete operation'); - logger.error(error); - } - }; - const handleRename = async (newName: string) => { - if (newName === fileName) { - setIsRenaming(false); - return; - } + let success; - const parentDir = path.dirname(fullPath); - const newPath = path.join(parentDir, newName); - - try { - const files = workbenchStore.files.get(); - const fileEntry = files[fullPath]; - - const isDirectory = !fileEntry || fileEntry.type === 'folder'; - - const success = isDirectory - ? await workbenchStore.renameFolder(fullPath, newPath) - : await workbenchStore.renameFile(fullPath, newPath); + if (isFolder) { + success = await workbenchStore.deleteFolder(fullPath); + } else { + success = await workbenchStore.deleteFile(fullPath); + } if (success) { - toast.success('Renamed successfully'); + toast.success(`${isFolder ? 'Folder' : 'File'} deleted successfully`); } else { - toast.error('Failed to rename'); + toast.error(`Failed to delete ${isFolder ? 'folder' : 'file'}`); } } catch (error) { - toast.error('Error during rename operation'); + toast.error(`Error deleting ${isFolder ? 'folder' : 'file'}`); logger.error(error); } - - setIsRenaming(false); }; return ( @@ -428,17 +425,7 @@ function FileContextMenu({ isDragging, })} > - {!isRenaming && children} - - {isRenaming && ( - setIsRenaming(false)} - /> - )} + {children} @@ -459,23 +446,20 @@ function FileContextMenu({ New Folder - setIsRenaming(true)}> -
-
- Rename -
- - -
-
- Delete -
- Copy path Copy relative path + {/* Add delete option in a new group */} + + +
+
+ Delete {isFolder ? 'Folder' : 'File'} +
+ + @@ -495,7 +479,6 @@ function FileContextMenu({ onCancel={() => setIsCreatingFolder(false)} /> )} - {/* Remove the isRenaming InlineInput from here since we moved it above */} ); } diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts index 60904a65..8227f94f 100644 --- a/app/lib/stores/files.ts +++ b/app/lib/stores/files.ts @@ -42,6 +42,11 @@ export class FilesStore { */ #modifiedFiles: Map = import.meta.hot?.data.modifiedFiles ?? new Map(); + /** + * Keeps track of deleted files and folders to prevent them from reappearing on reload + */ + #deletedPaths: Set = import.meta.hot?.data.deletedPaths ?? new Set(); + /** * Map of files that matches the state of WebContainer. */ @@ -54,9 +59,28 @@ export class FilesStore { constructor(webcontainerPromise: Promise) { this.#webcontainer = webcontainerPromise; + // Load deleted paths from localStorage if available + try { + if (typeof localStorage !== 'undefined') { + const deletedPathsJson = localStorage.getItem('bolt-deleted-paths'); + + if (deletedPathsJson) { + const deletedPaths = JSON.parse(deletedPathsJson); + + if (Array.isArray(deletedPaths)) { + deletedPaths.forEach((path) => this.#deletedPaths.add(path)); + } + } + } + } catch (error) { + logger.error('Failed to load deleted paths from localStorage', error); + } + if (import.meta.hot) { + // Persist our state across hot reloads import.meta.hot.data.files = this.files; import.meta.hot.data.modifiedFiles = this.#modifiedFiles; + import.meta.hot.data.deletedPaths = this.#deletedPaths; } this.#init(); @@ -139,18 +163,81 @@ export class FilesStore { async #init() { const webcontainer = await this.#webcontainer; + // Clean up any files that were previously deleted + this.#cleanupDeletedFiles(); + webcontainer.internal.watchPaths( { include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true }, bufferWatchEvents(100, this.#processEventBuffer.bind(this)), ); } + /** + * Removes any deleted files/folders from the store + */ + #cleanupDeletedFiles() { + if (this.#deletedPaths.size === 0) { + return; + } + + const currentFiles = this.files.get(); + + // Process each deleted path + for (const deletedPath of this.#deletedPaths) { + // Remove the path itself + if (currentFiles[deletedPath]) { + this.files.setKey(deletedPath, undefined); + + // Adjust file count if it was a file + if (currentFiles[deletedPath]?.type === 'file') { + this.#size--; + } + } + + // Also remove any files/folders inside deleted folders + for (const [path, dirent] of Object.entries(currentFiles)) { + if (path.startsWith(deletedPath + '/')) { + this.files.setKey(path, undefined); + + // Adjust file count if it was a file + if (dirent?.type === 'file') { + this.#size--; + } + + // Remove from modified files tracking if present + if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) { + this.#modifiedFiles.delete(path); + } + } + } + } + } + #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) { const watchEvents = events.flat(2); - for (const { type, path, buffer } of watchEvents) { + for (const { type, path: eventPath, buffer } of watchEvents) { // remove any trailing slashes - const sanitizedPath = path.replace(/\/+$/g, ''); + const sanitizedPath = eventPath.replace(/\/+$/g, ''); + + // Skip processing if this file/folder was explicitly deleted + if (this.#deletedPaths.has(sanitizedPath)) { + continue; + } + + // Also skip if this is a file/folder inside a deleted folder + let isInDeletedFolder = false; + + for (const deletedPath of this.#deletedPaths) { + if (sanitizedPath.startsWith(deletedPath + '/')) { + isInDeletedFolder = true; + break; + } + } + + if (isInDeletedFolder) { + continue; + } switch (type) { case 'add_dir': { @@ -176,21 +263,32 @@ export class FilesStore { } let content = ''; - - /** - * @note This check is purely for the editor. The way we detect this is not - * bullet-proof and it's a best guess so there might be false-positives. - * The reason we do this is because we don't want to display binary files - * in the editor nor allow to edit them. - */ const isBinary = isBinaryFile(buffer); - if (!isBinary) { + if (isBinary && buffer) { + // For binary files, we need to preserve the content as base64 + content = Buffer.from(buffer).toString('base64'); + } else if (!isBinary) { content = this.#decodeFileContent(buffer); + + /* + * If the content is a single space and this is from our empty file workaround, + * convert it back to an actual empty string + */ + if (content === ' ' && type === 'add_file') { + content = ''; + } + } + + // Check if we already have this file with content + const existingFile = this.files.get()[sanitizedPath]; + + if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) { + // Keep existing binary content if new content is empty + content = existingFile.content; } this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); - break; } case 'remove_file': { @@ -218,6 +316,179 @@ export class FilesStore { return ''; } } + + async createFile(filePath: string, content: string | Uint8Array = '') { + const webcontainer = await this.#webcontainer; + + try { + const relativePath = path.relative(webcontainer.workdir, filePath); + + if (!relativePath) { + throw new Error(`EINVAL: invalid file path, create '${relativePath}'`); + } + + // Create parent directories if they don't exist + const dirPath = path.dirname(relativePath); + + if (dirPath !== '.') { + await webcontainer.fs.mkdir(dirPath, { recursive: true }); + } + + // Detect binary content + const isBinary = content instanceof Uint8Array; + + if (isBinary) { + await webcontainer.fs.writeFile(relativePath, Buffer.from(content)); + + // Store Base64 encoded data instead of an empty string + const base64Content = Buffer.from(content).toString('base64'); + this.files.setKey(filePath, { type: 'file', content: base64Content, isBinary: true }); + + // Store the base64 content as the original content for tracking modifications + this.#modifiedFiles.set(filePath, base64Content); + } else { + // Ensure we write at least a space character for empty files to ensure they're tracked + const contentToWrite = (content as string).length === 0 ? ' ' : content; + await webcontainer.fs.writeFile(relativePath, contentToWrite); + + // But store the actual empty string in our file map if that's what was requested + this.files.setKey(filePath, { type: 'file', content: content as string, isBinary: false }); + + // Store the text content as the original content + this.#modifiedFiles.set(filePath, content as string); + } + + logger.info(`File created: ${filePath}`); + + return true; + } catch (error) { + logger.error('Failed to create file\n\n', error); + throw error; + } + } + + async createFolder(folderPath: string) { + const webcontainer = await this.#webcontainer; + + try { + const relativePath = path.relative(webcontainer.workdir, folderPath); + + if (!relativePath) { + throw new Error(`EINVAL: invalid folder path, create '${relativePath}'`); + } + + await webcontainer.fs.mkdir(relativePath, { recursive: true }); + + // Immediately update the folder in our store without waiting for the watcher + this.files.setKey(folderPath, { type: 'folder' }); + + logger.info(`Folder created: ${folderPath}`); + + return true; + } catch (error) { + logger.error('Failed to create folder\n\n', error); + throw error; + } + } + + async deleteFile(filePath: string) { + const webcontainer = await this.#webcontainer; + + try { + const relativePath = path.relative(webcontainer.workdir, filePath); + + if (!relativePath) { + throw new Error(`EINVAL: invalid file path, delete '${relativePath}'`); + } + + await webcontainer.fs.rm(relativePath); + + // Add to deleted paths set + this.#deletedPaths.add(filePath); + + // Immediately update our store without waiting for the watcher + this.files.setKey(filePath, undefined); + this.#size--; + + // Remove from modified files tracking if present + if (this.#modifiedFiles.has(filePath)) { + this.#modifiedFiles.delete(filePath); + } + + // Persist the deleted paths to localStorage for extra durability + this.#persistDeletedPaths(); + + logger.info(`File deleted: ${filePath}`); + + return true; + } catch (error) { + logger.error('Failed to delete file\n\n', error); + throw error; + } + } + + async deleteFolder(folderPath: string) { + const webcontainer = await this.#webcontainer; + + try { + const relativePath = path.relative(webcontainer.workdir, folderPath); + + if (!relativePath) { + throw new Error(`EINVAL: invalid folder path, delete '${relativePath}'`); + } + + await webcontainer.fs.rm(relativePath, { recursive: true }); + + // Add to deleted paths set + this.#deletedPaths.add(folderPath); + + // Immediately update our store without waiting for the watcher + this.files.setKey(folderPath, undefined); + + // Also remove all files and subfolders from our store + const allFiles = this.files.get(); + + for (const [path, dirent] of Object.entries(allFiles)) { + if (path.startsWith(folderPath + '/')) { + this.files.setKey(path, undefined); + + // Also add these paths to the deleted paths set + this.#deletedPaths.add(path); + + // Decrement file count for each file (not folder) removed + if (dirent?.type === 'file') { + this.#size--; + } + + // Remove from modified files tracking if present + if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) { + this.#modifiedFiles.delete(path); + } + } + } + + // Persist the deleted paths to localStorage for extra durability + this.#persistDeletedPaths(); + + logger.info(`Folder deleted: ${folderPath}`); + + return true; + } catch (error) { + logger.error('Failed to delete folder\n\n', error); + throw error; + } + } + + // Add a method to persist deleted paths to localStorage + #persistDeletedPaths() { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem('bolt-deleted-paths', JSON.stringify([...this.#deletedPaths])); + } + } catch (error) { + logger.error('Failed to persist deleted paths to localStorage', error); + } + } } function isBinaryFile(buffer: Uint8Array | undefined) { diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 2d5bbc08..37a7a59d 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -60,6 +60,16 @@ export class WorkbenchStore { import.meta.hot.data.showWorkbench = this.showWorkbench; import.meta.hot.data.currentView = this.currentView; import.meta.hot.data.actionAlert = this.actionAlert; + + // Ensure binary files are properly preserved across hot reloads + const filesMap = this.files.get(); + + for (const [path, dirent] of Object.entries(filesMap)) { + if (dirent?.type === 'file' && dirent.isBinary && dirent.content) { + // Make sure binary content is preserved + this.files.setKey(path, { ...dirent }); + } + } } } @@ -238,6 +248,7 @@ export class WorkbenchStore { getFileModifcations() { return this.#filesStore.getFileModifications(); } + getModifiedFiles() { return this.#filesStore.getModifiedFiles(); } @@ -246,6 +257,131 @@ export class WorkbenchStore { this.#filesStore.resetFileModifications(); } + async createFile(filePath: string, content: string | Uint8Array = '') { + try { + const success = await this.#filesStore.createFile(filePath, content); + + if (success) { + // If the file is created successfully, select it in the editor + this.setSelectedFile(filePath); + + /* + * For empty files, we need to ensure they're not marked as unsaved + * Only check for empty string, not empty Uint8Array + */ + if (typeof content === 'string' && content === '') { + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + newUnsavedFiles.delete(filePath); + this.unsavedFiles.set(newUnsavedFiles); + } + } + + return success; + } catch (error) { + console.error('Failed to create file:', error); + throw error; + } + } + + async createFolder(folderPath: string) { + try { + return await this.#filesStore.createFolder(folderPath); + } catch (error) { + console.error('Failed to create folder:', error); + throw error; + } + } + + async deleteFile(filePath: string) { + try { + // Check if the file is currently open in the editor + const currentDocument = this.currentDocument.get(); + const isCurrentFile = currentDocument?.filePath === filePath; + + // Delete the file + const success = await this.#filesStore.deleteFile(filePath); + + if (success) { + // Remove from unsaved files if present + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + + if (newUnsavedFiles.has(filePath)) { + newUnsavedFiles.delete(filePath); + this.unsavedFiles.set(newUnsavedFiles); + } + + // If this was the current file, select another file + if (isCurrentFile) { + // Find another file to select + const files = this.files.get(); + let nextFile: string | undefined = undefined; + + for (const [path, dirent] of Object.entries(files)) { + if (dirent?.type === 'file') { + nextFile = path; + break; + } + } + + this.setSelectedFile(nextFile); + } + } + + return success; + } catch (error) { + console.error('Failed to delete file:', error); + throw error; + } + } + + async deleteFolder(folderPath: string) { + try { + // Check if any file in this folder is currently open + const currentDocument = this.currentDocument.get(); + const isInCurrentFolder = currentDocument?.filePath?.startsWith(folderPath + '/'); + + // Delete the folder + const success = await this.#filesStore.deleteFolder(folderPath); + + if (success) { + // Remove any files in this folder from unsaved files + const unsavedFiles = this.unsavedFiles.get(); + const newUnsavedFiles = new Set(); + + for (const file of unsavedFiles) { + if (!file.startsWith(folderPath + '/')) { + newUnsavedFiles.add(file); + } + } + + if (newUnsavedFiles.size !== unsavedFiles.size) { + this.unsavedFiles.set(newUnsavedFiles); + } + + // If current file was in this folder, select another file + if (isInCurrentFolder) { + // Find another file to select + const files = this.files.get(); + let nextFile: string | undefined = undefined; + + for (const [path, dirent] of Object.entries(files)) { + if (dirent?.type === 'file') { + nextFile = path; + break; + } + } + + this.setSelectedFile(nextFile); + } + } + + return success; + } catch (error) { + console.error('Failed to delete folder:', error); + throw error; + } + } + abortAllActions() { // TODO: what do we wanna do and how do we wanna recover from this? } @@ -547,152 +683,6 @@ export class WorkbenchStore { throw error; // Rethrow the error for further handling } } - - async createNewFile(filePath: string, content: string | File | ArrayBuffer = '') { - try { - const wc = await webcontainer; - const relativePath = extractRelativePath(filePath); - - const dirPath = path.dirname(relativePath); - - if (dirPath !== '.') { - await wc.fs.mkdir(dirPath, { recursive: true }); - } - - let fileContent: string | Uint8Array; - - if (content instanceof File) { - const buffer = await content.arrayBuffer(); - fileContent = new Uint8Array(buffer); - } else if (content instanceof ArrayBuffer) { - fileContent = new Uint8Array(content); - } else { - fileContent = content || ''; - } - - await wc.fs.writeFile(relativePath, fileContent); - - const fullPath = path.join(wc.workdir, relativePath); - this.setSelectedFile(fullPath); - - return true; - } catch (error) { - console.error('Error creating file:', error); - return false; - } - } - - async createNewFolder(folderPath: string) { - try { - const wc = await webcontainer; - const relativePath = extractRelativePath(folderPath); - - await wc.fs.mkdir(relativePath, { recursive: true }); - - return true; - } catch (error) { - console.error('Error creating folder:', error); - return false; - } - } - - async deleteFile(filePath: string) { - try { - const wc = await webcontainer; - const relativePath = extractRelativePath(filePath); - - await wc.fs.rm(relativePath); - - // If the deleted file was selected, clear the selection - if (this.selectedFile.get() === filePath) { - this.setSelectedFile(undefined); - } - - return true; - } catch (error) { - console.error('Error deleting file:', error); - return false; - } - } - - async deleteFolder(folderPath: string) { - try { - const wc = await webcontainer; - const relativePath = extractRelativePath(folderPath); - - await wc.fs.rm(relativePath, { recursive: true }); - - const selectedFile = this.selectedFile.get(); - - if (selectedFile && selectedFile.startsWith(folderPath)) { - this.setSelectedFile(undefined); - } - - return true; - } catch (error) { - console.error('Error deleting folder:', error); - return false; - } - } - - async renameFile(oldPath: string, newPath: string) { - try { - const wc = await webcontainer; - const oldRelativePath = extractRelativePath(oldPath); - const newRelativePath = extractRelativePath(newPath); - - const fileContent = await wc.fs.readFile(oldRelativePath, 'utf-8'); - - await this.createNewFile(newPath, fileContent); - - await this.deleteFile(oldPath); - - if (this.selectedFile.get() === oldPath) { - const fullNewPath = path.join(wc.workdir, newRelativePath); - this.setSelectedFile(fullNewPath); - } - - return true; - } catch (error) { - console.error('Error renaming file:', error); - return false; - } - } - - async renameFolder(oldPath: string, newPath: string) { - try { - await this.createNewFolder(newPath); - - const files = this.files.get(); - const filesToMove = Object.entries(files) - .filter(([filePath]) => filePath.startsWith(oldPath)) - .map(([filePath, dirent]) => ({ path: filePath, dirent })); - - for (const { path: filePath, dirent } of filesToMove) { - if (dirent?.type === 'file') { - const relativePath = filePath.substring(oldPath.length); - const newFilePath = path.join(newPath, relativePath); - - await this.createNewFile(newFilePath, dirent.content); - } - } - - await this.deleteFolder(oldPath); - - const selectedFile = this.selectedFile.get(); - - if (selectedFile && selectedFile.startsWith(oldPath)) { - const relativePath = selectedFile.substring(oldPath.length); - const newSelectedPath = path.join(newPath, relativePath); - this.setSelectedFile(newSelectedPath); - } - - return true; - } catch (error) { - console.error('Error renaming folder:', error); - return false; - } - } } export const workbenchStore = new WorkbenchStore();