Skip to content

Commit 5b6b26b

Browse files
authored
Merge pull request #426 from wonderwhy-er/Folder-import-refinement
Refinement of folder import
2 parents 5615cc1 + 11f93a8 commit 5b6b26b

File tree

3 files changed

+223
-129
lines changed

3 files changed

+223
-129
lines changed

app/components/chat/ImportFolderButton.tsx

Lines changed: 63 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,73 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import type { Message } from 'ai';
33
import { toast } from 'react-toastify';
4-
import ignore from 'ignore';
4+
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '../../utils/fileUtils';
5+
import { createChatFromFolder } from '../../utils/folderImport';
56

67
interface ImportFolderButtonProps {
78
className?: string;
89
importChat?: (description: string, messages: Message[]) => Promise<void>;
910
}
1011

11-
// Common patterns to ignore, similar to .gitignore
12-
const IGNORE_PATTERNS = [
13-
'node_modules/**',
14-
'.git/**',
15-
'dist/**',
16-
'build/**',
17-
'.next/**',
18-
'coverage/**',
19-
'.cache/**',
20-
'.vscode/**',
21-
'.idea/**',
22-
'**/*.log',
23-
'**/.DS_Store',
24-
'**/npm-debug.log*',
25-
'**/yarn-debug.log*',
26-
'**/yarn-error.log*',
27-
];
28-
29-
const ig = ignore().add(IGNORE_PATTERNS);
30-
const generateId = () => Math.random().toString(36).substring(2, 15);
31-
32-
const isBinaryFile = async (file: File): Promise<boolean> => {
33-
const chunkSize = 1024; // Read the first 1 KB of the file
34-
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
35-
36-
for (let i = 0; i < buffer.length; i++) {
37-
const byte = buffer[i];
38-
39-
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
40-
return true; // Found a binary character
41-
}
42-
}
43-
44-
return false;
45-
};
46-
4712
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
48-
const shouldIncludeFile = (path: string): boolean => {
49-
return !ig.ignores(path);
50-
};
51-
52-
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
53-
const fileArtifacts = await Promise.all(
54-
files.map(async (file) => {
55-
return new Promise<string>((resolve, reject) => {
56-
const reader = new FileReader();
57-
58-
reader.onload = () => {
59-
const content = reader.result as string;
60-
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
61-
resolve(
62-
`<boltAction type="file" filePath="${relativePath}">
63-
${content}
64-
</boltAction>`,
65-
);
66-
};
67-
reader.onerror = reject;
68-
reader.readAsText(file);
69-
});
70-
}),
71-
);
72-
73-
const binaryFilesMessage =
74-
binaryFiles.length > 0
75-
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
76-
: '';
13+
const [isLoading, setIsLoading] = useState(false);
7714

78-
const message: Message = {
79-
role: 'assistant',
80-
content: `I'll help you set up these files.${binaryFilesMessage}
15+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
16+
const allFiles = Array.from(e.target.files || []);
8117

82-
<boltArtifact id="imported-files" title="Imported Files" type="bundled">
83-
${fileArtifacts.join('\n\n')}
84-
</boltArtifact>`,
85-
id: generateId(),
86-
createdAt: new Date(),
87-
};
88-
89-
const userMessage: Message = {
90-
role: 'user',
91-
id: generateId(),
92-
content: 'Import my files',
93-
createdAt: new Date(),
94-
};
95-
96-
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
97-
98-
if (importChat) {
99-
await importChat(description, [userMessage, message]);
18+
if (allFiles.length > MAX_FILES) {
19+
toast.error(
20+
`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.`
21+
);
22+
return;
23+
}
24+
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
25+
setIsLoading(true);
26+
const loadingToast = toast.loading(`Importing ${folderName}...`);
27+
28+
try {
29+
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
30+
31+
if (filteredFiles.length === 0) {
32+
toast.error('No files found in the selected folder');
33+
return;
34+
}
35+
36+
const fileChecks = await Promise.all(
37+
filteredFiles.map(async (file) => ({
38+
file,
39+
isBinary: await isBinaryFile(file),
40+
})),
41+
);
42+
43+
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
44+
const binaryFilePaths = fileChecks
45+
.filter((f) => f.isBinary)
46+
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
47+
48+
if (textFiles.length === 0) {
49+
toast.error('No text files found in the selected folder');
50+
return;
51+
}
52+
53+
if (binaryFilePaths.length > 0) {
54+
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
55+
}
56+
57+
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
58+
59+
if (importChat) {
60+
await importChat(folderName, [...messages]);
61+
}
62+
63+
toast.success('Folder imported successfully');
64+
} catch (error) {
65+
console.error('Failed to import folder:', error);
66+
toast.error('Failed to import folder');
67+
} finally {
68+
setIsLoading(false);
69+
toast.dismiss(loadingToast);
70+
e.target.value = ''; // Reset file input
10071
}
10172
};
10273

