Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: gitHub authentication to the application using GitHub's Device Flow O… #408

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0c1b5c0
GitHub authentication to the application using GitHub's Device Flow O…
Nov 25, 2024
60bbee4
Add type for error response
Nov 25, 2024
14c289f
fix error handling
Nov 25, 2024
e43953e
moved /github to /lib
Nov 25, 2024
0822ccd
Merge branch 'main' into feat/git-auth
thecodacus Dec 3, 2024
1b61aab
Merge branch 'main' into feat/git-auth
Dec 14, 2024
e4af820
merge main
Dec 14, 2024
9118ede
Merge branch 'main' into feat/git-auth
emcconnell Dec 14, 2024
0e2c9fb
GitHub OAuth optional feature
Dec 14, 2024
ea6d5e5
GitHub OAuth optional feature
Dec 14, 2024
5497f8f
Merge branch 'feat/git-auth' of https://github.com/emcconnell/bolt.ne…
Dec 14, 2024
e91bd2b
merge changes
Dec 14, 2024
66beb24
chore: update commit hash to 9efc709782ed44a36da6de2222b1d5dd004fb489
github-actions[bot] Dec 14, 2024
865324d
chore: update commit hash to 2638c1a704118b411f942e1b17b6765abce46721
github-actions[bot] Dec 20, 2024
54e7156
Merge branch 'main' of https://github.com/emcconnell/bolt.new-any-llm
Dec 20, 2024
1a4d300
commit
Dec 20, 2024
4a55c2b
Merge branch 'main' into feat/git-auth
Dec 20, 2024
93d121c
Merge branch 'main' into feat/git-auth
Dec 20, 2024
f0fb1e9
more changes
Dec 20, 2024
087e02e
more changes
Dec 20, 2024
f739713
more changes
Dec 20, 2024
9609a51
more changes
Dec 20, 2024
43d5f28
more changes
Dec 20, 2024
733fc83
more changes
Dec 20, 2024
f0464a2
more changes
Dec 20, 2024
dad7562
more changes
Dec 20, 2024
addeefe
more changes
Dec 20, 2024
6bd33c9
more changes
Dec 20, 2024
f3f1ea8
more changes
Dec 20, 2024
87579d8
more changes
Dec 20, 2024
d37cf4e
more changes
Dec 20, 2024
18d72e9
more changes
Dec 20, 2024
6a5deaf
more changes
emcconnell Dec 21, 2024
4273fe4
more changes
emcconnell Dec 21, 2024
aeb501f
more changes
Dec 21, 2024
84781ad
more changes
Dec 21, 2024
fd7e72a
more changes
Dec 21, 2024
b07702d
more changes
Dec 21, 2024
4e2d561
more changes
Dec 21, 2024
b819e9f
merge main
Dec 21, 2024
07fb2ea
fix repo visibility
Dec 22, 2024
099b003
debounce repo name lookup
Dec 22, 2024
820f6bb
more changes
Dec 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/commit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "commit": "b07702d0bc09434df42448248354821658ad14e7" }
2 changes: 1 addition & 1 deletion app/components/chat/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
{importChat && <GitCloneButton importChat={importChat} />}
</div>
)}
{!chatStarted &&
Expand Down
91 changes: 46 additions & 45 deletions app/components/chat/GitCloneButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { GitCloneModal } from '~/components/git/GitCloneModal';

const IGNORE_PATTERNS = [
'node_modules/**',
Expand Down Expand Up @@ -31,47 +33,43 @@ const IGNORE_PATTERNS = [
const ig = ignore().add(IGNORE_PATTERNS);

interface GitCloneButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}

export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
const onClick = async (_e: any) => {
const [isModalOpen, setIsModalOpen] = useState(false);

const handleClone = async (repoUrl: string) => {
if (!ready) {
return;
}

const repoUrl = prompt('Enter the Git url');

if (repoUrl) {
const { workdir, data } = await gitClone(repoUrl);

if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const { workdir, data } = await gitClone(repoUrl);

const textDecoder = new TextDecoder('utf-8');
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);
// 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);
// 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}
// 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(
Expand All @@ -82,29 +80,32 @@ ${file.content}
)
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
id: generateId(),
createdAt: new Date(),
};

const messages = [filesMessage];
const messages = [filesMessage];

if (commandsMessage) {
messages.push(commandsMessage);
}

await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
if (commandsMessage) {
messages.push(commandsMessage);
}

await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
};

return (
<button
onClick={onClick}
title="Clone a Git Repo"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
>
<span className="i-ph:git-branch" />
Clone a Git Repo
</button>
<>
<button
onClick={() => setIsModalOpen(true)}
title="Clone a Git Repo"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
>
<span className="i-ph:git-branch" />
Clone a Git Repo
</button>

<GitCloneModal open={isModalOpen} onClose={() => setIsModalOpen(false)} onClone={handleClone} />
</>
);
}
199 changes: 199 additions & 0 deletions app/components/git/GitCloneModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useState } from 'react';
import { Dialog, DialogRoot } from '~/components/ui/Dialog';
import { GitHubAuth } from '~/components/github/GitHubAuth';
import { getGitHubUser, getUserRepos } from '~/lib/github/github.client';
import { toast } from 'react-toastify';
import { GitCloneSpinner } from './GitCloneSpinner';

interface GitCloneModalProps {
open: boolean;
onClose: () => void;
onClone: (url: string) => Promise<void>;
}

export function GitCloneModal({ open, onClose, onClone }: GitCloneModalProps) {
const [publicUrl, setPublicUrl] = useState('');
const [userRepos, setUserRepos] = useState<Array<{ name: string; url: string }>>([]);
const [selectedRepo, setSelectedRepo] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCloning, setIsCloning] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState('');

