feat: gitLab Integration Implementation / github refactor / overal improvements (#1963)

* Add GitLab integration components

Introduced PushToGitLabDialog and GitlabConnection components to handle GitLab project connections and push functionality. Includes user authentication, project handling, and UI for seamless integration with GitLab.

* Add components for GitLab connection and push dialog

Introduce `GitlabConnection` and `PushToGitLabDialog` components to handle GitLab integration. These components allow users to connect their GitLab account, manage recent projects, and push code to a GitLab repository with detailed configurations and feedback.

* Fix GitLab personal access tokens link to use correct URL

* Update GitHub push call to use new pushToRepository method

* Enhance GitLab integration with performance improvements

- Add comprehensive caching system for repositories and user data
- Implement pagination and search/filter functionality with debouncing
- Add skeleton loaders and improved loading states
- Implement retry logic for API calls with exponential backoff
- Add background refresh capabilities
- Improve error handling and user feedback
- Optimize API calls to reduce loading times

* feat: implement GitLab integration with connection management and repository handling

- Add GitLab connection UI components
- Implement GitLab API service for repository operations
- Add GitLab connection store for state management
- Update existing connection components (Vercel, Netlify)
- Add repository listing and statistics display
- Refactor GitLab components into organized folder structure

* fix: resolve GitLab deployment issues and improve user experience

- Fix DialogTitle accessibility warnings for screen readers
- Remove CORS-problematic attributes from avatar images to prevent loading errors
- Enhance GitLab API error handling with detailed error messages
- Fix project creation settings to prevent initial commit conflicts
- Add automatic GitLab connection state initialization on app startup
- Improve deployment dialog UI with better error handling and user feedback
- Add GitLab deployment source type to action runner system
- Clean up deprecated push dialog files and consolidate deployment components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: implement GitHub clone repository dialog functionality

This commit fixes the missing GitHub repository selection dialog in the "Clone a repo" feature
by implementing the same elegant interface pattern used by GitLab.

Key Changes:
- Added onCloneRepository prop support to GitHubConnection component
- Updated RepositoryCard to generate proper GitHub clone URLs (https://github.com/{full_name}.git)
- Implemented full GitHub repository selection dialog in GitCloneButton.tsx
- Added proper dialog close handling after successful clone operations
- Maintained existing GitHub connection settings page functionality

Technical Details:
- Follows same component patterns as GitLab implementation
- Uses proper TypeScript interfaces for clone URL handling
- Includes professional dialog styling with loading states
- Supports repository search, pagination, and authentication flow

The GitHub clone experience now matches GitLab's functionality, providing users with
a unified and intuitive repository selection interface across both providers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Clean up unused connection components

- Remove ConnectionForm.tsx (unused GitHub form component)
- Remove CreateBranchDialog.tsx (unused branch creation dialog)
- Remove RepositoryDialogContext.tsx (unused context provider)
- Remove empty components/ directory

These files were not referenced anywhere in the codebase and were leftover from development.

* Remove environment variables info section from ConnectionsTab

- Remove collapsible environment variables section
- Clean up unused state and imports
- Simplify the connections tab UI

* Reorganize connections folder structure

- Create netlify/ folder and move NetlifyConnection.tsx
- Create vercel/ folder and move VercelConnection.tsx
- Add index.ts files for both netlify and vercel folders
- Update imports in ConnectionsTab.tsx to use new folder structure
- All connection components now follow consistent folder organization

---------

Co-authored-by: Hayat Bourgi <hayat.bourgi@montyholding.com>
Co-authored-by: Hayat55 <53140162+Hayat55@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Stijnus
2025-09-05 14:01:33 +02:00
committed by GitHub
parent 8a685603be
commit 3ea96506ea
46 changed files with 4401 additions and 4025 deletions

View File

@@ -1,595 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { Button } from '~/components/ui/Button';
import { Badge } from '~/components/ui/Badge';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
// Helper function to safely parse JSON
const safeJsonParse = (item: string | null) => {
if (!item) {
return null;
}
try {
return JSON.parse(item);
} catch (e) {
console.error('Failed to parse JSON from localStorage:', e);
return null;
}
};
/**
* A diagnostics component to help troubleshoot connection issues
*/
export default function ConnectionDiagnostics() {
const [diagnosticResults, setDiagnosticResults] = useState<any>(null);
const [isRunning, setIsRunning] = useState(false);
const [showDetails, setShowDetails] = useState(false);
// Run diagnostics when requested
const runDiagnostics = async () => {
try {
setIsRunning(true);
setDiagnosticResults(null);
// Check browser-side storage
const localStorageChecks = {
githubConnection: localStorage.getItem('github_connection'),
netlifyConnection: localStorage.getItem('netlify_connection'),
vercelConnection: localStorage.getItem('vercel_connection'),
supabaseConnection: localStorage.getItem('supabase_connection'),
};
// Get diagnostic data from server
const response = await fetch('/api/system/diagnostics');
if (!response.ok) {
throw new Error(`Diagnostics API error: ${response.status}`);
}
const serverDiagnostics = await response.json();
// === GitHub Checks ===
const githubConnectionParsed = safeJsonParse(localStorageChecks.githubConnection);
const githubToken = githubConnectionParsed?.token;
const githubAuthHeaders = {
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
'Content-Type': 'application/json',
};
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
const githubEndpoints = [
{ name: 'User', url: '/api/system/git-info?action=getUser' },
{ name: 'Repos', url: '/api/system/git-info?action=getRepos' },
{ name: 'Default', url: '/api/system/git-info' },
];
const githubResults = await Promise.all(
githubEndpoints.map(async (endpoint) => {
try {
const resp = await fetch(endpoint.url, { headers: githubAuthHeaders });
return { endpoint: endpoint.name, status: resp.status, ok: resp.ok };
} catch (error) {
return {
endpoint: endpoint.name,
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}),
);
// === Netlify Checks ===
const netlifyConnectionParsed = safeJsonParse(localStorageChecks.netlifyConnection);
const netlifyToken = netlifyConnectionParsed?.token;
let netlifyUserCheck = null;
if (netlifyToken) {
try {
const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', {
headers: { Authorization: `Bearer ${netlifyToken}` },
});
netlifyUserCheck = { status: netlifyResp.status, ok: netlifyResp.ok };
} catch (error) {
netlifyUserCheck = {
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}
// === Vercel Checks ===
const vercelConnectionParsed = safeJsonParse(localStorageChecks.vercelConnection);
const vercelToken = vercelConnectionParsed?.token;
let vercelUserCheck = null;
if (vercelToken) {
try {
const vercelResp = await fetch('https://api.vercel.com/v2/user', {
headers: { Authorization: `Bearer ${vercelToken}` },
});
vercelUserCheck = { status: vercelResp.status, ok: vercelResp.ok };
} catch (error) {
vercelUserCheck = {
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}
// === Supabase Checks ===
const supabaseConnectionParsed = safeJsonParse(localStorageChecks.supabaseConnection);
const supabaseUrl = supabaseConnectionParsed?.projectUrl;
const supabaseAnonKey = supabaseConnectionParsed?.anonKey;
let supabaseCheck = null;
if (supabaseUrl && supabaseAnonKey) {
supabaseCheck = { ok: true, status: 200, message: 'URL and Key present in localStorage' };
} else {
supabaseCheck = { ok: false, message: 'URL or Key missing in localStorage' };
}
// Compile results
const results = {
timestamp: new Date().toISOString(),
localStorage: {
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
hasVercelConnection: Boolean(localStorageChecks.vercelConnection),
hasSupabaseConnection: Boolean(localStorageChecks.supabaseConnection),
githubConnectionParsed,
netlifyConnectionParsed,
vercelConnectionParsed,
supabaseConnectionParsed,
},
apiEndpoints: {
github: githubResults,
netlify: netlifyUserCheck,
vercel: vercelUserCheck,
supabase: supabaseCheck,
},
serverDiagnostics,
};
setDiagnosticResults(results);
// Display simple results
if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) {
toast.error('GitHub API connections are failing. Try reconnecting.');
}
if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) {
toast.error('Netlify API connection is failing. Try reconnecting.');
}
if (results.localStorage.hasVercelConnection && vercelUserCheck && !vercelUserCheck.ok) {
toast.error('Vercel API connection is failing. Try reconnecting.');
}
if (results.localStorage.hasSupabaseConnection && supabaseCheck && !supabaseCheck.ok) {
toast.warning('Supabase connection check failed or missing details. Verify settings.');
}
if (
!results.localStorage.hasGithubConnection &&
!results.localStorage.hasNetlifyConnection &&
!results.localStorage.hasVercelConnection &&
!results.localStorage.hasSupabaseConnection
) {
toast.info('No connection data found in browser storage.');
}
} catch (error) {
console.error('Diagnostics error:', error);
toast.error('Error running diagnostics');
setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) });
} finally {
setIsRunning(false);
}
};
// Helper to reset GitHub connection
const resetGitHubConnection = () => {
try {
localStorage.removeItem('github_connection');
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('GitHub connection data cleared. Please refresh the page and reconnect.');
setDiagnosticResults(null);
} catch (error) {
console.error('Error clearing GitHub data:', error);
toast.error('Failed to clear GitHub connection data');
}
};
// Helper to reset Netlify connection
const resetNetlifyConnection = () => {
try {
localStorage.removeItem('netlify_connection');
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('Netlify connection data cleared. Please refresh the page and reconnect.');
setDiagnosticResults(null);
} catch (error) {
console.error('Error clearing Netlify data:', error);
toast.error('Failed to clear Netlify connection data');
}
};
// Helper to reset Vercel connection
const resetVercelConnection = () => {
try {
localStorage.removeItem('vercel_connection');
toast.success('Vercel connection data cleared. Please refresh the page and reconnect.');
setDiagnosticResults(null);
} catch (error) {
console.error('Error clearing Vercel data:', error);
toast.error('Failed to clear Vercel connection data');
}
};
// Helper to reset Supabase connection
const resetSupabaseConnection = () => {
try {
localStorage.removeItem('supabase_connection');
toast.success('Supabase connection data cleared. Please refresh the page and reconnect.');
setDiagnosticResults(null);
} catch (error) {
console.error('Error clearing Supabase data:', error);
toast.error('Failed to clear Supabase connection data');
}
};
return (
<div className="flex flex-col gap-6">
{/* Connection Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* GitHub Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-ph:github-logo text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasGithubConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasGithubConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={
diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok)
? 'default'
: 'destructive'
}
className="ml-1"
>
{diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasGithubConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
{/* Netlify Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-bolt:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasNetlifyConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasNetlifyConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User:{' '}
{diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name ||
diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email ||
'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={diagnosticResults.apiEndpoints.netlify?.ok ? 'default' : 'destructive'}
className="ml-1"
>
{diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasNetlifyConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
{/* Vercel Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-si:vercel text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Vercel Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasVercelConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasVercelConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasVercelConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User:{' '}
{diagnosticResults.localStorage.vercelConnectionParsed?.user?.username ||
diagnosticResults.localStorage.vercelConnectionParsed?.user?.user?.username ||
'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={diagnosticResults.apiEndpoints.vercel?.ok ? 'default' : 'destructive'}
className="ml-1"
>
{diagnosticResults.apiEndpoints.vercel?.ok ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasVercelConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
{/* Supabase Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-si:supabase text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Supabase Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasSupabaseConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasSupabaseConnection ? 'Configured' : 'Not Configured'}
</span>
</div>
{diagnosticResults.localStorage.hasSupabaseConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5 truncate">
<div className="i-ph:link w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent flex-shrink-0" />
Project URL: {diagnosticResults.localStorage.supabaseConnectionParsed?.projectUrl || 'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Config Status:{' '}
<Badge
variant={diagnosticResults.apiEndpoints.supabase?.ok ? 'default' : 'destructive'}
className="ml-1"
>
{diagnosticResults.apiEndpoints.supabase?.ok ? 'OK' : 'Check Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasSupabaseConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Configure Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-4">
<Button
onClick={runDiagnostics}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isRunning ? (
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
) : (
<div className="i-ph:activity w-4 h-4" />
)}
{isRunning ? 'Running Diagnostics...' : 'Run Diagnostics'}
</Button>
<Button
onClick={resetGitHubConnection}
disabled={isRunning || !diagnosticResults?.localStorage.hasGithubConnection}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="i-ph:github-logo w-4 h-4" />
Reset GitHub
</Button>
<Button
onClick={resetNetlifyConnection}
disabled={isRunning || !diagnosticResults?.localStorage.hasNetlifyConnection}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="i-si:netlify w-4 h-4" />
Reset Netlify
</Button>
<Button
onClick={resetVercelConnection}
disabled={isRunning || !diagnosticResults?.localStorage.hasVercelConnection}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="i-si:vercel w-4 h-4" />
Reset Vercel
</Button>
<Button
onClick={resetSupabaseConnection}
disabled={isRunning || !diagnosticResults?.localStorage.hasSupabaseConnection}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="i-si:supabase w-4 h-4" />
Reset Supabase
</Button>
</div>
{/* Details Panel */}
{diagnosticResults && (
<div className="mt-4">
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="w-full">
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<CodeBracketIcon className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Diagnostic Details
</span>
</div>
<ChevronDownIcon
className={classNames(
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary',
showDetails ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="p-4 mt-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
<pre className="text-xs overflow-auto max-h-96 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{JSON.stringify(diagnosticResults, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
);
}

View File

@@ -1,13 +1,11 @@
import { motion } from 'framer-motion';
import React, { Suspense, useState } from 'react';
import { classNames } from '~/utils/classNames';
import ConnectionDiagnostics from './ConnectionDiagnostics';
import { Button } from '~/components/ui/Button';
import VercelConnection from './VercelConnection';
import React, { Suspense } from 'react';
// Use React.lazy for dynamic imports
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
const GitHubConnection = React.lazy(() => import('./github/GitHubConnection'));
const GitlabConnection = React.lazy(() => import('./gitlab/GitLabConnection'));
const NetlifyConnection = React.lazy(() => import('./netlify/NetlifyConnection'));
const VercelConnection = React.lazy(() => import('./vercel/VercelConnection'));
// Loading fallback component
const LoadingFallback = () => (
@@ -20,139 +18,31 @@ const LoadingFallback = () => (
);
export default function ConnectionsTab() {
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
return (
<div className="space-y-6">
{/* Header */}
<motion.div
className="flex items-center justify-between gap-2"
className="flex items-center gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
<div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connection Settings
</h2>
</div>
<Button
onClick={() => setShowDiagnostics(!showDiagnostics)}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{showDiagnostics ? (
<>
<div className="i-ph:eye-slash w-4 h-4" />
Hide Diagnostics
</>
) : (
<>
<div className="i-ph:wrench w-4 h-4" />
Troubleshoot Connections
</>
)}
</Button>
<div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connection Settings
</h2>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your external service connections and integrations
</p>
{/* Diagnostics Tool - Conditionally rendered */}
{showDiagnostics && <ConnectionDiagnostics />}
{/* Environment Variables Info - Collapsible */}
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6">
<button
onClick={() => setIsEnvVarsExpanded(!isEnvVarsExpanded)}
className={classNames(
'w-full bg-transparent flex items-center justify-between',
'hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary',
'dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary',
'rounded-md p-2 -m-2 transition-colors',
)}
>
<div className="flex items-center gap-2">
<div className="i-ph:info w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Environment Variables
</h3>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary transition-transform',
isEnvVarsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isEnvVarsExpanded && (
<div className="mt-4">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
You can configure connections using environment variables in your{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
.env.local
</code>{' '}
file:
</p>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-3 rounded-md text-xs font-mono overflow-x-auto">
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# GitHub Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_ACCESS_TOKEN=your_token_here
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# Optional: Specify token type (defaults to 'classic' if not specified)
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_TOKEN_TYPE=classic|fine-grained
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2">
# Netlify Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_NETLIFY_ACCESS_TOKEN=your_token_here
</div>
</div>
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary space-y-1">
<p>
<span className="font-medium">Token types:</span>
</p>
<ul className="list-disc list-inside pl-2 space-y-1">
<li>
<span className="font-medium">classic</span> - Personal Access Token with{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
repo, read:org, read:user
</code>{' '}
scopes
</li>
<li>
<span className="font-medium">fine-grained</span> - Fine-grained token with Repository and
Organization access
</li>
</ul>
<p className="mt-2">
When set, these variables will be used automatically without requiring manual connection.
</p>
</div>
</div>
)}
</div>
</motion.div>
<div className="grid grid-cols-1 gap-6">
<Suspense fallback={<LoadingFallback />}>
<GitHubConnection />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<GitlabConnection />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<NetlifyConnection />
</Suspense>
@@ -168,8 +58,7 @@ export default function ConnectionsTab() {
<span className="font-medium">Troubleshooting Tip:</span>
</p>
<p className="mb-2">
If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can
help diagnose and fix common connection issues.
If you're having trouble with connections, here are some troubleshooting tips to help resolve common issues.
</p>
<p>For persistent issues:</p>
<ol className="list-decimal list-inside pl-4 mt-1">

View File

@@ -1,980 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import Cookies from 'js-cookie';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { Button } from '~/components/ui/Button';
interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
created_at: string;
public_gists: number;
}
interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
languages_url: string;
}
interface GitHubOrganization {
login: string;
avatar_url: string;
html_url: string;
}
interface GitHubEvent {
id: string;
type: string;
repo: {
name: string;
};
created_at: string;
}
interface GitHubLanguageStats {
[language: string]: number;
}
interface GitHubStats {
repos: GitHubRepoInfo[];
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
publicRepos: number;
privateRepos: number;
stars: number;
forks: number;
followers: number;
publicGists: number;
privateGists: number;
lastUpdated: string;
// Keep these for backward compatibility
totalStars?: number;
totalForks?: number;
organizations?: GitHubOrganization[];
}
interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
rateLimit?: {
limit: number;
remaining: number;
reset: number;
};
}
// Add the GitHub logo SVG component
const GithubLogo = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
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>
);
export default function GitHubConnection() {
const [connection, setConnection] = useState<GitHubConnection>({
user: null,
token: '',
tokenType: 'classic',
});
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
const fetchGithubUser = async (token: string) => {
try {
console.log('Fetching GitHub user with token:', token.substring(0, 5) + '...');
// Use server-side API endpoint instead of direct GitHub API call
const response = await fetch(`/api/system/git-info?action=getUser`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // Include token in headers for validation
},
});
if (!response.ok) {
console.error('Error fetching GitHub user. Status:', response.status);
throw new Error(`Error: ${response.status}`);
}
// Get rate limit information from headers
const rateLimit = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
};
const data = await response.json();
console.log('GitHub user API response:', data);
const { user } = data as { user: GitHubUserResponse };
// Validate that we received a user object
if (!user || !user.login) {
console.error('Invalid user data received:', user);
throw new Error('Invalid user data received');
}
// Use the response data
setConnection((prev) => ({
...prev,
user,
token,
tokenType: tokenTypeRef.current,
rateLimit,
}));
// Set cookies for client-side access
Cookies.set('githubUsername', user.login);
Cookies.set('githubToken', token);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
// Store connection details in localStorage
localStorage.setItem(
'github_connection',
JSON.stringify({
user,
token,
tokenType: tokenTypeRef.current,
}),
);
logStore.logInfo('Connected to GitHub', {
type: 'system',
message: `Connected to GitHub as ${user.login}`,
});
// Fetch additional GitHub stats
fetchGitHubStats(token);
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'system',
message: 'GitHub authentication failed',
});
toast.error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error; // Rethrow to allow handling in the calling function
}
};
const fetchGitHubStats = async (token: string) => {
setIsFetchingStats(true);
try {
// Get the current user first to ensure we have the latest value
const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!userResponse.ok) {
if (userResponse.status === 401) {
toast.error('Your GitHub token has expired. Please reconnect your account.');
handleDisconnect();
return;
}
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
}
const userData = (await userResponse.json()) as any;
// Fetch repositories with pagination
let allRepos: any[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!reposResponse.ok) {
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
}
const repos = (await reposResponse.json()) as any[];
allRepos = [...allRepos, ...repos];
// Check if there are more pages
const linkHeader = reposResponse.headers.get('Link');
hasMore = linkHeader?.includes('rel="next"') ?? false;
page++;
}
// Calculate stats
const repoStats = calculateRepoStats(allRepos);
// Fetch recent activity
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!eventsResponse.ok) {
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
}
const events = (await eventsResponse.json()) as any[];
const recentActivity = events.slice(0, 5).map((event: any) => ({
id: event.id,
type: event.type,
repo: event.repo.name,
created_at: event.created_at,
}));
// Calculate total stars and forks
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
// Update the stats in the store
const stats: GitHubStats = {
repos: repoStats.repos,
recentActivity,
languages: repoStats.languages || {},
totalGists: repoStats.totalGists || 0,
publicRepos: userData.public_repos || 0,
privateRepos: privateRepos || 0,
stars: totalStars || 0,
forks: totalForks || 0,
followers: userData.followers || 0,
publicGists: userData.public_gists || 0,
privateGists: userData.private_gists || 0,
lastUpdated: new Date().toISOString(),
// For backward compatibility
totalStars: totalStars || 0,
totalForks: totalForks || 0,
organizations: [],
};
// Get the current user first to ensure we have the latest value
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
const currentUser = currentConnection.user || connection.user;
// Update connection with stats
const updatedConnection: GitHubConnection = {
user: currentUser,
token,
tokenType: connection.tokenType,
stats,
rateLimit: connection.rateLimit,
};
// Update localStorage
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
// Update state
setConnection(updatedConnection);
toast.success('GitHub stats refreshed');
} catch (error) {
console.error('Error fetching GitHub stats:', error);
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsFetchingStats(false);
}
};
const calculateRepoStats = (repos: any[]) => {
const repoStats = {
repos: repos.map((repo: any) => ({
name: repo.name,
full_name: repo.full_name,
html_url: repo.html_url,
description: repo.description,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
default_branch: repo.default_branch,
updated_at: repo.updated_at,
languages_url: repo.languages_url,
})),
languages: {} as Record<string, number>,
totalGists: 0,
};
repos.forEach((repo: any) => {
fetch(repo.languages_url)
.then((response) => response.json())
.then((languages: any) => {
const typedLanguages = languages as Record<string, number>;
Object.keys(typedLanguages).forEach((language) => {
if (!repoStats.languages[language]) {
repoStats.languages[language] = 0;
}
repoStats.languages[language] += 1;
});
});
});
return repoStats;
};
useEffect(() => {
const loadSavedConnection = async () => {
setIsLoading(true);
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
try {
const parsed = JSON.parse(savedConnection);
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
// Update the ref with the parsed token type
tokenTypeRef.current = parsed.tokenType;
// Set the connection
setConnection(parsed);
// If we have a token but no stats or incomplete stats, fetch them
if (
parsed.user &&
parsed.token &&
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
) {
console.log('Fetching missing GitHub stats for saved connection');
await fetchGitHubStats(parsed.token);
}
} catch (error) {
console.error('Error parsing saved GitHub connection:', error);
localStorage.removeItem('github_connection');
}
} else {
// Check for environment variable token
const envToken = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
if (envToken) {
// Check if token type is specified in environment variables
const envTokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE;
console.log('Environment token type:', envTokenType);
const tokenType =
envTokenType === 'classic' || envTokenType === 'fine-grained'
? (envTokenType as 'classic' | 'fine-grained')
: 'classic';
console.log('Using token type:', tokenType);
// Update both the state and the ref
tokenTypeRef.current = tokenType;
setConnection((prev) => ({
...prev,
tokenType,
}));
try {
// Fetch user data with the environment token
await fetchGithubUser(envToken);
} catch (error) {
console.error('Failed to connect with environment token:', error);
}
}
}
setIsLoading(false);
};
loadSavedConnection();
}, []);
// Ensure cookies are updated when connection changes
useEffect(() => {
if (!connection) {
return;
}
const token = connection.token;
const data = connection.user;
if (token) {
Cookies.set('githubToken', token);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
}
if (data) {
Cookies.set('githubUsername', data.login);
}
}, [connection]);
// Add function to update rate limits
const updateRateLimits = async (token: string) => {
try {
const response = await fetch('https://api.github.com/rate_limit', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (response.ok) {
const rateLimit = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
};
setConnection((prev) => ({
...prev,
rateLimit,
}));
}
} catch (error) {
console.error('Failed to fetch rate limits:', error);
}
};
// Add effect to update rate limits periodically
useEffect(() => {
let interval: NodeJS.Timeout;
if (connection.token && connection.user) {
updateRateLimits(connection.token);
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [connection.token, connection.user]);
if (isLoading || isConnecting || isFetchingStats) {
return <LoadingSpinner />;
}
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
setIsConnecting(true);
try {
// Update the ref with the current state value before connecting
tokenTypeRef.current = connection.tokenType;
/*
* Save token type to localStorage even before connecting
* This ensures the token type is persisted even if connection fails
*/
localStorage.setItem(
'github_connection',
JSON.stringify({
user: null,
token: connection.token,
tokenType: connection.tokenType,
}),
);
// Attempt to fetch the user info which validates the token
await fetchGithubUser(connection.token);
toast.success('Connected to GitHub successfully');
} catch (error) {
console.error('Failed to connect to GitHub:', error);
// Reset connection state on failure
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
localStorage.removeItem('github_connection');
// Remove all GitHub-related cookies
Cookies.remove('githubToken');
Cookies.remove('githubUsername');
Cookies.remove('git:github.com');
// Reset the token type ref
tokenTypeRef.current = 'classic';
setConnection({ user: null, token: '', tokenType: 'classic' });
toast.success('Disconnected from GitHub');
};
return (
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GithubLogo />
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub Connection
</h3>
</div>
</div>
{!connection.user && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
VITE_GITHUB_ACCESS_TOKEN
</code>{' '}
environment variable to connect automatically.
</p>
<p>
For fine-grained tokens, also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
VITE_GITHUB_TOKEN_TYPE=fine-grained
</code>
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
Token Type
</label>
<select
value={connection.tokenType}
onChange={(e) => {
const newTokenType = e.target.value as 'classic' | 'fine-grained';
tokenTypeRef.current = newTokenType;
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
}}
disabled={isConnecting || !!connection.user}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
'disabled:opacity-50',
)}
>
<option value="classic">Personal Access Token (Classic)</option>
<option value="fine-grained">Fine-grained Token</option>
</select>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input
type="password"
value={connection.token}
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
disabled={isConnecting || !!connection.user}
placeholder={`Enter your GitHub ${
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
}`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<span className="mx-2"></span>
<span>
Required scopes:{' '}
{connection.tokenType === 'classic'
? 'repo, read:org, read:user'
: 'Repository access, Organization access'}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-between">
{!connection.user ? (
<button
onClick={handleConnect}
disabled={isConnecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
) : (
<>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to GitHub
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout w-4 h-4" />
Dashboard
</Button>
<Button
onClick={() => {
fetchGitHubStats(connection.token);
updateRateLimits(connection.token);
}}
disabled={isFetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isFetchingStats ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Refreshing...
</>
) : (
<>
<div className="i-ph:arrows-clockwise w-4 h-4" />
Refresh Stats
</>
)}
</Button>
</div>
</div>
</>
)}
</div>
{connection.user && connection.stats && (
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg mb-4">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent dark:border-bolt-elements-item-contentAccent"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{connection.user.name || connection.user.login}
</h4>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{connection.user.login}
</p>
</div>
</div>
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Stats</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
{/* Languages Section */}
<div className="mb-6">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(connection.stats.languages)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([language]) => (
<span
key={language}
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
>
{language}
</span>
))}
</div>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{
label: 'Member Since',
value: new Date(connection.user.created_at).toLocaleDateString(),
},
{
label: 'Public Gists',
value: connection.stats.publicGists,
},
{
label: 'Organizations',
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
},
{
label: 'Languages',
value: Object.keys(connection.stats.languages).length,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
{/* Repository Stats */}
<div className="mt-4">
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: connection.stats.publicRepos,
},
{
label: 'Private Repos',
value: connection.stats.privateRepos,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: connection.stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: connection.stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Followers',
value: connection.stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value}
</span>
</div>
))}
</div>
</div>
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Gists</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public',
value: connection.stats.publicGists,
},
{
label: 'Private',
value: connection.stats.privateGists || 0,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
<div className="pt-2 border-t border-bolt-elements-borderColor">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
</span>
</div>
</div>
</div>
{/* Repositories Section */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Recent Repositories</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{connection.stats.repos.map((repo) => (
<a
key={repo.full_name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-tertiary" />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">
{repo.description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1 ml-auto group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
</motion.div>
);
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center p-4">
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-bolt-elements-textSecondary">Loading...</span>
</div>
</div>
);
}

View File

@@ -1,183 +0,0 @@
import React, { useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
import Cookies from 'js-cookie';
import { getLocalStorage } from '~/lib/persistence';
const GITHUB_TOKEN_KEY = 'github_token';
interface ConnectionFormProps {
authState: GitHubAuthState;
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
onSave: (e: React.FormEvent) => void;
onDisconnect: () => void;
}
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount
useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: savedToken,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
}));
// Ensure the token is also saved with the correct key for API requests
Cookies.set('githubToken', savedToken);
}
}, []);
return (
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
</div>
</div>
</div>
<form onSubmit={onSave} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
GitHub Username
</label>
<input
id="username"
type="text"
value={authState.username}
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="e.g., octocat"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
Personal Access Token
</label>
<a
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
target="_blank"
rel="noopener noreferrer"
className={classNames(
'inline-flex items-center gap-1.5 text-xs',
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
'transition-colors duration-200',
)}
>
<span>Generate new token</span>
<div className="i-ph:plus-circle" />
</a>
</div>
<input
id="token"
type="password"
value={authState.tokenInfo?.token || ''}
onChange={(e) =>
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: e.target.value,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
username: '',
isConnected: false,
isVerifying: false,
isLoadingRepos: false,
}))
}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="ghp_xxxxxxxxxxxx"
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-4">
{!authState.isConnected ? (
<button
type="submit"
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-purple-500 hover:bg-purple-600',
'text-white',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{authState.isVerifying ? (
<>
<div className="i-ph:spinner animate-spin" />
<span>Verifying...</span>
</>
) : (
<>
<div className="i-ph:plug-fill" />
<span>Connect</span>
</>
)}
</button>
) : (
<>
<button
onClick={onDisconnect}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
'text-bolt-elements-textPrimary',
)}
>
<div className="i-ph:plug-fill" />
<span>Disconnect</span>
</button>
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
<div className="i-ph:check-circle-fill" />
<span>Connected</span>
</span>
</>
)}
</div>
{authState.rateLimits && (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
<div className="i-ph:clock-countdown opacity-60" />
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
</div>
)}
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,150 +0,0 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
import { GitBranch } from '@phosphor-icons/react';
interface GitHubBranch {
name: string;
default?: boolean;
}
interface CreateBranchDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (branchName: string, sourceBranch: string) => void;
repository: GitHubRepoInfo;
branches?: GitHubBranch[];
}
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm(branchName, sourceBranch);
setBranchName('');
onClose();
};
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
<Dialog.Content
className={classNames(
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-full max-w-md p-6 rounded-xl shadow-lg',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
Create New Branch
</Dialog.Title>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
Branch Name
</label>
<input
id="branchName"
type="text"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
placeholder="feature/my-new-branch"
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
required
/>
</div>
<div>
<label
htmlFor="sourceBranch"
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
>
Source Branch
</label>
<select
id="sourceBranch"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
>
{branches?.map((branch) => (
<option key={branch.name} value={branch.name}>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
</div>
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
<li className="flex items-center gap-2">
<GitBranch className="text-lg" />
Repository: {repository.name}
</li>
{branchName && (
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
New branch will be created as: {branchName}
</li>
)}
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
Based on: {sourceBranch}
</li>
</ul>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-bolt-elements-textPrimary',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'transition-colors',
)}
>
Cancel
</button>
<button
type="submit"
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-white bg-purple-500',
'hover:bg-purple-600',
'transition-colors',
)}
>
Create Branch
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,190 +0,0 @@
import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
import type { GitHubUserResponse } from '~/types/GitHub';
interface GitHubAuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
return;
}
setIsSubmitting(true);
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const userData = (await response.json()) as GitHubUserResponse;
// Save connection data
const connectionData = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(connectionData));
// Set cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
toast.success(`Successfully connected as ${userData.login}`);
setToken('');
onClose();
} else {
if (response.status === 401) {
toast.error('Invalid GitHub token. Please check and try again.');
} else {
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
}
}
} catch (error) {
console.error('Error connecting to GitHub:', error);
toast.error('Failed to connect to GitHub. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="p-4 space-y-3">
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
<p className="text-sm text-[#666666] dark:text-[#999999]">
To access private repositories, you need to connect your GitHub account by providing a personal access
token.
</p>
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
GitHub Personal Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
/>
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
Get your token at{' '}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline"
>
github.com/settings/tokens
</a>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'classic'}
onChange={() => setTokenType('classic')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'fine-grained'}
onChange={() => setTokenType('fine-grained')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
</label>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
</button>
</form>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
<span className="i-ph:warning-circle w-4 h-4" />
Accessing Private Repositories
</h3>
<p className="text-xs text-amber-700 dark:text-amber-400">
Important things to know about accessing private repositories:
</p>
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
<li>You must be granted access to the repository by its owner</li>
<li>Your GitHub token must have the 'repo' scope</li>
<li>For organization repositories, you may need additional permissions</li>
<li>No token can give you access to repositories you don't have permission for</li>
</ul>
</div>
</div>
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
<Dialog.Close asChild>
<button
onClick={onClose}
className="px-4 py-1.5 bg-transparent bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
>
Close
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,717 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'react-toastify';
import { motion } from 'framer-motion';
import { Octokit } from '@octokit/rest';
// Internal imports
import { getLocalStorage } from '~/lib/persistence';
import { classNames } from '~/utils/classNames';
import type { GitHubUserResponse } from '~/types/GitHub';
import { logStore } from '~/lib/stores/logs';
import { workbenchStore } from '~/lib/stores/workbench';
import { extractRelativePath } from '~/utils/diff';
import { formatSize } from '~/utils/formatSize';
import type { FileMap, File } from '~/lib/stores/files';
// UI Components
import { Badge, EmptyState, StatusIndicator, SearchInput } from '~/components/ui';
interface PushToGitHubDialogProps {
isOpen: boolean;
onClose: () => void;
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
private: boolean;
}
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
const [repoName, setRepoName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<GitHubUserResponse | null>(null);
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
const [repoSearchQuery, setRepoSearchQuery] = useState('');
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
// Load GitHub connection on mount
useEffect(() => {
if (isOpen) {
const connection = getLocalStorage('github_connection');
if (connection?.user && connection?.token) {
setUser(connection.user);
// Only fetch if we have both user and token
if (connection.token.trim()) {
fetchRecentRepos(connection.token);
}
}
}
}, [isOpen]);
/*
* Filter repositories based on search query
* const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300);
*/
useEffect(() => {
if (recentRepos.length === 0) {
setFilteredRepos([]);
return;
}
if (!repoSearchQuery.trim()) {
setFilteredRepos(recentRepos);
return;
}
const query = repoSearchQuery.toLowerCase().trim();
const filtered = recentRepos.filter(
(repo) =>
repo.name.toLowerCase().includes(query) ||
(repo.description && repo.description.toLowerCase().includes(query)) ||
(repo.language && repo.language.toLowerCase().includes(query)),
);
setFilteredRepos(filtered);
}, [recentRepos, repoSearchQuery]);
const fetchRecentRepos = useCallback(async (token: string) => {
if (!token) {
logStore.logError('No GitHub token available');
toast.error('GitHub authentication required');
return;
}
try {
setIsFetchingRepos(true);
console.log('Fetching GitHub repositories with token:', token.substring(0, 5) + '...');
// Fetch ALL repos by paginating through all pages
let allRepos: GitHubRepo[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const requestUrl = `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,organization_member`;
const response = await fetch(requestUrl, {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token.trim()}`,
},
});
if (!response.ok) {
let errorData: { message?: string } = {};
try {
errorData = await response.json();
console.error('Error response data:', errorData);
} catch (e) {
errorData = { message: 'Could not parse error response' };
console.error('Could not parse error response:', e);
}
if (response.status === 401) {
toast.error('GitHub token expired. Please reconnect your account.');
// Clear invalid token
const connection = getLocalStorage('github_connection');
if (connection) {
localStorage.removeItem('github_connection');
setUser(null);
}
} else if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
// Rate limit exceeded
const resetTime = response.headers.get('x-ratelimit-reset');
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'soon';
toast.error(`GitHub API rate limit exceeded. Limit resets at ${resetDate}`);
} else {
logStore.logError('Failed to fetch GitHub repositories', {
status: response.status,
statusText: response.statusText,
error: errorData,
});
toast.error(`Failed to fetch repositories: ${errorData.message || response.statusText}`);
}
return;
}
try {
const repos = (await response.json()) as GitHubRepo[];
allRepos = allRepos.concat(repos);
if (repos.length < 100) {
hasMore = false;
} else {
page += 1;
}
} catch (parseError) {
console.error('Error parsing JSON response:', parseError);
logStore.logError('Failed to parse GitHub repositories response', { parseError });
toast.error('Failed to parse repository data');
setRecentRepos([]);
return;
}
}
setRecentRepos(allRepos);
} catch (error) {
console.error('Exception while fetching GitHub repositories:', error);
logStore.logError('Failed to fetch GitHub repositories', { error });
toast.error('Failed to fetch recent repositories');
} finally {
setIsFetchingRepos(false);
}
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const connection = getLocalStorage('github_connection');
if (!connection?.token || !connection?.user) {
toast.error('Please connect your GitHub account in Settings > Connections first');
return;
}
if (!repoName.trim()) {
toast.error('Repository name is required');
return;
}
setIsLoading(true);
try {
// Check if repository exists first
const octokit = new Octokit({ auth: connection.token });
try {
const { data: existingRepo } = await octokit.repos.get({
owner: connection.user.login,
repo: repoName,
});
// If we get here, the repo exists
let confirmMessage = `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`;
// Add visibility change warning if needed
if (existingRepo.private !== isPrivate) {
const visibilityChange = isPrivate
? 'This will also change the repository from public to private.'
: 'This will also change the repository from private to public.';
confirmMessage += `\n\n${visibilityChange}`;
}
const confirmOverwrite = window.confirm(confirmMessage);
if (!confirmOverwrite) {
setIsLoading(false);
return;
}
} catch (error) {
// 404 means repo doesn't exist, which is what we want for new repos
if (error instanceof Error && 'status' in error && error.status !== 404) {
throw error;
}
}
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
setCreatedRepoUrl(repoUrl);
// Get list of pushed files
const files = workbenchStore.files.get();
const filesList = Object.entries(files as FileMap)
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
.map(([path, dirent]) => ({
path: extractRelativePath(path),
size: new TextEncoder().encode((dirent as File).content || '').length,
}));
setPushedFiles(filesList);
setShowSuccessDialog(true);
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
} finally {
setIsLoading(false);
}
}
const handleClose = () => {
setRepoName('');
setIsPrivate(false);
setShowSuccessDialog(false);
setCreatedRepoUrl('');
onClose();
};
// Success Dialog
if (showSuccessDialog) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
>
<Dialog.Content
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
aria-describedby="success-dialog-description"
>
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500">
<div className="i-ph:check-circle w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Successfully pushed to GitHub
</h3>
<p
id="success-dialog-description"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
>
Your code is now available on GitHub
</p>
</div>
</div>
<Dialog.Close asChild>
<button
onClick={handleClose}
className="p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</button>
</Dialog.Close>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 text-left border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
<span className="i-ph:github-logo w-4 h-4 text-purple-500" />
Repository URL
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
{createdRepoUrl}
</code>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:copy w-4 h-4" />
</motion.button>
</div>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
<span className="i-ph:files w-4 h-4 text-purple-500" />
Pushed Files ({pushedFiles.length})
</p>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar pr-2">
{pushedFiles.map((file) => (
<div
key={file.path}
className="flex items-center justify-between py-1.5 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark border-b border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 last:border-0"
>
<span className="font-mono truncate flex-1 text-xs">{file.path}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
{formatSize(file.size)}
</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<motion.a
href={createdRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:github-logo w-4 h-4" />
View Repository
</motion.a>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:copy w-4 h-4" />
Copy URL
</motion.button>
<motion.button
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Close
</motion.button>
</div>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
if (!user) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
aria-describedby="connection-required-description"
>
<div className="relative text-center space-y-4">
<Dialog.Close asChild>
<button
onClick={handleClose}
className="absolute right-0 top-0 p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</button>
</Dialog.Close>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:github-logo w-8 h-8" />
</motion.div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
GitHub Connection Required
</h3>
<p
id="connection-required-description"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark max-w-md mx-auto"
>
To push your code to GitHub, you need to connect your GitHub account in Settings {'>'} Connections
first.
</p>
<div className="pt-2 flex justify-center gap-3">
<motion.button
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-sm hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleClose}
>
Close
</motion.button>
<motion.a
href="/settings/connections"
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:gear" />
Go to Settings
</motion.a>
</div>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
aria-describedby="push-dialog-description"
>
<div className="p-6">
<div className="flex items-center gap-4 mb-6">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:github-logo w-5 h-5" />
</motion.div>
<div>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Push to GitHub
</Dialog.Title>
<p
id="push-dialog-description"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
>
Push your code to a new or existing GitHub repository
</p>
</div>
<Dialog.Close asChild>
<button
onClick={handleClose}
className="ml-auto p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</button>
</Dialog.Close>
</div>
<div className="flex items-center gap-3 mb-6 p-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<div className="relative">
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center text-white">
<div className="i-ph:github-logo w-3 h-3" />
</div>
</div>
<div>
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
{user.name || user.login}
</p>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
@{user.login}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="repoName"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
>
Repository Name
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
<span className="i-ph:git-branch w-4 h-4" />
</div>
<input
id="repoName"
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value)}
placeholder="my-awesome-project"
className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Recent Repositories
</label>
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
{filteredRepos.length} of {recentRepos.length}
</span>
</div>
<div className="mb-2">
<SearchInput
placeholder="Search repositories..."
value={repoSearchQuery}
onChange={(e) => setRepoSearchQuery(e.target.value)}
onClear={() => setRepoSearchQuery('')}
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm"
/>
</div>
{recentRepos.length === 0 && !isFetchingRepos ? (
<EmptyState
icon="i-ph:github-logo"
title="No repositories found"
description="We couldn't find any repositories in your GitHub account."
variant="compact"
/>
) : (
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-2 custom-scrollbar">
{filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? (
<EmptyState
icon="i-ph:magnifying-glass"
title="No matching repositories"
description="Try a different search term"
variant="compact"
/>
) : (
filteredRepos.map((repo) => (
<motion.button
key={repo.full_name}
type="button"
onClick={() => setRepoName(repo.name)}
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-purple-500">
{repo.name}
</span>
</div>
{repo.private && (
<Badge variant="primary" size="sm" icon="i-ph:lock w-3 h-3">
Private
</Badge>
)}
</div>
{repo.description && (
<p className="mt-1 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
{repo.description}
</p>
)}
<div className="mt-2 flex items-center gap-2 flex-wrap">
{repo.language && (
<Badge variant="subtle" size="sm" icon="i-ph:code w-3 h-3">
{repo.language}
</Badge>
)}
<Badge variant="subtle" size="sm" icon="i-ph:star w-3 h-3">
{repo.stargazers_count.toLocaleString()}
</Badge>
<Badge variant="subtle" size="sm" icon="i-ph:git-fork w-3 h-3">
{repo.forks_count.toLocaleString()}
</Badge>
<Badge variant="subtle" size="sm" icon="i-ph:clock w-3 h-3">
{new Date(repo.updated_at).toLocaleDateString()}
</Badge>
</div>
</motion.button>
))
)}
</div>
)}
</div>
{isFetchingRepos && (
<div className="flex items-center justify-center py-4">
<StatusIndicator status="loading" pulse={true} label="Loading repositories..." />
</div>
)}
<div className="p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="private"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3"
/>
<label
htmlFor="private"
className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
Make repository private
</label>
</div>
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark mt-2 ml-6">
Private repositories are only visible to you and people you share them with
</p>
</div>
<div className="pt-4 flex gap-2">
<motion.button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Cancel
</motion.button>
<motion.button
type="submit"
disabled={isLoading}
className={classNames(
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
isLoading ? 'opacity-50 cursor-not-allowed' : '',
)}
whileHover={!isLoading ? { scale: 1.02 } : {}}
whileTap={!isLoading ? { scale: 0.98 } : {}}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Pushing...
</>
) : (
<>
<div className="i-ph:github-logo w-4 h-4" />
Push to GitHub
</>
)}
</motion.button>
</div>
</form>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,146 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface RepositoryCardProps {
repo: GitHubRepoInfo;
onSelect: () => void;
}
import { useMemo } from 'react';
export function RepositoryCard({ repo, onSelect }: RepositoryCardProps) {
// Use a consistent styling for all repository cards
const getCardStyle = () => {
return 'from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark';
};
// Format the date in a more readable format
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 1) {
return 'Today';
}
if (diffDays <= 2) {
return 'Yesterday';
}
if (diffDays <= 7) {
return `${diffDays} days ago`;
}
if (diffDays <= 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const cardStyle = useMemo(() => getCardStyle(), []);
// const formattedDate = useMemo(() => formatDate(repo.updated_at), [repo.updated_at]);
return (
<motion.div
className={`p-5 rounded-xl bg-gradient-to-br ${cardStyle} border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md`}
whileHover={{
scale: 1.02,
y: -2,
transition: { type: 'spring', stiffness: 400, damping: 17 },
}}
whileTap={{ scale: 0.98 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-start justify-between mb-3 gap-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80 backdrop-blur-sm flex items-center justify-center text-purple-500 shadow-sm">
<span className="i-ph:git-branch w-5 h-5" />
</div>
<div>
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
{repo.name}
</h3>
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
<span className="i-ph:user w-3 h-3" />
{repo.full_name.split('/')[0]}
</p>
</div>
</div>
<motion.button
onClick={onSelect}
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<span className="i-ph:git-pull-request w-3.5 h-3.5" />
Import
</motion.button>
</div>
{repo.description && (
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
{repo.description}
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{repo.private && (
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-purple-500/10 text-purple-600 dark:text-purple-400 text-xs">
<span className="i-ph:lock w-3 h-3" />
Private
</span>
)}
{repo.language && (
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<span className="i-ph:code w-3 h-3" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<span className="i-ph:star w-3 h-3" />
{repo.stargazers_count.toLocaleString()}
</span>
{repo.forks_count > 0 && (
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<span className="i-ph:git-fork w-3 h-3" />
{repo.forks_count.toLocaleString()}
</span>
)}
</div>
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
<span className="i-ph:clock w-3 h-3" />
Updated {formatDate(repo.updated_at)}
</span>
{repo.topics && repo.topics.length > 0 && (
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
{repo.topics.slice(0, 1).map((topic) => (
<span
key={topic}
className="px-1.5 py-0.5 rounded-full bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 text-xs"
>
{topic}
</span>
))}
{repo.topics.length > 1 && <span className="ml-1">+{repo.topics.length - 1}</span>}
</span>
)}
</div>
</motion.div>
);
}

View File

@@ -1,14 +0,0 @@
import { createContext } from 'react';
// Create a context to share the setShowAuthDialog function with child components
export interface RepositoryDialogContextType {
setShowAuthDialog: React.Dispatch<React.SetStateAction<boolean>>;
}
// Default context value with a no-op function
export const RepositoryDialogContext = createContext<RepositoryDialogContextType>({
// This is intentionally empty as it will be overridden by the provider
setShowAuthDialog: () => {
// No operation
},
});

View File

@@ -1,58 +0,0 @@
import React, { useContext } from 'react';
import type { GitHubRepoInfo } from '~/types/GitHub';
import { EmptyState, StatusIndicator } from '~/components/ui';
import { RepositoryCard } from './RepositoryCard';
import { RepositoryDialogContext } from './RepositoryDialogContext';
interface RepositoryListProps {
repos: GitHubRepoInfo[];
isLoading: boolean;
onSelect: (repo: GitHubRepoInfo) => void;
activeTab: string;
}
export function RepositoryList({ repos, isLoading, onSelect, activeTab }: RepositoryListProps) {
// Access the parent component's setShowAuthDialog function
const { setShowAuthDialog } = useContext(RepositoryDialogContext);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
<StatusIndicator status="loading" pulse={true} size="lg" label="Loading repositories..." className="mb-2" />
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
This may take a moment
</p>
</div>
);
}
if (repos.length === 0) {
if (activeTab === 'my-repos') {
return (
<EmptyState
icon="i-ph:folder-simple-dashed"
title="No repositories found"
description="Connect your GitHub account or create a new repository to get started"
actionLabel="Connect GitHub Account"
onAction={() => setShowAuthDialog(true)}
/>
);
} else {
return (
<EmptyState
icon="i-ph:magnifying-glass"
title="No repositories found"
description="Try searching with different keywords or filters"
/>
);
}
}
return (
<div className="space-y-3">
{repos.map((repo) => (
<RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />
))}
</div>
);
}

View File

@@ -1,993 +0,0 @@
import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { getLocalStorage } from '~/lib/persistence';
import { motion, AnimatePresence } from 'framer-motion';
import Cookies from 'js-cookie';
// Import UI components
import { Input, SearchInput, Badge, FilterChip } from '~/components/ui';
// Import the components we've extracted
import { RepositoryList } from './RepositoryList';
import { StatsDialog } from './StatsDialog';
import { GitHubAuthDialog } from './GitHubAuthDialog';
import { RepositoryDialogContext } from './RepositoryDialogContext';
interface GitHubTreeResponse {
tree: Array<{
path: string;
type: string;
size?: number;
}>;
}
interface RepositorySelectionDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (url: string) => void;
}
interface SearchFilters {
language?: string;
stars?: number;
forks?: number;
}
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
const [customUrl, setCustomUrl] = useState('');
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
const [selectedBranch, setSelectedBranch] = useState('');
const [filters, setFilters] = useState<SearchFilters>({});
const [showStatsDialog, setShowStatsDialog] = useState(false);
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
const [showAuthDialog, setShowAuthDialog] = useState(false);
// Handle GitHub auth dialog close and refresh repositories
const handleAuthDialogClose = () => {
setShowAuthDialog(false);
// If we're on the my-repos tab, refresh the repository list
if (activeTab === 'my-repos') {
fetchUserRepos();
}
};
// Initialize GitHub connection and fetch repositories
useEffect(() => {
const savedConnection = getLocalStorage('github_connection');
// If no connection exists but environment variables are set, create a connection
if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic';
// Fetch GitHub user info to initialize the connection
fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
return response.json();
})
.then((data: unknown) => {
const userData = data as GitHubUserResponse;
// Save connection to local storage
const newConnection = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(newConnection));
// Also save as cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
// Refresh repositories after connection is established
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
})
.catch((error) => {
console.error('Failed to initialize GitHub connection from environment variables:', error);
});
}
}, [isOpen]);
// Fetch repositories when dialog opens or tab changes
useEffect(() => {
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
}, [isOpen, activeTab]);
const fetchUserRepos = async () => {
const connection = getLocalStorage('github_connection');
if (!connection?.token) {
toast.error('Please connect your GitHub account first');
return;
}
setIsLoading(true);
try {
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch repositories');
}
const data = await response.json();
// Add type assertion and validation
if (
Array.isArray(data) &&
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
) {
setRepositories(data as GitHubRepoInfo[]);
} else {
throw new Error('Invalid repository data format');
}
} catch (error) {
console.error('Error fetching repos:', error);
toast.error('Failed to fetch your repositories');
} finally {
setIsLoading(false);
}
};
const handleSearch = async (query: string) => {
setIsLoading(true);
setSearchResults([]);
try {
let searchQuery = query;
if (filters.language) {
searchQuery += ` language:${filters.language}`;
}
if (filters.stars) {
searchQuery += ` stars:>${filters.stars}`;
}
if (filters.forks) {
searchQuery += ` forks:>${filters.forks}`;
}
const response = await fetch(
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
throw new Error('Failed to search repositories');
}
const data = await response.json();
// Add type assertion and validation
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
setSearchResults(data.items as GitHubRepoInfo[]);
} else {
throw new Error('Invalid search results format');
}
} catch (error) {
console.error('Error searching repos:', error);
toast.error('Failed to search repositories');
} finally {
setIsLoading(false);
}
};
const fetchBranches = async (repo: GitHubRepoInfo) => {
setIsLoading(true);
try {
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token
? {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
}
: {};
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
headers,
});
if (!response.ok) {
throw new Error('Failed to fetch branches');
}
const data = await response.json();
// Add type assertion and validation
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
setBranches(
data.map((branch) => ({
name: branch.name,
default: branch.name === repo.default_branch,
})),
);
} else {
throw new Error('Invalid branch data format');
}
} catch (error) {
console.error('Error fetching branches:', error);
toast.error('Failed to fetch branches');
} finally {
setIsLoading(false);
}
};
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
setSelectedRepository(repo);
await fetchBranches(repo);
};
const formatGitUrl = (url: string): string => {
// Remove any tree references and ensure .git extension
const baseUrl = url
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
.replace(/\/$/, '') // Remove trailing slash
.replace(/\.git$/, ''); // Remove .git if present
return `${baseUrl}.git`;
};
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
try {
// Extract branch from URL if present (format: url#branch)
let branch: string | null = null;
let cleanUrl = repoUrl;
if (repoUrl.includes('#')) {
const parts = repoUrl.split('#');
cleanUrl = parts[0];
branch = parts[1];
}
const [owner, repo] = cleanUrl
.replace(/\.git$/, '')
.split('/')
.slice(-2);
// Try to get token from local storage first
const connection = getLocalStorage('github_connection');
// If no connection in local storage, check environment variables
let headers: HeadersInit = {};
if (connection?.token) {
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
};
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
// Use token from environment variables
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`,
};
}
// First, get the repository info to determine the default branch
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
if (!repoInfoResponse.ok) {
if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) {
throw new Error(
`Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`,
);
} else if (repoInfoResponse.status === 404) {
throw new Error(
`Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`,
);
} else {
throw new Error(
`Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`,
);
}
}
const repoInfo = (await repoInfoResponse.json()) as { default_branch: string };
let defaultBranch = repoInfo.default_branch || 'main';
// If a branch was specified in the URL, use that instead of the default
if (branch) {
defaultBranch = branch;
}
// Try to fetch the repository tree using the selected branch
let treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{
headers,
},
);
// If the selected branch doesn't work, try common branch names
if (!treeResponse.ok) {
// Try 'master' branch if default branch failed
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, {
headers,
});
// If master also fails, try 'main' branch
if (!treeResponse.ok) {
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
headers,
});
}
// If all common branches fail, throw an error
if (!treeResponse.ok) {
throw new Error(
'Failed to fetch repository structure. Please check the repository URL and your access permissions.',
);
}
}
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
// Calculate repository stats
let totalSize = 0;
let totalFiles = 0;
const languages: { [key: string]: number } = {};
let hasPackageJson = false;
let hasDependencies = false;
for (const file of treeData.tree) {
if (file.type === 'blob') {
totalFiles++;
if (file.size) {
totalSize += file.size;
}
// Check for package.json
if (file.path === 'package.json') {
hasPackageJson = true;
// Fetch package.json content to check dependencies
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
headers,
});
if (contentResponse.ok) {
const content = (await contentResponse.json()) as GitHubContent;
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
hasDependencies = !!(
packageJson.dependencies ||
packageJson.devDependencies ||
packageJson.peerDependencies
);
}
}
// Detect language based on file extension
const ext = file.path.split('.').pop()?.toLowerCase();
if (ext) {
languages[ext] = (languages[ext] || 0) + (file.size || 0);
}
}
}
const stats: RepositoryStats = {
totalFiles,
totalSize,
languages,
hasPackageJson,
hasDependencies,
};
return stats;
} catch (error) {
console.error('Error verifying repository:', error);
// Check if it's an authentication error and show the auth dialog
const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository';
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
setShowAuthDialog(true);
}
toast.error(errorMessage);
return null;
}
};
const handleImport = async () => {
try {
let gitUrl: string;
if (activeTab === 'url' && customUrl) {
gitUrl = formatGitUrl(customUrl);
} else if (selectedRepository) {
gitUrl = formatGitUrl(selectedRepository.html_url);
if (selectedBranch) {
gitUrl = `${gitUrl}#${selectedBranch}`;
}
} else {
return;
}
// Verify repository before importing
const stats = await verifyRepository(gitUrl);
if (!stats) {
return;
}
setCurrentStats(stats);
setPendingGitUrl(gitUrl);
setShowStatsDialog(true);
} catch (error) {
console.error('Error preparing repository:', error);
// Check if it's an authentication error
const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.';
// Show the GitHub auth dialog for any authentication or permission errors
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
// Directly show the auth dialog instead of just showing a toast
setShowAuthDialog(true);
toast.error(
<div className="space-y-2">
<p>{errorMessage}</p>
<button onClick={() => setShowAuthDialog(true)} className="underline font-medium block text-purple-500">
Learn how to access private repositories
</button>
</div>,
{ autoClose: 10000 }, // Keep the toast visible longer
);
} else {
toast.error(errorMessage);
}
}
};
const handleStatsConfirm = () => {
setShowStatsDialog(false);
if (pendingGitUrl) {
onSelect(pendingGitUrl);
onClose();
}
};
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
let parsedValue: string | number | undefined = value;
if (key === 'stars' || key === 'forks') {
parsedValue = value ? parseInt(value, 10) : undefined;
}
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
handleSearch(searchQuery);
};
// Handle dialog close properly
const handleClose = () => {
setIsLoading(false); // Reset loading state
setSearchQuery(''); // Reset search
setSearchResults([]); // Reset results
onClose();
};
return (
<RepositoryDialogContext.Provider value={{ setShowAuthDialog }}>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[650px] max-h-[85vh] overflow-hidden bg-white dark:bg-bolt-elements-background-depth-1 rounded-xl shadow-xl z-[51] border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
{/* Header */}
<div className="p-5 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/10 flex items-center justify-center text-purple-500 shadow-sm">
<span className="i-ph:github-logo w-5 h-5" />
</div>
<div>
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Import GitHub Repository
</Dialog.Title>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Clone a repository from GitHub to your workspace
</p>
</div>
</div>
<Dialog.Close
onClick={handleClose}
className={classNames(
'p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent',
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
)}
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</Dialog.Close>
</div>
{/* Auth Info Banner */}
<div className="p-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between bg-gradient-to-r from-bolt-elements-background-depth-2 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-3 dark:to-bolt-elements-background-depth-2">
<div className="flex items-center gap-2">
<span className="i-ph:info text-blue-500" />
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Need to access private repositories?
</span>
</div>
<motion.button
onClick={() => setShowAuthDialog(true)}
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5 shadow-sm"
whileHover={{ scale: 1.02, boxShadow: '0 4px 8px rgba(124, 58, 237, 0.2)' }}
whileTap={{ scale: 0.98 }}
>
<span className="i-ph:github-logo w-4 h-4" />
Connect GitHub Account
</motion.button>
</div>
{/* Content */}
<div className="p-5">
{/* Tabs */}
<div className="mb-6">
<div className="bg-[#f0f0f0] dark:bg-[#1e1e1e] rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<div className="flex">
<button
onClick={() => setActiveTab('my-repos')}
className={classNames(
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
activeTab === 'my-repos'
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
)}
>
My Repos
</button>
<button
onClick={() => setActiveTab('search')}
className={classNames(
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
activeTab === 'search'
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
)}
>
Search
</button>
<button
onClick={() => setActiveTab('url')}
className={classNames(
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
activeTab === 'url'
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
)}
>
From URL
</button>
</div>
</div>
</div>
{activeTab === 'url' ? (
<div className="space-y-5">
<div className="bg-gradient-to-br from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
<span className="i-ph:link-simple w-4 h-4 text-purple-500" />
Repository URL
</h3>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-purple-500">
<span className="i-ph:github-logo w-5 h-5" />
</div>
<Input
type="text"
placeholder="Enter GitHub repository URL (e.g., https://github.com/user/repo)"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className="w-full pl-10 py-3 border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
<p className="flex items-start gap-2">
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
<span>
You can paste any GitHub repository URL, including specific branches or tags.
<br />
<span className="text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
Example: https://github.com/username/repository/tree/branch-name
</span>
</span>
</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
<span>Ready to import?</span>
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
</div>
<motion.button
onClick={handleImport}
disabled={!customUrl}
className={classNames(
'w-full h-12 px-4 py-2 rounded-xl text-white transition-all duration-200 flex items-center gap-2 justify-center',
customUrl
? 'bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 shadow-md'
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
)}
whileHover={customUrl ? { scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' } : {}}
whileTap={customUrl ? { scale: 0.98 } : {}}
>
<span className="i-ph:git-pull-request w-5 h-5" />
Import Repository
</motion.button>
</div>
) : (
<>
{activeTab === 'search' && (
<div className="space-y-5 mb-5">
<div className="bg-gradient-to-br from-blue-500/5 to-cyan-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
<span className="i-ph:magnifying-glass w-4 h-4 text-blue-500" />
Search GitHub
</h3>
<div className="flex gap-2">
<div className="flex-1">
<SearchInput
placeholder="Search GitHub repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (e.target.value.length > 2) {
handleSearch(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery.length > 2) {
handleSearch(searchQuery);
}
}}
onClear={() => {
setSearchQuery('');
setSearchResults([]);
}}
iconClassName="text-blue-500"
className="py-3 bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm"
loading={isLoading}
/>
</div>
<motion.button
onClick={() => setFilters({})}
className="px-3 py-2 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-sm"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Clear filters"
>
<span className="i-ph:funnel-simple w-4 h-4" />
</motion.button>
</div>
<div className="mt-3">
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Filters
</div>
{/* Active filters */}
{(filters.language || filters.stars || filters.forks) && (
<div className="flex flex-wrap gap-2 mb-3">
<AnimatePresence>
{filters.language && (
<FilterChip
label="Language"
value={filters.language}
icon="i-ph:code"
active
onRemove={() => {
const newFilters = { ...filters };
delete newFilters.language;
setFilters(newFilters);
if (searchQuery.length > 2) {
handleSearch(searchQuery);
}
}}
/>
)}
{filters.stars && (
<FilterChip
label="Stars"
value={`>${filters.stars}`}
icon="i-ph:star"
active
onRemove={() => {
const newFilters = { ...filters };
delete newFilters.stars;
setFilters(newFilters);
if (searchQuery.length > 2) {
handleSearch(searchQuery);
}
}}
/>
)}
{filters.forks && (
<FilterChip
label="Forks"
value={`>${filters.forks}`}
icon="i-ph:git-fork"
active
onRemove={() => {
const newFilters = { ...filters };
delete newFilters.forks;
setFilters(newFilters);
if (searchQuery.length > 2) {
handleSearch(searchQuery);
}
}}
/>
)}
</AnimatePresence>
</div>
)}
<div className="grid grid-cols-3 gap-2">
<div className="relative col-span-3 md:col-span-1">
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
<span className="i-ph:code w-3.5 h-3.5" />
</div>
<input
type="text"
placeholder="Language (e.g., javascript)"
value={filters.language || ''}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value });
if (searchQuery.length > 2) {
handleSearch(searchQuery);
}
}}
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="relative">
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
<span className="i-ph:star w-3.5 h-3.5" />
</div>
<input
type="number"
placeholder="Min stars"
value={filters.stars || ''}
onChange={(e) => handleFilterChange('stars', e.target.value)}
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="relative">
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
<span className="i-ph:git-fork w-3.5 h-3.5" />
</div>
<input
type="number"
placeholder="Min forks"
value={filters.forks || ''}
onChange={(e) => handleFilterChange('forks', e.target.value)}
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
<p className="flex items-start gap-2">
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
<span>
Search for repositories by name, description, or topics. Use filters to narrow down
results.
</span>
</p>
</div>
</div>
</div>
)}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{selectedRepository ? (
<div className="space-y-5 bg-gradient-to-br from-purple-500/5 to-blue-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.button
onClick={() => setSelectedRepository(null)}
className="p-2 rounded-lg hover:bg-white dark:hover:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary shadow-sm"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<span className="i-ph:arrow-left w-4 h-4" />
</motion.button>
<div>
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-lg">
{selectedRepository.name}
</h3>
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
<span className="i-ph:user w-3 h-3" />
{selectedRepository.full_name.split('/')[0]}
</p>
</div>
</div>
{selectedRepository.private && (
<Badge variant="primary" size="md" icon="i-ph:lock w-3 h-3">
Private
</Badge>
)}
</div>
{selectedRepository.description && (
<div className="bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
{selectedRepository.description}
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{selectedRepository.language && (
<Badge variant="subtle" size="md" icon="i-ph:code w-3 h-3">
{selectedRepository.language}
</Badge>
)}
<Badge variant="subtle" size="md" icon="i-ph:star w-3 h-3">
{selectedRepository.stargazers_count.toLocaleString()}
</Badge>
{selectedRepository.forks_count > 0 && (
<Badge variant="subtle" size="md" icon="i-ph:git-fork w-3 h-3">
{selectedRepository.forks_count.toLocaleString()}
</Badge>
)}
</div>
<div className="pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<div className="flex items-center gap-2 mb-2">
<span className="i-ph:git-branch w-4 h-4 text-purple-500" />
<label className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Select Branch
</label>
</div>
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full px-3 py-3 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-purple-500 shadow-sm"
>
{branches.map((branch) => (
<option
key={branch.name}
value={branch.name}
className="bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
</div>
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
<span>Ready to import?</span>
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
</div>
<motion.button
onClick={handleImport}
className="w-full h-12 px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white transition-all duration-200 flex items-center gap-2 justify-center shadow-md"
whileHover={{ scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' }}
whileTap={{ scale: 0.98 }}
>
<span className="i-ph:git-pull-request w-5 h-5" />
Import {selectedRepository.name}
</motion.button>
</div>
) : (
<RepositoryList
repos={activeTab === 'my-repos' ? repositories : searchResults}
isLoading={isLoading}
onSelect={handleRepoSelect}
activeTab={activeTab}
/>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
{/* GitHub Auth Dialog */}
<GitHubAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
{/* Repository Stats Dialog */}
{currentStats && (
<StatsDialog
isOpen={showStatsDialog}
onClose={() => setShowStatsDialog(false)}
onConfirm={handleStatsConfirm}
stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/>
)}
</Dialog.Root>
</RepositoryDialogContext.Provider>
);
}

View File

@@ -1,83 +0,0 @@
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import type { RepositoryStats } from '~/types/GitHub';
import { formatSize } from '~/utils/formatSize';
import { RepositoryStats as RepoStats } from '~/components/ui';
interface StatsDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
stats: RepositoryStats;
isLargeRepo?: boolean;
}
export function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl">
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500">
<span className="i-ph:git-branch w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Repository Overview
</h3>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Review repository details before importing
</p>
</div>
</div>
<div className="mt-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 p-4 rounded-lg">
<RepoStats stats={stats} />
</div>
{isLargeRepo && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
<div className="text-yellow-800 dark:text-yellow-500">
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
and could impact performance.
</div>
</div>
)}
</div>
<div className="border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark p-4 flex justify-end gap-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-b-lg">
<motion.button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Cancel
</motion.button>
<motion.button
onClick={onConfirm}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Import Repository
</motion.button>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { Button } from '~/components/ui/Button';
import { githubConnectionStore } from '~/lib/stores/githubConnection';
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
toast.error('Please enter a valid GitHub token');
return;
}
setIsSubmitting(true);
try {
await githubConnectionStore.connect(token.trim(), tokenType);
toast.success('Successfully connected to GitHub!');
onClose();
setToken('');
} catch (error) {
console.error('GitHub connection failed:', error);
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setToken('');
onClose();
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={handleClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor shadow-xl z-50"
>
<div className="p-6">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">
Connect to GitHub
</Dialog.Title>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">Token Type</label>
<div className="flex gap-2">
<label className="flex items-center">
<input
type="radio"
value="classic"
checked={tokenType === 'classic'}
onChange={(e) => setTokenType(e.target.value as 'classic')}
className="mr-2"
/>
<span className="text-sm text-bolt-elements-textSecondary">Classic Token</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="fine-grained"
checked={tokenType === 'fine-grained'}
onChange={(e) => setTokenType(e.target.value as 'fine-grained')}
className="mr-2"
/>
<span className="text-sm text-bolt-elements-textSecondary">Fine-grained Token</span>
</label>
</div>
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
GitHub Personal Access Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
disabled={isSubmitting}
autoComplete="off"
/>
</div>
<div className="bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-md p-4">
<div className="flex items-start gap-3">
<div className="i-ph:info w-5 h-5 text-bolt-elements-icon-info mt-0.5 flex-shrink-0" />
<div className="text-sm text-bolt-elements-textSecondary space-y-2">
<p>To create a GitHub Personal Access Token:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Go to GitHub Settings Developer settings Personal access tokens</li>
<li>Click "Generate new token"</li>
<li>Select appropriate scopes (repo, user, etc.)</li>
<li>Copy and paste the token here</li>
</ol>
<p className="text-xs">
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-textAccent hover:underline"
>
Learn more about creating tokens
</a>
</p>
</div>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" disabled={!token.trim() || isSubmitting} className="flex-1">
{isSubmitting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</form>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,276 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { Button } from '~/components/ui/Button';
import {
githubConnectionAtom,
githubConnectionStore,
isGitHubConnected,
isGitHubConnecting,
isGitHubLoadingStats,
} from '~/lib/stores/githubConnection';
import { AuthDialog } from './AuthDialog';
import { StatsDisplay } from './StatsDisplay';
import { RepositoryList } from './RepositoryList';
interface GitHubConnectionProps {
onCloneRepository?: (repoUrl: string) => void;
}
export default function GitHubConnection({ onCloneRepository }: GitHubConnectionProps = {}) {
const connection = useStore(githubConnectionAtom);
const isConnected = useStore(isGitHubConnected);
const isConnecting = useStore(isGitHubConnecting);
const isLoadingStats = useStore(isGitHubLoadingStats);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const [isReposExpanded, setIsReposExpanded] = useState(false);
const handleConnect = () => {
setIsAuthDialogOpen(true);
};
const handleDisconnect = () => {
githubConnectionStore.disconnect();
setIsStatsExpanded(false);
setIsReposExpanded(false);
toast.success('Disconnected from GitHub');
};
const handleRefreshStats = async () => {
try {
await githubConnectionStore.fetchStats();
toast.success('GitHub stats refreshed');
} catch (error) {
toast.error(`Failed to refresh stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTokenTypeChange = (tokenType: 'classic' | 'fine-grained') => {
githubConnectionStore.updateTokenType(tokenType);
};
const handleCloneRepository = (repoUrl: string) => {
if (onCloneRepository) {
onCloneRepository(repoUrl);
} else {
window.open(repoUrl, '_blank');
}
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor">
<div className="i-ph:git-repository text-bolt-elements-icon-primary w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">GitHub</h3>
<p className="text-sm text-bolt-elements-textSecondary">
{isConnected
? `Connected as ${connection.user?.login}`
: 'Connect your GitHub account to manage repositories'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Button
onClick={handleRefreshStats}
disabled={isLoadingStats}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isLoadingStats ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Refreshing...
</>
) : (
<>
<div className="i-ph:arrows-clockwise w-4 h-4" />
Refresh Stats
</>
)}
</Button>
<Button
onClick={handleDisconnect}
variant="outline"
size="sm"
className="text-bolt-elements-textDanger hover:text-bolt-elements-textDanger"
>
<div className="i-ph:sign-out w-4 h-4 mr-2" />
Disconnect
</Button>
</>
) : (
<Button
onClick={handleConnect}
disabled={isConnecting}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plus w-4 h-4" />
Connect
</>
)}
</Button>
)}
</div>
</div>
{/* Connection Status */}
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor">
<div className="flex items-center gap-3">
<div
className={classNames(
'w-3 h-3 rounded-full',
isConnected ? 'bg-bolt-elements-icon-success' : 'bg-bolt-elements-icon-secondary',
)}
/>
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{isConnected ? 'Connected' : 'Not Connected'}
</span>
{connection.rateLimit && (
<span className="text-xs text-bolt-elements-textSecondary ml-auto">
Rate limit: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
</span>
)}
</div>
{/* Token Type Selection */}
{isConnected && (
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor">
<label className="block text-xs font-medium text-bolt-elements-textPrimary mb-2">Token Type</label>
<div className="flex gap-3">
{(['classic', 'fine-grained'] as const).map((type) => (
<label key={type} className="flex items-center cursor-pointer">
<input
type="radio"
value={type}
checked={connection.tokenType === type}
onChange={() => handleTokenTypeChange(type)}
className="mr-2 text-bolt-elements-item-contentAccent focus:ring-bolt-elements-item-contentAccent"
/>
<span className="text-xs text-bolt-elements-textSecondary capitalize">
{type.replace('-', ' ')} Token
</span>
</label>
))}
</div>
</div>
)}
</div>
{/* User Profile */}
{isConnected && connection.user && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor"
>
<div className="flex items-center gap-4">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent"
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user.name || connection.user.login}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
{connection.user.bio && (
<p className="text-xs text-bolt-elements-textTertiary mt-1 line-clamp-2">{connection.user.bio}</p>
)}
</div>
<div className="text-right">
<div className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user.public_repos?.toLocaleString() || 0}
</div>
<div className="text-xs text-bolt-elements-textSecondary">repositories</div>
</div>
</div>
</motion.div>
)}
{/* Stats Section */}
{isConnected && connection.stats && (
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Stats</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-4 p-4 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor">
<StatsDisplay stats={connection.stats} onRefresh={handleRefreshStats} isRefreshing={isLoadingStats} />
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Repositories Section */}
{isConnected && connection.stats?.repos && connection.stats.repos.length > 0 && (
<Collapsible open={isReposExpanded} onOpenChange={setIsReposExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({connection.stats.repos.length})
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isReposExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-4 p-4 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor">
<RepositoryList
repositories={connection.stats.repos}
onClone={handleCloneRepository}
onRefresh={handleRefreshStats}
isRefreshing={isLoadingStats}
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Auth Dialog */}
<AuthDialog isOpen={isAuthDialogOpen} onClose={() => setIsAuthDialogOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface RepositoryCardProps {
repo: GitHubRepoInfo;
onClone?: (repoUrl: string) => void;
}
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`i-ph:${repo.private ? 'lock' : 'git-repository'} w-4 h-4 text-bolt-elements-icon-info`} />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
{repo.private && (
<span className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor">
Private
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
{repo.topics && repo.topics.length > 0 && (
<div className="flex flex-wrap gap-1">
{repo.topics.slice(0, 3).map((topic) => (
<span
key={topic}
className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor"
>
{topic}
</span>
))}
{repo.topics.length > 3 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor">
+{repo.topics.length - 3}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
{repo.language && (
<span className="flex items-center gap-1" title="Primary Language">
<div className="i-ph:circle-fill w-2 h-2 text-bolt-elements-icon-success" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<div className="flex items-center gap-2 ml-auto">
{onClone && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const cloneUrl = `https://github.com/${repo.full_name}.git`;
onClone(cloneUrl);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
title="Clone repository"
>
<div className="i-ph:git-branch w-3.5 h-3.5" />
Clone
</button>
)}
<span className="flex items-center gap-1 group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</div>
</a>
);
}

View File

@@ -0,0 +1,144 @@
import React, { useState, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { RepositoryCard } from './RepositoryCard';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface RepositoryListProps {
repositories: GitHubRepoInfo[];
onClone?: (repoUrl: string) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const MAX_REPOS_PER_PAGE = 20;
export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isSearching, setIsSearching] = useState(false);
const filteredRepositories = useMemo(() => {
if (!searchQuery) {
return repositories;
}
setIsSearching(true);
const filtered = repositories.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.language && repo.language.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.topics && repo.topics.some((topic) => topic.toLowerCase().includes(searchQuery.toLowerCase()))),
);
setIsSearching(false);
return filtered;
}, [repositories, searchQuery]);
const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
const endIndex = startIndex + MAX_REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
const handleSearch = (query: string) => {
setSearchQuery(query);
setCurrentPage(1); // Reset to first page when searching
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({filteredRepositories.length})
</h4>
{onRefresh && (
<Button
onClick={onRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isRefreshing ? (
<div className="i-ph:spinner animate-spin w-4 h-4" />
) : (
<div className="i-ph:arrows-clockwise w-4 h-4" />
)}
Refresh
</Button>
)}
</div>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search repositories by name, description, language, or topics..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<div className="i-ph:spinner animate-spin w-4 h-4 text-bolt-elements-textSecondary" />
) : (
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textSecondary" />
)}
</div>
</div>
{/* Repository Grid */}
<div className="space-y-4">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
{searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} onClone={onClone} />
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
<div className="i-ph:caret-left w-4 h-4" />
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
<div className="i-ph:caret-right w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import type { GitHubStats } from '~/types/GitHub';
interface StatsDisplayProps {
stats: GitHubStats;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
// Calculate top languages for display
const topLanguages = Object.entries(stats.languages || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
return (
<div className="space-y-4">
{/* Repository Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: stats.publicRepos || 0,
},
{
label: 'Private Repos',
value: stats.privateRepos || 0,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Contribution Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: stats.totalStars || stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: stats.totalForks || stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Followers',
value: stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{/* Gist Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Gist Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Gists',
value: stats.publicGists || 0,
icon: 'i-ph:note',
},
{
label: 'Total Gists',
value: stats.totalGists || 0,
icon: 'i-ph:note-blank',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 text-bolt-elements-icon-tertiary`} />
{stat.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{/* Top Languages */}
{topLanguages.length > 0 && (
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Top Languages</h5>
<div className="space-y-2">
{topLanguages.map(([language, count]) => (
<div key={language} className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textPrimary">{language}</span>
<span className="text-sm text-bolt-elements-textSecondary">{count} repositories</span>
</div>
))}
</div>
</div>
)}
{/* Recent Activity */}
{stats.recentActivity && stats.recentActivity.length > 0 && (
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Activity</h5>
<div className="space-y-2">
{stats.recentActivity.slice(0, 3).map((activity) => (
<div key={activity.id} className="flex items-center gap-2 text-sm">
<div className="i-ph:git-commit w-3 h-3 text-bolt-elements-icon-tertiary" />
<span className="text-bolt-elements-textSecondary">
{activity.type.replace('Event', '')} in {activity.repo.name}
</span>
<span className="text-xs text-bolt-elements-textTertiary ml-auto">
{new Date(activity.created_at).toLocaleDateString()}
</span>
</div>
))}
</div>
</div>
)}
<div className="pt-2 border-t border-bolt-elements-borderColor">
<div className="flex items-center justify-between">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
</span>
{onRefresh && (
<Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm" className="text-xs">
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { default as GitHubConnection } from './GitHubConnection';
export { RepositoryCard } from './RepositoryCard';
export { RepositoryList } from './RepositoryList';
export { StatsDisplay } from './StatsDisplay';
export { AuthDialog } from './AuthDialog';

View File

@@ -0,0 +1,389 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { Button } from '~/components/ui/Button';
import { useGitLabConnection } from '~/lib/stores/gitlabConnection';
import { RepositoryList } from './RepositoryList';
import { StatsDisplay } from './StatsDisplay';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface GitLabConnectionProps {
onCloneRepository?: (repoUrl: string) => void;
}
export default function GitLabConnection({ onCloneRepository }: GitLabConnectionProps = {}) {
const {
connection: connectionAtom,
isConnected,
user: userAtom,
stats,
gitlabUrl: gitlabUrlAtom,
connect,
disconnect,
fetchStats,
loadSavedConnection,
setGitLabUrl,
setToken,
autoConnect,
} = useGitLabConnection();
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
useEffect(() => {
const initializeConnection = async () => {
setIsLoading(true);
const saved = loadSavedConnection();
if (saved?.user && saved?.token) {
// If we have stats, no need to fetch them again
if (!saved.stats || !saved.stats.projects || saved.stats.projects.length === 0) {
await fetchStats();
}
} else if (import.meta.env?.VITE_GITLAB_ACCESS_TOKEN) {
// Auto-connect using environment variable if no saved connection
const result = await autoConnect();
if (result.success) {
toast.success('Connected to GitLab automatically');
}
}
setIsLoading(false);
};
initializeConnection();
}, [autoConnect, fetchStats, loadSavedConnection]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
setIsConnecting(true);
try {
const result = await connect(connectionAtom.get().token, gitlabUrlAtom.get());
if (result.success) {
toast.success('Connected to GitLab successfully');
await fetchStats();
} else {
toast.error(`Failed to connect to GitLab: ${result.error}`);
}
} catch (error) {
console.error('Failed to connect to GitLab:', error);
toast.error(`Failed to connect to GitLab: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
disconnect();
toast.success('Disconnected from GitLab');
};
const handleCloneRepository = (repoUrl: string) => {
if (onCloneRepository) {
onCloneRepository(repoUrl);
} else {
window.open(repoUrl, '_blank');
}
};
if (isLoading || isConnecting || isFetchingStats) {
return (
<div className="flex items-center justify-center p-4">
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
<span className="text-bolt-elements-textSecondary">Loading...</span>
</div>
</div>
);
}
return (
<motion.div
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-5 h-5 text-orange-600">
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
</div>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitLab Connection</h3>
</div>
</div>
{!isConnected && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">VITE_GITLAB_ACCESS_TOKEN</code>{' '}
environment variable to connect automatically.
</p>
<p>
For self-hosted GitLab instances, also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">
VITE_GITLAB_URL=https://your-gitlab-instance.com
</code>
</p>
</div>
)}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitLab URL</label>
<input
type="text"
value={gitlabUrlAtom.get()}
onChange={(e) => setGitLabUrl(e.target.value)}
disabled={isConnecting || isConnected.get()}
placeholder="https://gitlab.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
<input
type="password"
value={connectionAtom.get().token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting || isConnected.get()}
placeholder="Enter your GitLab access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`${gitlabUrlAtom.get()}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<span className="mx-2"></span>
<span>Required scopes: api, read_repository</span>
</div>
</div>
</div>
<div className="flex items-center justify-between">
{!isConnected ? (
<button
onClick={handleConnect}
disabled={isConnecting || !connectionAtom.get().token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#FC6D26] text-white',
'hover:bg-[#E24329] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
) : (
<>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to GitLab
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => window.open(`${gitlabUrlAtom.get()}/dashboard`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout-dashboard w-4 h-4" />
Dashboard
</Button>
<Button
onClick={async () => {
setIsFetchingStats(true);
const result = await fetchStats();
setIsFetchingStats(false);
if (result.success) {
toast.success('GitLab stats refreshed');
} else {
toast.error(`Failed to refresh stats: ${result.error}`);
}
}}
disabled={isFetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isFetchingStats ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Refreshing...
</>
) : (
<>
<div className="i-ph:arrows-clockwise w-4 h-4" />
Refresh Stats
</>
)}
</Button>
</div>
</div>
</>
)}
</div>
{isConnected.get() && userAtom.get() && stats.get() && (
<div className="mt-6 border-t border-bolt-elements-borderColor pt-6">
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 rounded-lg mb-4">
<div className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent flex items-center justify-center bg-bolt-elements-background-depth-2 overflow-hidden">
{userAtom.get()?.avatar_url &&
userAtom.get()?.avatar_url !== 'null' &&
userAtom.get()?.avatar_url !== '' ? (
<img
src={userAtom.get()?.avatar_url}
alt={userAtom.get()?.username}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
// Fallback to initials if avatar fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const user = userAtom.get();
parent.innerHTML = (user?.name || user?.username || 'U').charAt(0).toUpperCase();
parent.classList.add(
'text-white',
'font-semibold',
'text-sm',
'flex',
'items-center',
'justify-center',
);
}
}}
/>
) : (
<div className="w-full h-full rounded-full bg-bolt-elements-item-contentAccent flex items-center justify-center text-white font-semibold text-sm">
{(userAtom.get()?.name || userAtom.get()?.username || 'U').charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{userAtom.get()?.name || userAtom.get()?.username}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">{userAtom.get()?.username}</p>
</div>
</div>
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitLab Stats</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<StatsDisplay
stats={stats.get()!}
onRefresh={async () => {
const result = await fetchStats();
if (result.success) {
toast.success('Stats refreshed');
} else {
toast.error(`Failed to refresh stats: ${result.error}`);
}
}}
isRefreshing={isFetchingStats}
/>
<RepositoryList
repositories={stats.get()?.projects || []}
onClone={(repo: GitLabProjectInfo) => handleCloneRepository(repo.http_url_to_repo)}
onRefresh={async () => {
const result = await fetchStats(true); // Force refresh
if (result.success) {
toast.success('Repositories refreshed');
} else {
toast.error(`Failed to refresh repositories: ${result.error}`);
}
}}
isRefreshing={isFetchingStats}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryCardProps {
repo: GitLabProjectInfo;
onClone?: (repo: GitLabProjectInfo) => void;
}
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.http_url_to_repo}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info" />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.star_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<div className="flex items-center gap-2 ml-auto">
{onClone && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClone(repo);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
title="Clone repository"
>
<div className="i-ph:git-branch w-3.5 h-3.5" />
Clone
</button>
)}
<span className="flex items-center gap-1 group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</div>
</a>
);
}

View File

@@ -0,0 +1,142 @@
import React, { useState, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { RepositoryCard } from './RepositoryCard';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryListProps {
repositories: GitLabProjectInfo[];
onClone?: (repo: GitLabProjectInfo) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const MAX_REPOS_PER_PAGE = 20;
export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isSearching, setIsSearching] = useState(false);
const filteredRepositories = useMemo(() => {
if (!searchQuery) {
return repositories;
}
setIsSearching(true);
const filtered = repositories.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
(repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())),
);
setIsSearching(false);
return filtered;
}, [repositories, searchQuery]);
const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
const endIndex = startIndex + MAX_REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
const handleSearch = (query: string) => {
setSearchQuery(query);
setCurrentPage(1); // Reset to first page when searching
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({filteredRepositories.length})
</h4>
{onRefresh && (
<Button
onClick={onRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isRefreshing ? (
<div className="i-ph:spinner animate-spin w-4 h-4" />
) : (
<div className="i-ph:arrows-clockwise w-4 h-4" />
)}
Refresh
</Button>
)}
</div>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<div className="i-ph:spinner animate-spin w-4 h-4 text-bolt-elements-textSecondary" />
) : (
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textSecondary" />
)}
</div>
</div>
{/* Repository Grid */}
<div className="space-y-4">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
{searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} onClone={onClone} />
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
<div className="i-ph:caret-left w-4 h-4" />
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
<div className="i-ph:caret-right w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import type { GitLabStats } from '~/types/GitLab';
interface StatsDisplayProps {
stats: GitLabStats;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
return (
<div className="space-y-4">
{/* Repository Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: stats.publicProjects,
},
{
label: 'Private Repos',
value: stats.privateProjects,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
{/* Contribution Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Followers',
value: stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value}
</span>
</div>
))}
</div>
</div>
<div className="pt-2 border-t border-bolt-elements-borderColor">
<div className="flex items-center justify-between">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
</span>
{onRefresh && (
<Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm" className="text-xs">
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { default as GitLabConnection } from './GitLabConnection';
export { RepositoryCard } from './RepositoryCard';
export { RepositoryList } from './RepositoryList';
export { StatsDisplay } from './StatsDisplay';

View File

@@ -42,12 +42,23 @@ interface SiteAction {
}
export default function NetlifyConnection() {
console.log('NetlifyConnection component mounted');
const connection = useStore(netlifyConnection);
const [tokenInput, setTokenInput] = useState('');
const [fetchingStats, setFetchingStats] = useState(false);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
console.log('NetlifyConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
@@ -140,6 +151,8 @@ export default function NetlifyConnection() {
};
useEffect(() => {
console.log('Netlify: Running initialization useEffect');
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
@@ -677,7 +690,13 @@ export default function NetlifyConnection() {
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
<div className="flex items-center justify-between mt-4">
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
@@ -701,6 +720,17 @@ export default function NetlifyConnection() {
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual Netlify auto-connect test');
await initializeNetlifyConnection();
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (

View File

@@ -0,0 +1 @@
export { default as NetlifyConnection } from './NetlifyConnection';

View File

@@ -1,95 +0,0 @@
export interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
public_gists: number;
created_at: string;
updated_at: string;
}
export interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
languages_url: string;
}
export interface GitHubOrganization {
login: string;
avatar_url: string;
description: string;
html_url: string;
}
export interface GitHubEvent {
id: string;
type: string;
created_at: string;
repo: {
name: string;
url: string;
};
payload: {
action?: string;
ref?: string;
ref_type?: string;
description?: string;
};
}
export interface GitHubLanguageStats {
[key: string]: number;
}
export interface GitHubStats {
repos: GitHubRepoInfo[];
totalStars: number;
totalForks: number;
organizations: GitHubOrganization[];
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
}
export interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
}
export interface GitHubTokenInfo {
token: string;
scope: string[];
avatar_url: string;
name: string | null;
created_at: string;
followers: number;
}
export interface GitHubRateLimits {
limit: number;
remaining: number;
reset: Date;
used: number;
}
export interface GitHubAuthState {
username: string;
tokenInfo: GitHubTokenInfo | null;
isConnected: boolean;
isVerifying: boolean;
isLoadingRepos: boolean;
rateLimits?: GitHubRateLimits;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
@@ -10,22 +10,64 @@ import {
isFetchingStats,
updateVercelConnection,
fetchVercelStats,
autoConnectVercel,
} from '~/lib/stores/vercel';
export default function VercelConnection() {
console.log('VercelConnection component mounted');
const connection = useStore(vercelConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
const hasInitialized = useRef(false);
console.log('VercelConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
useEffect(() => {
const fetchProjects = async () => {
if (connection.user && connection.token) {
// Prevent multiple initializations
if (hasInitialized.current) {
console.log('Vercel: Already initialized, skipping');
return;
}
const initializeConnection = async () => {
console.log('Vercel initializeConnection:', {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
hasInitialized.current = true;
// Auto-connect using environment variable if no existing connection but token exists
if (!connection.user && connection.token && import.meta.env?.VITE_VERCEL_ACCESS_TOKEN) {
console.log('Vercel: Attempting auto-connection');
const result = await autoConnectVercel();
if (result.success) {
toast.success('Connected to Vercel automatically');
} else {
console.error('Vercel auto-connection failed:', result.error);
}
} else if (connection.user && connection.token) {
// Fetch stats for existing connection
console.log('Vercel: Fetching stats for existing connection');
await fetchVercelStats(connection.token);
} else {
console.log('Vercel: No auto-connection conditions met');
}
};
fetchProjects();
}, [connection.user, connection.token]);
initializeConnection();
}, []); // Empty dependency array to run only once
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
@@ -118,32 +160,68 @@ export default function VercelConnection() {
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<div className="mt-2 text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-2 rounded">
<p className="flex items-center gap-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded text-xs">
VITE_VERCEL_ACCESS_TOKEN
</code>{' '}
in your .env.local for automatic connection.
</p>
</div>
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
<div className="flex gap-2">
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual auto-connect test');
const result = await autoConnectVercel();
if (result.success) {
toast.success('Manual auto-connect successful');
} else {
toast.error(`Manual auto-connect failed: ${result.error}`);
}
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (
<div className="space-y-6">

View File

@@ -0,0 +1 @@
export { default as VercelConnection } from './VercelConnection';