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,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';

View File

@@ -6,10 +6,16 @@ import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
// import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import type { IChatMetadata } from '~/lib/persistence/db';
import { X, Github, GitBranch } from 'lucide-react';
// Import GitLab and GitHub connections for unified repository access
import GitLabConnection from '~/components/@settings/tabs/connections/gitlab/GitLabConnection';
import GitHubConnection from '~/components/@settings/tabs/connections/github/GitHubConnection';
const IGNORE_PATTERNS = [
'node_modules/**',
@@ -46,6 +52,7 @@ export default function GitCloneButton({ importChat, className }: GitCloneButton
const { ready, gitClone } = useGit();
const [loading, setLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<'github' | 'gitlab' | null>(null);
const handleClone = async (repoUrl: string) => {
if (!ready) {
@@ -53,6 +60,8 @@ export default function GitCloneButton({ importChat, className }: GitCloneButton
}
setLoading(true);
setIsDialogOpen(false);
setSelectedProvider(null);
try {
const { workdir, data } = await gitClone(repoUrl);
@@ -155,8 +164,11 @@ ${escapeBoltTags(file.content)}
return (
<>
<Button
onClick={() => setIsDialogOpen(true)}
title="Clone a Git Repo"
onClick={() => {
setSelectedProvider(null);
setIsDialogOpen(true);
}}
title="Clone a repo"
variant="default"
size="lg"
className={classNames(
@@ -170,11 +182,145 @@ ${escapeBoltTags(file.content)}
)}
disabled={!ready || loading}
>
<span className="i-ph:git-branch w-4 h-4" />
Clone a Git Repo
Clone a repo
<div className="flex items-center gap-1 ml-2">
<Github className="w-4 h-4" />
<GitBranch className="w-4 h-4" />
</div>
</Button>
<RepositorySelectionDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onSelect={handleClone} />
{/* Provider Selection Dialog */}
{isDialogOpen && !selectedProvider && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor max-w-md w-full">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Choose Repository Provider
</h3>
<button
onClick={() => setIsDialogOpen(false)}
className="p-2 rounded-lg bg-transparent hover:bg-bolt-elements-background-depth-1 dark:hover:bg-bolt-elements-background-depth-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-all duration-200 hover:scale-105 active:scale-95"
>
<X className="w-5 h-5 transition-transform duration-200 hover:rotate-90" />
</button>
</div>
<div className="space-y-3">
<button
onClick={() => setSelectedProvider('github')}
className="w-full p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-2 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 text-left group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 dark:bg-blue-500/20 flex items-center justify-center group-hover:bg-blue-500/20 dark:group-hover:bg-blue-500/30 transition-colors">
<Github className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub
</div>
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Clone from GitHub repositories
</div>
</div>
</div>
</button>
<button
onClick={() => setSelectedProvider('gitlab')}
className="w-full p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-2 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 text-left group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/10 dark:bg-orange-500/20 flex items-center justify-center group-hover:bg-orange-500/20 dark:group-hover:bg-orange-500/30 transition-colors">
<GitBranch className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<div className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitLab
</div>
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Clone from GitLab repositories
</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
)}
{/* GitHub Repository Selection */}
{isDialogOpen && selectedProvider === 'github' && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor w-full max-w-4xl max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 dark:bg-blue-500/20 flex items-center justify-center">
<Github className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Import GitHub Repository
</h3>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Clone a repository from GitHub to your workspace
</p>
</div>
</div>
<button
onClick={() => {
setIsDialogOpen(false);
setSelectedProvider(null);
}}
className="p-2 rounded-lg bg-transparent hover:bg-bolt-elements-background-depth-1 dark:hover:bg-bolt-elements-background-depth-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-all duration-200 hover:scale-105 active:scale-95"
>
<X className="w-5 h-5 transition-transform duration-200 hover:rotate-90" />
</button>
</div>
<div className="p-6 max-h-[calc(90vh-140px)] overflow-y-auto">
<GitHubConnection onCloneRepository={handleClone} />
</div>
</div>
</div>
)}
{/* GitLab Repository Selection */}
{isDialogOpen && selectedProvider === 'gitlab' && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor w-full max-w-4xl max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/10 dark:bg-orange-500/20 flex items-center justify-center">
<GitBranch className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Import GitLab Repository
</h3>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Clone a repository from GitLab to your workspace
</p>
</div>
</div>
<button
onClick={() => {
setIsDialogOpen(false);
setSelectedProvider(null);
}}
className="p-2 rounded-lg bg-transparent hover:bg-bolt-elements-background-depth-1 dark:hover:bg-bolt-elements-background-depth-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-all duration-200 hover:scale-105 active:scale-95"
>
<X className="w-5 h-5 transition-transform duration-200 hover:rotate-90" />
</button>
</div>
<div className="p-6 max-h-[calc(90vh-140px)] overflow-y-auto">
<GitLabConnection onCloneRepository={handleClone} />
</div>
</div>
</div>
)}
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
</>

View File

@@ -2,6 +2,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { isGitLabConnected } from '~/lib/stores/gitlabConnection';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { classNames } from '~/utils/classNames';
@@ -11,29 +12,42 @@ import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.cli
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client';
import { useGitLabDeploy } from '~/components/deploy/GitLabDeploy.client';
import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog';
import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
interface DeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
onGitHubDeploy?: () => Promise<void>;
onGitLabDeploy?: () => Promise<void>;
}
export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy }: DeployButtonProps) => {
export const DeployButton = ({
onVercelDeploy,
onNetlifyDeploy,
onGitHubDeploy,
onGitLabDeploy,
}: DeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const gitlabIsConnected = useStore(isGitLabConnected);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | null>(null);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | null>(null);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const { handleGitHubDeploy } = useGitHubDeploy();
const { handleGitLabDeploy } = useGitLabDeploy();
const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false);
const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
const [githubDeploymentFiles, setGithubDeploymentFiles] = useState<Record<string, string> | null>(null);
const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState<Record<string, string> | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
const [gitlabProjectName, setGitlabProjectName] = useState('');
const handleVercelDeployClick = async () => {
setIsDeploying(true);
@@ -89,6 +103,28 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy }
}
};
const handleGitLabDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('gitlab');
try {
if (onGitLabDeploy) {
await onGitLabDeploy();
} else {
const result = await handleGitLabDeploy();
if (result && result.success && result.files) {
setGitlabDeploymentFiles(result.files);
setGitlabProjectName(result.projectName);
setShowGitLabDeploymentDialog(true);
}
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
@@ -178,6 +214,27 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy }
<span className="mx-auto">Deploy to GitHub</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabIsConnected,
},
)}
disabled={isDeploying || !activePreview || !gitlabIsConnected}
onClick={handleGitLabDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/gitlab"
alt="gitlab"
/>
<span className="mx-auto">{!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'}</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
@@ -205,6 +262,16 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy }
files={githubDeploymentFiles}
/>
)}
{/* GitLab Deployment Dialog */}
{showGitLabDeploymentDialog && gitlabDeploymentFiles && (
<GitLabDeploymentDialog
isOpen={showGitLabDeploymentDialog}
onClose={() => setShowGitLabDeploymentDialog(false)}
projectName={gitlabProjectName}
files={gitlabDeploymentFiles}
/>
)}
</>
);
};

View File

@@ -9,7 +9,7 @@ import type { GitHubUserResponse, GitHubRepoInfo } from '~/types/GitHub';
import { logStore } from '~/lib/stores/logs';
import { chatId } from '~/lib/persistence/useChatHistory';
import { useStore } from '@nanostores/react';
import { GitHubAuthDialog } from '~/components/@settings/tabs/connections/components/GitHubAuthDialog';
import { AuthDialog as GitHubAuthDialog } from '~/components/@settings/tabs/connections/github/AuthDialog';
import { SearchInput, EmptyState, StatusIndicator, Badge } from '~/components/ui';
interface GitHubDeploymentDialogProps {

View File

@@ -0,0 +1,168 @@
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { path } from '~/utils/path';
import { useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory';
import { getLocalStorage } from '~/lib/persistence/localStorage';
export function useGitLabDeploy() {
const [isDeploying, setIsDeploying] = useState(false);
const currentChatId = useStore(chatId);
const handleGitLabDeploy = async () => {
const connection = getLocalStorage('gitlab_connection');
if (!connection?.token || !connection?.user) {
toast.error('Please connect your GitLab account in Settings > Connections first');
return false;
}
if (!currentChatId) {
toast.error('No active chat found');
return false;
}
try {
setIsDeploying(true);
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
// Create a deployment artifact for visual feedback
const deploymentId = `deploy-gitlab-project`;
workbenchStore.addArtifact({
id: deploymentId,
messageId: deploymentId,
title: 'GitLab Deployment',
type: 'standalone',
});
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
// Notify that build is starting
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'gitlab' });
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'gitlab build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: 'npm run build',
},
};
// Add the action first
artifact.runner.addAction(actionData);
// Then run it
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
// Notify that build failed
deployArtifact.runner.handleDeployAction('building', 'failed', {
error: 'Build failed. Check the terminal for details.',
source: 'gitlab',
});
throw new Error('Build failed');
}
// Notify that build succeeded and deployment preparation is starting
deployArtifact.runner.handleDeployAction('deploying', 'running', {
source: 'gitlab',
});
// Get all project files instead of just the build directory since we're deploying to a repository
const container = await webcontainer;
// Get all files recursively - we'll deploy the entire project, not just the build directory
async function getAllFiles(dirPath: string, basePath: string = ''): Promise<Record<string, string>> {
const files: Record<string, string> = {};
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
// Create a relative path without the leading slash for GitLab
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
// Skip node_modules, .git directories and other common excludes
if (
entry.isDirectory() &&
(entry.name === 'node_modules' ||
entry.name === '.git' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.cache' ||
entry.name === '.next')
) {
continue;
}
if (entry.isFile()) {
// Skip binary files, large files and other common excludes
if (entry.name.endsWith('.DS_Store') || entry.name.endsWith('.log') || entry.name.startsWith('.env')) {
continue;
}
try {
const content = await container.fs.readFile(fullPath, 'utf-8');
// Store the file with its relative path, not the full system path
files[relativePath] = content;
} catch (error) {
console.warn(`Could not read file ${fullPath}:`, error);
continue;
}
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath, relativePath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles('/');
/*
* Show GitLab deployment dialog here - it will handle the actual deployment
* and will receive these files to deploy
*/
/*
* For now, we'll just complete the deployment with a success message
* Notify that deployment preparation is complete
*/
deployArtifact.runner.handleDeployAction('deploying', 'complete', {
source: 'gitlab',
});
return {
success: true,
files: fileContents,
projectName: artifact.title || 'bolt-project',
};
} catch (err) {
console.error('GitLab deploy error:', err);
toast.error(err instanceof Error ? err.message : 'GitLab deployment preparation failed');
return false;
} finally {
setIsDeploying(false);
}
};
return {
isDeploying,
handleGitLabDeploy,
isConnected: !!getLocalStorage('gitlab_connection')?.user,
};
}