const loadUserRepos = useCallback(async () => {
const token = localStorage.getItem('github_token');

if (!token) {
setIsAuthenticated(false);
return;
}

try {
setIsLoading(true);

const user = await getGitHubUser(token);
setUsername(user.login);

const repos = await getUserRepos(token);
setUserRepos(
repos.map((repo) => ({
name: repo.full_name,
url: repo.clone_url,
})),
);
setIsAuthenticated(true);
} catch (error) {
console.error('Error loading repos:', error);
toast.error('Failed to load repositories');

if (error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403)) {
localStorage.removeItem('github_token');
setIsAuthenticated(false);
}
} finally {
setIsLoading(false);
}
}, []);

useEffect(() => {
if (open) {
loadUserRepos();
}
}, [open, loadUserRepos]);

const handleAuthComplete = useCallback(
async (token: string) => {
try {
const user = await getGitHubUser(token);
setUsername(user.login);
setIsAuthenticated(true);
loadUserRepos();
} catch (error) {
console.error('Auth error:', error);
toast.error('Authentication failed');
localStorage.removeItem('github_token');
setIsAuthenticated(false);
}
},
[loadUserRepos],
);

const handleClone = useCallback(async () => {
try {
const cloneUrl = selectedRepo || publicUrl;

if (cloneUrl) {
setIsCloning(true);
onClose(); // Close the modal immediately when starting clone
await onClone(cloneUrl);
setIsCloning(false);
}
} catch (error) {
console.error('Clone error:', error);
toast.error('Failed to clone repository');
setIsCloning(false);
}
}, [selectedRepo, publicUrl, onClone, onClose]);

return (
<>
<DialogRoot open={open} onOpenChange={onClose}>
<Dialog className="w-[500px] bg-[#1E1E1E] rounded-lg border border-[#6F3FB6] shadow-2xl">
<div className="flex items-center justify-between p-4 pb-0">
<h2 className="text-[17px] font-medium text-white">Clone Repository</h2>
<button onClick={onClose} className="text-[#8B8B8B] hover:text-white">
<span className="i-ph:x-bold text-xl" />
</button>
</div>

<div className="p-4 space-y-4">
{(!selectedRepo || !isAuthenticated) && (
<div>
<div className="text-[13px] font-medium text-[#8B8B8B] mb-2">Public Repository URL</div>
<input
type="text"
placeholder="Enter Git URL"
value={publicUrl}
onChange={(e) => {
setPublicUrl(e.target.value);

if (e.target.value && selectedRepo) {
setSelectedRepo('');
}
}}
className="w-full p-2 h-[32px] rounded bg-[#2D2D2D] border border-[#383838] text-white placeholder-[#8B8B8B] focus:outline-none focus:border-[#525252]"
/>
</div>
)}

<div>
<div className="text-[13px] font-medium text-[#8B8B8B] mb-2">
{isAuthenticated ? `${username}'s GitHub Repositories` : 'Your GitHub Repositories'}
</div>
{isAuthenticated ? (
<select
value={selectedRepo}
onChange={(e) => {
setSelectedRepo(e.target.value);

if (e.target.value) {
setPublicUrl('');
}
}}
className="w-full px-2 h-9 rounded bg-[#2D2D2D] border border-[#383838] text-white focus:outline-none focus:border-[#525252] text-ellipsis appearance-none"
style={{
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '2rem',
paddingTop: '0',
paddingBottom: '0',
}}
>
<option value="" className="py-1">
Select a repository
</option>
{userRepos.map((repo) => (
<option key={repo.url} value={repo.url} className="py-1">
{repo.name}
</option>
))}
</select>
) : (
<GitHubAuth onAuthComplete={handleAuthComplete} onError={(error) => toast.error(error.message)}>
<button className="w-full h-[32px] flex gap-2 items-center justify-center bg-[#2D2D2D] text-white hover:bg-[#383838] rounded border border-[#383838] transition-colors">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span>Connect GitHub</span>
</button>
</GitHubAuth>
)}
{isLoading && (
<div className="flex items-center justify-center mt-2">
<div className="i-svg-spinners:90-ring-with-bg text-[#0969DA] text-xl animate-spin" />
</div>
)}
</div>

<div className="flex justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 h-[32px] rounded bg-[#2D2D2D] text-white hover:bg-[#383838] transition-colors border border-[#383838]"
>
Cancel
</button>
<button
onClick={handleClone}
disabled={!publicUrl && !selectedRepo}
className="px-4 h-[32px] rounded bg-[#6F3FB6]/80 text-white hover:bg-[#8B4FE3]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Clone Repository
</button>
</div>
</div>
</Dialog>
</DialogRoot>
<GitCloneSpinner isOpen={isCloning} />
</>
);
}
25 changes: 25 additions & 0 deletions app/components/git/GitCloneSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
interface GitCloneSpinnerProps {
isOpen: boolean;
}

export function GitCloneSpinner({ isOpen }: GitCloneSpinnerProps) {
if (!isOpen) {
return null;
}

return (
<>
{/* Full screen blocker that prevents all interactions */}
<div className="fixed inset-0 bg-transparent z-[9999]" />

{/* Spinner overlay */}
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]">
<div className="bg-[#1E1E1E] p-6 rounded-lg border border-[#6F3FB6] shadow-2xl flex flex-col items-center gap-4">
<div className="i-svg-spinners:90-ring-with-bg text-[#6F3FB6] text-4xl animate-spin" />
<div className="text-white text-lg">Cloning Repository...</div>
<div className="text-[#8B8B8B] text-sm">This may take a few moments</div>
</div>
</div>
</>
);
}
Loading
Loading