feat: git clone by url

ability to git clone by url
This commit is contained in:
Dustin Loring
2025-01-17 07:36:07 -05:00
parent e312b8cd07
commit 855121a70e
4 changed files with 278 additions and 32 deletions

View File

@@ -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}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
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 <ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>;
}

View File

@@ -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<boolean>(false);
const [urlId, setUrlId] = useState<string | undefined>();
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
};
}

37
app/routes/git.tsx Normal file
View File

@@ -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<LoaderData>({ url: gitUrl });
}
export default function Index() {
const data = useLoaderData<typeof loader>();
return (
<div className="flex flex-col h-full w-full">
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <GitUrlImport initialUrl={data.url} />}</ClientOnly>
</div>
);
}

View File

@@ -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<DetectedCommand[]> {
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 =>
`<boltAction type="shell" title="${cmd.description}">
${cmd.command}
</boltAction>`
).join('\n');
return {
role: 'assistant',
content: `Here are the available commands for this project:
<boltArtifact id="project-commands" title="Project Commands">
${commandsContent}
</boltArtifact>`,
id: generateId(),
createdAt: new Date()
};
}