feat: add creation of files and folders in the FileTree

- Drag and drop images directly in the file tree. Image will convert to base64 format
This commit is contained in:
KevIsDev 2025-03-03 12:16:13 +00:00
parent 964e1973fb
commit 8c83c3c9aa
2 changed files with 257 additions and 23 deletions

View File

@ -1,10 +1,13 @@
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import * as ContextMenu from '@radix-ui/react-context-menu';
import type { FileHistory } from '~/types/actions';
import { diffLines, type Change } from 'diff';
import { workbenchStore } from '~/lib/stores/workbench';
import { toast } from 'react-toastify';
import { path } from '~/utils/path';
const logger = createScopedLogger('FileTree');
@ -25,6 +28,13 @@ interface Props {
className?: string;
}
interface InlineInputProps {
depth: number;
placeholder: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
export const FileTree = memo(
({
files = {},
@ -213,28 +223,204 @@ function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; childr
);
}
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
function InlineInput({ depth, placeholder, onSubmit, onCancel }: InlineInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 50);
return () => clearTimeout(timer);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
const value = inputRef.current?.value.trim();
if (value) {
onSubmit(value);
}
} else if (e.key === 'Escape') {
onCancel();
}
};
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
style={{ zIndex: 998 }}
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
>
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
<div
className="flex items-center w-full px-2 bg-bolt-elements-background-depth-4 border border-bolt-elements-item-contentAccent py-0.5 text-bolt-elements-textPrimary"
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
>
<div className="scale-120 shrink-0 i-ph:file-plus text-bolt-elements-textTertiary" />
<input
ref={inputRef}
type="text"
className="ml-2 flex-1 bg-transparent border-none outline-none py-0.5 text-sm text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary min-w-0"
placeholder={placeholder}
onKeyDown={handleKeyDown}
onBlur={() => {
setTimeout(() => {
if (document.activeElement !== inputRef.current) {
onCancel();
}
}, 100);
}}
/>
</div>
);
}
// Modify the FileContextMenu component
function FileContextMenu({
onCopyPath,
onCopyRelativePath,
fullPath,
children,
}: FolderContextMenuProps & { fullPath: string }) {
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const items = Array.from(e.dataTransfer.items);
const imageFiles = items.filter((item) => item.type.startsWith('image/'));
for (const item of imageFiles) {
const file = item.getAsFile();
if (file) {
try {
const filePath = path.join(fullPath, file.name);
const success = await workbenchStore.createNewFile(filePath, file);
if (success) {
toast.success(`Image ${file.name} uploaded successfully`);
} else {
toast.error(`Failed to upload image ${file.name}`);
}
} catch (error) {
toast.error(`Error uploading ${file.name}`);
logger.error(error);
}
}
}
setIsDragging(false);
},
[fullPath],
);
const handleCreateFile = async (fileName: string) => {
const newFilePath = path.join(fullPath, fileName);
const success = await workbenchStore.createNewFile(newFilePath);
if (success) {
toast.success('File created successfully');
} else {
toast.error('Failed to create file');
}
setIsCreatingFile(false);
};
const handleCreateFolder = async (folderName: string) => {
const newFolderPath = path.join(fullPath, folderName);
const success = await workbenchStore.createNewFolder(newFolderPath);
if (success) {
toast.success('Folder created successfully');
} else {
toast.error('Failed to create folder');
}
setIsCreatingFolder(false);
};
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={classNames('relative', {
'bg-bolt-elements-background-depth-2 border border-dashed border-bolt-elements-item-contentAccent rounded-md':
isDragging,
})}
>
{children}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
style={{ zIndex: 998 }}
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
>
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={() => setIsCreatingFile(true)}>
<div className="flex items-center gap-2">
<div className="i-ph:file-plus" />
New File
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={() => setIsCreatingFolder(true)}>
<div className="flex items-center gap-2">
<div className="i-ph:folder-plus" />
New Folder
</div>
</ContextMenuItem>
</ContextMenu.Group>
<ContextMenu.Group className="p-1">
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
{isCreatingFile && (
<InlineInput
depth={depth}
placeholder="Enter file name..."
onSubmit={handleCreateFile}
onCancel={() => setIsCreatingFile(false)}
/>
)}
{isCreatingFolder && (
<InlineInput
depth={depth}
placeholder="Enter folder name..."
onSubmit={handleCreateFolder}
onCancel={() => setIsCreatingFolder(false)}
/>
)}
</>
);
}
// Update the Folder component to pass the fullPath
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={folder.fullPath}>
<NodeButton
className={classNames('group', {
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
@ -254,6 +440,7 @@ function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativ
);
}
// Add this interface after the FolderProps interface
interface FileProps {
file: FileNode;
selected: boolean;
@ -265,7 +452,7 @@ interface FileProps {
}
function File({
file: { depth, name, fullPath },
file,
onClick,
onCopyPath,
onCopyRelativePath,
@ -273,17 +460,16 @@ function File({
unsavedChanges = false,
fileHistory = {},
}: FileProps) {
const { depth, name, fullPath } = file;
const parentPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
const fileModifications = fileHistory[fullPath];
// const hasModifications = fileModifications !== undefined;
// Calculate added and removed lines from the most recent changes
const { additions, deletions } = useMemo(() => {
if (!fileModifications?.originalContent) {
return { additions: 0, deletions: 0 };
}
// Usar a mesma lógica do DiffView para processar as mudanças
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
@ -317,7 +503,7 @@ function File({
const showStats = additions > 0 || deletions > 0;
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={parentPath}>
<NodeButton
className={classNames('group', {
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':

View File

@ -547,6 +547,54 @@ 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;
}
}
}
export const workbenchStore = new WorkbenchStore();