From 870bfc58ee0572fa3dcbffa0e87e97cf4ca36d62 Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Fri, 9 May 2025 15:23:20 +0200 Subject: [PATCH] feat: github fix and ui improvements (#1685) * feat: Add reusable UI components and fix GitHub repository display * style: Fix linting issues in UI components * fix: Add close icon to GitHub Connection Required dialog * fix: Add CloseButton component to fix white background issue in dialog close icons * Fix close button styling in dialog components to address ghost white issue in dark mode * fix: update icon color to tertiary for consistency The icon color was changed from `text-bolt-elements-icon-info` to `text-bolt-elements-icon-tertiary` * fix: improve repository selection dialog tab styling for dark mode - Update tab menu styling to prevent white background in dark mode - Use explicit color values for better dark/light mode compatibility - Improve hover and active states for better visual hierarchy - Remove unused Tabs imports --------- Co-authored-by: KevIsDev --- .../tabs/connections/GithubConnection.tsx | 2 +- .../components/GitHubAuthDialog.tsx | 190 ++++ .../components/PushToGitHubDialog.tsx | 516 +++++++---- .../connections/components/RepositoryCard.tsx | 146 ++++ .../components/RepositoryDialogContext.tsx | 14 + .../connections/components/RepositoryList.tsx | 58 ++ .../components/RepositorySelectionDialog.tsx | 826 ++++++++---------- .../connections/components/StatsDialog.tsx | 83 ++ app/components/ui/Badge.tsx | 29 +- app/components/ui/Breadcrumbs.tsx | 101 +++ app/components/ui/CloseButton.tsx | 49 ++ app/components/ui/CodeBlock.tsx | 103 +++ app/components/ui/EmptyState.tsx | 154 ++++ app/components/ui/FileIcon.tsx | 346 ++++++++ app/components/ui/FilterChip.tsx | 92 ++ app/components/ui/GradientCard.tsx | 100 +++ app/components/ui/RepositoryStats.tsx | 87 ++ app/components/ui/SearchInput.tsx | 80 ++ app/components/ui/SearchResultItem.tsx | 134 +++ app/components/ui/StatusIndicator.tsx | 90 ++ app/components/ui/Tabs.tsx | 4 +- app/components/ui/TabsWithSlider.tsx | 112 +++ app/components/ui/Tooltip.tsx | 131 ++- app/components/ui/index.ts | 38 + app/lib/hooks/useGit.ts | 56 +- app/types/GitHub.ts | 2 + package.json | 1 + pnpm-lock.yaml | 13 + 28 files changed, 2868 insertions(+), 689 deletions(-) create mode 100644 app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx create mode 100644 app/components/@settings/tabs/connections/components/RepositoryCard.tsx create mode 100644 app/components/@settings/tabs/connections/components/RepositoryDialogContext.tsx create mode 100644 app/components/@settings/tabs/connections/components/RepositoryList.tsx create mode 100644 app/components/@settings/tabs/connections/components/StatsDialog.tsx create mode 100644 app/components/ui/Breadcrumbs.tsx create mode 100644 app/components/ui/CloseButton.tsx create mode 100644 app/components/ui/CodeBlock.tsx create mode 100644 app/components/ui/EmptyState.tsx create mode 100644 app/components/ui/FileIcon.tsx create mode 100644 app/components/ui/FilterChip.tsx create mode 100644 app/components/ui/GradientCard.tsx create mode 100644 app/components/ui/RepositoryStats.tsx create mode 100644 app/components/ui/SearchInput.tsx create mode 100644 app/components/ui/SearchResultItem.tsx create mode 100644 app/components/ui/StatusIndicator.tsx create mode 100644 app/components/ui/TabsWithSlider.tsx create mode 100644 app/components/ui/index.ts diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index e378d403..f57c4d16 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -912,7 +912,7 @@ export default function GitHubConnection() {
-
+
{repo.name}
diff --git a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx new file mode 100644 index 00000000..b53a64d4 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx @@ -0,0 +1,190 @@ +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/PushToGitHubDialog.tsx b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx index fe7f9f4c..1f8adb95 100644 --- a/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx +++ b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx @@ -1,7 +1,10 @@ import * as Dialog from '@radix-ui/react-dialog'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { toast } from 'react-toastify'; import { motion } from 'framer-motion'; +import { Octokit } from '@octokit/rest'; + +// Internal imports import { getLocalStorage } from '~/lib/persistence'; import { classNames } from '~/utils/classNames'; import type { GitHubUserResponse } from '~/types/GitHub'; @@ -10,7 +13,9 @@ import { workbenchStore } from '~/lib/stores/workbench'; import { extractRelativePath } from '~/utils/diff'; import { formatSize } from '~/utils/formatSize'; import type { FileMap, File } from '~/lib/stores/files'; -import { Octokit } from '@octokit/rest'; + +// UI Components +import { Badge, EmptyState, StatusIndicator, SearchInput } from '~/components/ui'; interface PushToGitHubDialogProps { isOpen: boolean; @@ -37,6 +42,8 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial const [isLoading, setIsLoading] = useState(false); 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(''); @@ -58,7 +65,34 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial } }, [isOpen]); - const fetchRecentRepos = async (token: string) => { + /* + * Filter repositories based on search query + * const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300); + */ + + useEffect(() => { + if (recentRepos.length === 0) { + setFilteredRepos([]); + return; + } + + if (!repoSearchQuery.trim()) { + setFilteredRepos(recentRepos); + return; + } + + const query = repoSearchQuery.toLowerCase().trim(); + const filtered = recentRepos.filter( + (repo) => + repo.name.toLowerCase().includes(query) || + (repo.description && repo.description.toLowerCase().includes(query)) || + (repo.language && repo.language.toLowerCase().includes(query)), + ); + + setFilteredRepos(filtered); + }, [recentRepos, repoSearchQuery]); + + const fetchRecentRepos = useCallback(async (token: string) => { if (!token) { logStore.logError('No GitHub token available'); toast.error('GitHub authentication required'); @@ -68,53 +102,89 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial try { setIsFetchingRepos(true); + console.log('Fetching GitHub repositories with token:', token.substring(0, 5) + '...'); - const response = await fetch( - 'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member', - { + // 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) { - const errorData = await response.json().catch(() => ({})); + if (!response.ok) { + let errorData: { message?: string } = {}; - 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); + 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); } - } else { - logStore.logError('Failed to fetch GitHub repositories', { - status: response.status, - statusText: response.statusText, - error: errorData, - }); - toast.error(`Failed to fetch repositories: ${response.statusText}`); + + 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; } - return; - } + try { + const repos = (await response.json()) as GitHubRepo[]; + allRepos = allRepos.concat(repos); - const repos = (await response.json()) as GitHubRepo[]; - setRecentRepos(repos); + if (repos.length < 100) { + hasMore = false; + } else { + page += 1; + } + } catch (parseError) { + console.error('Error parsing JSON response:', parseError); + logStore.logError('Failed to parse GitHub repositories response', { parseError }); + toast.error('Failed to parse repository data'); + setRecentRepos([]); + + return; + } + } + setRecentRepos(allRepos); } catch (error) { + console.error('Exception while fetching GitHub repositories:', error); logStore.logError('Failed to fetch GitHub repositories', { error }); toast.error('Failed to fetch recent repositories'); } finally { setIsFetchingRepos(false); } - }; + }, []); - const handleSubmit = async (e: React.FormEvent) => { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const connection = getLocalStorage('github_connection'); @@ -186,7 +256,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial } finally { setIsLoading(false); } - }; + } const handleClose = () => { setRepoName(''); @@ -210,27 +280,46 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial transition={{ duration: 0.2 }} className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto" > - +
-
-
-

Successfully pushed to GitHub

+
+
+
+
+
+

+ Successfully pushed to GitHub +

+

+ Your code is now available on GitHub +

+
- -
+ +
-
-

+

+

+ Repository URL

- + {createdRepoUrl}
-
-

+

+

+ Pushed Files ({pushedFiles.length})

-
+
{pushedFiles.map((file) => (
- {file.path} - + {file.path} + {formatSize(file.size)}
@@ -283,7 +373,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial navigator.clipboard.writeText(createdRepoUrl); toast.success('URL copied to clipboard'); }} - className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2" + className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} > @@ -292,7 +382,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial @@ -321,29 +411,57 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial transition={{ duration: 0.2 }} className="w-[90vw] md:w-[500px]" > - -
+ +
+ + + -
+
-

GitHub Connection Required

-

- Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub. -

- + GitHub Connection Required + +

-

- Close - + To push your code to GitHub, you need to connect your GitHub account in Settings {'>'} Connections + first. +

+
+ + Close + + +
+ Go to Settings + +
@@ -365,7 +483,10 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial transition={{ duration: 0.2 }} className="w-[90vw] md:w-[500px]" > - +
-
+
- + Push to GitHub -

+

Push your code to a new or existing GitHub repository

- -
+ +
-
- {user.login} +
+
+ {user.login} +
+
+
+
-

{user.name || user.login}

-

@{user.login}

+

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

+

+ @{user.login} +

-
- {recentRepos.length > 0 && ( -
- -
- {recentRepos.map((repo) => ( - setRepoName(repo.name)} - className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group" - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} - > -
-
-
- - {repo.name} - -
- {repo.private && ( - - Private - - )} -
- {repo.description && ( -

- {repo.description} -

- )} -
- {repo.language && ( - -
- {repo.language} - - )} - -
- {repo.stargazers_count.toLocaleString()} - - -
- {repo.forks_count.toLocaleString()} - - -
- {new Date(repo.updated_at).toLocaleDateString()} - -
- - ))} -
+
+
+ + + {filteredRepos.length} of {recentRepos.length} +
- )} + +
+ setRepoSearchQuery(e.target.value)} + onClear={() => setRepoSearchQuery('')} + className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm" + /> +
+ + {recentRepos.length === 0 && !isFetchingRepos ? ( + + ) : ( +
+ {filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? ( + + ) : ( + filteredRepos.map((repo) => ( + setRepoName(repo.name)} + className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30" + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+ + {repo.name} + +
+ {repo.private && ( + + Private + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} +
+ {repo.language && ( + + {repo.language} + + )} + + {repo.stargazers_count.toLocaleString()} + + + {repo.forks_count.toLocaleString()} + + + {new Date(repo.updated_at).toLocaleDateString()} + +
+ + )) + )} +
+ )} +
{isFetchingRepos && ( -
-
- Loading repositories... +
+
)} - -
- setIsPrivate(e.target.checked)} - className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]" - /> - +
+
+ setIsPrivate(e.target.checked)} + className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3" + /> + +
+