View File

@@ -1,75 +1,58 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } 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 { getLocalStorage } from '~/lib/persistence/localStorage';
import type { GitLabUserResponse, GitLabProjectInfo } from '~/types/GitLab';
import { logStore } from '~/lib/stores/logs';
import { workbenchStore } from '~/lib/stores/workbench';
import { extractRelativePath } from '~/utils/diff';
import { chatId } from '~/lib/persistence/useChatHistory';
import { useStore } from '@nanostores/react';
import { GitLabApiService } from '~/lib/services/gitlabApiService';
import { SearchInput, EmptyState, StatusIndicator, Badge } from '~/components/ui';
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 {
interface GitLabDeploymentDialogProps {
isOpen: boolean;
onClose: () => void;
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
projectName: string;
files: Record<string, 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) {
export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: GitLabDeploymentDialogProps) {
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 [user, setUser] = useState<GitLabUserResponse | null>(null);
const [recentRepos, setRecentRepos] = useState<GitLabProjectInfo[]>([]);
const [filteredRepos, setFilteredRepos] = useState<GitLabProjectInfo[]>([]);
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 }[]>([]);
const currentChatId = useStore(chatId);
// Load GitHub connection on mount
// Load GitLab connection on mount
useEffect(() => {
if (isOpen) {
const connection = getLocalStorage('github_connection');
const connection = getLocalStorage('gitlab_connection');
// Set a default repository name based on the project name
setRepoName(projectName.replace(/\s+/g, '-').toLowerCase());
if (connection?.user && connection?.token) {
setUser(connection.user);
// Only fetch if we have both user and token
if (connection.token.trim()) {
fetchRecentRepos(connection.token);
fetchRecentRepos(connection.token, connection.gitlabUrl || 'https://gitlab.com');
}
}
}
}, [isOpen]);
/*
* Filter repositories based on search query
* const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300);
*/
}, [isOpen, projectName]);
// Filter repositories based on search query
useEffect(() => {
if (recentRepos.length === 0) {
setFilteredRepos([]);
@@ -84,113 +67,43 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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)),
repo.name.toLowerCase().includes(query) || (repo.description && repo.description.toLowerCase().includes(query)),
);
setFilteredRepos(filtered);
}, [recentRepos, repoSearchQuery]);
const fetchRecentRepos = useCallback(async (token: string) => {
const fetchRecentRepos = async (token: string, gitlabUrl = 'https://gitlab.com') => {
if (!token) {
logStore.logError('No GitHub token available');
toast.error('GitHub authentication required');
logStore.logError('No GitLab token available');
toast.error('GitLab 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);
const apiService = new GitLabApiService(token, gitlabUrl);
const repos = await apiService.getProjects();
setRecentRepos(repos);
} catch (error) {
console.error('Exception while fetching GitHub repositories:', error);
logStore.logError('Failed to fetch GitHub repositories', { error });
console.error('Failed to fetch GitLab repositories:', error);
logStore.logError('Failed to fetch GitLab repositories', { error });
toast.error('Failed to fetch recent repositories');
} finally {
setIsFetchingRepos(false);
}
}, []);
};
async function handleSubmit(e: React.FormEvent) {
// Function to create a new repository or push to an existing one
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const connection = getLocalStorage('github_connection');
const connection = getLocalStorage('gitlab_connection');
if (!connection?.token || !connection?.user) {
toast.error('Please connect your GitHub account in Settings > Connections first');
toast.error('Please connect your GitLab account in Settings > Connections first');
return;
}
@@ -202,61 +115,115 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
setIsLoading(true);
try {
// Check if repository exists first
const octokit = new Octokit({ auth: connection.token });
const gitlabUrl = connection.gitlabUrl || 'https://gitlab.com';
const apiService = new GitLabApiService(connection.token, gitlabUrl);
try {
const { data: existingRepo } = await octokit.repos.get({
owner: connection.user.login,
repo: repoName,
});
// Check if project exists
const projectPath = `${connection.user.username}/${repoName}`;
const existingProject = await apiService.getProjectByPath(projectPath);
const projectExists = existingProject !== null;
// 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.`;
if (projectExists && existingProject) {
// Confirm overwrite
const visibilityChange =
existingProject.visibility !== (isPrivate ? 'private' : 'public')
? `\n\nThis will also change the repository from ${existingProject.visibility} to ${isPrivate ? 'private' : 'public'}.`
: '';
// 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);
const confirmOverwrite = window.confirm(
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.${visibilityChange}`,
);
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;
// Update visibility if needed
if (existingProject.visibility !== (isPrivate ? 'private' : 'public')) {
toast.info('Updating repository visibility...');
await apiService.updateProjectVisibility(existingProject.id, isPrivate ? 'private' : 'public');
}
// Update project with files
toast.info('Uploading files to existing repository...');
await apiService.updateProjectWithFiles(existingProject.id, files);
setCreatedRepoUrl(existingProject.http_url_to_repo);
toast.success('Repository updated successfully!');
} else {
// Create new project with files
toast.info('Creating new repository...');
const newProject = await apiService.createProjectWithFiles(repoName, isPrivate, files);
setCreatedRepoUrl(newProject.http_url_to_repo);
toast.success('Repository created successfully!');
}
// Set pushed files for display
const fileList = Object.entries(files).map(([filePath, content]) => ({
path: filePath,
size: new TextEncoder().encode(content).length,
}));
setPushedFiles(fileList);
setShowSuccessDialog(true);
// Save repository info
localStorage.setItem(
`gitlab-repo-${currentChatId}`,
JSON.stringify({
owner: connection.user.username,
name: repoName,
url: createdRepoUrl,
}),
);
logStore.logInfo('GitLab deployment completed successfully', {
type: 'system',
message: `Successfully deployed ${fileList.length} files to ${projectExists ? 'existing' : 'new'} GitLab repository: ${projectPath}`,
repoName,
projectPath,
filesCount: fileList.length,
isNewProject: !projectExists,
});
} catch (error) {
console.error('Error pushing to GitLab:', error);
logStore.logError('GitLab deployment failed', {
error,
repoName,
projectPath: `${connection.user.username}/${repoName}`,
});
// Provide specific error messages based on error type
let errorMessage = 'Failed to push to GitLab';
if (error instanceof Error) {
const errorMsg = error.message.toLowerCase();
if (errorMsg.includes('404') || errorMsg.includes('not found')) {
errorMessage =
'Repository or GitLab instance not found. Please check your GitLab URL and repository permissions.';
} else if (errorMsg.includes('401') || errorMsg.includes('unauthorized')) {
errorMessage = 'GitLab authentication failed. Please check your access token and permissions.';
} else if (errorMsg.includes('403') || errorMsg.includes('forbidden')) {
errorMessage =
'Access denied. Your GitLab token may not have sufficient permissions to create/modify repositories.';
} else if (errorMsg.includes('network') || errorMsg.includes('fetch')) {
errorMessage = 'Network error. Please check your internet connection and try again.';
} else if (errorMsg.includes('timeout')) {
errorMessage = 'Request timed out. Please try again or check your connection.';
} else if (errorMsg.includes('rate limit')) {
errorMessage = 'GitLab API rate limit exceeded. Please wait a moment and try again.';
} else {
errorMessage = `GitLab error: ${error.message}`;
}
}
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.');
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}
};
const handleClose = () => {
setRepoName('');
@@ -284,6 +251,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
>
<Dialog.Title className="sr-only">Successfully pushed to GitLab</Dialog.Title>
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -292,13 +260,13 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Successfully pushed to GitHub
Successfully pushed to GitLab
</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
Your code is now available on GitLab
</p>
</div>
</div>
@@ -315,7 +283,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
<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" />
<span className="i-ph:gitlab-logo w-4 h-4 text-orange-500" />
Repository URL
</p>
<div className="flex items-center gap-2">
@@ -342,7 +310,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
Pushed Files ({pushedFiles.length})
</p>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar pr-2">
{pushedFiles.map((file) => (
{pushedFiles.slice(0, 100).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"
@@ -353,6 +321,11 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
</span>
</div>
))}
{pushedFiles.length > 100 && (
<div className="py-2 text-center text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
+{pushedFiles.length - 100} more files
</div>
)}
</div>
</div>
@@ -361,11 +334,11 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
className="px-4 py-2 rounded-lg bg-orange-500 text-white hover:bg-orange-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" />
<div className="i-ph:gitlab-logo w-4 h-4" />
View Repository
</motion.a>
<motion.button
@@ -415,6 +388,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
>
<Dialog.Title className="sr-only">GitLab Connection Required</Dialog.Title>
<div className="relative text-center space-y-4">
<Dialog.Close asChild>
<button
@@ -429,19 +403,18 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-orange-500"
>
<div className="i-ph:github-logo w-8 h-8" />
<div className="i-ph:gitlab-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
GitLab 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.
To deploy your code to GitLab, you need to connect your GitLab account first.
</p>
<div className="pt-2 flex justify-center gap-3">
<motion.button
@@ -454,7 +427,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
</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"
className="px-4 py-2 rounded-lg bg-orange-500 text-white text-sm hover:bg-orange-600 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
@@ -493,19 +466,19 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-orange-500"
>
<div className="i-ph:github-logo w-5 h-5" />
<div className="i-ph:gitlab-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
Deploy to GitLab
</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
Deploy your code to a new or existing GitLab repository
</p>
</div>
<Dialog.Close asChild>
@@ -521,17 +494,60 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
<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" />
{user.avatar_url && user.avatar_url !== 'null' && user.avatar_url !== '' ? (
<img
src={user.avatar_url}
alt={user.username}
className="w-10 h-10 rounded-full object-cover"
onError={(e) => {
// Handle CORS/COEP errors by hiding the image and showing fallback
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
onLoad={(e) => {
// Ensure fallback is hidden when image loads successfully
const target = e.target as HTMLImageElement;
const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) {
fallback.style.display = 'none';
}
}}
/>
) : null}
<div
className="avatar-fallback w-10 h-10 rounded-full bg-bolt-elements-background-depth-4 flex items-center justify-center text-bolt-elements-textSecondary font-semibold text-sm"
style={{
display:
user.avatar_url && user.avatar_url !== 'null' && user.avatar_url !== '' ? 'none' : 'flex',
}}
>
{user.name ? (
user.name.charAt(0).toUpperCase()
) : user.username ? (
user.username.charAt(0).toUpperCase()
) : (
<div className="i-ph:user w-5 h-5" />
)}
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center text-white">
<div className="i-ph:gitlab-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}
{user.name || user.username}
</p>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
@{user.login}
@{user.username}
</p>
</div>
</div>
@@ -554,7 +570,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
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-orange-500"
required
/>
</div>
@@ -582,9 +598,9 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
{recentRepos.length === 0 && !isFetchingRepos ? (
<EmptyState
icon="i-ph:github-logo"
icon="i-ph:gitlab-logo"
title="No repositories found"
description="We couldn't find any repositories in your GitHub account."
description="We couldn't find any repositories in your GitLab account."
variant="compact"
/>
) : (
@@ -599,21 +615,21 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
) : (
filteredRepos.map((repo) => (
<motion.button
key={repo.full_name}
key={repo.id}
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"
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-orange-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">
<div className="i-ph:git-branch w-4 h-4 text-orange-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-orange-500">
{repo.name}
</span>
</div>
{repo.private && (
{repo.visibility === 'private' && (
<Badge variant="primary" size="sm" icon="i-ph:lock w-3 h-3">
Private
</Badge>
@@ -625,13 +641,8 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
</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()}
{repo.star_count.toLocaleString()}
</Badge>
<Badge variant="subtle" size="sm" icon="i-ph:git-fork w-3 h-3">
{repo.forks_count.toLocaleString()}
@@ -652,6 +663,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
<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
@@ -659,7 +671,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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"
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-orange-500 focus:ring-orange-500 dark:bg-bolt-elements-background-depth-3"
/>
<label
htmlFor="private"
@@ -687,7 +699,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
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',
'flex-1 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm inline-flex items-center justify-center gap-2',
isLoading ? 'opacity-50 cursor-not-allowed' : '',
)}
whileHover={!isLoading ? { scale: 1.02 } : {}}
@@ -696,12 +708,12 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
{isLoading ? (
<>
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Pushing...
Deploying...
</>
) : (
<>
<div className="i-ph:github-logo w-4 h-4" />
Push to GitHub
<div className="i-ph:gitlab-logo w-4 h-4" />
Deploy to GitLab
</>
)}
</motion.button>

View File

@@ -506,7 +506,7 @@ export class ActionRunner {
details?: {
url?: string;
error?: string;
source?: 'netlify' | 'vercel' | 'github';
source?: 'netlify' | 'vercel' | 'github' | 'gitlab';
},
): void {
if (!this.onDeployAlert) {

View File

@@ -0,0 +1,338 @@
import type {
GitHubUserResponse,
GitHubRepoInfo,
GitHubEvent,
GitHubStats,
GitHubLanguageStats,
GitHubRateLimits,
} from '~/types/GitHub';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private _cache = new Map<string, CacheEntry<any>>();
set<T>(key: string, data: T, duration = CACHE_DURATION): void {
const timestamp = Date.now();
this._cache.set(key, {
data,
timestamp,
expiresAt: timestamp + duration,
});
}
get<T>(key: string): T | null {
const entry = this._cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this._cache.delete(key);
return null;
}
return entry.data;
}
clear(): void {
this._cache.clear();
}
isExpired(key: string): boolean {
const entry = this._cache.get(key);
return !entry || Date.now() > entry.expiresAt;
}
delete(key: string): void {
this._cache.delete(key);
}
}
class GitHubApiService {
private _cache = new GitHubCache();
private _baseUrl = 'https://api.github.com';
private async _makeRequest<T>(
endpoint: string,
token: string,
tokenType: 'classic' | 'fine-grained' = 'classic',
options: RequestInit = {},
): Promise<{ data: T; rateLimit?: GitHubRateLimits }> {
const authHeader = tokenType === 'classic' ? `token ${token}` : `Bearer ${token}`;
const response = await fetch(`${this._baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
'User-Agent': 'bolt.diy-app',
...options.headers,
},
});
// Extract rate limit information
const rateLimit: GitHubRateLimits = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '5000'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '5000'),
reset: new Date(parseInt(response.headers.get('x-ratelimit-reset') || '0') * 1000),
used: parseInt(response.headers.get('x-ratelimit-used') || '0'),
};
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`GitHub API Error (${response.status}): ${response.statusText}. ${errorBody}`);
}
const data = (await response.json()) as T;
return { data, rateLimit };
}
async fetchUser(
token: string,
_tokenType: 'classic' | 'fine-grained' = 'classic',
): Promise<{
user: GitHubUserResponse;
rateLimit: GitHubRateLimits;
}> {
const cacheKey = `user:${token.slice(0, 8)}`;
const cached = this._cache.get<{ user: GitHubUserResponse; rateLimit: GitHubRateLimits }>(cacheKey);
if (cached) {
return cached;
}
try {
// Use server-side API endpoint for user validation
const response = await fetch('/api/system/git-info?action=getUser', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`GitHub API Error (${response.status}): ${response.statusText}`);
}
// Get rate limit information from headers
const rateLimit: GitHubRateLimits = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '5000'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '5000'),
reset: new Date(parseInt(response.headers.get('x-ratelimit-reset') || '0') * 1000),
used: parseInt(response.headers.get('x-ratelimit-used') || '0'),
};
const data = (await response.json()) as { user: GitHubUserResponse };
const user = data.user;
if (!user || !user.login) {
throw new Error('Invalid user data received');
}
const result = { user, rateLimit };
this._cache.set(cacheKey, result);
return result;
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
throw error;
}
}
async fetchRepositories(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<GitHubRepoInfo[]> {
const cacheKey = `repos:${token.slice(0, 8)}`;
const cached = this._cache.get<GitHubRepoInfo[]>(cacheKey);
if (cached) {
return cached;
}
try {
let allRepos: any[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const { data: repos } = await this._makeRequest<any[]>(
`/user/repos?per_page=100&page=${page}`,
token,
tokenType,
);
allRepos = [...allRepos, ...repos];
hasMore = repos.length === 100;
page++;
}
const repositories: GitHubRepoInfo[] = allRepos.map((repo) => ({
id: repo.id.toString(),
name: repo.name,
full_name: repo.full_name,
html_url: repo.html_url,
description: repo.description || '',
stargazers_count: repo.stargazers_count || 0,
forks_count: repo.forks_count || 0,
default_branch: repo.default_branch || 'main',
updated_at: repo.updated_at,
language: repo.language || '',
languages_url: repo.languages_url,
private: repo.private || false,
topics: repo.topics || [],
}));
this._cache.set(cacheKey, repositories);
return repositories;
} catch (error) {
console.error('Failed to fetch GitHub repositories:', error);
throw error;
}
}
async fetchRecentActivity(
username: string,
token: string,
tokenType: 'classic' | 'fine-grained' = 'classic',
): Promise<GitHubEvent[]> {
const cacheKey = `activity:${username}:${token.slice(0, 8)}`;
const cached = this._cache.get<GitHubEvent[]>(cacheKey);
if (cached) {
return cached;
}
try {
const { data: events } = await this._makeRequest<any[]>(
`/users/${username}/events?per_page=10`,
token,
tokenType,
);
const recentActivity: GitHubEvent[] = events.slice(0, 5).map((event) => ({
id: event.id,
type: event.type,
created_at: event.created_at,
repo: {
name: event.repo?.name || '',
url: event.repo?.url || '',
},
payload: {
action: event.payload?.action,
ref: event.payload?.ref,
ref_type: event.payload?.ref_type,
description: event.payload?.description,
},
}));
this._cache.set(cacheKey, recentActivity);
return recentActivity;
} catch (error) {
console.error('Failed to fetch GitHub recent activity:', error);
throw error;
}
}
async fetchRepositoryLanguages(languagesUrl: string, token: string): Promise<GitHubLanguageStats> {
const cacheKey = `languages:${languagesUrl}`;
const cached = this._cache.get<GitHubLanguageStats>(cacheKey);
if (cached) {
return cached;
}
try {
const response = await fetch(languagesUrl, {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'bolt.diy-app',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch languages: ${response.statusText}`);
}
const languages = (await response.json()) as GitHubLanguageStats;
this._cache.set(cacheKey, languages);
return languages;
} catch (error) {
console.error('Failed to fetch repository languages:', error);
return {};
}
}
async fetchStats(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<GitHubStats> {
try {
// Fetch user data
const { user } = await this.fetchUser(token, tokenType);
// Fetch repositories
const repositories = await this.fetchRepositories(token, tokenType);
// Fetch recent activity
const recentActivity = await this.fetchRecentActivity(user.login, token, tokenType);
// Calculate stats
const totalStars = repositories.reduce((sum, repo) => sum + repo.stargazers_count, 0);
const totalForks = repositories.reduce((sum, repo) => sum + repo.forks_count, 0);
const privateRepos = repositories.filter((repo) => repo.private).length;
// Calculate language statistics
const languages: GitHubLanguageStats = {};
for (const repo of repositories) {
if (repo.language) {
languages[repo.language] = (languages[repo.language] || 0) + 1;
}
}
const stats: GitHubStats = {
repos: repositories,
totalStars,
totalForks,
organizations: [], // TODO: Implement organizations fetching if needed
recentActivity,
languages,
totalGists: user.public_gists || 0,
publicRepos: user.public_repos || 0,
privateRepos,
stars: totalStars,
forks: totalForks,
followers: user.followers || 0,
publicGists: user.public_gists || 0,
privateGists: 0, // GitHub API doesn't provide private gists count directly
lastUpdated: new Date().toISOString(),
};
return stats;
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
throw error;
}
}
clearCache(): void {
this._cache.clear();
}
clearUserCache(token: string): void {
const keyPrefix = token.slice(0, 8);
this._cache.delete(`user:${keyPrefix}`);
this._cache.delete(`repos:${keyPrefix}`);
}
}
export const gitHubApiService = new GitHubApiService();

View File

@@ -0,0 +1,421 @@
import type {
GitLabUserResponse,
GitLabProjectInfo,
GitLabEvent,
GitLabGroupInfo,
GitLabProjectResponse,
GitLabCommitRequest,
} from '~/types/GitLab';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}
class GitLabCache {
private _cache = new Map<string, CacheEntry<any>>();
set<T>(key: string, data: T, duration = CACHE_DURATION): void {
const timestamp = Date.now();
this._cache.set(key, {
data,
timestamp,
expiresAt: timestamp + duration,
});
}
get<T>(key: string): T | null {
const entry = this._cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this._cache.delete(key);
return null;
}
return entry.data;
}
clear(): void {
this._cache.clear();
}
isExpired(key: string): boolean {
const entry = this._cache.get(key);
return !entry || Date.now() > entry.expiresAt;
}
}
const gitlabCache = new GitLabCache();
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Don't retry on client errors (4xx) except 429 (rate limit)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
return response;
}
// Retry on server errors (5xx) and rate limits
if (response.status >= 500 || response.status === 429) {
if (attempt === maxRetries) {
return response;
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw lastError;
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
export class GitLabApiService {
private _baseUrl: string;
private _token: string;
constructor(token: string, baseUrl = 'https://gitlab.com') {
this._token = token;
this._baseUrl = baseUrl;
}
private get _headers() {
return {
'Content-Type': 'application/json',
'PRIVATE-TOKEN': this._token,
};
}
private async _request(endpoint: string, options: RequestInit = {}): Promise<Response> {
const url = `${this._baseUrl}/api/v4${endpoint}`;
return fetchWithRetry(url, {
...options,
headers: {
...this._headers,
...options.headers,
},
});
}
async getUser(): Promise<GitLabUserResponse> {
const response = await this._request('/user');
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const user: GitLabUserResponse = await response.json();
// Get rate limit information from headers if available
const rateLimit = {
limit: parseInt(response.headers.get('ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('ratelimit-reset') || '0'),
};
// Handle different avatar URL fields that GitLab might return
const processedUser = {
...user,
avatar_url: user.avatar_url || (user as any).avatarUrl || (user as any).profile_image_url || null,
};
return { ...processedUser, rateLimit } as GitLabUserResponse & { rateLimit: typeof rateLimit };
}
async getProjects(membership = true, minAccessLevel = 20, perPage = 50): Promise<GitLabProjectInfo[]> {
const cacheKey = `projects_${this._token}_${membership}_${minAccessLevel}`;
const cached = gitlabCache.get<GitLabProjectInfo[]>(cacheKey);
if (cached) {
return cached;
}
let allProjects: any[] = [];
let page = 1;
const maxPages = 10; // Limit to prevent excessive API calls
while (page <= maxPages) {
const response = await this._request(
`/projects?membership=${membership}&min_access_level=${minAccessLevel}&per_page=${perPage}&page=${page}&order_by=updated_at&sort=desc`,
);
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.statusText}`);
}
const projects: any[] = await response.json();
if (projects.length === 0) {
break;
}
allProjects = [...allProjects, ...projects];
// Break if we have enough projects for initial load
if (allProjects.length >= 100) {
break;
}
page++;
}
// Transform to our interface
const transformedProjects: GitLabProjectInfo[] = allProjects.map((project: any) => ({
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
description: project.description,
http_url_to_repo: project.http_url_to_repo,
star_count: project.star_count,
forks_count: project.forks_count,
default_branch: project.default_branch,
updated_at: project.updated_at,
visibility: project.visibility,
}));
gitlabCache.set(cacheKey, transformedProjects);
return transformedProjects;
}
async getEvents(perPage = 10): Promise<GitLabEvent[]> {
const response = await this._request(`/events?per_page=${perPage}`);
if (!response.ok) {
throw new Error(`Failed to fetch events: ${response.statusText}`);
}
const events: any[] = await response.json();
return events.slice(0, 5).map((event: any) => ({
id: event.id,
action_name: event.action_name,
project_id: event.project_id,
project: event.project,
created_at: event.created_at,
}));
}
async getGroups(minAccessLevel = 10): Promise<GitLabGroupInfo[]> {
const response = await this._request(`/groups?min_access_level=${minAccessLevel}`);
if (response.ok) {
return await response.json();
}
return [];
}
async getSnippets(): Promise<any[]> {
const response = await this._request('/snippets');
if (response.ok) {
return await response.json();
}
return [];
}
async createProject(name: string, isPrivate: boolean = false): Promise<GitLabProjectResponse> {
const response = await this._request('/projects', {
method: 'POST',
body: JSON.stringify({
name,
visibility: isPrivate ? 'private' : 'public',
initialize_with_readme: false, // Don't initialize with README to avoid conflicts
default_branch: 'main', // Explicitly set default branch
}),
});
if (!response.ok) {
throw new Error(`Failed to create project: ${response.statusText}`);
}
return await response.json();
}
async getProject(owner: string, name: string): Promise<GitLabProjectResponse | null> {
const response = await this._request(`/projects/${encodeURIComponent(`${owner}/${name}`)}`);
if (response.ok) {
return await response.json();
}
return null;
}
async createBranch(projectId: number, branchName: string, ref: string): Promise<any> {
const response = await this._request(`/projects/${projectId}/repository/branches`, {
method: 'POST',
body: JSON.stringify({
branch: branchName,
ref,
}),
});
if (!response.ok) {
throw new Error(`Failed to create branch: ${response.statusText}`);
}
return await response.json();
}
async commitFiles(projectId: number, commitRequest: GitLabCommitRequest): Promise<any> {
const response = await this._request(`/projects/${projectId}/repository/commits`, {
method: 'POST',
body: JSON.stringify(commitRequest),
});
if (!response.ok) {
let errorMessage = `Failed to commit files: ${response.status} ${response.statusText}`;
try {
const errorData = (await response.json()) as { message?: string; error?: string };
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If JSON parsing fails, keep the default error message
}
throw new Error(errorMessage);
}
return await response.json();
}
async getFile(projectId: number, filePath: string, ref: string): Promise<Response> {
return this._request(`/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}?ref=${ref}`);
}
async getProjectByPath(projectPath: string): Promise<GitLabProjectResponse | null> {
try {
const response = await this._request(`/projects/${encodeURIComponent(projectPath)}`);
if (response.ok) {
return await response.json();
}
if (response.status === 404) {
return null;
}
throw new Error(`Failed to fetch project: ${response.status} ${response.statusText}`);
} catch (error) {
if (error instanceof Error && error.message.includes('404')) {
return null;
}
throw error;
}
}
async updateProjectVisibility(projectId: number, visibility: 'public' | 'private'): Promise<void> {
const response = await this._request(`/projects/${projectId}`, {
method: 'PUT',
body: JSON.stringify({ visibility }),
});
if (!response.ok) {
throw new Error(`Failed to update project visibility: ${response.status} ${response.statusText}`);
}
}
async createProjectWithFiles(
name: string,
isPrivate: boolean,
files: Record<string, string>,
): Promise<GitLabProjectResponse> {
// Create the project first
const project = await this.createProject(name, isPrivate);
// If we have files to commit, commit them
if (Object.keys(files).length > 0) {
const actions = Object.entries(files).map(([filePath, content]) => ({
action: 'create' as const,
file_path: filePath,
content,
}));
const commitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Initial commit from Bolt.diy',
actions,
};
await this.commitFiles(project.id, commitRequest);
}
return project;
}
async updateProjectWithFiles(projectId: number, files: Record<string, string>): Promise<void> {
if (Object.keys(files).length === 0) {
return;
}
// For existing projects, we need to determine which files exist and which are new
const actions = Object.entries(files).map(([filePath, content]) => ({
action: 'create' as const, // Start with create, we'll handle conflicts in the API response
file_path: filePath,
content,
}));
const commitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Update from Bolt.diy',
actions,
};
try {
await this.commitFiles(projectId, commitRequest);
} catch (error) {
// If we get file conflicts, retry with update actions
if (error instanceof Error && error.message.includes('already exists')) {
const updateActions = Object.entries(files).map(([filePath, content]) => ({
action: 'update' as const,
file_path: filePath,
content,
}));
const updateCommitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Update from Bolt.diy',
actions: updateActions,
};
await this.commitFiles(projectId, updateCommitRequest);
} else {
throw error;
}
}
}
}
export { gitlabCache };

