mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: git clone by url
ability to git clone by url
This commit is contained in:
122
app/components/git/GitUrlImport.client.tsx
Normal file
122
app/components/git/GitUrlImport.client.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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
37
app/routes/git.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
app/utils/projectCommands.ts
Normal file
75
app/utils/projectCommands.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user