+ Private repositories are only visible to you and people you share them with +

@@ -515,12 +695,12 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial > {isLoading ? ( <> -
+
Pushing... ) : ( <> -
+
Push to GitHub )} diff --git a/app/components/@settings/tabs/connections/components/RepositoryCard.tsx b/app/components/@settings/tabs/connections/components/RepositoryCard.tsx new file mode 100644 index 00000000..0d63277c --- /dev/null +++ b/app/components/@settings/tabs/connections/components/RepositoryCard.tsx @@ -0,0 +1,146 @@ +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 new file mode 100644 index 00000000..8a0490e2 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/RepositoryDialogContext.tsx @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..d6f0abda --- /dev/null +++ b/app/components/@settings/tabs/connections/components/RepositoryList.tsx @@ -0,0 +1,58 @@ +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 index e7605911..82e1fbc4 100644 --- a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx @@ -4,11 +4,18 @@ 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 } from 'framer-motion'; -import { formatSize } from '~/utils/formatSize'; -import { Input } from '~/components/ui/Input'; +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; @@ -29,278 +36,6 @@ interface SearchFilters { forks?: number; } -interface StatsDialogProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - stats: RepositoryStats; - isLargeRepo?: boolean; -} - -function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) { - return ( - !open && onClose()}> - - -
- - -
-
-

Repository Overview

-
-

Repository Statistics:

-
-
- - Total Files: {stats.totalFiles} -
-
- - Total Size: {formatSize(stats.totalSize)} -
-
- - - Languages:{' '} - {Object.entries(stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 3) - .map(([lang, size]) => `${lang} (${formatSize(size)})`) - .join(', ')} - -
- {stats.hasPackageJson && ( -
- - Has package.json -
- )} - {stats.hasDependencies && ( -
- - Has dependencies -
- )} -
-
- {isLargeRepo && ( -
- -
- This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while - and could impact performance. -
-
- )} -
-
-
- - -
-
-
-
-
-
- ); -} - -function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { - 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}`); - 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
  • -
-
-
- -
- - - -
-
-
-
-
-
- ); -} - export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { const [selectedRepository, setSelectedRepository] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -798,7 +533,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit }; return ( - <> + { @@ -809,15 +544,26 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit > - -
- - Import GitHub Repository - + + {/* Header */} +
+
+
+ +
+
+ + Import GitHub Repository + +

+ Clone a repository from GitHub to your workspace +

+
+
-
+ {/* Auth Info Banner */} +
Need to access private repositories?
- +
-
-
- setActiveTab('my-repos')}> - - My Repos - - setActiveTab('search')}> - - Search - - setActiveTab('url')}> - - URL - + {/* Content */} +
+ {/* Tabs */} +
+
+
+ + + +
+
{activeTab === 'url' ? ( -
- setCustomUrl(e.target.value)} - className="w-full" - /> +
+
+

+ + Repository URL +

-
+ +
+

+ + + 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' && ( -
-
- { - setSearchQuery(e.target.value); - handleSearch(e.target.value); - }} - className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary" - /> - +
+
+

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

+
-
- { - setFilters({ ...filters, language: e.target.value }); - handleSearch(searchQuery); - }} - className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" - /> - handleFilterChange('stars', e.target.value)} - className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" - /> -
- handleFilterChange('forks', e.target.value)} - className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" - />
)}
{selectedRepository ? ( -
-
- -

{selectedRepository.full_name}

+
+
+
+ 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} +
) : ( )} - - ); -} - -function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { - return ( - - ); -} - -function RepositoryList({ - repos, - isLoading, - onSelect, - activeTab, -}: { - repos: GitHubRepoInfo[]; - isLoading: boolean; - onSelect: (repo: GitHubRepoInfo) => void; - activeTab: string; -}) { - if (isLoading) { - return ( -
- - Loading repositories... -
- ); - } - - if (repos.length === 0) { - return ( -
- -

{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}

-
- ); - } - - return repos.map((repo) => onSelect(repo)} />); -} - -function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) { - return ( -
-
-
- -

{repo.name}

-
- -
- {repo.description &&

{repo.description}

} -
- {repo.language && ( - - - {repo.language} - - )} - - - {repo.stargazers_count.toLocaleString()} - - - - {new Date(repo.updated_at).toLocaleDateString()} - -
-
+ ); } diff --git a/app/components/@settings/tabs/connections/components/StatsDialog.tsx b/app/components/@settings/tabs/connections/components/StatsDialog.tsx new file mode 100644 index 00000000..933ae225 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/StatsDialog.tsx @@ -0,0 +1,83 @@ +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/ui/Badge.tsx b/app/components/ui/Badge.tsx index 5f2ccdb2..14729e6b 100644 --- a/app/components/ui/Badge.tsx +++ b/app/components/ui/Badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { classNames } from '~/utils/classNames'; const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2', + 'inline-flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2', { variants: { variant: { @@ -15,18 +15,39 @@ const badgeVariants = cva( 'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80', destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20', outline: 'text-bolt-elements-textPrimary', + primary: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', + success: 'bg-green-500/10 text-green-600 dark:text-green-400', + warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + danger: 'bg-red-500/10 text-red-600 dark:text-red-400', + info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + subtle: + 'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-white/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark', + }, + size: { + default: 'rounded-full px-2.5 py-0.5 text-xs font-semibold', + sm: 'rounded-full px-1.5 py-0.5 text-xs', + md: 'rounded-md px-2 py-1 text-xs font-medium', + lg: 'rounded-md px-2.5 py-1.5 text-sm', }, }, defaultVariants: { variant: 'default', + size: 'default', }, }, ); -export interface BadgeProps extends React.HTMLAttributes, VariantProps {} +export interface BadgeProps extends React.HTMLAttributes, VariantProps { + icon?: string; +} -function Badge({ className, variant, ...props }: BadgeProps) { - return
; +function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) { + return ( +
+ {icon && } + {children} +
+ ); } export { Badge, badgeVariants }; diff --git a/app/components/ui/Breadcrumbs.tsx b/app/components/ui/Breadcrumbs.tsx new file mode 100644 index 00000000..1ba2b935 --- /dev/null +++ b/app/components/ui/Breadcrumbs.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; +import { motion } from 'framer-motion'; + +interface BreadcrumbItem { + label: string; + href?: string; + icon?: string; + onClick?: () => void; +} + +interface BreadcrumbsProps { + items: BreadcrumbItem[]; + className?: string; + separator?: string; + maxItems?: number; + renderItem?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode; +} + +export function Breadcrumbs({ + items, + className, + separator = 'i-ph:caret-right', + maxItems = 0, + renderItem, +}: BreadcrumbsProps) { + const displayItems = + maxItems > 0 && items.length > maxItems + ? [ + ...items.slice(0, 1), + { label: '...', onClick: undefined, href: undefined }, + ...items.slice(-Math.max(1, maxItems - 2)), + ] + : items; + + const defaultRenderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => { + const content = ( +
+ {item.icon && } + + {item.label} + +
+ ); + + if (item.href && !isLast) { + return ( + + {content} + + ); + } + + if (item.onClick && !isLast) { + return ( + + {content} + + ); + } + + return content; + }; + + return ( + + ); +} diff --git a/app/components/ui/CloseButton.tsx b/app/components/ui/CloseButton.tsx new file mode 100644 index 00000000..5c8fff5e --- /dev/null +++ b/app/components/ui/CloseButton.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +interface CloseButtonProps { + onClick?: () => void; + className?: string; + size?: 'sm' | 'md' | 'lg'; +} + +/** + * CloseButton component + * + * A button with an X icon used for closing dialogs, modals, etc. + * The button has a transparent background and only shows a background on hover. + */ +export function CloseButton({ onClick, className, size = 'md' }: CloseButtonProps) { + const sizeClasses = { + sm: 'p-1', + md: 'p-2', + lg: 'p-3', + }; + + const iconSizeClasses = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', + }; + + return ( + +
+ + ); +} diff --git a/app/components/ui/CodeBlock.tsx b/app/components/ui/CodeBlock.tsx new file mode 100644 index 00000000..71dfbc29 --- /dev/null +++ b/app/components/ui/CodeBlock.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { classNames } from '~/utils/classNames'; +import { motion } from 'framer-motion'; +import { FileIcon } from './FileIcon'; +import { Tooltip } from './Tooltip'; + +interface CodeBlockProps { + code: string; + language?: string; + filename?: string; + showLineNumbers?: boolean; + highlightLines?: number[]; + maxHeight?: string; + className?: string; + onCopy?: () => void; +} + +export function CodeBlock({ + code, + language, + filename, + showLineNumbers = true, + highlightLines = [], + maxHeight = '400px', + className, + onCopy, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + onCopy?.(); + }; + + const lines = code.split('\n'); + + return ( +
+ {/* Header */} +
+
+ {filename && ( + <> + + + {filename} + + + )} + {language && !filename && ( + + {language} + + )} +
+ + + {copied ? : } + + +
+ + {/* Code content */} +
+ + + {lines.map((line, index) => ( + + {showLineNumbers && ( + + )} + + + ))} + +
+ {index + 1} + + {line || ' '} +
+
+
+ ); +} diff --git a/app/components/ui/EmptyState.tsx b/app/components/ui/EmptyState.tsx new file mode 100644 index 00000000..22375f06 --- /dev/null +++ b/app/components/ui/EmptyState.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; +import { Button } from './Button'; +import { motion } from 'framer-motion'; + +// Variant-specific styles +const VARIANT_STYLES = { + default: { + container: 'py-8 p-6', + icon: { + container: 'w-12 h-12 mb-3', + size: 'w-6 h-6', + }, + title: 'text-base', + description: 'text-sm mt-1', + actions: 'mt-4', + buttonSize: 'default' as const, + }, + compact: { + container: 'py-4 p-4', + icon: { + container: 'w-10 h-10 mb-2', + size: 'w-5 h-5', + }, + title: 'text-sm', + description: 'text-xs mt-0.5', + actions: 'mt-3', + buttonSize: 'sm' as const, + }, +}; + +interface EmptyStateProps { + /** Icon class name */ + icon?: string; + + /** Title text */ + title: string; + + /** Optional description text */ + description?: string; + + /** Primary action button label */ + actionLabel?: string; + + /** Primary action button callback */ + onAction?: () => void; + + /** Secondary action button label */ + secondaryActionLabel?: string; + + /** Secondary action button callback */ + onSecondaryAction?: () => void; + + /** Additional class name */ + className?: string; + + /** Component size variant */ + variant?: 'default' | 'compact'; +} + +/** + * EmptyState component + * + * A component for displaying empty states with optional actions. + */ +export function EmptyState({ + icon = 'i-ph:folder-simple-dashed', + title, + description, + actionLabel, + onAction, + secondaryActionLabel, + onSecondaryAction, + className, + variant = 'default', +}: EmptyStateProps) { + // Get styles based on variant + const styles = VARIANT_STYLES[variant]; + + // Animation variants for buttons + const buttonAnimation = { + whileHover: { scale: 1.02 }, + whileTap: { scale: 0.98 }, + }; + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

{title}

+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Action buttons */} + {(actionLabel || secondaryActionLabel) && ( +
+ {actionLabel && onAction && ( + + + + )} + + {secondaryActionLabel && onSecondaryAction && ( + + + + )} +
+ )} +
+ ); +} diff --git a/app/components/ui/FileIcon.tsx b/app/components/ui/FileIcon.tsx new file mode 100644 index 00000000..05f69796 --- /dev/null +++ b/app/components/ui/FileIcon.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; + +interface FileIconProps { + filename: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function FileIcon({ filename, size = 'md', className }: FileIconProps) { + const getFileExtension = (filename: string): string => { + return filename.split('.').pop()?.toLowerCase() || ''; + }; + + const getIconForExtension = (extension: string): string => { + // Code files + if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) { + return 'i-ph:file-js'; + } + + if (['html', 'htm', 'xhtml'].includes(extension)) { + return 'i-ph:file-html'; + } + + if (['css', 'scss', 'sass', 'less'].includes(extension)) { + return 'i-ph:file-css'; + } + + if (['json', 'jsonc'].includes(extension)) { + return 'i-ph:brackets-curly'; + } + + if (['md', 'markdown'].includes(extension)) { + return 'i-ph:file-text'; + } + + if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) { + return 'i-ph:file-py'; + } + + if (['java', 'class', 'jar'].includes(extension)) { + return 'i-ph:file-java'; + } + + if (['php'].includes(extension)) { + return 'i-ph:file-php'; + } + + if (['rb', 'ruby'].includes(extension)) { + return 'i-ph:file-rs'; + } + + if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) { + return 'i-ph:file-cpp'; + } + + if (['go'].includes(extension)) { + return 'i-ph:file-rs'; + } + + if (['rs', 'rust'].includes(extension)) { + return 'i-ph:file-rs'; + } + + if (['swift'].includes(extension)) { + return 'i-ph:file-swift'; + } + + if (['kt', 'kotlin'].includes(extension)) { + return 'i-ph:file-kotlin'; + } + + if (['dart'].includes(extension)) { + return 'i-ph:file-dart'; + } + + // Config files + if (['yml', 'yaml'].includes(extension)) { + return 'i-ph:file-cloud'; + } + + if (['xml', 'svg'].includes(extension)) { + return 'i-ph:file-xml'; + } + + if (['toml'].includes(extension)) { + return 'i-ph:file-text'; + } + + if (['ini', 'conf', 'config'].includes(extension)) { + return 'i-ph:file-text'; + } + + if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) { + return 'i-ph:file-lock'; + } + + // Document files + if (['pdf'].includes(extension)) { + return 'i-ph:file-pdf'; + } + + if (['doc', 'docx'].includes(extension)) { + return 'i-ph:file-doc'; + } + + if (['xls', 'xlsx'].includes(extension)) { + return 'i-ph:file-xls'; + } + + if (['ppt', 'pptx'].includes(extension)) { + return 'i-ph:file-ppt'; + } + + if (['txt'].includes(extension)) { + return 'i-ph:file-text'; + } + + // Image files + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) { + return 'i-ph:file-image'; + } + + // Audio/Video files + if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) { + return 'i-ph:file-audio'; + } + + if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) { + return 'i-ph:file-video'; + } + + // Archive files + if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) { + return 'i-ph:file-zip'; + } + + // Special files + if (filename === 'package.json') { + return 'i-ph:package'; + } + + if (filename === 'tsconfig.json') { + return 'i-ph:file-ts'; + } + + if (filename === 'README.md') { + return 'i-ph:book-open'; + } + + if (filename === 'LICENSE') { + return 'i-ph:scales'; + } + + if (filename === '.gitignore') { + return 'i-ph:git-branch'; + } + + if (filename.startsWith('Dockerfile')) { + return 'i-ph:docker-logo'; + } + + // Default + return 'i-ph:file'; + }; + + const getIconColorForExtension = (extension: string): string => { + // Code files + if (['js', 'jsx'].includes(extension)) { + return 'text-yellow-500'; + } + + if (['ts', 'tsx'].includes(extension)) { + return 'text-blue-500'; + } + + if (['html', 'htm', 'xhtml'].includes(extension)) { + return 'text-orange-500'; + } + + if (['css', 'scss', 'sass', 'less'].includes(extension)) { + return 'text-blue-400'; + } + + if (['json', 'jsonc'].includes(extension)) { + return 'text-yellow-400'; + } + + if (['md', 'markdown'].includes(extension)) { + return 'text-gray-500'; + } + + if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) { + return 'text-green-500'; + } + + if (['java', 'class', 'jar'].includes(extension)) { + return 'text-red-500'; + } + + if (['php'].includes(extension)) { + return 'text-purple-500'; + } + + if (['rb', 'ruby'].includes(extension)) { + return 'text-red-600'; + } + + if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) { + return 'text-blue-600'; + } + + if (['go'].includes(extension)) { + return 'text-cyan-500'; + } + + if (['rs', 'rust'].includes(extension)) { + return 'text-orange-600'; + } + + if (['swift'].includes(extension)) { + return 'text-orange-500'; + } + + if (['kt', 'kotlin'].includes(extension)) { + return 'text-purple-400'; + } + + if (['dart'].includes(extension)) { + return 'text-cyan-400'; + } + + // Config files + if (['yml', 'yaml'].includes(extension)) { + return 'text-purple-300'; + } + + if (['xml'].includes(extension)) { + return 'text-orange-300'; + } + + if (['svg'].includes(extension)) { + return 'text-green-400'; + } + + if (['toml'].includes(extension)) { + return 'text-gray-500'; + } + + if (['ini', 'conf', 'config'].includes(extension)) { + return 'text-gray-500'; + } + + if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) { + return 'text-green-500'; + } + + // Document files + if (['pdf'].includes(extension)) { + return 'text-red-500'; + } + + if (['doc', 'docx'].includes(extension)) { + return 'text-blue-600'; + } + + if (['xls', 'xlsx'].includes(extension)) { + return 'text-green-600'; + } + + if (['ppt', 'pptx'].includes(extension)) { + return 'text-red-600'; + } + + if (['txt'].includes(extension)) { + return 'text-gray-500'; + } + + // Image files + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) { + return 'text-pink-500'; + } + + // Audio/Video files + if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) { + return 'text-green-500'; + } + + if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) { + return 'text-blue-500'; + } + + // Archive files + if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) { + return 'text-yellow-600'; + } + + // Special files + if (filename === 'package.json') { + return 'text-red-400'; + } + + if (filename === 'tsconfig.json') { + return 'text-blue-500'; + } + + if (filename === 'README.md') { + return 'text-blue-400'; + } + + if (filename === 'LICENSE') { + return 'text-gray-500'; + } + + if (filename === '.gitignore') { + return 'text-orange-500'; + } + + if (filename.startsWith('Dockerfile')) { + return 'text-blue-500'; + } + + // Default + return 'text-gray-400'; + }; + + const getSizeClass = (size: 'sm' | 'md' | 'lg'): string => { + switch (size) { + case 'sm': + return 'w-4 h-4'; + case 'md': + return 'w-5 h-5'; + case 'lg': + return 'w-6 h-6'; + default: + return 'w-5 h-5'; + } + }; + + const extension = getFileExtension(filename); + const icon = getIconForExtension(extension); + const color = getIconColorForExtension(extension); + const sizeClass = getSizeClass(size); + + return ; +} diff --git a/app/components/ui/FilterChip.tsx b/app/components/ui/FilterChip.tsx new file mode 100644 index 00000000..705cec20 --- /dev/null +++ b/app/components/ui/FilterChip.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +interface FilterChipProps { + /** The label text to display */ + label: string; + + /** Optional value to display after the label */ + value?: string | number; + + /** Function to call when the remove button is clicked */ + onRemove?: () => void; + + /** Whether the chip is active/selected */ + active?: boolean; + + /** Optional icon to display before the label */ + icon?: string; + + /** Additional class name */ + className?: string; +} + +/** + * FilterChip component + * + * A chip component for displaying filters with optional remove button. + */ +export function FilterChip({ label, value, onRemove, active = false, icon, className }: FilterChipProps) { + // Animation variants + const variants = { + initial: { opacity: 0, scale: 0.9 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.9 }, + }; + + return ( + + {/* Icon */} + {icon && } + + {/* Label and value */} + + {label} + {value !== undefined && ': '} + {value !== undefined && ( + + {value} + + )} + + + {/* Remove button */} + {onRemove && ( + + )} + + ); +} diff --git a/app/components/ui/GradientCard.tsx b/app/components/ui/GradientCard.tsx new file mode 100644 index 00000000..154bf97f --- /dev/null +++ b/app/components/ui/GradientCard.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +// Predefined gradient colors +const GRADIENT_COLORS = [ + 'from-purple-500/10 to-blue-500/5', + 'from-blue-500/10 to-cyan-500/5', + 'from-cyan-500/10 to-green-500/5', + 'from-green-500/10 to-yellow-500/5', + 'from-yellow-500/10 to-orange-500/5', + 'from-orange-500/10 to-red-500/5', + 'from-red-500/10 to-pink-500/5', + 'from-pink-500/10 to-purple-500/5', +]; + +interface GradientCardProps { + /** Custom gradient class (overrides seed-based gradient) */ + gradient?: string; + + /** Seed string to determine gradient color */ + seed?: string; + + /** Whether to apply hover animation effect */ + hoverEffect?: boolean; + + /** Whether to apply border effect */ + borderEffect?: boolean; + + /** Card content */ + children: React.ReactNode; + + /** Additional class name */ + className?: string; + + /** Additional props */ + [key: string]: any; +} + +/** + * GradientCard component + * + * A card with a gradient background that can be determined by a seed string. + */ +export function GradientCard({ + gradient, + seed, + hoverEffect = true, + borderEffect = true, + className, + children, + ...props +}: GradientCardProps) { + // Get gradient color based on seed or use provided gradient + const gradientClass = gradient || getGradientColorFromSeed(seed); + + // Animation variants for hover effect + const hoverAnimation = hoverEffect + ? { + whileHover: { + scale: 1.02, + y: -2, + transition: { type: 'spring', stiffness: 400, damping: 17 }, + }, + whileTap: { scale: 0.98 }, + } + : undefined; + + return ( + + {children} + + ); +} + +/** + * Calculate a gradient color based on the seed string for visual variety + */ +function getGradientColorFromSeed(seedString?: string): string { + if (!seedString) { + return GRADIENT_COLORS[0]; + } + + const index = seedString.length % GRADIENT_COLORS.length; + + return GRADIENT_COLORS[index]; +} diff --git a/app/components/ui/RepositoryStats.tsx b/app/components/ui/RepositoryStats.tsx new file mode 100644 index 00000000..98d2bf6a --- /dev/null +++ b/app/components/ui/RepositoryStats.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Badge } from './Badge'; +import { classNames } from '~/utils/classNames'; +import { formatSize } from '~/utils/formatSize'; + +interface RepositoryStatsProps { + stats: { + totalFiles?: number; + totalSize?: number; + languages?: Record; + hasPackageJson?: boolean; + hasDependencies?: boolean; + }; + className?: string; + compact?: boolean; +} + +export function RepositoryStats({ stats, className, compact = false }: RepositoryStatsProps) { + const { totalFiles, totalSize, languages, hasPackageJson, hasDependencies } = stats; + + return ( +
+ {!compact && ( +

+ Repository Statistics: +

+ )} + +
+ {totalFiles !== undefined && ( +
+ + Total Files: {totalFiles.toLocaleString()} +
+ )} + + {totalSize !== undefined && ( +
+ + Total Size: {formatSize(totalSize)} +
+ )} +
+ + {languages && Object.keys(languages).length > 0 && ( +
+
+ + Languages: +
+
+ {Object.entries(languages) + .sort(([, a], [, b]) => b - a) + .slice(0, compact ? 3 : 5) + .map(([lang, size]) => ( + + {lang} ({formatSize(size)}) + + ))} + {Object.keys(languages).length > (compact ? 3 : 5) && ( + + +{Object.keys(languages).length - (compact ? 3 : 5)} more + + )} +
+
+ )} + + {(hasPackageJson || hasDependencies) && ( +
+
+ {hasPackageJson && ( + + package.json + + )} + {hasDependencies && ( + + Dependencies + + )} +
+
+ )} +
+ ); +} diff --git a/app/components/ui/SearchInput.tsx b/app/components/ui/SearchInput.tsx new file mode 100644 index 00000000..c3209218 --- /dev/null +++ b/app/components/ui/SearchInput.tsx @@ -0,0 +1,80 @@ +import React, { forwardRef } from 'react'; +import { classNames } from '~/utils/classNames'; +import { Input } from './Input'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface SearchInputProps extends React.InputHTMLAttributes { + /** Function to call when the clear button is clicked */ + onClear?: () => void; + + /** Whether to show the clear button when there is input */ + showClearButton?: boolean; + + /** Additional class name for the search icon */ + iconClassName?: string; + + /** Additional class name for the container */ + containerClassName?: string; + + /** Whether the search is loading */ + loading?: boolean; +} + +/** + * SearchInput component + * + * A search input field with a search icon and optional clear button. + */ +export const SearchInput = forwardRef( + ( + { className, onClear, showClearButton = true, iconClassName, containerClassName, loading = false, ...props }, + ref, + ) => { + const hasValue = Boolean(props.value); + + return ( +
+ {/* Search icon or loading spinner */} +
+ {loading ? ( + + ) : ( + + )} +
+ + {/* Input field */} + + + {/* Clear button */} + + {hasValue && showClearButton && ( + + + + )} + +
+ ); + }, +); + +SearchInput.displayName = 'SearchInput'; diff --git a/app/components/ui/SearchResultItem.tsx b/app/components/ui/SearchResultItem.tsx new file mode 100644 index 00000000..b2dddccc --- /dev/null +++ b/app/components/ui/SearchResultItem.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { Badge } from './Badge'; + +interface SearchResultItemProps { + title: string; + subtitle?: string; + description?: string; + icon?: string; + iconBackground?: string; + iconColor?: string; + tags?: string[]; + metadata?: Array<{ + icon?: string; + label: string; + value?: string | number; + }>; + actionLabel?: string; + onAction?: () => void; + onClick?: () => void; + className?: string; +} + +export function SearchResultItem({ + title, + subtitle, + description, + icon, + iconBackground = 'bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80', + iconColor = 'text-purple-500', + tags, + metadata, + actionLabel, + onAction, + onClick, + className, +}: SearchResultItemProps) { + return ( + +
+
+ {icon && ( +
+ +
+ )} +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ + {actionLabel && onAction && ( + { + e.stopPropagation(); + onAction(); + }} + className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {actionLabel} + + )} +
+ + {description && ( +
+

+ {description} +

+
+ )} + + {tags && tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {metadata && metadata.length > 0 && ( +
+ {metadata.map((item, index) => ( +
+ {item.icon && } + + {item.label} + {item.value !== undefined && ': '} + {item.value !== undefined && ( + + {item.value} + + )} + +
+ ))} +
+ )} +
+ ); +} diff --git a/app/components/ui/StatusIndicator.tsx b/app/components/ui/StatusIndicator.tsx new file mode 100644 index 00000000..6466dfbe --- /dev/null +++ b/app/components/ui/StatusIndicator.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; + +// Status types supported by the component +type StatusType = 'online' | 'offline' | 'away' | 'busy' | 'success' | 'warning' | 'error' | 'info' | 'loading'; + +// Size types for the indicator +type SizeType = 'sm' | 'md' | 'lg'; + +// Status color mapping +const STATUS_COLORS: Record = { + online: 'bg-green-500', + success: 'bg-green-500', + offline: 'bg-red-500', + error: 'bg-red-500', + away: 'bg-yellow-500', + warning: 'bg-yellow-500', + busy: 'bg-red-500', + info: 'bg-blue-500', + loading: 'bg-purple-500', +}; + +// Size class mapping +const SIZE_CLASSES: Record = { + sm: 'w-2 h-2', + md: 'w-3 h-3', + lg: 'w-4 h-4', +}; + +// Text size mapping based on indicator size +const TEXT_SIZE_CLASSES: Record = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', +}; + +interface StatusIndicatorProps { + /** The status to display */ + status: StatusType; + + /** Size of the indicator */ + size?: SizeType; + + /** Whether to show a pulsing animation */ + pulse?: boolean; + + /** Optional label text */ + label?: string; + + /** Additional class name */ + className?: string; +} + +/** + * StatusIndicator component + * + * A component for displaying status indicators with optional labels and pulse animations. + */ +export function StatusIndicator({ status, size = 'md', pulse = false, label, className }: StatusIndicatorProps) { + // Get the color class for the status + const colorClass = STATUS_COLORS[status] || 'bg-gray-500'; + + // Get the size class for the indicator + const sizeClass = SIZE_CLASSES[size]; + + // Get the text size class for the label + const textSizeClass = TEXT_SIZE_CLASSES[size]; + + return ( +
+ {/* Status indicator dot */} + + {/* Pulse animation */} + {pulse && } + + + {/* Optional label */} + {label && ( + + {label} + + )} +
+ ); +} diff --git a/app/components/ui/Tabs.tsx b/app/components/ui/Tabs.tsx index 018d8434..34ae64e5 100644 --- a/app/components/ui/Tabs.tsx +++ b/app/components/ui/Tabs.tsx @@ -11,7 +11,7 @@ const TabsList = React.forwardRef< void; + + /** Additional class name for the container */ + className?: string; + + /** Additional class name for inactive tabs */ + tabClassName?: string; + + /** Additional class name for the active tab */ + activeTabClassName?: string; + + /** Additional class name for the slider */ + sliderClassName?: string; +} + +/** + * TabsWithSlider component + * + * A tabs component with an animated slider that moves to the active tab. + */ +export function TabsWithSlider({ + tabs, + activeTab, + onChange, + className, + tabClassName, + activeTabClassName, + sliderClassName, +}: TabsWithSliderProps) { + // State for slider dimensions + const [sliderDimensions, setSliderDimensions] = useState({ width: 0, left: 0 }); + + // Refs for tab elements + const tabsRef = useRef<(HTMLButtonElement | null)[]>([]); + + // Update slider position when active tab changes + useEffect(() => { + const activeIndex = tabs.findIndex((tab) => tab.id === activeTab); + + if (activeIndex !== -1 && tabsRef.current[activeIndex]) { + const activeTabElement = tabsRef.current[activeIndex]; + + if (activeTabElement) { + setSliderDimensions({ + width: activeTabElement.offsetWidth, + left: activeTabElement.offsetLeft, + }); + } + } + }, [activeTab, tabs]); + + return ( +
+ {/* Tab buttons */} + {tabs.map((tab, index) => ( + + ))} + + {/* Animated slider */} + +
+ ); +} diff --git a/app/components/ui/Tooltip.tsx b/app/components/ui/Tooltip.tsx index 278fa1ea..74f0e0e9 100644 --- a/app/components/ui/Tooltip.tsx +++ b/app/components/ui/Tooltip.tsx @@ -1,7 +1,9 @@ -import * as Tooltip from '@radix-ui/react-tooltip'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { forwardRef, type ForwardedRef, type ReactElement } from 'react'; +import { classNames } from '~/utils/classNames'; -interface TooltipProps { +// Original WithTooltip component +interface WithTooltipProps { tooltip: React.ReactNode; children: ReactElement; sideOffset?: number; @@ -25,55 +27,96 @@ const WithTooltip = forwardRef( position = 'top', maxWidth = 250, delay = 0, - }: TooltipProps, + }: WithTooltipProps, _ref: ForwardedRef, ) => { return ( - - {children} - - -
{tooltip}
- + + {children} + + -
-
-
+ sideOffset={sideOffset} + style={{ + maxWidth, + ...tooltipStyle, + }} + > +
{tooltip}
+ + + + + ); }, ); +// New Tooltip component with simpler API +interface TooltipProps { + content: React.ReactNode; + children: React.ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + delayDuration?: number; + className?: string; +} + +export function Tooltip({ + content, + children, + side = 'top', + align = 'center', + delayDuration = 300, + className, +}: TooltipProps) { + return ( + + + {children} + + {content} + + + + + ); +} + export default WithTooltip; diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts new file mode 100644 index 00000000..15ade2f2 --- /dev/null +++ b/app/components/ui/index.ts @@ -0,0 +1,38 @@ +// Export all UI components for easier imports + +// Core components +export * from './Badge'; +export * from './Button'; +export * from './Card'; +export * from './Checkbox'; +export * from './Collapsible'; +export * from './Dialog'; +export * from './IconButton'; +export * from './Input'; +export * from './Label'; +export * from './ScrollArea'; +export * from './Switch'; +export * from './Tabs'; +export * from './ThemeSwitch'; + +// Loading components +export * from './LoadingDots'; +export * from './LoadingOverlay'; + +// New components +export * from './Breadcrumbs'; +export * from './CloseButton'; +export * from './CodeBlock'; +export * from './EmptyState'; +export * from './FileIcon'; +export * from './FilterChip'; +export * from './GradientCard'; +export * from './RepositoryStats'; +export * from './SearchInput'; +export * from './SearchResultItem'; +export * from './StatusIndicator'; +export * from './TabsWithSlider'; + +// Tooltip components +export { default as WithTooltip } from './Tooltip'; +export { Tooltip } from './Tooltip'; diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index b726d97d..387f794f 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -43,9 +43,9 @@ export function useGit() { }, []); const gitClone = useCallback( - async (url: string) => { + async (url: string, retryCount = 0) => { if (!webcontainer || !fs || !ready) { - throw 'Webcontainer not initialized'; + throw new Error('Webcontainer not initialized. Please try again later.'); } fileData.current = {}; @@ -68,6 +68,12 @@ export function useGit() { } try { + // Add a small delay before retrying to allow for network recovery + if (retryCount > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); + console.log(`Retrying git clone (attempt ${retryCount + 1})...`); + } + await git.clone({ fs, http, @@ -90,10 +96,10 @@ export function useGit() { console.log('Repository requires authentication:', url); - if (confirm('This repo is password protected. Ready to enter a username & password?')) { + if (confirm('This repository requires authentication. Would you like to enter your GitHub credentials?')) { auth = { username: prompt('Enter username') || '', - password: prompt('Enter password') || '', + password: prompt('Enter password or personal access token') || '', }; return auth; } else { @@ -102,8 +108,10 @@ export function useGit() { }, onAuthFailure: (url, _auth) => { console.error(`Authentication failed for ${url}`); - toast.error(`Error Authenticating with ${url.split('/')[2]}`); - throw `Error Authenticating with ${url.split('/')[2]}`; + toast.error(`Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`); + throw new Error( + `Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`, + ); }, onAuthSuccess: (url, auth) => { console.log(`Authentication successful for ${url}`); @@ -121,8 +129,40 @@ export function useGit() { } catch (error) { console.error('Git clone error:', error); - // toast.error(`Git clone error ${(error as any).message||""}`); - throw error; + // Handle specific error types + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check for common error patterns + if (errorMessage.includes('Authentication failed')) { + toast.error(`Authentication failed. Please check your GitHub credentials and try again.`); + throw error; + } else if ( + errorMessage.includes('ENOTFOUND') || + errorMessage.includes('ETIMEDOUT') || + errorMessage.includes('ECONNREFUSED') + ) { + toast.error(`Network error while connecting to repository. Please check your internet connection.`); + + // Retry for network errors, up to 3 times + if (retryCount < 3) { + return gitClone(url, retryCount + 1); + } + + throw new Error( + `Failed to connect to repository after multiple attempts. Please check your internet connection.`, + ); + } else if (errorMessage.includes('404')) { + toast.error(`Repository not found. Please check the URL and make sure the repository exists.`); + throw new Error(`Repository not found. Please check the URL and make sure the repository exists.`); + } else if (errorMessage.includes('401')) { + toast.error(`Unauthorized access to repository. Please connect your GitHub account with proper permissions.`); + throw new Error( + `Unauthorized access to repository. Please connect your GitHub account with proper permissions.`, + ); + } else { + toast.error(`Failed to clone repository: ${errorMessage}`); + throw error; + } } }, [webcontainer, fs, ready], diff --git a/app/types/GitHub.ts b/app/types/GitHub.ts index babe4642..6999c359 100644 --- a/app/types/GitHub.ts +++ b/app/types/GitHub.ts @@ -23,6 +23,8 @@ export interface GitHubRepoInfo { updated_at: string; language: string; languages_url: string; + private?: boolean; + topics?: string[]; } export interface GitHubContent { diff --git a/package.json b/package.json index c377efd4..b28b0e92 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "shiki": "^1.24.0", "tailwind-merge": "^2.2.1", "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4", "vite-plugin-node-polyfills": "^0.22.0", "zod": "^3.24.1", "zustand": "^5.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a01b3362..c4f026db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.0.0 + use-debounce: + specifier: ^10.0.4 + version: 10.0.4(react@18.3.1) vite-plugin-node-polyfills: specifier: ^0.22.0 version: 0.22.0(rollup@4.38.0)(vite@5.4.15(@types/node@22.13.14)(sass-embedded@1.86.0)) @@ -7743,6 +7746,12 @@ packages: '@types/react': optional: true + use-debounce@10.0.4: + resolution: {integrity: sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + use-memo-one@1.1.3: resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} peerDependencies: @@ -16864,6 +16873,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.20 + use-debounce@10.0.4(react@18.3.1): + dependencies: + react: 18.3.1 + use-memo-one@1.1.3(react@18.3.1): dependencies: react: 18.3.1