diff --git a/.env.example b/.env.example index 35fe67e..dc7a664 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,37 @@ VITE_GITHUB_ACCESS_TOKEN=your_github_personal_access_token_here # GitHub Token Type ('classic' or 'fine-grained') VITE_GITHUB_TOKEN_TYPE=classic +# ====================================== +# GITLAB INTEGRATION +# ====================================== + +# GitLab Personal Access Token +# Get your GitLab Personal Access Token here: +# https://gitlab.com/-/profile/personal_access_tokens +# +# This token is used for: +# 1. Importing/cloning GitLab repositories +# 2. Accessing private projects +# 3. Creating/updating branches +# 4. Creating/updating commits and pushing code +# 5. Creating new GitLab projects via the API +# +# Make sure your token has the following scopes: +# - api (for full API access including project creation and commits) +# - read_repository (to clone/import repositories) +# - write_repository (to push commits and update branches) +VITE_GITLAB_ACCESS_TOKEN= + +# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) +VITE_GITLAB_URL=https://gitlab.com + +# GitLab token type should be 'personal-access-token' +VITE_GITLAB_TOKEN_TYPE=personal-access-token + +# ====================================== +# DEVELOPMENT SETTINGS +# ====================================== + # ====================================== # DEVELOPMENT SETTINGS # ====================================== @@ -139,4 +170,85 @@ DEFAULT_NUM_CTX=32768 # 1. Copy this file to .env.local: cp .env.example .env.local # 2. Fill in the API keys you want to use # 3. Restart your development server: npm run dev -# 4. Go to Settings > Providers to enable/configure providers \ No newline at end of file +# 4. Go to Settings > Providers to enable/configure providers +# ====================================== +# GITLAB INTEGRATION +# ====================================== + +# GitLab Personal Access Token +# Get your GitLab Personal Access Token here: +# https://gitlab.com/-/profile/personal_access_tokens +# +# This token is used for: +# 1. Importing/cloning GitLab repositories +# 2. Accessing private projects +# 3. Creating/updating branches +# 4. Creating/updating commits and pushing code +# 5. Creating new GitLab projects via the API +# +# Make sure your token has the following scopes: +# - api (for full API access including project creation and commits) +# - read_repository (to clone/import repositories) +# - write_repository (to push commits and update branches) +VITE_GITLAB_ACCESS_TOKEN= + +# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) +VITE_GITLAB_URL=https://gitlab.com + +# GitLab token type should be 'personal-access-token' +VITE_GITLAB_TOKEN_TYPE=personal-access-token + +# ====================================== +# GITLAB INTEGRATION +# ====================================== + +# GitLab Personal Access Token +# Get your GitLab Personal Access Token here: +# https://gitlab.com/-/profile/personal_access_tokens +# +# This token is used for: +# 1. Importing/cloning GitLab repositories +# 2. Accessing private projects +# 3. Creating/updating branches +# 4. Creating/updating commits and pushing code +# 5. Creating new GitLab projects via the API +# +# Make sure your token has the following scopes: +# - api (for full API access including project creation and commits) +# - read_repository (to clone/import repositories) +# - write_repository (to push commits and update branches) +VITE_GITLAB_ACCESS_TOKEN= + +# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) +VITE_GITLAB_URL=https://gitlab.com + +# GitLab token type should be 'personal-access-token' +VITE_GITLAB_TOKEN_TYPE=personal-access-token + + +# ====================================== +# GITLAB INTEGRATION +# ====================================== + +# GitLab Personal Access Token +# Get your GitLab Personal Access Token here: +# https://gitlab.com/-/profile/personal_access_tokens +# +# This token is used for: +# 1. Importing/cloning GitLab repositories +# 2. Accessing private projects +# 3. Creating/updating branches +# 4. Creating/updating commits and pushing code +# 5. Creating new GitLab projects via the API +# +# Make sure your token has the following scopes: +# - api (for full API access including project creation and commits) +# - read_repository (to clone/import repositories) +# - write_repository (to push commits and update branches) +VITE_GITLAB_ACCESS_TOKEN= + +# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) +VITE_GITLAB_URL=https://gitlab.com + +# GitLab token type should be 'personal-access-token' +VITE_GITLAB_TOKEN_TYPE=personal-access-token diff --git a/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx b/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx deleted file mode 100644 index c14d4f9..0000000 --- a/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx +++ /dev/null @@ -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(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 ( -
- {/* Connection Status Cards */} -
- {/* GitHub Connection Card */} -
-
-
-
- GitHub Connection -
-
- {diagnosticResults ? ( - <> -
- - {diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'} - -
- {diagnosticResults.localStorage.hasGithubConnection && ( - <> -
-
- User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'} -
-
-
- API Status:{' '} - r.ok) - ? 'default' - : 'destructive' - } - className="ml-1" - > - {diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'} - -
- - )} - {!diagnosticResults.localStorage.hasGithubConnection && ( - - )} - - ) : ( -
-
-
- Run diagnostics to check connection status -
-
- )} -
- - {/* Netlify Connection Card */} -
-
-
-
- Netlify Connection -
-
- {diagnosticResults ? ( - <> -
- - {diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'} - -
- {diagnosticResults.localStorage.hasNetlifyConnection && ( - <> -
-
- User:{' '} - {diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name || - diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email || - 'N/A'} -
-
-
- API Status:{' '} - - {diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'} - -
- - )} - {!diagnosticResults.localStorage.hasNetlifyConnection && ( - - )} - - ) : ( -
-
-
- Run diagnostics to check connection status -
-
- )} -
- - {/* Vercel Connection Card */} -
-
-
-
- Vercel Connection -
-
- {diagnosticResults ? ( - <> -
- - {diagnosticResults.localStorage.hasVercelConnection ? 'Connected' : 'Not Connected'} - -
- {diagnosticResults.localStorage.hasVercelConnection && ( - <> -
-
- User:{' '} - {diagnosticResults.localStorage.vercelConnectionParsed?.user?.username || - diagnosticResults.localStorage.vercelConnectionParsed?.user?.user?.username || - 'N/A'} -
-
-
- API Status:{' '} - - {diagnosticResults.apiEndpoints.vercel?.ok ? 'OK' : 'Failed'} - -
- - )} - {!diagnosticResults.localStorage.hasVercelConnection && ( - - )} - - ) : ( -
-
-
- Run diagnostics to check connection status -
-
- )} -
- - {/* Supabase Connection Card */} -
-
-
-
- Supabase Connection -
-
- {diagnosticResults ? ( - <> -
- - {diagnosticResults.localStorage.hasSupabaseConnection ? 'Configured' : 'Not Configured'} - -
- {diagnosticResults.localStorage.hasSupabaseConnection && ( - <> -
-
- Project URL: {diagnosticResults.localStorage.supabaseConnectionParsed?.projectUrl || 'N/A'} -
-
-
- Config Status:{' '} - - {diagnosticResults.apiEndpoints.supabase?.ok ? 'OK' : 'Check Failed'} - -
- - )} - {!diagnosticResults.localStorage.hasSupabaseConnection && ( - - )} - - ) : ( -
-
-
- Run diagnostics to check connection status -
-
- )} -
-
- - {/* Action Buttons */} -
- - - - - - - - - -
- - {/* Details Panel */} - {diagnosticResults && ( -
- - -
-
- - - Diagnostic Details - -
- -
-
- -
-
-                  {JSON.stringify(diagnosticResults, null, 2)}
-                
-
-
-
-
- )} -
- ); -} diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index d61b6fd..c1fae79 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -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 (
{/* Header */} -
-
-

- Connection Settings -

-
- +
+

+ Connection Settings +

Manage your external service connections and integrations

- {/* Diagnostics Tool - Conditionally rendered */} - {showDiagnostics && } - - {/* Environment Variables Info - Collapsible */} - -
- - - {isEnvVarsExpanded && ( -
-

- You can configure connections using environment variables in your{' '} - - .env.local - {' '} - file: -

-
-
- # GitHub Authentication -
-
- VITE_GITHUB_ACCESS_TOKEN=your_token_here -
-
- # Optional: Specify token type (defaults to 'classic' if not specified) -
-
- VITE_GITHUB_TOKEN_TYPE=classic|fine-grained -
-
- # Netlify Authentication -
-
- VITE_NETLIFY_ACCESS_TOKEN=your_token_here -
-
-
-

- Token types: -

-
    -
  • - classic - Personal Access Token with{' '} - - repo, read:org, read:user - {' '} - scopes -
  • -
  • - fine-grained - Fine-grained token with Repository and - Organization access -
  • -
-

- When set, these variables will be used automatically without requiring manual connection. -

-
-
- )} -
-
-
}> + }> + + }> @@ -168,8 +58,7 @@ export default function ConnectionsTab() { Troubleshooting Tip:

- 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.

For persistent issues:

    diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx deleted file mode 100644 index f57c4d1..0000000 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ /dev/null @@ -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 = () => ( - - - -); - -export default function GitHubConnection() { - const [connection, setConnection] = useState({ - 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, - totalGists: 0, - }; - - repos.forEach((repo: any) => { - fetch(repo.languages_url) - .then((response) => response.json()) - .then((languages: any) => { - const typedLanguages = languages as Record; - 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 ; - } - - 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 ( - -
    -
    -
    - -

    - GitHub Connection -

    -
    -
    - - {!connection.user && ( -
    -

    - - Tip: You can also set the{' '} - - VITE_GITHUB_ACCESS_TOKEN - {' '} - environment variable to connect automatically. -

    -

    - For fine-grained tokens, also set{' '} - - VITE_GITHUB_TOKEN_TYPE=fine-grained - -

    -
    - )} -
    -
    - - -
    - -
    - - 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', - )} - /> -
    - - Get your token -
    - - - - Required scopes:{' '} - {connection.tokenType === 'classic' - ? 'repo, read:org, read:user' - : 'Repository access, Organization access'} - -
    -
    -
    - -
    - {!connection.user ? ( - - ) : ( - <> -
    -
    - - -
    - Connected to GitHub - -
    -
    - - -
    -
    - - )} -
    - - {connection.user && connection.stats && ( -
    -
    - {connection.user.login} -
    -

    - {connection.user.name || connection.user.login} -

    -

    - {connection.user.login} -

    -
    -
    - - - -
    -
    -
    - GitHub Stats -
    -
    -
    - - -
    - {/* Languages Section */} -
    -

    Top Languages

    -
    - {Object.entries(connection.stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([language]) => ( - - {language} - - ))} -
    -
    - - {/* Additional Stats */} -
    - {[ - { - 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) => ( -
    - {stat.label} - {stat.value} -
    - ))} -
    - - {/* Repository Stats */} -
    -
    -
    -
    Repository Stats
    -
    - {[ - { - label: 'Public Repos', - value: connection.stats.publicRepos, - }, - { - label: 'Private Repos', - value: connection.stats.privateRepos, - }, - ].map((stat, index) => ( -
    - {stat.label} - {stat.value} -
    - ))} -
    -
    - -
    -
    Contribution Stats
    -
    - {[ - { - 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) => ( -
    - {stat.label} - -
    - {stat.value} - -
    - ))} -
    -
    - -
    -
    Gists
    -
    - {[ - { - label: 'Public', - value: connection.stats.publicGists, - }, - { - label: 'Private', - value: connection.stats.privateGists || 0, - }, - ].map((stat, index) => ( -
    - {stat.label} - {stat.value} -
    - ))} -
    -
    - -
    - - Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()} - -
    -
    -
    - - {/* Repositories Section */} -
    -

    Recent Repositories

    -
    - {connection.stats.repos.map((repo) => ( - -
    - - - ); -} - -function LoadingSpinner() { - return ( -
    -
    -
    - Loading... -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx deleted file mode 100644 index 2c9876b..0000000 --- a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx +++ /dev/null @@ -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>; - 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 ( -
    -
    -
    -
    -
    -
    -
    -
    -

    Connection Settings

    -

    Configure your GitHub connection

    -
    -
    -
    - -
    -
    - - 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" - /> -
    - -
    -
    - - - Generate new token -
    - -
    - - 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" - /> -
    - -
    -
    - {!authState.isConnected ? ( - - ) : ( - <> - - -
    - Connected - - - )} -
    - {authState.rateLimits && ( -
    -
    - Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()} -
    - )} -
    - -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx deleted file mode 100644 index 3fd32ff..0000000 --- a/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx +++ /dev/null @@ -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 ( - - - - - - Create New Branch - - -
    -
    -
    - - 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 - /> -
    - -
    - - -
    - -
    -

    Branch Overview

    -
      -
    • - - Repository: {repository.name} -
    • - {branchName && ( -
    • -
      - New branch will be created as: {branchName} -
    • - )} -
    • -
      - Based on: {sourceBranch} -
    • -
    -
    -
    - -
    - - -
    -
    -
    -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx deleted file mode 100644 index b53a64d..0000000 --- a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx +++ /dev/null @@ -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 ( - !open && onClose()}> - - -
    - - -
    -

    Access Private Repositories

    - -

    - To access private repositories, you need to connect your GitHub account by providing a personal access - token. -

    - -
    -

    Connect with GitHub Token

    - -
    -
    - - 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" - /> -
    - Get your token at{' '} - - github.com/settings/tokens - -
    -
    - -
    - -
    - - -
    -
    - - -
    -
    - -
    -

    - - Accessing Private Repositories -

    -

    - Important things to know about accessing private repositories: -

    -
      -
    • You must be granted access to the repository by its owner
    • -
    • Your GitHub token must have the 'repo' scope
    • -
    • For organization repositories, you may need additional permissions
    • -
    • No token can give you access to repositories you don't have permission for
    • -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/RepositoryCard.tsx b/app/components/@settings/tabs/connections/components/RepositoryCard.tsx deleted file mode 100644 index 0d63277..0000000 --- a/app/components/@settings/tabs/connections/components/RepositoryCard.tsx +++ /dev/null @@ -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 ( - -
    -
    -
    - -
    -
    -

    - {repo.name} -

    -

    - - {repo.full_name.split('/')[0]} -

    -
    -
    - - - Import - -
    - - {repo.description && ( -
    -

    - {repo.description} -

    -
    - )} - -
    - {repo.private && ( - - - Private - - )} - {repo.language && ( - - - {repo.language} - - )} - - - {repo.stargazers_count.toLocaleString()} - - {repo.forks_count > 0 && ( - - - {repo.forks_count.toLocaleString()} - - )} -
    - -
    - - - Updated {formatDate(repo.updated_at)} - - - {repo.topics && repo.topics.length > 0 && ( - - {repo.topics.slice(0, 1).map((topic) => ( - - {topic} - - ))} - {repo.topics.length > 1 && +{repo.topics.length - 1}} - - )} -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/RepositoryDialogContext.tsx b/app/components/@settings/tabs/connections/components/RepositoryDialogContext.tsx deleted file mode 100644 index 8a0490e..0000000 --- a/app/components/@settings/tabs/connections/components/RepositoryDialogContext.tsx +++ /dev/null @@ -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>; -} - -// Default context value with a no-op function -export const RepositoryDialogContext = createContext({ - // This is intentionally empty as it will be overridden by the provider - setShowAuthDialog: () => { - // No operation - }, -}); diff --git a/app/components/@settings/tabs/connections/components/RepositoryList.tsx b/app/components/@settings/tabs/connections/components/RepositoryList.tsx deleted file mode 100644 index d6f0abd..0000000 --- a/app/components/@settings/tabs/connections/components/RepositoryList.tsx +++ /dev/null @@ -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 ( -
    - -

    - This may take a moment -

    -
    - ); - } - - if (repos.length === 0) { - if (activeTab === 'my-repos') { - return ( - setShowAuthDialog(true)} - /> - ); - } else { - return ( - - ); - } - } - - return ( -
    - {repos.map((repo) => ( - onSelect(repo)} /> - ))} -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx deleted file mode 100644 index 82e1fbc..0000000 --- a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +++ /dev/null @@ -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(null); - const [isLoading, setIsLoading] = useState(false); - const [repositories, setRepositories] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - 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({}); - const [showStatsDialog, setShowStatsDialog] = useState(false); - const [currentStats, setCurrentStats] = useState(null); - const [pendingGitUrl, setPendingGitUrl] = useState(''); - 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 => { - 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( -
    -

    {errorMessage}

    - -
    , - { 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 ( - - { - if (!open) { - handleClose(); - } - }} - > - - - - {/* Header */} -
    -
    -
    - -
    -
    - - Import GitHub Repository - -

    - Clone a repository from GitHub to your workspace -

    -
    -
    - - -
    - - {/* Auth Info Banner */} -
    -
    - - - Need to access private repositories? - -
    - 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 }} - > - - Connect GitHub Account - -
    - - {/* Content */} -
    - {/* Tabs */} -
    -
    -
    - - - -
    -
    -
    - - {activeTab === 'url' ? ( -
    -
    -

    - - Repository URL -

    - -
    -
    - -
    - 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" - /> -
    - -
    -

    - - - You can paste any GitHub repository URL, including specific branches or tags. -
    - - Example: https://github.com/username/repository/tree/branch-name - -
    -

    -
    -
    - -
    -
    - Ready to import? -
    -
    - - - - Import Repository - -
    - ) : ( - <> - {activeTab === 'search' && ( -
    -
    -

    - - Search GitHub -

    - -
    -
    - { - 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} - /> -
    - 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" - > - - -
    - -
    -
    - Filters -
    - - {/* Active filters */} - {(filters.language || filters.stars || filters.forks) && ( -
    - - {filters.language && ( - { - const newFilters = { ...filters }; - delete newFilters.language; - setFilters(newFilters); - - if (searchQuery.length > 2) { - handleSearch(searchQuery); - } - }} - /> - )} - {filters.stars && ( - ${filters.stars}`} - icon="i-ph:star" - active - onRemove={() => { - const newFilters = { ...filters }; - delete newFilters.stars; - setFilters(newFilters); - - if (searchQuery.length > 2) { - handleSearch(searchQuery); - } - }} - /> - )} - {filters.forks && ( - ${filters.forks}`} - icon="i-ph:git-fork" - active - onRemove={() => { - const newFilters = { ...filters }; - delete newFilters.forks; - setFilters(newFilters); - - if (searchQuery.length > 2) { - handleSearch(searchQuery); - } - }} - /> - )} - -
    - )} - -
    -
    -
    - -
    - { - 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" - /> -
    -
    -
    - -
    - 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" - /> -
    -
    -
    - -
    - 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" - /> -
    -
    -
    - -
    -

    - - - Search for repositories by name, description, or topics. Use filters to narrow down - results. - -

    -
    -
    -
    - )} - -
    - {selectedRepository ? ( -
    -
    -
    - 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 }} - > - - -
    -

    - {selectedRepository.name} -

    -

    - - {selectedRepository.full_name.split('/')[0]} -

    -
    -
    - - {selectedRepository.private && ( - - Private - - )} -
    - - {selectedRepository.description && ( -
    -

    - {selectedRepository.description} -

    -
    - )} - -
    - {selectedRepository.language && ( - - {selectedRepository.language} - - )} - - {selectedRepository.stargazers_count.toLocaleString()} - - {selectedRepository.forks_count > 0 && ( - - {selectedRepository.forks_count.toLocaleString()} - - )} -
    - -
    -
    - - -
    - -
    - -
    -
    - Ready to import? -
    -
    - - - - Import {selectedRepository.name} - -
    - ) : ( - - )} -
    - - )} -
    -
    -
    - - {/* GitHub Auth Dialog */} - - - {/* Repository Stats Dialog */} - {currentStats && ( - setShowStatsDialog(false)} - onConfirm={handleStatsConfirm} - stats={currentStats} - isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024} - /> - )} -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/components/StatsDialog.tsx b/app/components/@settings/tabs/connections/components/StatsDialog.tsx deleted file mode 100644 index 933ae22..0000000 --- a/app/components/@settings/tabs/connections/components/StatsDialog.tsx +++ /dev/null @@ -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 ( - !open && onClose()}> - - -
    - - -
    -
    -
    - -
    -
    -

    - Repository Overview -

    -

    - Review repository details before importing -

    -
    -
    - -
    - -
    - - {isLargeRepo && ( -
    - -
    - This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while - and could impact performance. -
    -
    - )} -
    -
    - - Cancel - - - Import Repository - -
    -
    -
    -
    -
    -
    - ); -} diff --git a/app/components/@settings/tabs/connections/github/AuthDialog.tsx b/app/components/@settings/tabs/connections/github/AuthDialog.tsx new file mode 100644 index 0000000..dd4b4e1 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/AuthDialog.tsx @@ -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 ( + + + + + +
    + + Connect to GitHub + + +
    +
    + +
    + + +
    +
    + +
    + + 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" + /> +
    + +
    +
    +
    +
    +

    To create a GitHub Personal Access Token:

    +
      +
    1. Go to GitHub Settings → Developer settings → Personal access tokens
    2. +
    3. Click "Generate new token"
    4. +
    5. Select appropriate scopes (repo, user, etc.)
    6. +
    7. Copy and paste the token here
    8. +
    +

    + + Learn more about creating tokens → + +

    +
    +
    +
    + +
    + + +
    + +
    + + + + + ); +} diff --git a/app/components/@settings/tabs/connections/github/GitHubConnection.tsx b/app/components/@settings/tabs/connections/github/GitHubConnection.tsx new file mode 100644 index 0000000..b762821 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/GitHubConnection.tsx @@ -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 ( +
    + {/* Header */} +
    +
    +
    +
    +
    +
    +

    GitHub

    +

    + {isConnected + ? `Connected as ${connection.user?.login}` + : 'Connect your GitHub account to manage repositories'} +

    +
    +
    + +
    + {isConnected ? ( + <> + + + + ) : ( + + )} +
    +
    + + {/* Connection Status */} +
    +
    +
    + + {isConnected ? 'Connected' : 'Not Connected'} + + + {connection.rateLimit && ( + + Rate limit: {connection.rateLimit.remaining}/{connection.rateLimit.limit} + + )} +
    + + {/* Token Type Selection */} + {isConnected && ( +
    + +
    + {(['classic', 'fine-grained'] as const).map((type) => ( + + ))} +
    +
    + )} +
    + + {/* User Profile */} + {isConnected && connection.user && ( + +
    + {connection.user.login} +
    +

    + {connection.user.name || connection.user.login} +

    +

    @{connection.user.login}

    + {connection.user.bio && ( +

    {connection.user.bio}

    + )} +
    +
    +
    + {connection.user.public_repos?.toLocaleString() || 0} +
    +
    repositories
    +
    +
    +
    + )} + + {/* Stats Section */} + {isConnected && connection.stats && ( + + +
    +
    +
    + GitHub Stats +
    +
    +
    + + +
    + +
    +
    + + )} + + {/* Repositories Section */} + {isConnected && connection.stats?.repos && connection.stats.repos.length > 0 && ( + + +
    +
    +
    + + Repositories ({connection.stats.repos.length}) + +
    +
    +
    + + +
    + +
    +
    + + )} + + {/* Auth Dialog */} + setIsAuthDialogOpen(false)} /> +
    + ); +} diff --git a/app/components/@settings/tabs/connections/github/RepositoryCard.tsx b/app/components/@settings/tabs/connections/github/RepositoryCard.tsx new file mode 100644 index 0000000..780da30 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/RepositoryCard.tsx @@ -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 ( + +
    +
    +
    +
    +
    + {repo.name} +
    + {repo.private && ( + + Private + + )} +
    +
    + +
    + {repo.stargazers_count.toLocaleString()} + + +
    + {repo.forks_count.toLocaleString()} + +
    +
    + + {repo.description && ( +

    {repo.description}

    + )} + + {repo.topics && repo.topics.length > 0 && ( +
    + {repo.topics.slice(0, 3).map((topic) => ( + + {topic} + + ))} + {repo.topics.length > 3 && ( + + +{repo.topics.length - 3} + + )} +
    + )} + +
    + {repo.language && ( + +
    + {repo.language} + + )} + +
    + {repo.default_branch} + + +
    + {new Date(repo.updated_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + +
    + {onClone && ( + + )} + +
    + View + +
    +
    +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/github/RepositoryList.tsx b/app/components/@settings/tabs/connections/github/RepositoryList.tsx new file mode 100644 index 0000000..ba9e6ae --- /dev/null +++ b/app/components/@settings/tabs/connections/github/RepositoryList.tsx @@ -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 ( +
    +
    +

    + Repositories ({filteredRepositories.length}) +

    + {onRefresh && ( + + )} +
    + + {/* Search Input */} +
    + 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" + /> +
    + {isSearching ? ( +
    + ) : ( +
    + )} +
    +
    + + {/* Repository Grid */} +
    + {filteredRepositories.length === 0 ? ( +
    + {searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'} +
    + ) : ( + <> +
    + {currentRepositories.map((repo) => ( + + ))} +
    + + {/* Pagination Controls */} + {totalPages > 1 && ( +
    +
    + Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '} + {Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories +
    +
    + + + {currentPage} of {totalPages} + + +
    +
    + )} + + )} +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/github/StatsDisplay.tsx b/app/components/@settings/tabs/connections/github/StatsDisplay.tsx new file mode 100644 index 0000000..9f2b926 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/StatsDisplay.tsx @@ -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 ( +
    + {/* Repository Stats */} +
    +
    Repository Stats
    +
    + {[ + { + label: 'Public Repos', + value: stats.publicRepos || 0, + }, + { + label: 'Private Repos', + value: stats.privateRepos || 0, + }, + ].map((stat, index) => ( +
    + {stat.label} + {stat.value.toLocaleString()} +
    + ))} +
    +
    + + {/* Contribution Stats */} +
    +
    Contribution Stats
    +
    + {[ + { + 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) => ( +
    + {stat.label} + +
    + {stat.value.toLocaleString()} + +
    + ))} +
    +
    + + {/* Gist Stats */} +
    +
    Gist Stats
    +
    + {[ + { + 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) => ( +
    + {stat.label} + +
    + {stat.value.toLocaleString()} + +
    + ))} +
    +
    + + {/* Top Languages */} + {topLanguages.length > 0 && ( +
    +
    Top Languages
    +
    + {topLanguages.map(([language, count]) => ( +
    + {language} + {count} repositories +
    + ))} +
    +
    + )} + + {/* Recent Activity */} + {stats.recentActivity && stats.recentActivity.length > 0 && ( +
    +
    Recent Activity
    +
    + {stats.recentActivity.slice(0, 3).map((activity) => ( +
    +
    + + {activity.type.replace('Event', '')} in {activity.repo.name} + + + {new Date(activity.created_at).toLocaleDateString()} + +
    + ))} +
    +
    + )} + +
    +
    + + Last updated: {new Date(stats.lastUpdated).toLocaleString()} + + {onRefresh && ( + + )} +
    +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/github/index.ts b/app/components/@settings/tabs/connections/github/index.ts new file mode 100644 index 0000000..5b906f8 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/index.ts @@ -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'; diff --git a/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx b/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx new file mode 100644 index 0000000..28b4112 --- /dev/null +++ b/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx @@ -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 ( +
    +
    +
    + Loading... +
    +
    + ); + } + + return ( + +
    +
    +
    +
    + + + +
    +

    GitLab Connection

    +
    +
    + + {!isConnected && ( +
    +

    + + Tip: You can also set the{' '} + VITE_GITLAB_ACCESS_TOKEN{' '} + environment variable to connect automatically. +

    +

    + For self-hosted GitLab instances, also set{' '} + + VITE_GITLAB_URL=https://your-gitlab-instance.com + +

    +
    + )} + +
    +
    + + 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', + )} + /> +
    + +
    + + 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', + )} + /> +
    + + Get your token +
    + + + Required scopes: api, read_repository +
    +
    +
    + +
    + {!isConnected ? ( + + ) : ( + <> +
    +
    + + +
    + Connected to GitLab + +
    +
    + + +
    +
    + + )} +
    + + {isConnected.get() && userAtom.get() && stats.get() && ( +
    +
    +
    + {userAtom.get()?.avatar_url && + userAtom.get()?.avatar_url !== 'null' && + userAtom.get()?.avatar_url !== '' ? ( + {userAtom.get()?.username} { + // 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', + ); + } + }} + /> + ) : ( +
    + {(userAtom.get()?.name || userAtom.get()?.username || 'U').charAt(0).toUpperCase()} +
    + )} +
    +
    +

    + {userAtom.get()?.name || userAtom.get()?.username} +

    +

    {userAtom.get()?.username}

    +
    +
    + + + +
    +
    +
    + GitLab Stats +
    +
    +
    + + +
    + { + const result = await fetchStats(); + + if (result.success) { + toast.success('Stats refreshed'); + } else { + toast.error(`Failed to refresh stats: ${result.error}`); + } + }} + isRefreshing={isFetchingStats} + /> + + 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} + /> +
    +
    + +
    + )} +
    + + ); +} diff --git a/app/components/@settings/tabs/connections/gitlab/RepositoryCard.tsx b/app/components/@settings/tabs/connections/gitlab/RepositoryCard.tsx new file mode 100644 index 0000000..7f40211 --- /dev/null +++ b/app/components/@settings/tabs/connections/gitlab/RepositoryCard.tsx @@ -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 ( + +
    +
    +
    +
    +
    + {repo.name} +
    +
    +
    + +
    + {repo.star_count.toLocaleString()} + + +
    + {repo.forks_count.toLocaleString()} + +
    +
    + + {repo.description && ( +

    {repo.description}

    + )} + +
    + +
    + {repo.default_branch} + + +
    + {new Date(repo.updated_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + +
    + {onClone && ( + + )} + +
    + View + +
    +
    +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/gitlab/RepositoryList.tsx b/app/components/@settings/tabs/connections/gitlab/RepositoryList.tsx new file mode 100644 index 0000000..80062d9 --- /dev/null +++ b/app/components/@settings/tabs/connections/gitlab/RepositoryList.tsx @@ -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 ( +
    +
    +

    + Repositories ({filteredRepositories.length}) +

    + {onRefresh && ( + + )} +
    + + {/* Search Input */} +
    + 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" + /> +
    + {isSearching ? ( +
    + ) : ( +
    + )} +
    +
    + + {/* Repository Grid */} +
    + {filteredRepositories.length === 0 ? ( +
    + {searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'} +
    + ) : ( + <> +
    + {currentRepositories.map((repo) => ( + + ))} +
    + + {/* Pagination Controls */} + {totalPages > 1 && ( +
    +
    + Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '} + {Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories +
    +
    + + + {currentPage} of {totalPages} + + +
    +
    + )} + + )} +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/gitlab/StatsDisplay.tsx b/app/components/@settings/tabs/connections/gitlab/StatsDisplay.tsx new file mode 100644 index 0000000..a3955b6 --- /dev/null +++ b/app/components/@settings/tabs/connections/gitlab/StatsDisplay.tsx @@ -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 ( +
    + {/* Repository Stats */} +
    +
    Repository Stats
    +
    + {[ + { + label: 'Public Repos', + value: stats.publicProjects, + }, + { + label: 'Private Repos', + value: stats.privateProjects, + }, + ].map((stat, index) => ( +
    + {stat.label} + {stat.value} +
    + ))} +
    +
    + + {/* Contribution Stats */} +
    +
    Contribution Stats
    +
    + {[ + { + 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) => ( +
    + {stat.label} + +
    + {stat.value} + +
    + ))} +
    +
    + +
    +
    + + Last updated: {new Date(stats.lastUpdated).toLocaleString()} + + {onRefresh && ( + + )} +
    +
    +
    + ); +} diff --git a/app/components/@settings/tabs/connections/gitlab/index.ts b/app/components/@settings/tabs/connections/gitlab/index.ts new file mode 100644 index 0000000..2664902 --- /dev/null +++ b/app/components/@settings/tabs/connections/gitlab/index.ts @@ -0,0 +1,4 @@ +export { default as GitLabConnection } from './GitLabConnection'; +export { RepositoryCard } from './RepositoryCard'; +export { RepositoryList } from './RepositoryList'; +export { StatsDisplay } from './StatsDisplay'; diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx similarity index 96% rename from app/components/@settings/tabs/connections/NetlifyConnection.tsx rename to app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx index 2bd95f4..7a0f238 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx @@ -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([]); const [deploys, setDeploys] = useState([]); const [builds, setBuilds] = useState([]); + + 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() {
    -
    + {/* Debug info - remove this later */} +
    +

    Debug: Token present: {connection.token ? '✅' : '❌'}

    +

    Debug: User present: {connection.user ? '✅' : '❌'}

    +

    Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}

    +
    +
    + + {/* Debug button - remove this later */} +
    ) : ( diff --git a/app/components/@settings/tabs/connections/netlify/index.ts b/app/components/@settings/tabs/connections/netlify/index.ts new file mode 100644 index 0000000..b9086ae --- /dev/null +++ b/app/components/@settings/tabs/connections/netlify/index.ts @@ -0,0 +1 @@ +export { default as NetlifyConnection } from './NetlifyConnection'; diff --git a/app/components/@settings/tabs/connections/types/GitHub.ts b/app/components/@settings/tabs/connections/types/GitHub.ts deleted file mode 100644 index f2f1af6..0000000 --- a/app/components/@settings/tabs/connections/types/GitHub.ts +++ /dev/null @@ -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; -} diff --git a/app/components/@settings/tabs/connections/VercelConnection.tsx b/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx similarity index 72% rename from app/components/@settings/tabs/connections/VercelConnection.tsx rename to app/components/@settings/tabs/connections/vercel/VercelConnection.tsx index 8527822..b715aac 100644 --- a/app/components/@settings/tabs/connections/VercelConnection.tsx +++ b/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx @@ -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
    +
    +

    + + Tip: You can also set{' '} + + VITE_VERCEL_ACCESS_TOKEN + {' '} + in your .env.local for automatic connection. +

    +
    + {/* Debug info - remove this later */} +
    +

    Debug: Token present: {connection.token ? '✅' : '❌'}

    +

    Debug: User present: {connection.user ? '✅' : '❌'}

    +

    Debug: Env token: {import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '✅' : '❌'}

    +
    - +
    + + + {/* Debug button - remove this later */} + +
    ) : (
    diff --git a/app/components/@settings/tabs/connections/vercel/index.ts b/app/components/@settings/tabs/connections/vercel/index.ts new file mode 100644 index 0000000..f35045e --- /dev/null +++ b/app/components/@settings/tabs/connections/vercel/index.ts @@ -0,0 +1 @@ +export { default as VercelConnection } from './VercelConnection'; diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index aa0182f..098480d 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -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 ( <> - setIsDialogOpen(false)} onSelect={handleClone} /> + {/* Provider Selection Dialog */} + {isDialogOpen && !selectedProvider && ( +
    +
    +
    +
    +

    + Choose Repository Provider +

    + +
    + +
    + + + +
    +
    +
    +
    + )} + + {/* GitHub Repository Selection */} + {isDialogOpen && selectedProvider === 'github' && ( +
    +
    +
    +
    +
    + +
    +
    +

    + Import GitHub Repository +

    +

    + Clone a repository from GitHub to your workspace +

    +
    +
    + +
    + +
    + +
    +
    +
    + )} + + {/* GitLab Repository Selection */} + {isDialogOpen && selectedProvider === 'gitlab' && ( +
    +
    +
    +
    +
    + +
    +
    +

    + Import GitLab Repository +

    +

    + Clone a repository from GitLab to your workspace +

    +
    +
    + +
    + +
    + +
    +
    +
    + )} {loading && } diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx index 6ff6280..ffdeb37 100644 --- a/app/components/deploy/DeployButton.tsx +++ b/app/components/deploy/DeployButton.tsx @@ -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; onNetlifyDeploy?: () => Promise; onGitHubDeploy?: () => Promise; + onGitLabDeploy?: () => Promise; } -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 | null>(null); + const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState | 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 ( <>
    @@ -178,6 +214,27 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy } Deploy to GitHub + + gitlab + {!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'} + + )} + + {/* GitLab Deployment Dialog */} + {showGitLabDeploymentDialog && gitlabDeploymentFiles && ( + setShowGitLabDeploymentDialog(false)} + projectName={gitlabProjectName} + files={gitlabDeploymentFiles} + /> + )} ); }; diff --git a/app/components/deploy/GitHubDeploymentDialog.tsx b/app/components/deploy/GitHubDeploymentDialog.tsx index 533b2f6..84885da 100644 --- a/app/components/deploy/GitHubDeploymentDialog.tsx +++ b/app/components/deploy/GitHubDeploymentDialog.tsx @@ -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 { diff --git a/app/components/deploy/GitLabDeploy.client.tsx b/app/components/deploy/GitLabDeploy.client.tsx new file mode 100644 index 0000000..ba58095 --- /dev/null +++ b/app/components/deploy/GitLabDeploy.client.tsx @@ -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> { + const files: Record = {}; + 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, + }; +} diff --git a/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx b/app/components/deploy/GitLabDeploymentDialog.tsx similarity index 70% rename from app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx rename to app/components/deploy/GitLabDeploymentDialog.tsx index 1f8adb9..7f2b5cb 100644 --- a/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx +++ b/app/components/deploy/GitLabDeploymentDialog.tsx @@ -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; + projectName: string; + files: Record; } -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(null); - const [recentRepos, setRecentRepos] = useState([]); - const [filteredRepos, setFilteredRepos] = useState([]); + const [user, setUser] = useState(null); + const [recentRepos, setRecentRepos] = useState([]); + const [filteredRepos, setFilteredRepos] = useState([]); 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" > + Successfully pushed to GitLab
    @@ -292,13 +260,13 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial

    - Successfully pushed to GitHub + Successfully pushed to GitLab

    - Your code is now available on GitHub + Your code is now available on GitLab

    @@ -315,7 +283,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial

    - + Repository URL

    @@ -342,7 +310,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial Pushed Files ({pushedFiles.length})

    - {pushedFiles.map((file) => ( + {pushedFiles.slice(0, 100).map((file) => (
    ))} + {pushedFiles.length > 100 && ( +
    + +{pushedFiles.length - 100} more files +
    + )}
    @@ -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 }} > -
    +
    View Repository + GitLab Connection Required