From 03ef4173afd279cd941fd9235113e2d15cf25739 Mon Sep 17 00:00:00 2001 From: Dustin Loring Date: Fri, 17 Jan 2025 12:03:04 -0500 Subject: [PATCH] feat: import folder added the ability to import a local folder --- app/components/chat/BaseChat.tsx | 21 ++- app/components/chat/ImportFolderButton.tsx | 167 +++++++++++++++++++++ 2 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 app/components/chat/ImportFolderButton.tsx diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 8ee7fd6..5a07333 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -11,6 +11,7 @@ import { IconButton } from '~/components/ui/IconButton'; import { Workbench } from '~/components/workbench/Workbench.client'; import { classNames } from '~/utils/classNames'; import GitCloneButton from './GitCloneButton'; +import { ImportFolderButton } from './ImportFolderButton'; interface BaseChatProps { textareaRef?: React.RefObject | undefined; @@ -224,12 +225,20 @@ export const BaseChat = React.forwardRef( {!chatStarted && (
- { - sendMessage?.(new Event('click') as any, description); - messages.forEach((message) => { - sendMessage?.(new Event('click') as any, message.content); - }); - }} /> +
+ { + sendMessage?.(new Event('click') as any, description); + messages.forEach((message) => { + sendMessage?.(new Event('click') as any, message.content); + }); + }} /> + { + sendMessage?.(new Event('click') as any, description); + messages.forEach((message) => { + sendMessage?.(new Event('click') as any, message.content); + }); + }} /> +
{EXAMPLE_PROMPTS.map((examplePrompt, index) => { return ( diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx new file mode 100644 index 0000000..50146c9 --- /dev/null +++ b/app/components/chat/ImportFolderButton.tsx @@ -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; +} + +// 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 createChatFromFolder = async (files: File[], binaryFiles: string[]) => { + const fileArtifacts = await Promise.all( + files.map(async (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const content = reader.result as string; + const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); + resolve( + ` +${content} +`, + ); + }; + 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} + + +${fileArtifacts.join('\n\n')} +`, + 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 ( + +
+ { + 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 + /> + +
+
+ ); +}; \ No newline at end of file