From 855121a70e59b2c795c2fa976f9d5fd748151dc7 Mon Sep 17 00:00:00 2001 From: Dustin Loring Date: Fri, 17 Jan 2025 07:36:07 -0500 Subject: [PATCH] feat: git clone by url ability to git clone by url --- app/components/git/GitUrlImport.client.tsx | 122 +++++++++++++++++++++ app/lib/persistence/useChatHistory.ts | 76 +++++++------ app/routes/git.tsx | 37 +++++++ app/utils/projectCommands.ts | 75 +++++++++++++ 4 files changed, 278 insertions(+), 32 deletions(-) create mode 100644 app/components/git/GitUrlImport.client.tsx create mode 100644 app/routes/git.tsx create mode 100644 app/utils/projectCommands.ts diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx new file mode 100644 index 0000000..1321bc2 --- /dev/null +++ b/app/components/git/GitUrlImport.client.tsx @@ -0,0 +1,122 @@ +import { useSearchParams } from '@remix-run/react'; +import { generateId, type Message } from 'ai'; +import ignore from 'ignore'; +import { useEffect, useState } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { BaseChat } from '~/components/chat/BaseChat'; +import { Chat } from '~/components/chat/Chat.client'; +import { useGit } from '~/lib/hooks/useGit'; +import { useChatHistory } from '~/lib/persistence'; +import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands'; + +const IGNORE_PATTERNS = [ + 'node_modules/**', + '.git/**', + '.github/**', + '.vscode/**', + '**/*.jpg', + '**/*.jpeg', + '**/*.png', + 'dist/**', + 'build/**', + '.next/**', + 'coverage/**', + '.cache/**', + '.vscode/**', + '.idea/**', + '**/*.log', + '**/.DS_Store', + '**/npm-debug.log*', + '**/yarn-debug.log*', + '**/yarn-error.log*', + '**/*lock.json', + '**/*lock.yaml', +]; + +interface GitUrlImportProps { + initialUrl?: string; +} + +export function GitUrlImport({ initialUrl }: GitUrlImportProps) { + const [searchParams] = useSearchParams(); + const { ready: historyReady, importChat } = useChatHistory(); + const { ready: gitReady, gitClone } = useGit(); + const [imported, setImported] = useState(false); + + const importRepo = async (repoUrl?: string) => { + if (!gitReady && !historyReady) { + return; + } + + if (repoUrl) { + const ig = ignore().add(IGNORE_PATTERNS); + const { workdir, data } = await gitClone(repoUrl); + + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + + const textDecoder = new TextDecoder('utf-8'); + + // 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} + +${fileContents + .map( + (file) => + ` +${file.content} +`, + ) + .join('\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const messages = [filesMessage]; + + if (commandsMessage) { + messages.push(commandsMessage); + } + + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); + } + } + }; + + useEffect(() => { + if (!historyReady || !gitReady || imported) { + return; + } + + // Use initialUrl if provided, otherwise fallback to URL parameter + const url = initialUrl || searchParams.get('url'); + + if (!url) { + window.location.href = '/'; + return; + } + + importRepo(url); + setImported(true); + }, [searchParams, historyReady, gitReady, imported, initialUrl]); + + return }>{() => }; +} \ No newline at end of file diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index 3afa401..935f616 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -1,10 +1,13 @@ import { useLoaderData, useNavigate } from '@remix-run/react'; import type { Message } from 'ai'; import { atom } from 'nanostores'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { toast } from 'react-toastify'; import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db'; import { workbenchStore } from '~/lib/stores/workbench'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('useChatHistory'); export interface ChatHistoryItem { id: string; @@ -29,6 +32,43 @@ export function useChatHistory() { const [ready, setReady] = useState(false); const [urlId, setUrlId] = useState(); + const storeMessageHistory = useCallback(async (messages: Message[]) => { + if (!db || messages.length === 0) { + return; + } + + const { firstArtifact } = workbenchStore; + + if (!urlId && firstArtifact?.id) { + const urlId = await getUrlId(db, firstArtifact.id); + + navigateChat(urlId); + setUrlId(urlId); + } + + if (!description.get() && firstArtifact?.title) { + description.set(firstArtifact?.title); + } + + if (initialMessages.length === 0 && !chatId.get()) { + const nextId = await getNextId(db); + + chatId.set(nextId); + + if (!urlId) { + navigateChat(nextId); + } + } + + await setMessages(db, chatId.get() as string, messages, urlId, description.get()); + }, [initialMessages.length, urlId]); + + const importChat = useCallback(async (chatDescription: string, messages: Message[]) => { + logger.trace('Importing chat', { description: chatDescription, messages }); + description.set(chatDescription); + await storeMessageHistory(messages); + }, [storeMessageHistory]); + useEffect(() => { if (!db) { setReady(true); @@ -58,41 +98,13 @@ export function useChatHistory() { toast.error(error.message); }); } - }, []); + }, [mixedId, navigate]); return { ready: !mixedId || ready, initialMessages, - storeMessageHistory: async (messages: Message[]) => { - if (!db || messages.length === 0) { - return; - } - - const { firstArtifact } = workbenchStore; - - if (!urlId && firstArtifact?.id) { - const urlId = await getUrlId(db, firstArtifact.id); - - navigateChat(urlId); - setUrlId(urlId); - } - - if (!description.get() && firstArtifact?.title) { - description.set(firstArtifact?.title); - } - - if (initialMessages.length === 0 && !chatId.get()) { - const nextId = await getNextId(db); - - chatId.set(nextId); - - if (!urlId) { - navigateChat(nextId); - } - } - - await setMessages(db, chatId.get() as string, messages, urlId, description.get()); - }, + storeMessageHistory, + importChat }; } diff --git a/app/routes/git.tsx b/app/routes/git.tsx new file mode 100644 index 0000000..2bad25c --- /dev/null +++ b/app/routes/git.tsx @@ -0,0 +1,37 @@ +import type { LoaderFunctionArgs } from '@remix-run/cloudflare'; +import { json, type MetaFunction } from '@remix-run/cloudflare'; +import { useLoaderData } from '@remix-run/react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { BaseChat } from '~/components/chat/BaseChat'; +import { GitUrlImport } from '~/components/git/GitUrlImport.client'; +import { Header } from '~/components/header/Header'; + +export const meta: MetaFunction = () => { + return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; +}; + +interface LoaderData { + url: string; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const gitUrl = url.searchParams.get('url'); + + if (!gitUrl) { + throw new Response('No Git URL provided', { status: 400 }); + } + + return json({ url: gitUrl }); +} + +export default function Index() { + const data = useLoaderData(); + + return ( +
+
+ }>{() => } +
+ ); +} \ No newline at end of file diff --git a/app/utils/projectCommands.ts b/app/utils/projectCommands.ts new file mode 100644 index 0000000..cffabb9 --- /dev/null +++ b/app/utils/projectCommands.ts @@ -0,0 +1,75 @@ +import { generateId, type Message } from 'ai'; + +interface FileContent { + path: string; + content: string; +} + +interface DetectedCommand { + type: string; + command: string; + description: string; +} + +export async function detectProjectCommands(files: FileContent[]): Promise { + const commands: DetectedCommand[] = []; + + // Look for package.json to detect npm/node projects + const packageJson = files.find(f => f.path === 'package.json'); + if (packageJson) { + try { + const pkg = JSON.parse(packageJson.content); + + // Add install command + commands.push({ + type: 'install', + command: 'npm install', + description: 'Install dependencies' + }); + + // Add dev command if it exists + if (pkg.scripts?.dev) { + commands.push({ + type: 'dev', + command: 'npm run dev', + description: 'Start development server' + }); + } + + // Add build command if it exists + if (pkg.scripts?.build) { + commands.push({ + type: 'build', + command: 'npm run build', + description: 'Build the project' + }); + } + } catch (e) { + console.error('Error parsing package.json:', e); + } + } + + return commands; +} + +export function createCommandsMessage(commands: DetectedCommand[]): Message | null { + if (commands.length === 0) { + return null; + } + + const commandsContent = commands.map(cmd => + ` +${cmd.command} +` + ).join('\n'); + + return { + role: 'assistant', + content: `Here are the available commands for this project: + +${commandsContent} +`, + id: generateId(), + createdAt: new Date() + }; +} \ No newline at end of file