View File

@@ -0,0 +1,226 @@
import { atom, computed } from 'nanostores';
import Cookies from 'js-cookie';
import { logStore } from '~/lib/stores/logs';
import { gitHubApiService } from '~/lib/services/githubApiService';
import { calculateStatsSummary } from '~/utils/githubStats';
import type { GitHubConnection } from '~/types/GitHub';
// Auto-connect using environment variable
const envToken = import.meta.env?.VITE_GITHUB_ACCESS_TOKEN;
const envTokenType = import.meta.env?.VITE_GITHUB_TOKEN_TYPE;
const githubConnectionAtom = atom<GitHubConnection>({
user: null,
token: envToken || '',
tokenType:
envTokenType === 'classic' || envTokenType === 'fine-grained'
? (envTokenType as 'classic' | 'fine-grained')
: 'classic',
});
// Initialize connection from localStorage on startup
function initializeConnection() {
try {
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
// Ensure tokenType is set
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
// Only set if we have a valid user
if (parsed.user) {
githubConnectionAtom.set(parsed);
}
}
} catch (error) {
console.error('Error initializing GitHub connection:', error);
localStorage.removeItem('github_connection');
}
}
// Initialize on module load (client-side only)
if (typeof window !== 'undefined') {
initializeConnection();
}
// Computed store for checking if connected
export const isGitHubConnected = computed(githubConnectionAtom, (connection) => !!connection.user);
// Computed store for GitHub stats summary
export const githubStatsSummary = computed(githubConnectionAtom, (connection) => {
if (!connection.stats) {
return null;
}
return calculateStatsSummary(connection.stats);
});
// Connection status atoms
export const isGitHubConnecting = atom(false);
export const isGitHubLoadingStats = atom(false);
// GitHub connection store methods
export const githubConnectionStore = {
// Get current connection
get: () => githubConnectionAtom.get(),
// Connect to GitHub
async connect(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<void> {
if (isGitHubConnecting.get()) {
throw new Error('Connection already in progress');
}
isGitHubConnecting.set(true);
try {
// Fetch user data
const { user, rateLimit } = await gitHubApiService.fetchUser(token, tokenType);
// Create connection object
const connection: GitHubConnection = {
user,
token,
tokenType,
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(connection));
// Update atom
githubConnectionAtom.set(connection);
logStore.logInfo('Connected to GitHub', {
type: 'system',
message: `Connected to GitHub as ${user.login}`,
});
// Fetch stats in background
this.fetchStats().catch((error) => {
console.error('Failed to fetch initial GitHub stats:', error);
});
} catch (error) {
console.error('Failed to connect to GitHub:', error);
logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'system',
message: 'GitHub authentication failed',
});
throw error;
} finally {
isGitHubConnecting.set(false);
}
},
// Disconnect from GitHub
disconnect(): void {
// Clear atoms
githubConnectionAtom.set({
user: null,
token: '',
tokenType: 'classic',
});
// Clear localStorage
localStorage.removeItem('github_connection');
// Clear cookies
Cookies.remove('githubUsername');
Cookies.remove('githubToken');
Cookies.remove('git:github.com');
// Clear API service cache
gitHubApiService.clearCache();
logStore.logInfo('Disconnected from GitHub', {
type: 'system',
message: 'Disconnected from GitHub',
});
},
// Fetch GitHub stats
async fetchStats(): Promise<void> {
const connection = githubConnectionAtom.get();
if (!connection.user || !connection.token) {
throw new Error('Not connected to GitHub');
}
if (isGitHubLoadingStats.get()) {
return; // Already loading
}
isGitHubLoadingStats.set(true);
try {
const stats = await gitHubApiService.fetchStats(connection.token, connection.tokenType);
// Update connection with stats
const updatedConnection: GitHubConnection = {
...connection,
stats,
};
// Update localStorage
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
// Update atom
githubConnectionAtom.set(updatedConnection);
logStore.logInfo('GitHub stats refreshed', {
type: 'system',
message: 'Successfully refreshed GitHub statistics',
});
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
// If the error is due to expired token, disconnect
if (error instanceof Error && error.message.includes('401')) {
logStore.logError('GitHub token has expired', {
type: 'system',
message: 'GitHub token has expired. Please reconnect your account.',
});
this.disconnect();
}
throw error;
} finally {
isGitHubLoadingStats.set(false);
}
},
// Update token type
updateTokenType(tokenType: 'classic' | 'fine-grained'): void {
const connection = githubConnectionAtom.get();
const updatedConnection = {
...connection,
tokenType,
};
githubConnectionAtom.set(updatedConnection);
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
},
// Clear stats cache
clearCache(): void {
const connection = githubConnectionAtom.get();
if (connection.token) {
gitHubApiService.clearUserCache(connection.token);
}
},
// Subscribe to connection changes
subscribe: githubConnectionAtom.subscribe.bind(githubConnectionAtom),
};
// Export the atom for direct access
export { githubConnectionAtom };

