mirror of
https://github.com/stackblitz/bolt.new
synced 2025-02-05 20:46:43 +00:00
Merge pull request #426 from wonderwhy-er/Folder-import-refinement
Refinement of folder import
This commit is contained in:
commit
5b6b26bc9c
@ -1,102 +1,73 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { Message } from 'ai';
|
import type { Message } from 'ai';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import ignore from 'ignore';
|
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '../../utils/fileUtils';
|
||||||
|
import { createChatFromFolder } from '../../utils/folderImport';
|
||||||
|
|
||||||
interface ImportFolderButtonProps {
|
interface ImportFolderButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
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 }) => {
|
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
||||||
const shouldIncludeFile = (path: string): boolean => {
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
return !ig.ignores(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const fileArtifacts = await Promise.all(
|
const allFiles = Array.from(e.target.files || []);
|
||||||
files.map(async (file) => {
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
if (allFiles.length > MAX_FILES) {
|
||||||
const content = reader.result as string;
|
toast.error(
|
||||||
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`
|
||||||
resolve(
|
);
|
||||||
`<boltAction type="file" filePath="${relativePath}">
|
return;
|
||||||
${content}
|
}
|
||||||
</boltAction>`,
|
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
|
||||||
);
|
setIsLoading(true);
|
||||||
};
|
const loadingToast = toast.loading(`Importing ${folderName}...`);
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const binaryFilesMessage =
|
try {
|
||||||
binaryFiles.length > 0
|
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const message: Message = {
|
if (filteredFiles.length === 0) {
|
||||||
role: 'assistant',
|
toast.error('No files found in the selected folder');
|
||||||
content: `I'll help you set up these files.${binaryFilesMessage}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
<boltArtifact id="imported-files" title="Imported Files" type="bundled">
|
const fileChecks = await Promise.all(
|
||||||
${fileArtifacts.join('\n\n')}
|
filteredFiles.map(async (file) => ({
|
||||||
</boltArtifact>`,
|
file,
|
||||||
id: generateId(),
|
isBinary: await isBinaryFile(file),
|
||||||
createdAt: new Date(),
|
})),
|
||||||
};
|
);
|
||||||
|
|
||||||
const userMessage: Message = {
|
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||||
role: 'user',
|
const binaryFilePaths = fileChecks
|
||||||
id: generateId(),
|
.filter((f) => f.isBinary)
|
||||||
content: 'Import my files',
|
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
|
if (textFiles.length === 0) {
|
||||||
|
toast.error('No text files found in the selected folder');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (importChat) {
|
if (binaryFilePaths.length > 0) {
|
||||||
await importChat(description, [userMessage, message]);
|
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
|
||||||
|
|
||||||
|
if (importChat) {
|
||||||
|
await importChat(folderName, [...messages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Folder imported successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import folder:', error);
|
||||||
|
toast.error('Failed to import folder');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
e.target.value = ''; // Reset file input
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,46 +79,8 @@ ${fileArtifacts.join('\n\n')}
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
webkitdirectory=""
|
webkitdirectory=""
|
||||||
directory=""
|
directory=""
|
||||||
onChange={async (e) => {
|
onChange={handleFileChange}
|
||||||
const allFiles = Array.from(e.target.files || []);
|
{...({} as any)}
|
||||||
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -155,9 +88,10 @@ ${fileArtifacts.join('\n\n')}
|
|||||||
input?.click();
|
input?.click();
|
||||||
}}
|
}}
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<div className="i-ph:upload-simple" />
|
<div className="i-ph:upload-simple" />
|
||||||
Import Folder
|
{isLoading ? 'Importing...' : 'Import Folder'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
97
app/utils/fileUtils.ts
Normal file
97
app/utils/fileUtils.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import ignore from 'ignore';
|
||||||
|
|
||||||
|
// Common patterns to ignore, similar to .gitignore
|
||||||
|
export 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*',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAX_FILES = 1000;
|
||||||
|
export const ig = ignore().add(IGNORE_PATTERNS);
|
||||||
|
|
||||||
|
export const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
export const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||||
|
const chunkSize = 1024;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldIncludeFile = (path: string): boolean => {
|
||||||
|
return !ig.ignores(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => {
|
||||||
|
const packageJsonFile = files.find(f => f.webkitRelativePath.endsWith('package.json'));
|
||||||
|
if (!packageJsonFile) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsText(packageJsonFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading package.json:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectProjectType = async (files: File[]): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
|
||||||
|
const hasFile = (name: string) => files.some(f => f.webkitRelativePath.endsWith(name));
|
||||||
|
|
||||||
|
if (hasFile('package.json')) {
|
||||||
|
const packageJson = await readPackageJson(files);
|
||||||
|
const scripts = packageJson?.scripts || {};
|
||||||
|
|
||||||
|
// Check for preferred commands in priority order
|
||||||
|
const preferredCommands = ['dev', 'start', 'preview'];
|
||||||
|
const availableCommand = preferredCommands.find(cmd => scripts[cmd]);
|
||||||
|
|
||||||
|
if (availableCommand) {
|
||||||
|
return {
|
||||||
|
type: 'Node.js',
|
||||||
|
setupCommand: `npm install && npm run ${availableCommand}`,
|
||||||
|
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'Node.js',
|
||||||
|
setupCommand: 'npm install',
|
||||||
|
followupMessage: 'Would you like me to inspect package.json to determine the available scripts for running this project?'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFile('index.html')) {
|
||||||
|
return {
|
||||||
|
type: 'Static',
|
||||||
|
setupCommand: 'npx --yes serve',
|
||||||
|
followupMessage: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: '', setupCommand: '', followupMessage: '' };
|
||||||
|
};
|
63
app/utils/folderImport.ts
Normal file
63
app/utils/folderImport.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { Message } from 'ai';
|
||||||
|
import { generateId, detectProjectType } from './fileUtils';
|
||||||
|
|
||||||
|
export const createChatFromFolder = async (
|
||||||
|
files: File[],
|
||||||
|
binaryFiles: string[],
|
||||||
|
folderName: string
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
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 project = await detectProjectType(files);
|
||||||
|
const setupCommand = project.setupCommand ? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>` : '';
|
||||||
|
const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : '';
|
||||||
|
|
||||||
|
const binaryFilesMessage = binaryFiles.length > 0
|
||||||
|
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const assistantMessages: Message[] = [{
|
||||||
|
role: 'assistant',
|
||||||
|
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
|
||||||
|
|
||||||
|
<boltArtifact id="imported-files" title="Imported Files">
|
||||||
|
${fileArtifacts.join('\n\n')}
|
||||||
|
</boltArtifact>`,
|
||||||
|
id: generateId(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},{
|
||||||
|
role: 'assistant',
|
||||||
|
content: `
|
||||||
|
<boltArtifact id="imported-files" title="Imported Files">
|
||||||
|
${setupCommand}
|
||||||
|
</boltArtifact>${followupMessage}`,
|
||||||
|
id: generateId(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
role: 'user',
|
||||||
|
id: generateId(),
|
||||||
|
content: `Import the "${folderName}" folder`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ userMessage, ...assistantMessages ];
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user