mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: import folder
added the ability to import a local folder
This commit is contained in:
commit
5715a041e6
@ -11,6 +11,7 @@ import { IconButton } from '~/components/ui/IconButton';
|
|||||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import GitCloneButton from './GitCloneButton';
|
import GitCloneButton from './GitCloneButton';
|
||||||
|
import { ImportFolderButton } from './ImportFolderButton';
|
||||||
|
|
||||||
interface BaseChatProps {
|
interface BaseChatProps {
|
||||||
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
||||||
@ -224,12 +225,20 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
</div>
|
</div>
|
||||||
{!chatStarted && (
|
{!chatStarted && (
|
||||||
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex flex-col items-center">
|
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex flex-col items-center">
|
||||||
<GitCloneButton importChat={async (description, messages) => {
|
<div className="flex gap-4">
|
||||||
sendMessage?.(new Event('click') as any, description);
|
<GitCloneButton importChat={async (description, messages) => {
|
||||||
messages.forEach((message) => {
|
sendMessage?.(new Event('click') as any, description);
|
||||||
sendMessage?.(new Event('click') as any, message.content);
|
messages.forEach((message) => {
|
||||||
});
|
sendMessage?.(new Event('click') as any, message.content);
|
||||||
}} />
|
});
|
||||||
|
}} />
|
||||||
|
<ImportFolderButton importChat={async (description, messages) => {
|
||||||
|
sendMessage?.(new Event('click') as any, description);
|
||||||
|
messages.forEach((message) => {
|
||||||
|
sendMessage?.(new Event('click') as any, message.content);
|
||||||
|
});
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-col space-y-2 mt-4 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
|
<div className="flex flex-col space-y-2 mt-4 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
|
||||||
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
|
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
167
app/components/chat/ImportFolderButton.tsx
Normal file
167
app/components/chat/ImportFolderButton.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Message } from 'ai';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import ignore from 'ignore';
|
||||||
|
import WithTooltip from '~/components/ui/Tooltip';
|
||||||
|
|
||||||
|
interface ImportFolderButtonProps {
|
||||||
|
className?: string;
|
||||||
|
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common patterns to ignore, similar to .gitignore
|
||||||
|
const IGNORE_PATTERNS = [
|
||||||
|
'node_modules/**',
|
||||||
|
'.git/**',
|
||||||
|
'dist/**',
|
||||||
|
'build/**',
|
||||||
|
'.next/**',
|
||||||
|
'coverage/**',
|
||||||
|
'.cache/**',
|
||||||
|
'.vscode/**',
|
||||||
|
'.idea/**',
|
||||||
|
'**/*.log',
|
||||||
|
'**/.DS_Store',
|
||||||
|
'**/npm-debug.log*',
|
||||||
|
'**/yarn-debug.log*',
|
||||||
|
'**/yarn-error.log*',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ig = ignore().add(IGNORE_PATTERNS);
|
||||||
|
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||||
|
const chunkSize = 1024; // Read the first 1 KB of the file
|
||||||
|
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||||
|
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
const byte = buffer[i];
|
||||||
|
|
||||||
|
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||||
|
return true; // Found a binary character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
||||||
|
const shouldIncludeFile = (path: string): boolean => {
|
||||||
|
return !ig.ignores(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
|
||||||
|
const fileArtifacts = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const content = reader.result as string;
|
||||||
|
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
||||||
|
resolve(
|
||||||
|
`<boltAction type="file" filePath="${relativePath}">
|
||||||
|
${content}
|
||||||
|
</boltAction>`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const binaryFilesMessage =
|
||||||
|
binaryFiles.length > 0
|
||||||
|
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: `I'll help you set up these files.${binaryFilesMessage}
|
||||||
|
|
||||||
|
<boltArtifact id="imported-files" title="Imported Files">
|
||||||
|
${fileArtifacts.join('\n\n')}
|
||||||
|
</boltArtifact>`,
|
||||||
|
id: generateId(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
role: 'user',
|
||||||
|
id: generateId(),
|
||||||
|
content: 'Import my files',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
|
||||||
|
|
||||||
|
if (importChat) {
|
||||||
|
await importChat(description, [userMessage, message]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WithTooltip tooltip="Import Local Folder">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="folder-import"
|
||||||
|
className="hidden"
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
onChange={async (e) => {
|
||||||
|
const allFiles = Array.from(e.target.files || []);
|
||||||
|
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||||
|
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
toast.error('No files found in the selected folder');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileChecks = await Promise.all(
|
||||||
|
filteredFiles.map(async (file) => ({
|
||||||
|
file,
|
||||||
|
isBinary: await isBinaryFile(file),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||||
|
const binaryFilePaths = fileChecks
|
||||||
|
.filter((f) => f.isBinary)
|
||||||
|
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||||
|
|
||||||
|
if (textFiles.length === 0) {
|
||||||
|
toast.error('No text files found in the selected folder');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binaryFilePaths.length > 0) {
|
||||||
|
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createChatFromFolder(textFiles, binaryFilePaths);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import folder:', error);
|
||||||
|
toast.error('Failed to import folder');
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''; // Reset file input
|
||||||
|
}}
|
||||||
|
{...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('folder-import');
|
||||||
|
input?.click();
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="i-ph:folder-duotone" />
|
||||||
|
Import Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user