@@ -108,56 +79,19 @@ ${fileArtifacts.join('\n\n')}
10879
className="hidden"
10980
webkitdirectory=""
11081
directory=""
111-
onChange={async (e) => {
112-
const allFiles = Array.from(e.target.files || []);
113-
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
114-
115-
if (filteredFiles.length === 0) {
116-
toast.error('No files found in the selected folder');
117-
return;
118-
}
119-
120-
try {
121-
const fileChecks = await Promise.all(
122-
filteredFiles.map(async (file) => ({
123-
file,
124-
isBinary: await isBinaryFile(file),
125-
})),
126-
);
127-
128-
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
129-
const binaryFilePaths = fileChecks
130-
.filter((f) => f.isBinary)
131-
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
132-
133-
if (textFiles.length === 0) {
134-
toast.error('No text files found in the selected folder');
135-
return;
136-
}
137-
138-
if (binaryFilePaths.length > 0) {
139-
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
140-
}
141-
142-
await createChatFromFolder(textFiles, binaryFilePaths);
143-
} catch (error) {
144-
console.error('Failed to import folder:', error);
145-
toast.error('Failed to import folder');
146-
}
147-
148-
e.target.value = ''; // Reset file input
149-
}}
150-
{...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute
82+
onChange={handleFileChange}
83+
{...({} as any)}
15184
/>
15285
<button
15386
onClick={() => {
15487
const input = document.getElementById('folder-import');
15588
input?.click();
15689
}}
15790
className={className}
91+
disabled={isLoading}
15892
>
15993
<div className="i-ph:upload-simple" />
160-
Import Folder
94+
{isLoading ? 'Importing...' : 'Import Folder'}
16195
</button>
16296
</>
16397
);

