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
This commit is contained in:
KevIsDev 2025-03-10 11:12:25 +00:00
parent b079a56788
commit f02e10c9ac
3 changed files with 474 additions and 230 deletions

View File

@ -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 && (
<InlineInput
depth={depth}
placeholder="Enter new name..."
initialValue={fileName}
onSubmit={handleRename}
onCancel={() => setIsRenaming(false)}
/>
)}
{children}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
@ -459,23 +446,20 @@ function FileContextMenu({
New Folder
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={() => setIsRenaming(true)}>
<div className="flex items-center gap-2">
<div className="i-ph:pencil-simple" />
Rename
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={handleDelete}>
<div className="flex items-center gap-2">
<div className="i-ph:trash text-red-500" />
<span className="text-red-500">Delete</span>
</div>
</ContextMenuItem>
</ContextMenu.Group>
<ContextMenu.Group className="p-1">
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
{/* Add delete option in a new group */}
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={handleDelete}>
<div className="flex items-center gap-2 text-red-500">
<div className="i-ph:trash" />
Delete {isFolder ? 'Folder' : 'File'}
</div>
</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
@ -495,7 +479,6 @@ function FileContextMenu({
onCancel={() => setIsCreatingFolder(false)}
/>
)}
{/* Remove the isRenaming InlineInput from here since we moved it above */}
</>
);
}

View File

@ -42,6 +42,11 @@ export class FilesStore {
*/
#modifiedFiles: Map<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map();
/**
* Keeps track of deleted files and folders to prevent them from reappearing on reload
*/
#deletedPaths: Set<string> = 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<WebContainer>) {
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) {

View File

@ -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<string>();
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();