mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-03-09 21:50:36 +00:00
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:
parent
964e1973fb
commit
8c83c3c9aa
@ -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':
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user