app/utils/fileUtils.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import ignore from 'ignore';
2+
3+
// Common patterns to ignore, similar to .gitignore
4+
export const IGNORE_PATTERNS = [
5+
'node_modules/**',
6+
'.git/**',
7+
'dist/**',
8+
'build/**',
9+
'.next/**',
10+
'coverage/**',
11+
'.cache/**',
12+
'.vscode/**',
13+
'.idea/**',
14+
'**/*.log',
15+
'**/.DS_Store',
16+
'**/npm-debug.log*',
17+
'**/yarn-debug.log*',
18+
'**/yarn-error.log*',
19+
];
20+
21+
export const MAX_FILES = 1000;
22+
export const ig = ignore().add(IGNORE_PATTERNS);
23+
24+
export const generateId = () => Math.random().toString(36).substring(2, 15);
25+
26+
export const isBinaryFile = async (file: File): Promise<boolean> => {
27+
const chunkSize = 1024;
28+
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
29+
30+
for (let i = 0; i < buffer.length; i++) {
31+
const byte = buffer[i];
32+
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
33+
return true;
34+
}
35+
}
36+
return false;
37+
};
38+
39+
export const shouldIncludeFile = (path: string): boolean => {
40+
return !ig.ignores(path);
41+
};
42+
43+
const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => {
44+
const packageJsonFile = files.find(f => f.webkitRelativePath.endsWith('package.json'));
45+
if (!packageJsonFile) return null;
46+
47+
try {
48+
const content = await new Promise<string>((resolve, reject) => {
49+
const reader = new FileReader();
50+
reader.onload = () => resolve(reader.result as string);
51+
reader.onerror = reject;
52+
reader.readAsText(packageJsonFile);
53+
});
54+
55+
return JSON.parse(content);
56+
} catch (error) {
57+
console.error('Error reading package.json:', error);
58+
return null;
59+
}
60+
};
61+
62+
export const detectProjectType = async (files: File[]): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
63+
const hasFile = (name: string) => files.some(f => f.webkitRelativePath.endsWith(name));
64+
65+
if (hasFile('package.json')) {
66+
const packageJson = await readPackageJson(files);
67+
const scripts = packageJson?.scripts || {};
68+
69+
// Check for preferred commands in priority order
70+
const preferredCommands = ['dev', 'start', 'preview'];
71+
const availableCommand = preferredCommands.find(cmd => scripts[cmd]);
72+
73+
if (availableCommand) {
74+
return {
75+
type: 'Node.js',
76+
setupCommand: `npm install && npm run ${availableCommand}`,
77+
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`
78+
};
79+
}
80+
81+
return {
82+
type: 'Node.js',
83+
setupCommand: 'npm install',
84+
followupMessage: 'Would you like me to inspect package.json to determine the available scripts for running this project?'
85+
};
86+
}
87+
88+
if (hasFile('index.html')) {
89+
return {
90+
type: 'Static',
91+
setupCommand: 'npx --yes serve',
92+
followupMessage: ''
93+
};
94+
}
95+
96+
return { type: '', setupCommand: '', followupMessage: '' };
97+
};

app/utils/folderImport.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Message } from 'ai';
2+
import { generateId, detectProjectType } from './fileUtils';
3+
4+
export const createChatFromFolder = async (
5+
files: File[],
6+
binaryFiles: string[],
7+
folderName: string
8+
): Promise<Message[]> => {
9+
const fileArtifacts = await Promise.all(
10+
files.map(async (file) => {
11+
return new Promise<string>((resolve, reject) => {
12+
const reader = new FileReader();
13+
reader.onload = () => {
14+
const content = reader.result as string;
15+
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
16+
resolve(
17+
`<boltAction type="file" filePath="${relativePath}">
18+
${content}
19+
</boltAction>`,
20+
);
21+
};
22+
reader.onerror = reject;
23+
reader.readAsText(file);
24+
});
25+
}),
26+
);
27+
28+
const project = await detectProjectType(files);
29+
const setupCommand = project.setupCommand ? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>` : '';
30+
const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : '';
31+
32+
const binaryFilesMessage = binaryFiles.length > 0
33+
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
34+
: '';
35+
36+
const assistantMessages: Message[] = [{
37+
role: 'assistant',
38+
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
39+
40+
<boltArtifact id="imported-files" title="Imported Files">
41+
${fileArtifacts.join('\n\n')}
42+
</boltArtifact>`,
43+
id: generateId(),
44+
createdAt: new Date(),
45+
},{
46+
role: 'assistant',
47+
content: `
48+
<boltArtifact id="imported-files" title="Imported Files">
49+
${setupCommand}
50+
</boltArtifact>${followupMessage}`,
51+
id: generateId(),
52+
createdAt: new Date(),
53+
}];
54+
55+
const userMessage: Message = {
56+
role: 'user',
57+
id: generateId(),
58+
content: `Import the "${folderName}" folder`,
59+
createdAt: new Date(),
60+
};
61+
62+
return [ userMessage, ...assistantMessages ];
63+
};

0 commit comments

Comments
 (0)