diff --git a/README.md b/README.md
index 33f861f..9ac2581 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ https://thinktank.ottomator.ai
- ✅ Mobile friendly (@qwikode)
- ✅ Better prompt enhancing (@SujalXplores)
- ✅ Attach images to prompts (@atrokhym)
+- ✅ Detect package.json and commands to auto install and run preview for folder and git import (@wonderwhy-er)
- ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
diff --git a/app/commit.json b/app/commit.json
index a756318..64fcbce 100644
--- a/app/commit.json
+++ b/app/commit.json
@@ -1 +1 @@
-{ "commit": "95e38e020cc8a4d865172187fc25c94b39806275" }
+{ "commit": "67f63aaf31f406379daa97708d6a1a9f8ac41d43" }
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx
index ddecdc8..7b7c9f7 100644
--- a/app/components/chat/GitCloneButton.tsx
+++ b/app/components/chat/GitCloneButton.tsx
@@ -2,6 +2,8 @@ import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import WithTooltip from '~/components/ui/Tooltip';
+import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
+import { generateId } from '~/utils/fileUtils';
const IGNORE_PATTERNS = [
'node_modules/**',
@@ -28,7 +30,6 @@ const IGNORE_PATTERNS = [
];
const ig = ignore().add(IGNORE_PATTERNS);
-const generateId = () => Math.random().toString(36).substring(2, 15);
interface GitCloneButtonProps {
className?: string;
@@ -52,36 +53,47 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
- const message: Message = {
+
+ // Convert files to common format for command detection
+ const fileContents = filePaths
+ .map((filePath) => {
+ const { data: content, encoding } = data[filePath];
+ return {
+ path: filePath,
+ content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
+ };
+ })
+ .filter((f) => f.content);
+
+ // Detect and create commands message
+ const commands = await detectProjectCommands(fileContents);
+ const commandsMessage = createCommandsMessage(commands);
+
+ // Create files message
+ const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
-
- ${filePaths
- .map((filePath) => {
- const { data: content, encoding } = data[filePath];
-
- if (encoding === 'utf8') {
- return `
-${content}
-`;
- } else if (content instanceof Uint8Array) {
- return `
-${textDecoder.decode(content)}
-`;
- } else {
- return '';
- }
- })
- .join('\n')}
- `,
+
+${fileContents
+ .map(
+ (file) =>
+ `
+${file.content}
+`,
+ )
+ .join('\n')}
+`,
id: generateId(),
createdAt: new Date(),
};
- console.log(JSON.stringify(message));
- importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]);
+ const messages = [filesMessage];
- // console.log(files);
+ if (commandsMessage) {
+ messages.push(commandsMessage);
+ }
+
+ await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
}
};
diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx
index 3da78c1..6cbfcac 100644
--- a/app/components/chat/ImportFolderButton.tsx
+++ b/app/components/chat/ImportFolderButton.tsx
@@ -1,102 +1,75 @@
-import React from 'react';
+import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
-import ignore from 'ignore';
+import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
+import { createChatFromFolder } from '~/utils/folderImport';
interface ImportFolderButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise;
}
-// 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 => {
- 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 = ({ className, importChat }) => {
- const shouldIncludeFile = (path: string): boolean => {
- return !ig.ignores(path);
- };
+ const [isLoading, setIsLoading] = useState(false);
- const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
- const fileArtifacts = await Promise.all(
- files.map(async (file) => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const allFiles = Array.from(e.target.files || []);
- reader.onload = () => {
- const content = reader.result as string;
- const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
- resolve(
- `
-${content}
-`,
- );
- };
- reader.onerror = reject;
- reader.readAsText(file);
- });
- }),
- );
+ if (allFiles.length > MAX_FILES) {
+ toast.error(
+ `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.`,
+ );
+ return;
+ }
- const binaryFilesMessage =
- binaryFiles.length > 0
- ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
- : '';
+ const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
+ setIsLoading(true);
- const message: Message = {
- role: 'assistant',
- content: `I'll help you set up these files.${binaryFilesMessage}
+ const loadingToast = toast.loading(`Importing ${folderName}...`);
-
-${fileArtifacts.join('\n\n')}
-`,
- id: generateId(),
- createdAt: new Date(),
- };
+ try {
+ const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
- const userMessage: Message = {
- role: 'user',
- id: generateId(),
- content: 'Import my files',
- createdAt: new Date(),
- };
+ if (filteredFiles.length === 0) {
+ toast.error('No files found in the selected folder');
+ return;
+ }
- const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
+ const fileChecks = await Promise.all(
+ filteredFiles.map(async (file) => ({
+ file,
+ isBinary: await isBinaryFile(file),
+ })),
+ );
- if (importChat) {
- await importChat(description, [userMessage, message]);
+ 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`);
+ }
+
+ 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 +81,8 @@ ${fileArtifacts.join('\n\n')}
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
+ onChange={handleFileChange}
+ {...({} as any)}
/>
>
);
diff --git a/app/components/chat/SendButton.client.tsx b/app/components/chat/SendButton.client.tsx
index c5aa830..389ca3b 100644
--- a/app/components/chat/SendButton.client.tsx
+++ b/app/components/chat/SendButton.client.tsx
@@ -23,6 +23,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
disabled={disabled}
onClick={(event) => {
event.preventDefault();
+
if (!disabled) {
onClick?.(event);
}
diff --git a/app/utils/fileUtils.ts b/app/utils/fileUtils.ts
new file mode 100644
index 0000000..fcf2a01
--- /dev/null
+++ b/app/utils/fileUtils.ts
@@ -0,0 +1,105 @@
+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 => {
+ 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 } | null> => {
+ const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));
+
+ if (!packageJsonFile) {
+ return null;
+ }
+
+ try {
+ const content = await new Promise((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: '' };
+};
diff --git a/app/utils/folderImport.ts b/app/utils/folderImport.ts
new file mode 100644
index 0000000..759df10
--- /dev/null
+++ b/app/utils/folderImport.ts
@@ -0,0 +1,68 @@
+import type { Message } from 'ai';
+import { generateId } from './fileUtils';
+import { detectProjectCommands, createCommandsMessage } from './projectCommands';
+
+export const createChatFromFolder = async (
+ files: File[],
+ binaryFiles: string[],
+ folderName: string,
+): Promise => {
+ const fileArtifacts = await Promise.all(
+ files.map(async (file) => {
+ return new Promise<{ content: string; path: string }>((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = () => {
+ const content = reader.result as string;
+ const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
+ resolve({
+ content,
+ path: relativePath,
+ });
+ };
+ reader.onerror = reject;
+ reader.readAsText(file);
+ });
+ }),
+ );
+
+ const commands = await detectProjectCommands(fileArtifacts);
+ const commandsMessage = createCommandsMessage(commands);
+
+ const binaryFilesMessage =
+ binaryFiles.length > 0
+ ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
+ : '';
+
+ const filesMessage: Message = {
+ role: 'assistant',
+ content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
+
+
+${fileArtifacts
+ .map(
+ (file) => `
+${file.content}
+`,
+ )
+ .join('\n\n')}
+`,
+ id: generateId(),
+ createdAt: new Date(),
+ };
+
+ const userMessage: Message = {
+ role: 'user',
+ id: generateId(),
+ content: `Import the "${folderName}" folder`,
+ createdAt: new Date(),
+ };
+
+ const messages = [userMessage, filesMessage];
+
+ if (commandsMessage) {
+ messages.push(commandsMessage);
+ }
+
+ return messages;
+};
diff --git a/app/utils/projectCommands.ts b/app/utils/projectCommands.ts
new file mode 100644
index 0000000..050663a
--- /dev/null
+++ b/app/utils/projectCommands.ts
@@ -0,0 +1,80 @@
+import type { Message } from 'ai';
+import { generateId } from './fileUtils';
+
+export interface ProjectCommands {
+ type: string;
+ setupCommand: string;
+ followupMessage: string;
+}
+
+interface FileContent {
+ content: string;
+ path: string;
+}
+
+export async function detectProjectCommands(files: FileContent[]): Promise {
+ const hasFile = (name: string) => files.some((f) => f.path.endsWith(name));
+
+ if (hasFile('package.json')) {
+ const packageJsonFile = files.find((f) => f.path.endsWith('package.json'));
+
+ if (!packageJsonFile) {
+ return { type: '', setupCommand: '', followupMessage: '' };
+ }
+
+ try {
+ const packageJson = JSON.parse(packageJsonFile.content);
+ 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?',
+ };
+ } catch (error) {
+ console.error('Error parsing package.json:', error);
+ return { type: '', setupCommand: '', followupMessage: '' };
+ }
+ }
+
+ if (hasFile('index.html')) {
+ return {
+ type: 'Static',
+ setupCommand: 'npx --yes serve',
+ followupMessage: '',
+ };
+ }
+
+ return { type: '', setupCommand: '', followupMessage: '' };
+}
+
+export function createCommandsMessage(commands: ProjectCommands): Message | null {
+ if (!commands.setupCommand) {
+ return null;
+ }
+
+ return {
+ role: 'assistant',
+ content: `
+
+
+${commands.setupCommand}
+
+${commands.followupMessage ? `\n\n${commands.followupMessage}` : ''}`,
+ id: generateId(),
+ createdAt: new Date(),
+ };
+}