|
import ignore from 'ignore'; |
|
import { useGit } from '~/lib/hooks/useGit'; |
|
import type { Message } from 'ai'; |
|
import { detectProjectCommands, createCommandsMessage, escapeBoltTags } from '~/utils/projectCommands'; |
|
import { generateId } from '~/utils/fileUtils'; |
|
import { useState } from 'react'; |
|
import { toast } from 'react-toastify'; |
|
import { LoadingOverlay } from '~/components/ui/LoadingOverlay'; |
|
import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog'; |
|
import { classNames } from '~/utils/classNames'; |
|
import { Button } from '~/components/ui/Button'; |
|
import type { IChatMetadata } from '~/lib/persistence/db'; |
|
|
|
const IGNORE_PATTERNS = [ |
|
'node_modules/**', |
|
'.git/**', |
|
'.github/**', |
|
'.vscode/**', |
|
'dist/**', |
|
'build/**', |
|
'.next/**', |
|
'coverage/**', |
|
'.cache/**', |
|
'.idea/**', |
|
'**/*.log', |
|
'**/.DS_Store', |
|
'**/npm-debug.log*', |
|
'**/yarn-debug.log*', |
|
'**/yarn-error.log*', |
|
'**/*lock.json', |
|
'**/*lock.yaml', |
|
]; |
|
|
|
const ig = ignore().add(IGNORE_PATTERNS); |
|
|
|
const MAX_FILE_SIZE = 100 * 1024; |
|
const MAX_TOTAL_SIZE = 500 * 1024; |
|
|
|
interface GitCloneButtonProps { |
|
className?: string; |
|
importChat?: (description: string, messages: Message[], metadata?: IChatMetadata) => Promise<void>; |
|
} |
|
|
|
export default function GitCloneButton({ importChat, className }: GitCloneButtonProps) { |
|
const { ready, gitClone } = useGit(); |
|
const [loading, setLoading] = useState(false); |
|
const [isDialogOpen, setIsDialogOpen] = useState(false); |
|
|
|
const handleClone = async (repoUrl: string) => { |
|
if (!ready) { |
|
return; |
|
} |
|
|
|
setLoading(true); |
|
|
|
try { |
|
const { workdir, data } = await gitClone(repoUrl); |
|
|
|
if (importChat) { |
|
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); |
|
const textDecoder = new TextDecoder('utf-8'); |
|
|
|
let totalSize = 0; |
|
const skippedFiles: string[] = []; |
|
const fileContents = []; |
|
|
|
for (const filePath of filePaths) { |
|
const { data: content, encoding } = data[filePath]; |
|
|
|
|
|
if ( |
|
content instanceof Uint8Array && |
|
!filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i) |
|
) { |
|
skippedFiles.push(filePath); |
|
continue; |
|
} |
|
|
|
try { |
|
const textContent = |
|
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : ''; |
|
|
|
if (!textContent) { |
|
continue; |
|
} |
|
|
|
|
|
const fileSize = new TextEncoder().encode(textContent).length; |
|
|
|
if (fileSize > MAX_FILE_SIZE) { |
|
skippedFiles.push(`${filePath} (too large: ${Math.round(fileSize / 1024)}KB)`); |
|
continue; |
|
} |
|
|
|
|
|
if (totalSize + fileSize > MAX_TOTAL_SIZE) { |
|
skippedFiles.push(`${filePath} (would exceed total size limit)`); |
|
continue; |
|
} |
|
|
|
totalSize += fileSize; |
|
fileContents.push({ |
|
path: filePath, |
|
content: textContent, |
|
}); |
|
} catch (e: any) { |
|
skippedFiles.push(`${filePath} (error: ${e.message})`); |
|
} |
|
} |
|
|
|
const commands = await detectProjectCommands(fileContents); |
|
const commandsMessage = createCommandsMessage(commands); |
|
|
|
const filesMessage: Message = { |
|
role: 'assistant', |
|
content: `Cloning the repo ${repoUrl} into ${workdir} |
|
${ |
|
skippedFiles.length > 0 |
|
? `\nSkipped files (${skippedFiles.length}): |
|
${skippedFiles.map((f) => `- ${f}`).join('\n')}` |
|
: '' |
|
} |
|
|
|
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled"> |
|
${fileContents |
|
.map( |
|
(file) => |
|
`<boltAction type="file" filePath="${file.path}"> |
|
${escapeBoltTags(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); |
|
} |
|
} catch (error) { |
|
console.error('Error during import:', error); |
|
toast.error('Failed to import repository'); |
|
} finally { |
|
setLoading(false); |
|
} |
|
}; |
|
|
|
return ( |
|
<> |
|
<Button |
|
onClick={() => setIsDialogOpen(true)} |
|
title="Clone a Git Repo" |
|
variant="outline" |
|
size="lg" |
|
className={classNames( |
|
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', |
|
'text-bolt-elements-textPrimary dark:text-white', |
|
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', |
|
'border-[#E5E5E5] dark:border-[#333333]', |
|
'h-10 px-4 py-2 min-w-[120px] justify-center', |
|
'transition-all duration-200 ease-in-out', |
|
className, |
|
)} |
|
disabled={!ready || loading} |
|
> |
|
<span className="i-ph:git-branch w-4 h-4" /> |
|
Clone a Git Repo |
|
</Button> |
|
|
|
<RepositorySelectionDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onSelect={handleClone} /> |
|
|
|
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />} |
|
</> |
|
); |
|
} |
|
|