View File

@@ -0,0 +1,300 @@
import { atom, computed } from 'nanostores';
import Cookies from 'js-cookie';
import { logStore } from '~/lib/stores/logs';
import { GitLabApiService } from '~/lib/services/gitlabApiService';
import { calculateStatsSummary } from '~/utils/gitlabStats';
import type { GitLabConnection, GitLabStats } from '~/types/GitLab';
// Auto-connect using environment variable
const envToken = import.meta.env?.VITE_GITLAB_ACCESS_TOKEN;
const gitlabConnectionAtom = atom<GitLabConnection>({
user: null,
token: envToken || '',
tokenType: 'personal-access-token',
});
const gitlabUrlAtom = atom('https://gitlab.com');
// Initialize connection from localStorage on startup
function initializeConnection() {
try {
const savedConnection = localStorage.getItem('gitlab_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
parsed.tokenType = 'personal-access-token';
if (parsed.gitlabUrl) {
gitlabUrlAtom.set(parsed.gitlabUrl);
}
// Only set if we have a valid user
if (parsed.user) {
gitlabConnectionAtom.set(parsed);
}
}
} catch (error) {
console.error('Error initializing GitLab connection:', error);
localStorage.removeItem('gitlab_connection');
}
}
// Initialize on module load (client-side only)
if (typeof window !== 'undefined') {
initializeConnection();
}
// Computed store for checking if connected
export const isGitLabConnected = computed(gitlabConnectionAtom, (connection) => !!connection.user);
// Computed store for current connection
export const gitlabConnection = computed(gitlabConnectionAtom, (connection) => connection);
// Computed store for current user
export const gitlabUser = computed(gitlabConnectionAtom, (connection) => connection.user);
// Computed store for current stats
export const gitlabStats = computed(gitlabConnectionAtom, (connection) => connection.stats);
// Computed store for current URL
export const gitlabUrl = computed(gitlabUrlAtom, (url) => url);
class GitLabConnectionStore {
async connect(token: string, gitlabUrl = 'https://gitlab.com') {
try {
const apiService = new GitLabApiService(token, gitlabUrl);
// Test connection by fetching user
const user = await apiService.getUser();
// Update state
gitlabConnectionAtom.set({
user,
token,
tokenType: 'personal-access-token',
gitlabUrl,
});
// Set cookies for client-side access
Cookies.set('gitlabUsername', user.username);
Cookies.set('gitlabToken', token);
Cookies.set('git:gitlab.com', JSON.stringify({ username: user.username, password: token }));
Cookies.set('gitlabUrl', gitlabUrl);
// Store connection details in localStorage
localStorage.setItem(
'gitlab_connection',
JSON.stringify({
user,
token,
tokenType: 'personal-access-token',
gitlabUrl,
}),
);
logStore.logInfo('Connected to GitLab', {
type: 'system',
message: `Connected to GitLab as ${user.username}`,
});
return { success: true };
} catch (error) {
console.error('Failed to connect to GitLab:', error);
logStore.logError(`GitLab authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'system',
message: 'GitLab authentication failed',
});
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async fetchStats(_forceRefresh = false) {
const connection = gitlabConnectionAtom.get();
if (!connection.user || !connection.token) {
throw new Error('Not connected to GitLab');
}
try {
const apiService = new GitLabApiService(connection.token, connection.gitlabUrl || 'https://gitlab.com');
// Fetch user data
const userData = await apiService.getUser();
// Fetch projects
const projects = await apiService.getProjects();
// Fetch events
const events = await apiService.getEvents();
// Fetch groups
const groups = await apiService.getGroups();
// Fetch snippets
const snippets = await apiService.getSnippets();
// Calculate stats
const stats: GitLabStats = calculateStatsSummary(projects, events, groups, snippets, userData);
// Update connection with stats
gitlabConnectionAtom.set({
...connection,
stats,
});
// Update localStorage
const updatedConnection = { ...connection, stats };
localStorage.setItem('gitlab_connection', JSON.stringify(updatedConnection));
return { success: true, stats };
} catch (error) {
console.error('Error fetching GitLab stats:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
disconnect() {
// Remove cookies
Cookies.remove('gitlabToken');
Cookies.remove('gitlabUsername');
Cookies.remove('git:gitlab.com');
Cookies.remove('gitlabUrl');
// Clear localStorage
localStorage.removeItem('gitlab_connection');
// Reset state
gitlabConnectionAtom.set({
user: null,
token: '',
tokenType: 'personal-access-token',
});
logStore.logInfo('Disconnected from GitLab', {
type: 'system',
message: 'Disconnected from GitLab',
});
}
loadSavedConnection() {
try {
const savedConnection = localStorage.getItem('gitlab_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
parsed.tokenType = 'personal-access-token';
// Set GitLab URL if saved
if (parsed.gitlabUrl) {
gitlabUrlAtom.set(parsed.gitlabUrl);
}
// Set connection
gitlabConnectionAtom.set(parsed);
return parsed;
}
} catch (error) {
console.error('Error parsing saved GitLab connection:', error);
localStorage.removeItem('gitlab_connection');
}
return null;
}
setGitLabUrl(url: string) {
gitlabUrlAtom.set(url);
}
setToken(token: string) {
gitlabConnectionAtom.set({
...gitlabConnectionAtom.get(),
token,
});
}
// Auto-connect using environment token
async autoConnect() {
if (!envToken) {
return { success: false, error: 'No GitLab token found in environment' };
}
try {
const apiService = new GitLabApiService(envToken);
const user = await apiService.getUser();
// Update state
gitlabConnectionAtom.set({
user,
token: envToken,
tokenType: 'personal-access-token',
gitlabUrl: 'https://gitlab.com',
});
// Set cookies for client-side access
Cookies.set('gitlabUsername', user.username);
Cookies.set('gitlabToken', envToken);
Cookies.set('git:gitlab.com', JSON.stringify({ username: user.username, password: envToken }));
Cookies.set('gitlabUrl', 'https://gitlab.com');
// Store connection details in localStorage
localStorage.setItem(
'gitlab_connection',
JSON.stringify({
user,
token: envToken,
tokenType: 'personal-access-token',
gitlabUrl: 'https://gitlab.com',
}),
);
logStore.logInfo('Auto-connected to GitLab', {
type: 'system',
message: `Auto-connected to GitLab as ${user.username}`,
});
return { success: true };
} catch (error) {
console.error('Failed to auto-connect to GitLab:', error);
logStore.logError(`GitLab auto-connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'system',
message: 'GitLab auto-connection failed',
});
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
}
export const gitlabConnectionStore = new GitLabConnectionStore();
// Export hooks for React components
export function useGitLabConnection() {
return {
connection: gitlabConnection,
isConnected: isGitLabConnected,
user: gitlabUser,
stats: gitlabStats,
gitlabUrl,
connect: gitlabConnectionStore.connect.bind(gitlabConnectionStore),
disconnect: gitlabConnectionStore.disconnect.bind(gitlabConnectionStore),
fetchStats: gitlabConnectionStore.fetchStats.bind(gitlabConnectionStore),
loadSavedConnection: gitlabConnectionStore.loadSavedConnection.bind(gitlabConnectionStore),
setGitLabUrl: gitlabConnectionStore.setGitLabUrl.bind(gitlabConnectionStore),
setToken: gitlabConnectionStore.setToken.bind(gitlabConnectionStore),
autoConnect: gitlabConnectionStore.autoConnect.bind(gitlabConnectionStore),
};
}

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
// Initialize with stored connection or environment variable
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
const envToken = import.meta.env.VITE_NETLIFY_ACCESS_TOKEN;
console.log('Netlify store: envToken loaded:', envToken ? '[TOKEN_EXISTS]' : '[NO_TOKEN]');
// If we have an environment token but no stored connection, initialize with the env token
const initialConnection: NetlifyConnection = storedConnection
@@ -24,11 +25,14 @@ export const isFetchingStats = atom<boolean>(false);
export async function initializeNetlifyConnection() {
const currentState = netlifyConnection.get();
// If we already have a connection, don't override it
// If we already have a connection or no token, don't try to connect
if (currentState.user || !envToken) {
console.log('Netlify: Skipping auto-connect - user exists or no env token');
return;
}
console.log('Netlify: Attempting auto-connection with env token');
try {
isConnecting.set(true);

View File

@@ -3,15 +3,48 @@ import type { VercelConnection } from '~/types/vercel';
import { logStore } from './logs';
import { toast } from 'react-toastify';
// Auto-connect using environment variable
const envToken = import.meta.env?.VITE_VERCEL_ACCESS_TOKEN;
// Initialize with stored connection or defaults
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null;
const initialConnection: VercelConnection = storedConnection
? JSON.parse(storedConnection)
: {
let initialConnection: VercelConnection;
if (storedConnection) {
try {
const parsed = JSON.parse(storedConnection);
// If we have a stored connection but no user and no token, clear it and use env token
if (!parsed.user && !parsed.token && envToken) {
console.log('Vercel store: Clearing incomplete saved connection, using env token');
if (typeof window !== 'undefined') {
localStorage.removeItem('vercel_connection');
}
initialConnection = {
user: null,
token: envToken,
stats: undefined,
};
} else {
initialConnection = parsed;
}
} catch (error) {
console.error('Error parsing saved Vercel connection:', error);
initialConnection = {
user: null,
token: '',
token: envToken || '',
stats: undefined,
};
}
} else {
initialConnection = {
user: null,
token: envToken || '',
stats: undefined,
};
}
export const vercelConnection = atom<VercelConnection>(initialConnection);
export const isConnecting = atom<boolean>(false);
@@ -28,6 +61,74 @@ export const updateVercelConnection = (updates: Partial<VercelConnection>) => {
}
};
// Auto-connect using environment token
export async function autoConnectVercel() {
console.log('autoConnectVercel called, envToken exists:', !!envToken);
if (!envToken) {
console.error('No Vercel token found in environment');
return { success: false, error: 'No Vercel token found in environment' };
}
try {
console.log('Setting isConnecting to true');
isConnecting.set(true);
// Test the connection
console.log('Making API call to Vercel');
const response = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${envToken}`,
'Content-Type': 'application/json',
},
});
console.log('Vercel API response status:', response.status);
if (!response.ok) {
throw new Error(`Vercel API error: ${response.status}`);
}
const userData = (await response.json()) as any;
console.log('Vercel API response userData:', userData);
// Update connection
console.log('Updating Vercel connection');
updateVercelConnection({
user: userData.user || userData,
token: envToken,
});
logStore.logInfo('Auto-connected to Vercel', {
type: 'system',
message: `Auto-connected to Vercel as ${userData.user?.username || userData.username}`,
});
// Fetch stats
console.log('Fetching Vercel stats');
await fetchVercelStats(envToken);
console.log('Vercel auto-connection successful');
return { success: true };
} catch (error) {
console.error('Failed to auto-connect to Vercel:', error);
logStore.logError(`Vercel auto-connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
type: 'system',
message: 'Vercel auto-connection failed',
});
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
console.log('Setting isConnecting to false');
isConnecting.set(false);
}
}
export async function fetchVercelStats(token: string) {
try {
isFetchingStats.set(true);

View File

@@ -674,197 +674,264 @@ export class WorkbenchStore {
return syncedFiles;
}
async pushToGitHub(
async pushToRepository(
provider: 'github' | 'gitlab',
repoName: string,
commitMessage?: string,
githubUsername?: string,
ghToken?: string,
username?: string,
token?: string,
isPrivate: boolean = false,
branchName: string = 'main',
) {
try {
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
const owner = githubUsername || Cookies.get('githubUsername');
const isGitHub = provider === 'github';
const isGitLab = provider === 'gitlab';
if (!githubToken || !owner) {
throw new Error('GitHub token or username is not set in cookies or provided.');
const authToken = token || Cookies.get(isGitHub ? 'githubToken' : 'gitlabToken');
const owner = username || Cookies.get(isGitHub ? 'githubUsername' : 'gitlabUsername');
if (!authToken || !owner) {
throw new Error(`${provider} token or username is not set in cookies or provided.`);
}
// Log the isPrivate flag to verify it's being properly passed
console.log(`pushToGitHub called with isPrivate=${isPrivate}`);
// Initialize Octokit with the auth token
const octokit = new Octokit({ auth: githubToken });
// Check if the repository already exists before creating it
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
let visibilityJustChanged = false;
try {
const resp = await octokit.repos.get({ owner, repo: repoName });
repo = resp.data;
console.log('Repository already exists, using existing repo');
// Check if we need to update visibility of existing repo
if (repo.private !== isPrivate) {
console.log(
`Updating repository visibility from ${repo.private ? 'private' : 'public'} to ${isPrivate ? 'private' : 'public'}`,
);
try {
// Update repository visibility using the update method
const { data: updatedRepo } = await octokit.repos.update({
owner,
repo: repoName,
private: isPrivate,
});
console.log('Repository visibility updated successfully');
repo = updatedRepo;
visibilityJustChanged = true;
// Add a delay after changing visibility to allow GitHub to fully process the change
console.log('Waiting for visibility change to propagate...');
await new Promise((resolve) => setTimeout(resolve, 3000)); // 3 second delay
} catch (visibilityError) {
console.error('Failed to update repository visibility:', visibilityError);
// Continue with push even if visibility update fails
}
}
} catch (error) {
if (error instanceof Error && 'status' in error && error.status === 404) {
// Repository doesn't exist, so create a new one
console.log(`Creating new repository with private=${isPrivate}`);
// Create new repository with specified privacy setting
const createRepoOptions = {
name: repoName,
private: isPrivate,
auto_init: true,
};
console.log('Create repo options:', createRepoOptions);
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser(createRepoOptions);
console.log('Repository created:', newRepo.html_url, 'Private:', newRepo.private);
repo = newRepo;
// Add a small delay after creating a repository to allow GitHub to fully initialize it
console.log('Waiting for repository to initialize...');
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second delay
} else {
console.error('Cannot create repo:', error);
throw error; // Some other error occurred
}
}
// Get all files
const files = this.files.get();
if (!files || Object.keys(files).length === 0) {
throw new Error('No files found to push');
}
// Function to push files with retry logic
const pushFilesToRepo = async (attempt = 1): Promise<string> => {
const maxAttempts = 3;
if (isGitHub) {
// Initialize Octokit with the auth token
const octokit = new Octokit({ auth: authToken });
// Check if the repository already exists before creating it
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
let visibilityJustChanged = false;
try {
console.log(`Pushing files to repository (attempt ${attempt}/${maxAttempts})...`);
const resp = await octokit.repos.get({ owner, repo: repoName });
repo = resp.data;
console.log('Repository already exists, using existing repo');
// Create blobs for each file
const blobs = await Promise.all(
Object.entries(files).map(async ([filePath, dirent]) => {
if (dirent?.type === 'file' && dirent.content) {
const { data: blob } = await octokit.git.createBlob({
owner: repo.owner.login,
repo: repo.name,
content: Buffer.from(dirent.content).toString('base64'),
encoding: 'base64',
});
return { path: extractRelativePath(filePath), sha: blob.sha };
}
// Check if we need to update visibility of existing repo
if (repo.private !== isPrivate) {
console.log(
`Updating repository visibility from ${repo.private ? 'private' : 'public'} to ${isPrivate ? 'private' : 'public'}`,
);
return null;
}),
);
try {
// Update repository visibility using the update method
const { data: updatedRepo } = await octokit.repos.update({
owner,
repo: repoName,
private: isPrivate,
});
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
console.log('Repository visibility updated successfully');
repo = updatedRepo;
visibilityJustChanged = true;
if (validBlobs.length === 0) {
throw new Error('No valid files to push');
// Add a delay after changing visibility to allow GitHub to fully process the change
console.log('Waiting for visibility change to propagate...');
await new Promise((resolve) => setTimeout(resolve, 3000)); // 3 second delay
} catch (visibilityError) {
console.error('Failed to update repository visibility:', visibilityError);
// Continue with push even if visibility update fails
}
}
// Refresh repository reference to ensure we have the latest data
const repoRefresh = await octokit.repos.get({ owner, repo: repoName });
repo = repoRefresh.data;
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
const { data: ref } = await octokit.git.getRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
});
const latestCommitSha = ref.object.sha;
// Create a new tree
const { data: newTree } = await octokit.git.createTree({
owner: repo.owner.login,
repo: repo.name,
base_tree: latestCommitSha,
tree: validBlobs.map((blob) => ({
path: blob!.path,
mode: '100644',
type: 'blob',
sha: blob!.sha,
})),
});
// Create a new commit
const { data: newCommit } = await octokit.git.createCommit({
owner: repo.owner.login,
repo: repo.name,
message: commitMessage || 'Initial commit from your app',
tree: newTree.sha,
parents: [latestCommitSha],
});
// Update the reference
await octokit.git.updateRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha,
});
console.log('Files successfully pushed to repository');
return repo.html_url;
} catch (error) {
console.error(`Error during push attempt ${attempt}:`, error);
if (error instanceof Error && 'status' in error && error.status === 404) {
// Repository doesn't exist, so create a new one
console.log(`Creating new repository with private=${isPrivate}`);
// If we've just changed visibility and this is not our last attempt, wait and retry
if ((visibilityJustChanged || attempt === 1) && attempt < maxAttempts) {
const delayMs = attempt * 2000; // Increasing delay with each attempt
console.log(`Waiting ${delayMs}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
// Create new repository with specified privacy setting
const createRepoOptions = {
name: repoName,
private: isPrivate,
auto_init: true,
};
return pushFilesToRepo(attempt + 1);
console.log('Create repo options:', createRepoOptions);
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser(createRepoOptions);
console.log('Repository created:', newRepo.html_url, 'Private:', newRepo.private);
repo = newRepo;
// Add a small delay after creating a repository to allow GitHub to fully initialize it
console.log('Waiting for repository to initialize...');
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second delay
} else {
console.error('Cannot create repo:', error);
throw error; // Some other error occurred
}
throw error; // Rethrow if we're out of attempts
}
};
// Execute the push function with retry logic
const repoUrl = await pushFilesToRepo();
// Get all files
const files = this.files.get();
// Return the repository URL
return repoUrl;
if (!files || Object.keys(files).length === 0) {
throw new Error('No files found to push');
}
// Function to push files with retry logic
const pushFilesToRepo = async (attempt = 1): Promise<string> => {
const maxAttempts = 3;
try {
console.log(`Pushing files to repository (attempt ${attempt}/${maxAttempts})...`);
// Create blobs for each file
const blobs = await Promise.all(
Object.entries(files).map(async ([filePath, dirent]) => {
if (dirent?.type === 'file' && dirent.content) {
const { data: blob } = await octokit.git.createBlob({
owner: repo.owner.login,
repo: repo.name,
content: Buffer.from(dirent.content).toString('base64'),
encoding: 'base64',
});
return { path: extractRelativePath(filePath), sha: blob.sha };
}
return null;
}),
);
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
if (validBlobs.length === 0) {
throw new Error('No valid files to push');
}
// Refresh repository reference to ensure we have the latest data
const repoRefresh = await octokit.repos.get({ owner, repo: repoName });
repo = repoRefresh.data;
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
const { data: ref } = await octokit.git.getRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
});
const latestCommitSha = ref.object.sha;
// Create a new tree
const { data: newTree } = await octokit.git.createTree({
owner: repo.owner.login,
repo: repo.name,
base_tree: latestCommitSha,
tree: validBlobs.map((blob) => ({
path: blob!.path,
mode: '100644',
type: 'blob',
sha: blob!.sha,
})),
});
// Create a new commit
const { data: newCommit } = await octokit.git.createCommit({
owner: repo.owner.login,
repo: repo.name,
message: commitMessage || 'Initial commit from your app',
tree: newTree.sha,
parents: [latestCommitSha],
});
// Update the reference
await octokit.git.updateRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha,
});
console.log('Files successfully pushed to repository');
return repo.html_url;
} catch (error) {
console.error(`Error during push attempt ${attempt}:`, error);
// If we've just changed visibility and this is not our last attempt, wait and retry
if ((visibilityJustChanged || attempt === 1) && attempt < maxAttempts) {
const delayMs = attempt * 2000; // Increasing delay with each attempt
console.log(`Waiting ${delayMs}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return pushFilesToRepo(attempt + 1);
}
throw error; // Rethrow if we're out of attempts
}
};
// Execute the push function with retry logic
const repoUrl = await pushFilesToRepo();
// Return the repository URL
return repoUrl;
}
if (isGitLab) {
const { GitLabApiService: gitLabApiServiceClass } = await import('~/lib/services/gitlabApiService');
const gitLabApiService = new gitLabApiServiceClass(authToken, 'https://gitlab.com');
// Check or create repo
let repo = await gitLabApiService.getProject(owner, repoName);
if (!repo) {
repo = await gitLabApiService.createProject(repoName, isPrivate);
await new Promise((r) => setTimeout(r, 2000)); // Wait for repo initialization
}
// Check if branch exists, create if not
const branchRes = await gitLabApiService.getFile(repo.id, 'README.md', branchName).catch(() => null);
if (!branchRes || !branchRes.ok) {
// Create branch from default
await gitLabApiService.createBranch(repo.id, branchName, repo.default_branch);
await new Promise((r) => setTimeout(r, 1000));
}
const actions = Object.entries(files).reduce(
(acc, [filePath, dirent]) => {
if (dirent?.type === 'file' && dirent.content) {
acc.push({
action: 'create',
file_path: extractRelativePath(filePath),
content: dirent.content,
});
}
return acc;
},
[] as { action: 'create' | 'update'; file_path: string; content: string }[],
);
// Check which files exist and update action accordingly
for (const action of actions) {
const fileCheck = await gitLabApiService.getFile(repo.id, action.file_path, branchName);
if (fileCheck.ok) {
action.action = 'update';
}
}
// Commit all files
await gitLabApiService.commitFiles(repo.id, {
branch: branchName,
commit_message: commitMessage || 'Commit multiple files',
actions,
});
return repo.web_url;
}
// Should not reach here since we only handle GitHub and GitLab
throw new Error(`Unsupported provider: ${provider}`);
} catch (error) {
console.error('Error pushing to GitHub:', error);
console.error('Error pushing to repository:', error);
throw error; // Rethrow the error for further handling
}
}

View File

@@ -12,7 +12,22 @@ export interface GitHubUserResponse {
updated_at: string;
}
export interface GitLabProjectInfo {
id: string;
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 GitHubRepoInfo {
id: string;
name: string;
full_name: string;
html_url: string;
@@ -92,6 +107,14 @@ export interface GitHubStats {
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
publicRepos: number;
privateRepos: number;
stars: number;
forks: number;
followers: number;
publicGists: number;
privateGists: number;
lastUpdated: string;
}
export interface GitHubConnection {
@@ -99,6 +122,7 @@ export interface GitHubConnection {
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
rateLimit?: GitHubRateLimits;
}
export interface GitHubTokenInfo {

103
app/types/GitLab.ts Normal file
View File

@@ -0,0 +1,103 @@
// GitLab API Response Types
export interface GitLabUserResponse {
id: number;
username: string;
name: string;
avatar_url: string;
web_url: string;
created_at: string;
bio: string;
public_repos: number;
followers: number;
following: number;
}
export interface GitLabProjectInfo {
id: number;
name: string;
path_with_namespace: string;
description: string;
http_url_to_repo: string;
star_count: number;
forks_count: number;
updated_at: string;
default_branch: string;
visibility: string;
}
export interface GitLabGroupInfo {
id: number;
name: string;
web_url: string;
avatar_url: string;
}
export interface GitLabEvent {
id: number;
action_name: string;
project_id: number;
project: {
name: string;
path_with_namespace: string;
};
created_at: string;
}
export interface GitLabStats {
projects: GitLabProjectInfo[];
recentActivity: GitLabEvent[];
totalSnippets: number;
publicProjects: number;
privateProjects: number;
stars: number;
forks: number;
followers: number;
snippets: number;
groups: GitLabGroupInfo[];
lastUpdated: string;
}
export interface GitLabConnection {
user: GitLabUserResponse | null;
token: string;
tokenType: 'personal-access-token' | 'oauth';
stats?: GitLabStats;
rateLimit?: {
limit: number;
remaining: number;
reset: number;
};
gitlabUrl?: string;
}
export interface GitLabProjectResponse {
id: number;
name: string;
path_with_namespace: string;
description: string;
web_url: string;
http_url_to_repo: string;
star_count: number;
forks_count: number;
updated_at: string;
default_branch: string;
visibility: string;
owner: {
id: number;
username: string;
name: string;
};
}
export interface GitLabCommitAction {
action: 'create' | 'update' | 'delete';
file_path: string;
content?: string;
encoding?: 'text' | 'base64';
}
export interface GitLabCommitRequest {
branch: string;
commit_message: string;
actions: GitLabCommitAction[];
}

View File

@@ -59,7 +59,7 @@ export interface DeployAlert {
stage?: 'building' | 'deploying' | 'complete';
buildStatus?: 'pending' | 'running' | 'complete' | 'failed';
deployStatus?: 'pending' | 'running' | 'complete' | 'failed';
source?: 'vercel' | 'netlify' | 'github';
source?: 'vercel' | 'netlify' | 'github' | 'gitlab';
}
export interface LlmErrorAlertType {

167
app/utils/githubStats.ts Normal file
View File

@@ -0,0 +1,167 @@
import type { GitHubStats, GitHubLanguageStats } from '~/types/GitHub';
export interface GitHubStatsSummary {
totalRepositories: number;
totalStars: number;
totalForks: number;
publicRepositories: number;
privateRepositories: number;
followers: number;
publicGists: number;
topLanguages: Array<{ name: string; count: number; percentage: number }>;
recentActivityCount: number;
lastUpdated?: string;
}
export function calculateStatsSummary(stats: GitHubStats): GitHubStatsSummary {
// Calculate total repositories
const totalRepositories = stats.repos?.length || stats.publicRepos || 0;
// Calculate language statistics
const topLanguages = calculateTopLanguages(stats.languages || {});
return {
totalRepositories,
totalStars: stats.totalStars || stats.stars || 0,
totalForks: stats.totalForks || stats.forks || 0,
publicRepositories: stats.publicRepos || 0,
privateRepositories: stats.privateRepos || 0,
followers: stats.followers || 0,
publicGists: stats.totalGists || stats.publicGists || 0,
topLanguages,
recentActivityCount: stats.recentActivity?.length || 0,
lastUpdated: stats.lastUpdated,
};
}
export function calculateTopLanguages(languages: GitHubLanguageStats): Array<{
name: string;
count: number;
percentage: number;
}> {
if (!languages || Object.keys(languages).length === 0) {
return [];
}
const totalCount = Object.values(languages).reduce((sum, count) => sum + count, 0);
if (totalCount === 0) {
return [];
}
return Object.entries(languages)
.map(([name, count]) => ({
name,
count,
percentage: Math.round((count / totalCount) * 100),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10); // Top 10 languages
}
export function formatRepositoryStats(stats: GitHubStats) {
const repositories = stats.repos || [];
// Sort repositories by stars (descending)
const topStarredRepos = repositories
.filter((repo) => repo.stargazers_count > 0)
.sort((a, b) => b.stargazers_count - a.stargazers_count)
.slice(0, 5);
// Sort repositories by forks (descending)
const topForkedRepos = repositories
.filter((repo) => repo.forks_count > 0)
.sort((a, b) => b.forks_count - a.forks_count)
.slice(0, 5);
// Recent repositories (by update date)
const recentRepos = repositories
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 10);
return {
total: repositories.length,
topStarredRepos,
topForkedRepos,
recentRepos,
totalStars: repositories.reduce((sum, repo) => sum + repo.stargazers_count, 0),
totalForks: repositories.reduce((sum, repo) => sum + repo.forks_count, 0),
};
}
export function formatActivitySummary(stats: GitHubStats) {
const activity = stats.recentActivity || [];
// Group activities by type
const activityByType = activity.reduce(
(acc, event) => {
acc[event.type] = (acc[event.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
// Format activity types for display
const formattedActivity = Object.entries(activityByType)
.map(([type, count]) => ({
type: formatActivityType(type),
count,
}))
.sort((a, b) => b.count - a.count);
return {
total: activity.length,
byType: formattedActivity,
recent: activity.slice(0, 5),
};
}
function formatActivityType(type: string): string {
const typeMap: Record<string, string> = {
PushEvent: 'Pushes',
CreateEvent: 'Created',
DeleteEvent: 'Deleted',
ForkEvent: 'Forks',
WatchEvent: 'Stars',
IssuesEvent: 'Issues',
PullRequestEvent: 'Pull Requests',
ReleaseEvent: 'Releases',
PublicEvent: 'Made Public',
};
return typeMap[type] || type.replace('Event', '');
}
export function calculateGrowthMetrics(currentStats: GitHubStats, previousStats?: GitHubStats) {
if (!previousStats) {
return null;
}
const starsDiff = (currentStats.totalStars || 0) - (previousStats.totalStars || 0);
const forksDiff = (currentStats.totalForks || 0) - (previousStats.totalForks || 0);
const followersDiff = (currentStats.followers || 0) - (previousStats.followers || 0);
const reposDiff = (currentStats.repos?.length || 0) - (previousStats.repos?.length || 0);
return {
stars: {
current: currentStats.totalStars || 0,
change: starsDiff,
percentage: previousStats.totalStars ? Math.round((starsDiff / previousStats.totalStars) * 100) : 0,
},
forks: {
current: currentStats.totalForks || 0,
change: forksDiff,
percentage: previousStats.totalForks ? Math.round((forksDiff / previousStats.totalForks) * 100) : 0,
},
followers: {
current: currentStats.followers || 0,
change: followersDiff,
percentage: previousStats.followers ? Math.round((followersDiff / previousStats.followers) * 100) : 0,
},
repositories: {
current: currentStats.repos?.length || 0,
change: reposDiff,
percentage: previousStats.repos?.length ? Math.round((reposDiff / previousStats.repos.length) * 100) : 0,
},
};
}

54
app/utils/gitlabStats.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { GitLabProjectInfo, GitLabStats } from '~/types/GitLab';
export function calculateProjectStats(projects: any[]): { projects: GitLabProjectInfo[] } {
const projectStats = {
projects: projects.map((project: any) => ({
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
description: project.description,
http_url_to_repo: project.http_url_to_repo,
star_count: project.star_count || 0,
forks_count: project.forks_count || 0,
default_branch: project.default_branch,
updated_at: project.updated_at,
visibility: project.visibility,
})),
};
return projectStats;
}
export function calculateStatsSummary(
projects: GitLabProjectInfo[],
events: any[],
groups: any[],
snippets: any[],
user: any,
): GitLabStats {
const totalStars = projects.reduce((sum, p) => sum + (p.star_count || 0), 0);
const totalForks = projects.reduce((sum, p) => sum + (p.forks_count || 0), 0);
const privateProjects = projects.filter((p) => p.visibility === 'private').length;
const recentActivity = events.slice(0, 5).map((event: any) => ({
id: event.id,
action_name: event.action_name,
project_id: event.project_id,
project: event.project,
created_at: event.created_at,
}));
return {
projects,
recentActivity,
totalSnippets: snippets.length,
publicProjects: projects.filter((p) => p.visibility === 'public').length,
privateProjects,
stars: totalStars,
forks: totalForks,
followers: user.followers || 0,
snippets: snippets.length,
groups,
lastUpdated: new Date().toISOString(),
};
}