Refinement of folder import
Browse files- app/components/chat/ImportFolderButton.tsx +63 -128
- app/utils/fileUtils.ts +97 -0
- app/utils/folderImport.ts +56 -0
app/components/chat/ImportFolderButton.tsx
CHANGED
@@ -1,102 +1,74 @@
|
|
1 |
-
import React from 'react';
|
2 |
import type { Message } from 'ai';
|
3 |
import { toast } from 'react-toastify';
|
4 |
-
import
|
|
|
5 |
|
6 |
interface ImportFolderButtonProps {
|
7 |
className?: string;
|
8 |
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
9 |
}
|
10 |
|
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 |
-
|
47 |
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
48 |
-
const
|
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 |
-
: '';
|
77 |
|
78 |
-
|
79 |
-
|
80 |
-
content: `I'll help you set up these files.${binaryFilesMessage}
|
81 |
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
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 |
-
|
99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
}
|
101 |
};
|
102 |
|
@@ -108,46 +80,8 @@ ${fileArtifacts.join('\n\n')}
|
|
108 |
className="hidden"
|
109 |
webkitdirectory=""
|
110 |
directory=""
|
111 |
-
onChange={
|
112 |
-
|
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
|
151 |
/>
|
152 |
<button
|
153 |
onClick={() => {
|
@@ -155,9 +89,10 @@ ${fileArtifacts.join('\n\n')}
|
|
155 |
input?.click();
|
156 |
}}
|
157 |
className={className}
|
|
|
158 |
>
|
159 |
<div className="i-ph:folder-simple-upload" />
|
160 |
-
Import Folder
|
161 |
</button>
|
162 |
</>
|
163 |
);
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
import type { Message } from 'ai';
|
3 |
import { toast } from 'react-toastify';
|
4 |
+
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '../../utils/fileUtils';
|
5 |
+
import { createChatFromFolder } from '../../utils/folderImport';
|
6 |
|
7 |
interface ImportFolderButtonProps {
|
8 |
className?: string;
|
9 |
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
10 |
}
|
11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
13 |
+
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
|
15 |
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
16 |
+
const allFiles = Array.from(e.target.files || []);
|
|
|
17 |
|
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 |
|
25 |
+
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
|
26 |
+
setIsLoading(true);
|
27 |
+
const loadingToast = toast.loading(`Importing ${folderName}...`);
|
28 |
+
|
29 |
+
try {
|
30 |
+
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
31 |
+
|
32 |
+
if (filteredFiles.length === 0) {
|
33 |
+
toast.error('No files found in the selected folder');
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
|
37 |
+
const fileChecks = await Promise.all(
|
38 |
+
filteredFiles.map(async (file) => ({
|
39 |
+
file,
|
40 |
+
isBinary: await isBinaryFile(file),
|
41 |
+
})),
|
42 |
+
);
|
43 |
+
|
44 |
+
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
45 |
+
const binaryFilePaths = fileChecks
|
46 |
+
.filter((f) => f.isBinary)
|
47 |
+
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
48 |
+
|
49 |
+
if (textFiles.length === 0) {
|
50 |
+
toast.error('No text files found in the selected folder');
|
51 |
+
return;
|
52 |
+
}
|
53 |
+
|
54 |
+
if (binaryFilePaths.length > 0) {
|
55 |
+
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
56 |
+
}
|
57 |
+
|
58 |
+
const { userMessage, assistantMessage } = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
|
59 |
+
|
60 |
+
if (importChat) {
|
61 |
+
await importChat(folderName, [userMessage, assistantMessage]);
|
62 |
+
}
|
63 |
+
|
64 |
+
toast.success('Folder imported successfully');
|
65 |
+
} catch (error) {
|
66 |
+
console.error('Failed to import folder:', error);
|
67 |
+
toast.error('Failed to import folder');
|
68 |
+
} finally {
|
69 |
+
setIsLoading(false);
|
70 |
+
toast.dismiss(loadingToast);
|
71 |
+
e.target.value = ''; // Reset file input
|
72 |
}
|
73 |
};
|
74 |
|
|
|
80 |
className="hidden"
|
81 |
webkitdirectory=""
|
82 |
directory=""
|
83 |
+
onChange={handleFileChange}
|
84 |
+
{...({} as any)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
/>
|
86 |
<button
|
87 |
onClick={() => {
|
|
|
89 |
input?.click();
|
90 |
}}
|
91 |
className={className}
|
92 |
+
disabled={isLoading}
|
93 |
>
|
94 |
<div className="i-ph:folder-simple-upload" />
|
95 |
+
{isLoading ? 'Importing...' : 'Import Folder'}
|
96 |
</button>
|
97 |
</>
|
98 |
);
|
app/utils/fileUtils.ts
ADDED
@@ -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
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<{ userMessage: Message; assistantMessage: 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 assistantMessage: 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 |
+
${setupCommand}
|
43 |
+
</boltArtifact>${followupMessage}`,
|
44 |
+
id: generateId(),
|
45 |
+
createdAt: new Date(),
|
46 |
+
};
|
47 |
+
|
48 |
+
const userMessage: Message = {
|
49 |
+
role: 'user',
|
50 |
+
id: generateId(),
|
51 |
+
content: `Import the "${folderName}" folder`,
|
52 |
+
createdAt: new Date(),
|
53 |
+
};
|
54 |
+
|
55 |
+
return { userMessage, assistantMessage };
|
56 |
+
};
|