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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
153
app/components/@settings/tabs/connections/github/AuthDialog.tsx
Normal file
153
app/components/@settings/tabs/connections/github/AuthDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as GitLabConnection } from './GitLabConnection';
|
||||
export { RepositoryCard } from './RepositoryCard';
|
||||
export { RepositoryList } from './RepositoryList';
|
||||
export { StatsDisplay } from './StatsDisplay';
|
||||
@@ -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>
|
||||
) : (
|
||||
@@ -0,0 +1 @@
|
||||
export { default as NetlifyConnection } from './NetlifyConnection';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VercelConnection } from './VercelConnection';
|
||||
@@ -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..." />}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
168
app/components/deploy/GitLabDeploy.client.tsx
Normal file
168
app/components/deploy/GitLabDeploy.client.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
338
app/lib/services/githubApiService.ts
Normal file
338
app/lib/services/githubApiService.ts
Normal 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();
|
||||
421
app/lib/services/gitlabApiService.ts
Normal file
421
app/lib/services/gitlabApiService.ts
Normal 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 };
|
||||
226
app/lib/stores/githubConnection.ts
Normal file
226
app/lib/stores/githubConnection.ts
Normal 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 };
|
||||
300
app/lib/stores/gitlabConnection.ts
Normal file
300
app/lib/stores/gitlabConnection.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
103
app/types/GitLab.ts
Normal 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[];
|
||||
}
|
||||
@@ -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
167
app/utils/githubStats.ts
Normal 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
54
app/utils/gitlabStats.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user