diff --git a/.env.production b/.env.production index 8fe4367a0e..dd5896e1b4 100644 --- a/.env.production +++ b/.env.production @@ -1,115 +1,183 @@ -# Rename this file to .env once you have filled in the below environment variables! +# ============================================================================= +# BOLT.DIY PRODUCTION ENVIRONMENT CONFIGURATION +# ============================================================================= +# Rename this file to .env once you have filled in the required values +# This file should contain production-ready API keys and configuration -# Get your GROQ API Key here - -# https://console.groq.com/keys -# You only need this environment variable set if you want to use Groq models -GROQ_API_KEY= +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= -# Get your HuggingFace API Key here - -# https://huggingface.co/settings/tokens -# You only need this environment variable set if you want to use HuggingFace models -HuggingFace_API_KEY= +# Environment mode (must be 'production' for production deployment) +NODE_ENV=production -# Get your Open AI API Key by following these instructions - -# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key -# You only need this environment variable set if you want to use GPT models -OPENAI_API_KEY= +# Application port (defaults to 5173 for development, 3000 for production) +PORT=3000 + +# Logging level (warn, error for production) +VITE_LOG_LEVEL=warn + +# Default context window size for local models +DEFAULT_NUM_CTX=32768 -# Get your Anthropic API Key in your account settings - -# https://console.anthropic.com/settings/keys -# You only need this environment variable set if you want to use Claude models +# ============================================================================= +# MAJOR AI PROVIDER API KEYS +# ============================================================================= + +# Anthropic Claude - Primary provider +# Get your API key from: https://console.anthropic.com/ ANTHROPIC_API_KEY= -# Get your OpenRouter API Key in your account settings - -# https://openrouter.ai/settings/keys -# You only need this environment variable set if you want to use OpenRouter models -OPEN_ROUTER_API_KEY= +# OpenAI GPT models - Primary provider +# Get your API key from: https://platform.openai.com/api-keys +OPENAI_API_KEY= -# Get your Google Generative AI API Key by following these instructions - -# https://console.cloud.google.com/apis/credentials -# You only need this environment variable set if you want to use Google Generative AI models +# Google Gemini - Primary provider +# Get your API key from: https://makersuite.google.com/app/apikey GOOGLE_GENERATIVE_AI_API_KEY= -# You only need this environment variable set if you want to use oLLAMA models -# DONT USE http://localhost:11434 due to IPV6 issues -# USE EXAMPLE http://127.0.0.1:11434 -OLLAMA_API_BASE_URL= +# ============================================================================= +# SPECIALIZED AI PROVIDERS +# ============================================================================= -# You only need this environment variable set if you want to use OpenAI Like models -OPENAI_LIKE_API_BASE_URL= +# Groq (Fast inference models) +# Get your API key from: https://console.groq.com/keys +GROQ_API_KEY= + +# Together AI (Fine-tuned models) +# Get your API key from: https://api.together.xyz/settings/api-keys +TOGETHER_API_KEY= + +# OpenRouter (Multi-provider routing) +# Get your API key from: https://openrouter.ai/keys +OPEN_ROUTER_API_KEY= -# You only need this environment variable set if you want to use Together AI models -TOGETHER_API_BASE_URL= +# ============================================================================= +# INTERNATIONAL & SPECIALIZED PROVIDERS +# ============================================================================= -# You only need this environment variable set if you want to use DeepSeek models through their API +# DeepSeek (Chinese models) +# Get your API key from: https://platform.deepseek.com/api_keys DEEPSEEK_API_KEY= -# Get your OpenAI Like API Key -OPENAI_LIKE_API_KEY= +# Moonshot AI / Kimi (Chinese models) +# Get your API key from: https://platform.moonshot.ai/console/api-keys +MOONSHOT_API_KEY= -# Get your Together API Key -TOGETHER_API_KEY= +# X.AI (Elon Musk's company) +# Get your API key from: https://console.x.ai/ +XAI_API_KEY= -# You only need this environment variable set if you want to use Hyperbolic models -HYPERBOLIC_API_KEY= -HYPERBOLIC_API_BASE_URL= +# ============================================================================= +# EUROPEAN & ADDITIONAL PROVIDERS +# ============================================================================= -# Get your Mistral API Key by following these instructions - -# https://console.mistral.ai/api-keys/ -# You only need this environment variable set if you want to use Mistral models +# Mistral (European models) +# Get your API key from: https://console.mistral.ai/api-keys/ MISTRAL_API_KEY= -# Get the Cohere Api key by following these instructions - -# https://dashboard.cohere.com/api-keys -# You only need this environment variable set if you want to use Cohere models +# Cohere (Canadian models) +# Get your API key from: https://dashboard.cohere.ai/api-keys COHERE_API_KEY= -# Get LMStudio Base URL from LM Studio Developer Console -# Make sure to enable CORS -# DONT USE http://localhost:1234 due to IPV6 issues -# Example: http://127.0.0.1:1234 +# Perplexity AI (Search-augmented models) +# Get your API key from: https://www.perplexity.ai/settings/api +PERPLEXITY_API_KEY= + +# ============================================================================= +# COMMUNITY & OPEN SOURCE PROVIDERS +# ============================================================================= + +# Hugging Face (Open source models) +# Get your API key from: https://huggingface.co/settings/tokens +HuggingFace_API_KEY= + +# Hyperbolic (High-performance inference) +# Get your API key from: https://app.hyperbolic.xyz/settings +HYPERBOLIC_API_KEY= + +# GitHub Models (GitHub-hosted OpenAI models) +# Get your Personal Access Token from: https://github.com/settings/tokens +GITHUB_API_KEY= + +# ============================================================================= +# LOCAL MODEL PROVIDERS +# ============================================================================= + +# Ollama (Local model server) +# DON'T USE http://localhost:11434 due to IPv6 issues +# USE: http://127.0.0.1:11434 +OLLAMA_API_BASE_URL= + +# LMStudio (Local model interface) +# Make sure to enable CORS in LMStudio LMSTUDIO_API_BASE_URL= -# Get your xAI API key -# https://x.ai/api -# You only need this environment variable set if you want to use xAI models -XAI_API_KEY= +# ============================================================================= +# COMPATIBLE API PROVIDERS +# ============================================================================= -# Get your Perplexity API Key here - -# https://www.perplexity.ai/settings/api -# You only need this environment variable set if you want to use Perplexity models -PERPLEXITY_API_KEY= +# OpenAI-compatible API (Any provider using OpenAI format) +OPENAI_LIKE_API_BASE_URL= +OPENAI_LIKE_API_KEY= -# Get your AWS configuration -# https://console.aws.amazon.com/iam/home -AWS_BEDROCK_CONFIG= +# ============================================================================= +# CLOUD INFRASTRUCTURE PROVIDERS +# ============================================================================= -# Include this environment variable if you want more logging for debugging locally -VITE_LOG_LEVEL= - -# Get your GitHub Personal Access Token here - -# https://github.com/settings/tokens -# This token is used for: -# 1. Importing/cloning GitHub repositories without rate limiting -# 2. Accessing private repositories -# 3. Automatic GitHub authentication (no need to manually connect in the UI) -# -# For classic tokens, ensure it has these scopes: repo, read:org, read:user -# For fine-grained tokens, ensure it has Repository and Organization access -VITE_GITHUB_ACCESS_TOKEN= +# AWS Bedrock Configuration (JSON format) +# Get your credentials from: https://console.aws.amazon.com/iam/home +# Example: {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey"} +AWS_BEDROCK_CONFIG= -# Specify the type of GitHub token you're using -# Can be 'classic' or 'fine-grained' -# Classic tokens are recommended for broader access -VITE_GITHUB_TOKEN_TYPE= +# ============================================================================= +# THIRD-PARTY INTEGRATIONS +# ============================================================================= -# Netlify Authentication +# GitHub Integration +# Personal Access Token for repository access +VITE_GITHUB_ACCESS_TOKEN= +VITE_GITHUB_TOKEN_TYPE=classic + +# Supabase Integration +# Database URL and API keys for Supabase projects +# IMPORTANT: Use production-ready API keys, not development keys +# - Project URL: Your production Supabase project URL +# - Anon Key: Production anon/public key (safe for client-side) +# - Access Token: Production service role key (keep secure, server-side only) +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= +VITE_SUPABASE_ACCESS_TOKEN= + +# Vercel Integration +# Access token for Vercel deployments and project management +# IMPORTANT: Use production token with appropriate permissions +VITE_VERCEL_ACCESS_TOKEN= + +# Netlify Deployment VITE_NETLIFY_ACCESS_TOKEN= -# Example Context Values for qwen2.5-coder:32b -# -# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM -# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM -# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM -# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM -DEFAULT_NUM_CTX= \ No newline at end of file +# ============================================================================= +# PRODUCTION CONTEXT WINDOW EXAMPLES +# ============================================================================= +# Example values for different model configurations: +# +# qwen2.5-coder:32b context window sizes: +# DEFAULT_NUM_CTX=32768 # Consumes ~36GB VRAM +# DEFAULT_NUM_CTX=24576 # Consumes ~32GB VRAM +# DEFAULT_NUM_CTX=12288 # Consumes ~26GB VRAM +# DEFAULT_NUM_CTX=6144 # Consumes ~24GB VRAM + +# ============================================================================= +# SETUP INSTRUCTIONS +# ============================================================================= +# 1. Fill in the API keys for the providers you want to use in production +# 2. Rename this file to .env: mv .env.production .env +# 3. Verify all required keys are set before deployment +# 4. Test the application thoroughly in a staging environment first +# +# SECURITY NOTES: +# - Never commit production API keys to version control +# - Use environment-specific keys for production +# - Rotate keys regularly for security +# - Monitor usage and costs for all providers diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100644 index 0b70664039..0000000000 --- a/CHANGES.md +++ /dev/null @@ -1,92 +0,0 @@ -# File and Folder Locking Feature Implementation - -## Overview - -This implementation adds persistent file and folder locking functionality to the BoltDIY project. When a file or folder is locked, it cannot be modified by either the user or the AI until it is unlocked. All locks are scoped to the current chat/project to prevent locks from one project affecting files with matching names in other projects. - -## New Files - -### 1. `app/components/chat/LockAlert.tsx` - -- A dedicated alert component for displaying lock-related error messages -- Features a distinctive amber/yellow color scheme and lock icon -- Provides clear instructions to the user about locked files - -### 2. `app/lib/persistence/lockedFiles.ts` - -- Core functionality for persisting file and folder locks in localStorage -- Provides functions for adding, removing, and retrieving locked files and folders -- Defines the lock modes: "full" (no modifications) and "scoped" (only additions allowed) -- Implements chat ID scoping to isolate locks to specific projects - -### 3. `app/utils/fileLocks.ts` - -- Utility functions for checking if a file or folder is locked -- Helps avoid circular dependencies between components and stores -- Provides a consistent interface for lock checking across the application -- Extracts chat ID from URL for project-specific lock scoping - -## Modified Files - -### 1. `app/components/chat/ChatAlert.tsx` - -- Updated to use the new LockAlert component for locked file errors -- Maintains backward compatibility with other error types - -### 2. `app/components/editor/codemirror/CodeMirrorEditor.tsx` - -- Added checks to prevent editing of locked files -- Updated to use the new fileLocks utility -- Displays appropriate tooltips when a user attempts to edit a locked file - -### 3. `app/components/workbench/EditorPanel.tsx` - -- Added safety checks for unsavedFiles to prevent errors -- Improved handling of locked files in the editor panel - -### 4. `app/components/workbench/FileTree.tsx` - -- Added visual indicators for locked files and folders in the file tree -- Improved handling of locked files and folders in the file tree -- Added context menu options for locking and unlocking folders - -### 5. `app/lib/stores/editor.ts` - -- Added checks to prevent updating locked files -- Improved error handling for locked files - -### 6. `app/lib/stores/files.ts` - -- Added core functionality for locking and unlocking files and folders -- Implemented persistence of locked files and folders across page refreshes -- Added methods for checking if a file or folder is locked -- Added chat ID scoping to prevent locks from affecting other projects - -### 7. `app/lib/stores/workbench.ts` - -- Added methods for locking and unlocking files and folders -- Improved error handling for locked files and folders -- Fixed issues with alert initialization -- Added support for chat ID scoping of locks - -### 8. `app/types/actions.ts` - -- Added `isLockedFile` property to the ActionAlert interface -- Improved type definitions for locked file alerts - -## Key Features - -1. **Persistent File and Folder Locking**: Locks are stored in localStorage and persist across page refreshes -2. **Visual Indicators**: Locked files and folders are clearly marked in the UI with lock icons -3. **Improved Error Messages**: Clear, visually distinct error messages when attempting to modify locked items -4. **Lock Modes**: Support for both full locks (no modifications) and scoped locks (only additions allowed) -5. **Prevention of AI Modifications**: The AI is prevented from modifying locked files and folders -6. **Project-Specific Locks**: Locks are scoped to the current chat/project to prevent conflicts -7. **Recursive Folder Locking**: Locking a folder automatically locks all files and subfolders within it - -## UI Improvements - -1. **Enhanced Alert Design**: Modern, visually appealing alert design with better spacing and typography -2. **Contextual Icons**: Different icons and colors for different types of alerts -3. **Improved Error Details**: Better formatting of error details with monospace font and left border -4. **Responsive Buttons**: Better positioned and styled buttons with appropriate hover effects diff --git a/FAQ.md b/FAQ.md index cf00f54672..a675a4da80 100644 --- a/FAQ.md +++ b/FAQ.md @@ -102,4 +102,32 @@ You will need to make sure you have the latest version of Visual Studio C++ inst --- +
+What about free models and OpenRouter limitations? + +Free models (especially on OpenRouter) can be a great starting point but have some limitations: + +**Common Issues:** +- Rate limiting and usage restrictions +- Slower response times during peak hours +- Less consistent response quality +- Higher chance of service interruptions + +**Best Practices:** +- Use free models for simple tasks like code review or basic questions +- Switch to paid models (Claude 3.5 Sonnet, GPT-4o) for complex development +- The app shows visual warnings when using free models +- Check our [Free Models Guide](../docs/free-models-guide.md) for detailed recommendations + +**Recommended Paid Alternatives:** +- Claude 3.5 Sonnet (best overall performance) +- GPT-4o (excellent for code generation) +- DeepSeek Coder V2 (cost-effective, high quality) +- Gemini 2.0 Flash (fast and reliable) + +The app includes smart recommendations and visual indicators to help you choose the right model for your needs. +
+ +--- + Got more questions? Feel free to reach out or open an issue in our GitHub repo! diff --git a/app/components/@settings/core/AvatarDropdown.tsx b/app/components/@settings/core/AvatarDropdown.tsx index 3f5334fb25..0b1dd373b0 100644 --- a/app/components/@settings/core/AvatarDropdown.tsx +++ b/app/components/@settings/core/AvatarDropdown.tsx @@ -3,7 +3,9 @@ import { motion } from 'framer-motion'; import { useStore } from '@nanostores/react'; import { classNames } from '~/utils/classNames'; import { profileStore } from '~/lib/stores/profile'; +import { useNotifications } from '~/lib/hooks/useNotifications'; import type { TabType, Profile } from './types'; +import { User, Settings, Bug, Bell } from 'lucide-react'; interface AvatarDropdownProps { onSelectTab: (tab: TabType) => void; @@ -11,12 +13,13 @@ interface AvatarDropdownProps { export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { const profile = useStore(profileStore) as Profile; + const { hasUnreadNotifications, unreadNotifications } = useNotifications(); return ( @@ -30,7 +33,16 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { /> ) : (
-
+ +
+ )} + + {/* Notification Badge */} + {hasUnreadNotifications && ( +
+ + {unreadNotifications.length > 9 ? '9+' : unreadNotifications.length} +
)} @@ -66,7 +78,7 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { /> ) : (
-
+
)}
@@ -90,7 +102,7 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { )} onClick={() => onSelectTab('profile')} > -
+ Edit Profile @@ -106,10 +118,34 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { )} onClick={() => onSelectTab('settings')} > -
+ Settings + onSelectTab('notifications')} + > +
+ + Notifications + {hasUnreadNotifications && ( +
+
+ {unreadNotifications.length} +
+ )} +
+
+
{ window.open('https://github.com/stackblitz-labs/bolt.diy/issues/new?template=bug_report.yml', '_blank') } > -
+ Report Bug diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 276151b8fd..756d2bbab9 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -5,7 +5,6 @@ import { classNames } from '~/utils/classNames'; import { TabTile } from '~/components/@settings/shared/components/TabTile'; import { useFeatures } from '~/lib/hooks/useFeatures'; import { useNotifications } from '~/lib/hooks/useNotifications'; -import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus'; import { tabConfigurationStore, resetTabConfiguration } from '~/lib/stores/settings'; import { profileStore } from '~/lib/stores/profile'; import type { TabType, Profile } from './types'; @@ -13,6 +12,7 @@ import { TAB_LABELS, DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants'; import { DialogTitle } from '~/components/ui/Dialog'; import { AvatarDropdown } from './AvatarDropdown'; import BackgroundRays from '~/components/ui/BackgroundRays'; +import { ArrowLeft, X } from 'lucide-react'; // Import all tab components import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab'; @@ -21,9 +21,12 @@ import NotificationsTab from '~/components/@settings/tabs/notifications/Notifica import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; import { DataTab } from '~/components/@settings/tabs/data/DataTab'; import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; -import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; +import GitHubTab from '~/components/@settings/tabs/github/GitHubTab'; +import GitLabTab from '~/components/@settings/tabs/gitlab/GitLabTab'; +import NetlifyTab from '~/components/@settings/tabs/netlify/NetlifyTab'; +import VercelTab from '~/components/@settings/tabs/vercel/VercelTab'; +import SupabaseTab from '~/components/@settings/tabs/supabase/SupabaseTab'; import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; -import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab'; import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; import McpTab from '~/components/@settings/tabs/mcp/McpTab'; @@ -33,7 +36,7 @@ interface ControlPanelProps { } // Beta status for experimental features -const BETA_TABS = new Set(['service-status', 'local-providers', 'mcp']); +const BETA_TABS = new Set(['local-providers', 'mcp']); const BetaLabel = () => (
@@ -54,7 +57,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { // Status hooks const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures(); const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications(); - const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); // Memoize the base tab configurations to avoid recalculation const baseTabConfig = useMemo(() => { @@ -83,6 +85,11 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return false; } + // Exclude profile, settings, and notifications tabs from tiles (accessible via avatar dropdown) + if (tab.id === 'profile' || tab.id === 'settings' || tab.id === 'notifications') { + return false; + } + return tab.visible && tab.window === 'user'; }) .sort((a, b) => a.order - b.order); @@ -91,13 +98,9 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { // Reset to default view when modal opens/closes useEffect(() => { if (!open) { - // Reset when closing setActiveTab(null); setLoadingTab(null); setShowTabManagement(false); - } else { - // When opening, set to null to show the main view - setActiveTab(null); } }, [open]); @@ -134,12 +137,21 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return ; case 'local-providers': return ; + case 'github': + return ; + case 'gitlab': + return ; + case 'netlify': + return ; + case 'vercel': + return ; + case 'supabase': + return ; case 'connection': - return ; + // Handle connection tab - this might need to be implemented + return null; case 'event-logs': return ; - case 'service-status': - return ; case 'mcp': return ; @@ -154,8 +166,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return hasNewFeatures; case 'notifications': return hasUnreadNotifications; - case 'connection': - return hasConnectionIssues; default: return false; } @@ -167,12 +177,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`; case 'notifications': return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; - case 'connection': - return currentIssue === 'disconnected' - ? 'Connection lost' - : currentIssue === 'high-latency' - ? 'High latency detected' - : 'Connection issues detected'; default: return ''; } @@ -191,9 +195,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { case 'notifications': markAllAsRead(); break; - case 'connection': - acknowledgeIssue(); - break; } // Clear loading state after a delay @@ -236,7 +237,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { onClick={handleBack} className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150" > -
+ )} @@ -255,7 +256,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { onClick={handleClose} className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200" > -
+
diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.tsx similarity index 54% rename from app/components/@settings/core/constants.ts rename to app/components/@settings/core/constants.tsx index fc7088aa3b..fc9d5f3b21 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.tsx @@ -1,17 +1,22 @@ import type { TabType } from './types'; +import { User, Settings, Bell, Star, Database, Cloud, Laptop, Github, Wrench, List } from 'lucide-react'; -export const TAB_ICONS: Record = { - profile: 'i-ph:user-circle', - settings: 'i-ph:gear-six', - notifications: 'i-ph:bell', - features: 'i-ph:star', - data: 'i-ph:database', - 'cloud-providers': 'i-ph:cloud', - 'local-providers': 'i-ph:laptop', - 'service-status': 'i-ph:activity-bold', - connection: 'i-ph:wifi-high', - 'event-logs': 'i-ph:list-bullets', - mcp: 'i-ph:wrench', +export const TAB_ICONS: Record> = { + profile: User, + settings: Settings, + notifications: Bell, + features: Star, + data: Database, + 'cloud-providers': Cloud, + 'local-providers': Laptop, + github: () => , + gitlab: () => , + netlify: Database, + vercel: Cloud, + supabase: Database, + connection: Database, + 'event-logs': List, + mcp: Wrench, }; export const TAB_LABELS: Record = { @@ -22,8 +27,12 @@ export const TAB_LABELS: Record = { data: 'Data Management', 'cloud-providers': 'Cloud Providers', 'local-providers': 'Local Providers', - 'service-status': 'Service Status', - connection: 'Connection', + github: 'GitHub', + gitlab: 'GitLab', + netlify: 'Netlify', + vercel: 'Vercel', + supabase: 'Supabase', + connection: 'Connections', 'event-logs': 'Event Logs', mcp: 'MCP Servers', }; @@ -36,7 +45,11 @@ export const TAB_DESCRIPTIONS: Record = { data: 'Manage your data and storage', 'cloud-providers': 'Configure cloud AI providers and models', 'local-providers': 'Configure local AI providers and models', - 'service-status': 'Monitor cloud LLM service status', + github: 'Connect and manage GitHub integration', + gitlab: 'Connect and manage GitLab integration', + netlify: 'Configure Netlify deployment settings', + vercel: 'Manage Vercel projects and deployments', + supabase: 'Setup Supabase database connection', connection: 'Check connection status and settings', 'event-logs': 'View system events and logs', mcp: 'Configure MCP (Model Context Protocol) servers', @@ -48,14 +61,17 @@ export const DEFAULT_TAB_CONFIG = [ { id: 'data', visible: true, window: 'user' as const, order: 1 }, { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 }, { id: 'local-providers', visible: true, window: 'user' as const, order: 3 }, - { id: 'connection', visible: true, window: 'user' as const, order: 4 }, - { id: 'notifications', visible: true, window: 'user' as const, order: 5 }, - { id: 'event-logs', visible: true, window: 'user' as const, order: 6 }, - { id: 'mcp', visible: true, window: 'user' as const, order: 7 }, - - { id: 'profile', visible: true, window: 'user' as const, order: 9 }, - { id: 'service-status', visible: true, window: 'user' as const, order: 10 }, - { id: 'settings', visible: true, window: 'user' as const, order: 11 }, + { id: 'github', visible: true, window: 'user' as const, order: 4 }, + { id: 'gitlab', visible: true, window: 'user' as const, order: 5 }, + { id: 'netlify', visible: true, window: 'user' as const, order: 6 }, + { id: 'vercel', visible: true, window: 'user' as const, order: 7 }, + { id: 'supabase', visible: true, window: 'user' as const, order: 8 }, + { id: 'connection', visible: true, window: 'user' as const, order: 9 }, + { id: 'notifications', visible: true, window: 'user' as const, order: 10 }, + { id: 'event-logs', visible: true, window: 'user' as const, order: 11 }, + { id: 'mcp', visible: true, window: 'user' as const, order: 12 }, + { id: 'profile', visible: true, window: 'user' as const, order: 13 }, + { id: 'settings', visible: true, window: 'user' as const, order: 14 }, // User Window Tabs (In dropdown, initially hidden) ]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index d4a518f4b5..e8bcb46a5e 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { User, Folder, Wifi, Settings, Box, Sliders } from 'lucide-react'; export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; @@ -10,7 +11,11 @@ export type TabType = | 'data' | 'cloud-providers' | 'local-providers' - | 'service-status' + | 'github' + | 'gitlab' + | 'netlify' + | 'vercel' + | 'supabase' | 'connection' | 'event-logs' | 'mcp'; @@ -70,7 +75,6 @@ export const TAB_LABELS: Record = { data: 'Data Management', 'cloud-providers': 'Cloud Providers', 'local-providers': 'Local Providers', - 'service-status': 'Service Status', connection: 'Connections', 'event-logs': 'Event Logs', mcp: 'MCP Servers', @@ -85,13 +89,13 @@ export const categoryLabels: Record = { preferences: 'Preferences', }; -export const categoryIcons: Record = { - profile: 'i-ph:user-circle', - file_sharing: 'i-ph:folder-simple', - connectivity: 'i-ph:wifi-high', - system: 'i-ph:gear', - services: 'i-ph:cube', - preferences: 'i-ph:sliders', +export const categoryIcons: Record> = { + profile: User, + file_sharing: Folder, + connectivity: Wifi, + system: Settings, + services: Box, + preferences: Sliders, }; export interface Profile { diff --git a/app/components/@settings/shared/components/DraggableTabList.tsx b/app/components/@settings/shared/components/DraggableTabList.tsx index a8681835dc..fe743109c4 100644 --- a/app/components/@settings/shared/components/DraggableTabList.tsx +++ b/app/components/@settings/shared/components/DraggableTabList.tsx @@ -4,6 +4,7 @@ import { classNames } from '~/utils/classNames'; import type { TabVisibilityConfig } from '~/components/@settings/core/types'; import { TAB_LABELS } from '~/components/@settings/core/types'; import { Switch } from '~/components/ui/Switch'; +import { GripVertical } from 'lucide-react'; interface DraggableTabListProps { tabs: TabVisibilityConfig[]; @@ -86,7 +87,7 @@ const DraggableTabItem = ({ >
-
+
{TAB_LABELS[tab.id]}
diff --git a/app/components/@settings/shared/components/TabTile.tsx b/app/components/@settings/shared/components/TabTile.tsx index ebc0b19c59..65aca738b2 100644 --- a/app/components/@settings/shared/components/TabTile.tsx +++ b/app/components/@settings/shared/components/TabTile.tsx @@ -32,7 +32,7 @@ export const TabTile: React.FC = ({
-
+
= ({ onClick={onClick} className={classNames( 'relative flex flex-col items-center justify-center h-full p-4 rounded-lg', - 'bg-white dark:bg-[#141414]', + 'bg-bolt-elements-bg-depth-1', 'group cursor-pointer', - 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]', + 'hover:bg-bolt-elements-item-backgroundActive', 'transition-colors duration-100 ease-out', - isActive ? 'bg-purple-500/5 dark:bg-purple-500/10' : '', + isActive ? 'bg-bolt-elements-item-backgroundAccent/10' : '', isLoading ? 'cursor-wait opacity-70 pointer-events-none' : '', )} > @@ -62,24 +62,28 @@ export const TabTile: React.FC = ({ 'w-14 h-14', 'flex items-center justify-center', 'rounded-xl', - 'bg-gray-100 dark:bg-gray-800', - 'ring-1 ring-gray-200 dark:ring-gray-700', - 'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80', - 'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30', + 'bg-bolt-elements-bg-depth-2', + 'ring-1 ring-bolt-elements-borderColor', + 'group-hover:bg-bolt-elements-item-backgroundActive', + 'group-hover:ring-bolt-elements-borderColorActive', 'transition-all duration-100 ease-out', - isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '', + isActive ? 'bg-bolt-elements-item-backgroundAccent/20 ring-bolt-elements-borderColorActive' : '', )} > -
+ {(() => { + const IconComponent = TAB_ICONS[tab.id]; + return ( + + ); + })()}
{/* Label and Description */} @@ -87,10 +91,10 @@ export const TabTile: React.FC = ({

{TAB_LABELS[tab.id]} @@ -99,12 +103,12 @@ export const TabTile: React.FC = ({

{description} @@ -115,12 +119,13 @@ export const TabTile: React.FC = ({ {/* Update Indicator with Tooltip */} {hasUpdate && ( <> -

+
= ({ sideOffset={5} > {statusMessage} - + diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx deleted file mode 100644 index c1fae7984c..0000000000 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { motion } from 'framer-motion'; -import React, { Suspense } from 'react'; - -// Use React.lazy for dynamic imports -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 = () => ( -
-
-
- Loading connection... -
-
-); - -export default function ConnectionsTab() { - return ( -
- {/* Header */} - -
-

- Connection Settings -

- -

- Manage your external service connections and integrations -

- -
- }> - - - }> - - - }> - - - }> - - -
- - {/* Additional help text */} -
-

- - Troubleshooting Tip: -

-

- If you're having trouble with connections, here are some troubleshooting tips to help resolve common issues. -

-

For persistent issues:

-
    -
  1. Check your browser console for errors
  2. -
  3. Verify that your tokens have the correct permissions
  4. -
  5. Try clearing your browser cache and cookies
  6. -
  7. Ensure your browser allows third-party cookies if using integrations
  8. -
-
-
- ); -} diff --git a/app/components/@settings/tabs/connections/github/BranchSelectionDialog.tsx b/app/components/@settings/tabs/connections/github/BranchSelectionDialog.tsx new file mode 100644 index 0000000000..b04af3ebb9 --- /dev/null +++ b/app/components/@settings/tabs/connections/github/BranchSelectionDialog.tsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '~/components/ui/Button'; +import { X, GitBranch, Loader2 } from 'lucide-react'; +import type { GitHubRepoInfo, GitHubBranch } from '~/types/GitHub'; + +interface BranchSelectionDialogProps { + isOpen: boolean; + onClose: () => void; + repository: GitHubRepoInfo; + onClone: (repoUrl: string, branch: string) => void; + connection: any; +} + +export function BranchSelectionDialog({ + isOpen, + onClose, + repository, + onClone, + connection, +}: BranchSelectionDialogProps) { + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedBranch, setSelectedBranch] = useState(repository.default_branch || 'main'); + const [cloning, setCloning] = useState(false); + const [error, setError] = useState(null); + + // Fetch branches when dialog opens + useEffect(() => { + if (isOpen && repository) { + fetchBranches(); + } + }, [isOpen, repository]); + + const fetchBranches = async () => { + setLoading(true); + setError(null); + + try { + let branchesData: GitHubBranch[]; + + // Check if we have a client-side token + if (connection?.token) { + // Client-side connection - fetch directly from GitHub API + const apiUrl = `https://api.github.com/repos/${repository.full_name}/branches?per_page=100`; + + const response = await fetch(apiUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${connection.token}`, + 'User-Agent': 'Bolt.diy', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + + if (response.status === 404) { + throw new Error("Repository not found or you don't have access to it"); + } else if (response.status === 403) { + throw new Error('Access forbidden - check your token permissions'); + } else if (response.status === 401) { + throw new Error('Authentication failed - token may be invalid'); + } else { + throw new Error(`Failed to fetch branches: ${response.status} - ${errorText}`); + } + } + + branchesData = await response.json(); + } else { + // Server-side connection - fetch via our API + const response = await fetch('/api/github-user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'get_branches', + repo: repository.full_name, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server API error: ${response.status} - ${errorText}`); + } + + const data = (await response.json()) as { branches?: GitHubBranch[] }; + branchesData = data.branches || []; + } + + setBranches(branchesData); + + // Set default branch as selected if it exists + const defaultBranch = branchesData.find((branch: GitHubBranch) => branch.name === repository.default_branch); + + if (defaultBranch) { + setSelectedBranch(repository.default_branch); + } else if (branchesData.length > 0) { + setSelectedBranch(branchesData[0].name); + } + } catch (error) { + console.error('Error fetching branches:', error); + + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch branches'; + setError(errorMessage); + + // Fallback to default branch + setBranches([{ name: repository.default_branch || 'main', commit: { sha: '', url: '' } }]); + } finally { + setLoading(false); + } + }; + + const handleClone = async () => { + console.log('BranchSelectionDialog handleClone called with branch:', selectedBranch); + setCloning(true); + + try { + const cloneUrl = `https://github.com/${repository.full_name}.git`; + console.log('Calling onClone with:', { cloneUrl, selectedBranch }); + await onClone(cloneUrl, selectedBranch); + onClose(); + } catch (error) { + console.error('Error cloning repository:', error); + } finally { + setCloning(false); + } + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+
+
+
+
+ +
+
+

Select Branch

+

+ Choose a branch to clone from {repository.name} +

+
+
+ +
+ +
+
+ + {loading ? ( +
+ + Loading branches... +
+ ) : error ? ( +
+
+
+ Failed to load branches +
+

{error}

+

+ Using default branch: {repository.default_branch || 'main'} +

+
+ ) : ( + + )} +
+ +
+ + {branches.length} branches available +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/github/GitHubConnection.tsx b/app/components/@settings/tabs/connections/github/GitHubConnection.tsx index b76282131f..9ba8f12539 100644 --- a/app/components/@settings/tabs/connections/github/GitHubConnection.tsx +++ b/app/components/@settings/tabs/connections/github/GitHubConnection.tsx @@ -1,30 +1,20 @@ 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 { useGitHubConnection } from '~/lib/hooks/useGitHubConnection'; import { AuthDialog } from './AuthDialog'; import { StatsDisplay } from './StatsDisplay'; import { RepositoryList } from './RepositoryList'; interface GitHubConnectionProps { - onCloneRepository?: (repoUrl: string) => void; + onCloneRepository?: (repoUrl: string, branch?: 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 { connection, isConnected, isConnecting, isLoading, testConnection, disconnect } = useGitHubConnection(); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false); const [isStatsExpanded, setIsStatsExpanded] = useState(false); @@ -35,7 +25,7 @@ export default function GitHubConnection({ onCloneRepository }: GitHubConnection }; const handleDisconnect = () => { - githubConnectionStore.disconnect(); + disconnect(); setIsStatsExpanded(false); setIsReposExpanded(false); toast.success('Disconnected from GitHub'); @@ -43,20 +33,29 @@ export default function GitHubConnection({ onCloneRepository }: GitHubConnection const handleRefreshStats = async () => { try { - await githubConnectionStore.fetchStats(); - toast.success('GitHub stats refreshed'); + // For now, we'll just test the connection as a refresh + const isValid = await testConnection(); + + if (isValid) { + toast.success('GitHub connection verified'); + } else { + toast.error('GitHub connection test failed'); + } } catch (error) { - toast.error(`Failed to refresh stats: ${error instanceof Error ? error.message : 'Unknown error'}`); + toast.error(`Failed to refresh connection: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; - const handleTokenTypeChange = (tokenType: 'classic' | 'fine-grained') => { - githubConnectionStore.updateTokenType(tokenType); + const handleTokenTypeChange = (_tokenType: 'classic' | 'fine-grained') => { + // Token type changes are not currently supported in the unified connection system + toast.info('Token type changes are managed in the main GitHub settings'); }; - const handleCloneRepository = (repoUrl: string) => { + const handleCloneRepository = (repoUrl: string, branch?: string) => { + console.log('GitHubConnection handleCloneRepository called with:', { repoUrl, branch }); + if (onCloneRepository) { - onCloneRepository(repoUrl); + onCloneRepository(repoUrl, branch); } else { window.open(repoUrl, '_blank'); } @@ -74,7 +73,7 @@ export default function GitHubConnection({ onCloneRepository }: GitHubConnection

GitHub

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

@@ -85,12 +84,12 @@ export default function GitHubConnection({ onCloneRepository }: GitHubConnection <> )} - +
- + + {/* Branch Selection Dialog */} + setIsBranchDialogOpen(false)} + repository={repo} + onClone={handleClone} + connection={connection} + /> +
); } diff --git a/app/components/@settings/tabs/connections/github/RepositoryList.tsx b/app/components/@settings/tabs/connections/github/RepositoryList.tsx index ba9e6aeccb..779b42c4e0 100644 --- a/app/components/@settings/tabs/connections/github/RepositoryList.tsx +++ b/app/components/@settings/tabs/connections/github/RepositoryList.tsx @@ -5,14 +5,23 @@ import type { GitHubRepoInfo } from '~/types/GitHub'; interface RepositoryListProps { repositories: GitHubRepoInfo[]; - onClone?: (repoUrl: string) => void; + onClone?: (repoUrl: string, branch?: string) => void; onRefresh?: () => void; isRefreshing?: boolean; + showExtendedMetrics?: boolean; + connection?: any; } const MAX_REPOS_PER_PAGE = 20; -export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) { +export function RepositoryList({ + repositories, + onClone, + onRefresh, + isRefreshing, + showExtendedMetrics, + connection, +}: RepositoryListProps) { const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [isSearching, setIsSearching] = useState(false); @@ -100,7 +109,13 @@ export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing <>
{currentRepositories.map((repo) => ( - + ))}
diff --git a/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx index 7a0f2388ce..e49af20a0e 100644 --- a/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx @@ -1,761 +1,14 @@ -import React, { useState, useEffect } from 'react'; -import { toast } from 'react-toastify'; -import { classNames } from '~/utils/classNames'; -import { useStore } from '@nanostores/react'; -import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify'; -import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify'; -import { - CloudIcon, - BuildingLibraryIcon, - ClockIcon, - CodeBracketIcon, - CheckCircleIcon, - XCircleIcon, - TrashIcon, - ArrowPathIcon, - LockClosedIcon, - LockOpenIcon, - RocketLaunchIcon, -} from '@heroicons/react/24/outline'; -import { Button } from '~/components/ui/Button'; -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; -import { formatDistanceToNow } from 'date-fns'; -import { Badge } from '~/components/ui/Badge'; +import React from 'react'; -// Add the Netlify logo SVG component at the top of the file -const NetlifyLogo = () => ( - - - -); - -// Add new interface for site actions -interface SiteAction { - name: string; - icon: React.ComponentType; - action: (siteId: string) => Promise; - requiresConfirmation?: boolean; - variant?: 'default' | 'destructive' | 'outline'; +interface NetlifyConnectionProps { + onDeploySite?: (siteId: string) => void; } -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); - const [activeSiteIndex, setActiveSiteIndex] = useState(0); - const [isActionLoading, setIsActionLoading] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - - // Add site actions - const siteActions: SiteAction[] = [ - { - name: 'Clear Cache', - icon: ArrowPathIcon, - action: async (siteId: string) => { - try { - const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, { - method: 'POST', - headers: { - Authorization: `Bearer ${connection.token}`, - }, - }); - - if (!response.ok) { - throw new Error('Failed to clear cache'); - } - - toast.success('Site cache cleared successfully'); - } catch (err: unknown) { - const error = err instanceof Error ? err.message : 'Unknown error'; - toast.error(`Failed to clear site cache: ${error}`); - } - }, - }, - { - name: 'Delete Site', - icon: TrashIcon, - action: async (siteId: string) => { - try { - const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${connection.token}`, - }, - }); - - if (!response.ok) { - throw new Error('Failed to delete site'); - } - - toast.success('Site deleted successfully'); - fetchNetlifyStats(connection.token); - } catch (err: unknown) { - const error = err instanceof Error ? err.message : 'Unknown error'; - toast.error(`Failed to delete site: ${error}`); - } - }, - requiresConfirmation: true, - variant: 'destructive', - }, - ]; - - // Add deploy management functions - const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => { - try { - setIsActionLoading(true); - - const endpoint = - action === 'publish' - ? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore` - : `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${connection.token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to ${action} deploy`); - } - - toast.success(`Deploy ${action}ed successfully`); - fetchNetlifyStats(connection.token); - } catch (err: unknown) { - const error = err instanceof Error ? err.message : 'Unknown error'; - toast.error(`Failed to ${action} deploy: ${error}`); - } finally { - setIsActionLoading(false); - } - }; - - useEffect(() => { - console.log('Netlify: Running initialization useEffect'); - - // Initialize connection with environment token if available - initializeNetlifyConnection(); - }, []); - - useEffect(() => { - // Check if we have a connection with a token but no stats - if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) { - fetchNetlifyStats(connection.token); - } - - // Update local state from connection - if (connection.stats) { - setSites(connection.stats.sites || []); - setDeploys(connection.stats.deploys || []); - setBuilds(connection.stats.builds || []); - setDeploymentCount(connection.stats.deploys?.length || 0); - setLastUpdated(connection.stats.lastDeployTime || ''); - } - }, [connection]); - - const handleConnect = async () => { - if (!tokenInput) { - toast.error('Please enter a Netlify API token'); - return; - } - - setIsConnecting(true); - - try { - const response = await fetch('https://api.netlify.com/api/v1/user', { - headers: { - Authorization: `Bearer ${tokenInput}`, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const userData = (await response.json()) as NetlifyUser; - - // Update the connection store - updateNetlifyConnection({ - user: userData, - token: tokenInput, - }); - - toast.success('Connected to Netlify successfully'); - - // Fetch stats after successful connection - fetchNetlifyStats(tokenInput); - } catch (error) { - console.error('Error connecting to Netlify:', error); - toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`); - } finally { - setIsConnecting(false); - setTokenInput(''); - } - }; - - const handleDisconnect = () => { - // Clear from localStorage - localStorage.removeItem('netlify_connection'); - - // Remove cookies - document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; - - // Update the store - updateNetlifyConnection({ user: null, token: '' }); - toast.success('Disconnected from Netlify'); - }; - - const fetchNetlifyStats = async (token: string) => { - setFetchingStats(true); - - try { - // Fetch sites - const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!sitesResponse.ok) { - throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`); - } - - const sitesData = (await sitesResponse.json()) as NetlifySite[]; - setSites(sitesData); - - // Fetch recent deploys for the first site (if any) - let deploysData: NetlifyDeploy[] = []; - let buildsData: NetlifyBuild[] = []; - let lastDeployTime = ''; - - if (sitesData && sitesData.length > 0) { - const firstSite = sitesData[0]; - - // Fetch deploys - const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (deploysResponse.ok) { - deploysData = (await deploysResponse.json()) as NetlifyDeploy[]; - setDeploys(deploysData); - setDeploymentCount(deploysData.length); - - // Get the latest deploy time - if (deploysData.length > 0) { - lastDeployTime = deploysData[0].created_at; - setLastUpdated(lastDeployTime); - - // Fetch builds for the site - const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (buildsResponse.ok) { - buildsData = (await buildsResponse.json()) as NetlifyBuild[]; - setBuilds(buildsData); - } - } - } - } - - // Update the stats in the store - updateNetlifyConnection({ - stats: { - sites: sitesData, - deploys: deploysData, - builds: buildsData, - lastDeployTime, - totalSites: sitesData.length, - }, - }); - - toast.success('Netlify stats updated'); - } catch (error) { - console.error('Error fetching Netlify stats:', error); - toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`); - } finally { - setFetchingStats(false); - } - }; - - const renderStats = () => { - if (!connection.user || !connection.stats) { - return null; - } - - return ( -
- - -
-
-
- - Netlify Stats - -
-
-
- - -
-
- - - {connection.stats.totalSites} Sites - - - - {deploymentCount} Deployments - - {lastUpdated && ( - - - Updated {formatDistanceToNow(new Date(lastUpdated))} ago - - )} -
- {sites.length > 0 && ( -
-
-
-

- - Your Sites -

- -
-
- {sites.map((site, index) => ( -
{ - setActiveSiteIndex(index); - }} - > -
-
- - - {site.name} - -
-
- - {site.published_deploy?.state === 'ready' ? ( - - ) : ( - - )} - - {site.published_deploy?.state || 'Unknown'} - - -
-
- - - - {activeSiteIndex === index && ( - <> -
-
- {siteActions.map((action) => ( - - ))} -
-
- {site.published_deploy && ( -
-
- - - Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago - -
- {site.published_deploy.branch && ( -
- - - Branch: {site.published_deploy.branch} - -
- )} -
- )} - - )} -
- ))} -
-
- {activeSiteIndex !== -1 && deploys.length > 0 && ( -
-
-

- - Recent Deployments -

-
-
- {deploys.map((deploy) => ( -
-
-
- - {deploy.state === 'ready' ? ( - - ) : deploy.state === 'error' ? ( - - ) : ( - - )} - - {deploy.state} - - -
- - {formatDistanceToNow(new Date(deploy.created_at))} ago - -
- {deploy.branch && ( -
- - - Branch: {deploy.branch} - -
- )} - {deploy.deploy_url && ( - - )} -
- - {deploy.state === 'ready' ? ( - - ) : ( - - )} -
-
- ))} -
-
- )} - {activeSiteIndex !== -1 && builds.length > 0 && ( -
-
-

- - Recent Builds -

-
-
- {builds.map((build) => ( -
-
-
- - {build.done && !build.error ? ( - - ) : build.error ? ( - - ) : ( - - )} - - {build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'} - - -
- - {formatDistanceToNow(new Date(build.created_at))} ago - -
- {build.error && ( -
- - Error: {build.error} -
- )} -
- ))} -
-
- )} -
- )} -
-
- -
- ); - }; - +export default function NetlifyConnection({ onDeploySite: _onDeploySite }: NetlifyConnectionProps = {}) { return ( -
-
-
-
-
- -
-

Netlify Connection

-
-
- - {!connection.user ? ( -
- - setTokenInput(e.target.value)} - placeholder="Enter your Netlify API 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 -
- -
- {/* 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 */} - -
-
- ) : ( -
-
- - -
- Connected to Netlify - -
- {renderStats()} -
- )} -
+
+

Netlify Connection

+

Netlify connection component coming soon...

); } diff --git a/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx b/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx index b715aaccfc..daebdbe92f 100644 --- a/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx +++ b/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx @@ -1,368 +1,14 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { motion } from 'framer-motion'; -import { toast } from 'react-toastify'; -import { useStore } from '@nanostores/react'; -import { logStore } from '~/lib/stores/logs'; -import { classNames } from '~/utils/classNames'; -import { - vercelConnection, - isConnecting, - isFetchingStats, - updateVercelConnection, - fetchVercelStats, - autoConnectVercel, -} from '~/lib/stores/vercel'; +import React from 'react'; -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(() => { - // 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'); - } - }; - - initializeConnection(); - }, []); // Empty dependency array to run only once - - const handleConnect = async (event: React.FormEvent) => { - event.preventDefault(); - isConnecting.set(true); - - try { - const response = await fetch('https://api.vercel.com/v2/user', { - headers: { - Authorization: `Bearer ${connection.token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error('Invalid token or unauthorized'); - } - - const userData = (await response.json()) as any; - updateVercelConnection({ - user: userData.user || userData, // Handle both possible structures - token: connection.token, - }); - - await fetchVercelStats(connection.token); - toast.success('Successfully connected to Vercel'); - } catch (error) { - console.error('Auth error:', error); - logStore.logError('Failed to authenticate with Vercel', { error }); - toast.error('Failed to connect to Vercel'); - updateVercelConnection({ user: null, token: '' }); - } finally { - isConnecting.set(false); - } - }; - - const handleDisconnect = () => { - updateVercelConnection({ user: null, token: '' }); - toast.success('Disconnected from Vercel'); - }; - - console.log('connection', connection); +interface VercelConnectionProps { + onDeployProject?: (projectId: string) => void; +} +export default function VercelConnection({ onDeployProject: _onDeployProject }: VercelConnectionProps = {}) { return ( - -
-
-
- -

Vercel Connection

-
-
- - {!connection.user ? ( -
-
- - updateVercelConnection({ ...connection, token: e.target.value })} - disabled={connecting} - placeholder="Enter your Vercel personal 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 -
- -
-

- - 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 */} - -
-
- ) : ( -
-
-
- - -
- Connected to Vercel - -
-
- -
- {/* Debug output */} -
{JSON.stringify(connection.user, null, 2)}
- - User Avatar -
-

- {connection.user?.username || connection.user?.user?.username || 'Vercel User'} -

-

- {connection.user?.email || connection.user?.user?.email || 'No email available'} -

-
-
- - {fetchingStats ? ( -
-
- Fetching Vercel projects... -
- ) : ( -
- - {isProjectsExpanded && connection.stats?.projects?.length ? ( -
- {connection.stats.projects.map((project) => ( - -
-
-
-
- {project.name} -
-
- {project.targets?.production?.alias && project.targets.production.alias.length > 0 ? ( - <> - a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`} - target="_blank" - rel="noopener noreferrer" - className="hover:text-bolt-elements-borderColorActive" - > - {project.targets.production.alias.find( - (a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'), - ) || project.targets.production.alias[0]} - - - -
- {new Date(project.createdAt).toLocaleDateString()} - - - ) : project.latestDeployments && project.latestDeployments.length > 0 ? ( - <> - - {project.latestDeployments[0].url} - - - -
- {new Date(project.latestDeployments[0].created).toLocaleDateString()} - - - ) : null} -
-
- {project.framework && ( -
- -
- {project.framework} - -
- )} -
- - ))} -
- ) : isProjectsExpanded ? ( -
-
- No projects found in your Vercel account -
- ) : null} -
- )} -
- )} -
- +
+

Vercel Connection

+

Vercel connection component coming soon...

+
); } diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx index df42c1daf9..7ce01f0755 100644 --- a/app/components/@settings/tabs/data/DataTab.tsx +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -9,6 +9,7 @@ import { getAllChats, type Chat } from '~/lib/persistence/chats'; import { DataVisualization } from './DataVisualization'; import { classNames } from '~/utils/classNames'; import { toast } from 'react-toastify'; +import { CheckSquare } from 'lucide-react'; // Create a custom hook to connect to the boltHistory database function useBoltHistoryDB() { @@ -355,7 +356,7 @@ export function DataTab() {
-
+ Export Selected Chats diff --git a/app/components/@settings/tabs/data/DataVisualization.tsx b/app/components/@settings/tabs/data/DataVisualization.tsx index 27d2738834..893b1b4a38 100644 --- a/app/components/@settings/tabs/data/DataVisualization.tsx +++ b/app/components/@settings/tabs/data/DataVisualization.tsx @@ -321,7 +321,7 @@ export function DataVisualization({ chats }: DataVisualizationProps) { const cardClasses = classNames( 'p-6 rounded-lg shadow-sm', - 'bg-bolt-elements-bg-depth-1', + 'bg-bolt-elements-background-depth-1', 'border border-bolt-elements-borderColor', ); diff --git a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx index f3983dfcbf..7313df8ece 100644 --- a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx +++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx @@ -8,11 +8,28 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { jsPDF } from 'jspdf'; import { toast } from 'react-toastify'; +import { + Funnel, + Bot, + Cloud, + AlertCircle, + AlertTriangle, + Info, + Bug, + FileCode, + FileText, + File, + Download, + ChevronDown, + RefreshCw, + Search, + Clipboard, +} from 'lucide-react'; interface SelectOption { value: string; label: string; - icon?: string; + icon?: string | React.ComponentType<{ className?: string }>; color?: string; } @@ -20,43 +37,43 @@ const logLevelOptions: SelectOption[] = [ { value: 'all', label: 'All Types', - icon: 'i-ph:funnel', + icon: Funnel, color: '#9333ea', }, { value: 'provider', label: 'LLM', - icon: 'i-ph:robot', + icon: Bot, color: '#10b981', }, { value: 'api', label: 'API', - icon: 'i-ph:cloud', + icon: Cloud, color: '#3b82f6', }, { value: 'error', label: 'Errors', - icon: 'i-ph:warning-circle', + icon: AlertCircle, color: '#ef4444', }, { value: 'warning', label: 'Warnings', - icon: 'i-ph:warning', + icon: AlertTriangle, color: '#f59e0b', }, { value: 'info', label: 'Info', - icon: 'i-ph:info', + icon: Info, color: '#3b82f6', }, { value: 'debug', label: 'Debug', - icon: 'i-ph:bug', + icon: Bug, color: '#6b7280', }, ]; @@ -83,7 +100,7 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp const style = useMemo(() => { if (log.category === 'provider') { return { - icon: 'i-ph:robot', + icon: Bot, color: 'text-emerald-500 dark:text-emerald-400', bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20', badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10', @@ -92,7 +109,7 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp if (log.category === 'api') { return { - icon: 'i-ph:cloud', + icon: Cloud, color: 'text-blue-500 dark:text-blue-400', bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', @@ -102,28 +119,28 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp switch (log.level) { case 'error': return { - icon: 'i-ph:warning-circle', + icon: AlertCircle, color: 'text-red-500 dark:text-red-400', bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20', badge: 'text-red-500 bg-red-50 dark:bg-red-500/10', }; case 'warning': return { - icon: 'i-ph:warning', + icon: AlertTriangle, color: 'text-yellow-500 dark:text-yellow-400', bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20', badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10', }; case 'debug': return { - icon: 'i-ph:bug', + icon: Bug, color: 'text-gray-500 dark:text-gray-400', bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20', badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10', }; default: return { - icon: 'i-ph:info', + icon: Info, color: 'text-blue-500 dark:text-blue-400', bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', @@ -135,7 +152,7 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp if (log.category === 'provider') { return (
-
+
Model: {details.model} Tokens: {details.totalTokens} @@ -144,16 +161,16 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp
{details.prompt && (
-
Prompt:
-
+              
Prompt:
+
                 {details.prompt}
               
)} {details.response && (
-
Response:
-
+              
Response:
+
                 {details.response}
               
@@ -165,26 +182,26 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp if (log.category === 'api') { return (
-
+
{details.method} Status: {details.statusCode} Duration: {details.duration}ms
-
{details.url}
+
{details.url}
{details.request && (
-
Request:
-
+              
Request:
+
                 {JSON.stringify(details.request, null, 2)}
               
)} {details.response && (
-
Response:
-
+              
Response:
+
                 {JSON.stringify(details.response, null, 2)}
               
@@ -202,7 +219,7 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp } return ( -
+      
         {JSON.stringify(details, null, 2)}
       
); @@ -215,22 +232,22 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp className={classNames( 'flex flex-col gap-2', 'rounded-lg p-4', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', style.bg, 'transition-all duration-200', )} >
- +
-
{log.message}
+
{log.message}
{log.details && ( <> @@ -242,14 +259,14 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp {log.level}
{log.category && ( -
+
{log.category}
)}
- {showTimestamp && } + {showTimestamp && }
); @@ -258,7 +275,7 @@ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp interface ExportFormat { id: string; label: string; - icon: string; + icon: React.ComponentType<{ className?: string }>; handler: () => void; } @@ -763,25 +780,25 @@ export function EventLogsTab() { { id: 'json', label: 'Export as JSON', - icon: 'i-ph:file-js', + icon: FileCode, handler: exportAsJSON, }, { id: 'csv', label: 'Export as CSV', - icon: 'i-ph:file-csv', + icon: FileText, handler: exportAsCSV, }, { id: 'pdf', label: 'Export as PDF', - icon: 'i-ph:file-pdf', + icon: File, handler: exportAsPDF, }, { id: 'txt', label: 'Export as Text', - icon: 'i-ph:file-text', + icon: FileText, handler: exportAsText, }, ]; @@ -805,21 +822,21 @@ export function EventLogsTab() { className={classNames( 'group flex items-center gap-2', 'rounded-lg px-3 py-1.5', - 'text-sm text-gray-900 dark:text-white', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', - 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20', + 'text-sm text-bolt-elements-textPrimary', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'hover:bg-bolt-elements-item-backgroundActive', 'transition-all duration-200', )} > - + Export
-
+ Export Event Logs @@ -830,14 +847,14 @@ export function EventLogsTab() { onClick={() => handleFormatClick(format.handler)} className={classNames( 'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left', - 'bg-white dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', - 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]', - 'hover:border-purple-200 dark:hover:border-purple-900/30', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'hover:bg-bolt-elements-item-backgroundActive', + 'hover:border-bolt-elements-borderColorActive', 'text-bolt-elements-textPrimary', )} > -
+
{format.label}
@@ -865,25 +882,33 @@ export function EventLogsTab() { className={classNames( 'flex items-center gap-2', 'rounded-lg px-3 py-1.5', - 'text-sm text-gray-900 dark:text-white', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', - 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20', + 'text-sm text-bolt-elements-textPrimary', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'hover:bg-bolt-elements-item-backgroundActive', 'transition-all duration-200', )} > - + {(() => { + const IconComponent = selectedLevelOption?.icon; + const iconColor = selectedLevelOption?.color; + + return typeof IconComponent === 'string' ? ( + + ) : IconComponent ? ( + + ) : ( + + ); + })()} {selectedLevelOption?.label || 'All Types'} - + ( handleLevelFilterChange(option.value)} >
-
+ {typeof option.icon === 'string' ? ( +
+ ) : ( + option.icon && ( + + ) + )}
- {option.label} + + {option.label} + ))} @@ -912,7 +951,7 @@ export function EventLogsTab() { handlePreferenceChange('timestamps', value)} - className="data-[state=checked]:bg-purple-500" + className="data-[state=checked]:bg-bolt-elements-item-contentAccent" /> Show Timestamps
@@ -921,7 +960,7 @@ export function EventLogsTab() { handlePreferenceChange('24hour', value)} - className="data-[state=checked]:bg-purple-500" + className="data-[state=checked]:bg-bolt-elements-item-contentAccent" /> 24h Time
@@ -930,7 +969,7 @@ export function EventLogsTab() { handlePreferenceChange('autoExpand', value)} - className="data-[state=checked]:bg-purple-500" + className="data-[state=checked]:bg-bolt-elements-item-contentAccent" /> Auto Expand
@@ -942,15 +981,15 @@ export function EventLogsTab() { className={classNames( 'group flex items-center gap-2', 'rounded-lg px-3 py-1.5', - 'text-sm text-gray-900 dark:text-white', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', - 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20', + 'text-sm text-bolt-elements-textPrimary', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'hover:bg-bolt-elements-item-backgroundActive', 'transition-all duration-200', { 'animate-spin': isRefreshing }, )} > - + Refresh @@ -967,15 +1006,15 @@ export function EventLogsTab() { onChange={(e) => setSearchQuery(e.target.value)} className={classNames( 'w-full px-4 py-2 pl-10 rounded-lg', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', - 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400', - 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textSecondary', + 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-item-contentAccent/20 focus:border-bolt-elements-item-contentAccent', 'transition-all duration-200', )} />
-
+
@@ -986,14 +1025,14 @@ export function EventLogsTab() { className={classNames( 'flex flex-col items-center justify-center gap-4', 'rounded-lg p-8 text-center', - 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', - 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', )} > - +
-

No Logs Found

-

Try adjusting your search or filters

+

No Logs Found

+

Try adjusting your search or filters

) : ( diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx index 3b14a7565d..93466fa85f 100644 --- a/app/components/@settings/tabs/features/FeaturesTab.tsx +++ b/app/components/@settings/tabs/features/FeaturesTab.tsx @@ -6,12 +6,15 @@ import { useSettings } from '~/lib/hooks/useSettings'; import { classNames } from '~/utils/classNames'; import { toast } from 'react-toastify'; import { PromptLibrary } from '~/lib/common/prompt-library'; +import { PromptInfoCard } from '~/components/ui/PromptInfoCard'; +import { CustomPromptManager } from '~/components/@settings/tabs/prompts/CustomPromptManager'; +import { GitBranch, MousePointer, Brain, List, CheckCircle, TestTube, BookOpen, Settings } from 'lucide-react'; interface FeatureToggle { id: string; title: string; description: string; - icon: string; + icon: string | React.ComponentType<{ className?: string }>; enabled: boolean; beta?: boolean; experimental?: boolean; @@ -45,7 +48,11 @@ const FeatureCard = memo(
-
+ {typeof feature.icon === 'string' ? ( +
+ ) : ( + + )}

{feature.title}

{feature.beta && ( @@ -77,7 +84,7 @@ const FeatureSection = memo( }: { title: string; features: FeatureToggle[]; - icon: string; + icon: string | React.ComponentType<{ className?: string }>; description: string; onToggleFeature: (id: string, enabled: boolean) => void; }) => ( @@ -89,7 +96,14 @@ const FeatureSection = memo( transition={{ duration: 0.3 }} >
-
+ {(() => { + if (typeof icon === 'string') { + return
; + } else { + const IconComponent = icon; + return ; + } + })()}

{title}

{description}

@@ -135,7 +149,7 @@ export default function FeaturesTab() { } if (promptId === undefined) { - setPromptId('default'); // Default: 'default' + setPromptId('coding'); // Default: 'coding' (was 'default') } if (eventLogs === undefined) { @@ -183,7 +197,7 @@ export default function FeaturesTab() { id: 'latestBranch', title: 'Main Branch Updates', description: 'Get the latest updates from the main branch', - icon: 'i-ph:git-branch', + icon: GitBranch, enabled: isLatestBranch, tooltip: 'Enabled by default to receive updates from the main development branch', }, @@ -191,7 +205,7 @@ export default function FeaturesTab() { id: 'autoSelectTemplate', title: 'Auto Select Template', description: 'Automatically select starter template', - icon: 'i-ph:selection', + icon: MousePointer, enabled: autoSelectTemplate, tooltip: 'Enabled by default to automatically select the most appropriate starter template', }, @@ -199,7 +213,7 @@ export default function FeaturesTab() { id: 'contextOptimization', title: 'Context Optimization', description: 'Optimize context for better responses', - icon: 'i-ph:brain', + icon: Brain, enabled: contextOptimizationEnabled, tooltip: 'Enabled by default for improved AI responses', }, @@ -207,7 +221,7 @@ export default function FeaturesTab() { id: 'eventLogs', title: 'Event Logging', description: 'Enable detailed event logging and history', - icon: 'i-ph:list-bullets', + icon: List, enabled: eventLogs, tooltip: 'Enabled by default to record detailed logs of system events and user actions', }, @@ -220,7 +234,7 @@ export default function FeaturesTab() { @@ -229,66 +243,72 @@ export default function FeaturesTab() { )} + {/* Prompt Library Section */} -
-
-
+
+ +
+

Prompt Templates

+

+ Choose the AI prompt that best fits your workflow and project requirements +

-
-

- Prompt Library -

-

- Choose a prompt from the library to use as the system prompt +

+ +
+ {PromptLibrary.getInfoList().map((prompt, index) => ( + + { + setPromptId(id); + toast.success(`Switched to ${prompt.label} template`); + }} + className="h-full" + /> + + ))} +
+ + + {/* Custom Prompt Management Section */} + +
+ +
+

Custom Prompts

+

+ Create and manage your own custom AI assistant prompts for specialized workflows

-
+ +
); diff --git a/app/components/@settings/tabs/github/GitHubTab.tsx b/app/components/@settings/tabs/github/GitHubTab.tsx new file mode 100644 index 0000000000..b619fb5f02 --- /dev/null +++ b/app/components/@settings/tabs/github/GitHubTab.tsx @@ -0,0 +1,281 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useGitHubConnection, useGitHubStats } from '~/lib/hooks'; +import { LoadingState, ErrorState, ConnectionTestIndicator, RepositoryCard } from './components/shared'; +import { GitHubConnection } from './components/GitHubConnection'; +import { GitHubUserProfile } from './components/GitHubUserProfile'; +import { GitHubStats } from './components/GitHubStats'; +import { Button } from '~/components/ui/Button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { classNames } from '~/utils/classNames'; +import { ChevronDown } from 'lucide-react'; +import { GitHubErrorBoundary } from './components/GitHubErrorBoundary'; +import { GitHubProgressiveLoader } from './components/GitHubProgressiveLoader'; +import { GitHubCacheManager } from './components/GitHubCacheManager'; + +interface ConnectionTestResult { + status: 'success' | 'error' | 'testing'; + message: string; + timestamp?: number; +} + +// GitHub logo SVG component +const GithubLogo = () => ( + + + +); + +export default function GitHubTab() { + const { connection, isConnected, isLoading, error, testConnection } = useGitHubConnection(); + const { + stats, + isLoading: isStatsLoading, + error: statsError, + } = useGitHubStats( + connection, + { + autoFetch: true, + cacheTimeout: 30 * 60 * 1000, // 30 minutes + }, + isConnected && connection ? !connection.token : false, + ); // Use server-side when no token but connected + + const [connectionTest, setConnectionTest] = useState(null); + const [isStatsExpanded, setIsStatsExpanded] = useState(false); + const [isReposExpanded, setIsReposExpanded] = useState(false); + + const handleTestConnection = async () => { + if (!connection?.user) { + setConnectionTest({ + status: 'error', + message: 'No connection established', + timestamp: Date.now(), + }); + return; + } + + setConnectionTest({ + status: 'testing', + message: 'Testing connection...', + }); + + try { + const isValid = await testConnection(); + + if (isValid) { + setConnectionTest({ + status: 'success', + message: `Connected successfully as ${connection.user.login}`, + timestamp: Date.now(), + }); + } else { + setConnectionTest({ + status: 'error', + message: 'Connection test failed', + timestamp: Date.now(), + }); + } + } catch (error) { + setConnectionTest({ + status: 'error', + message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + timestamp: Date.now(), + }); + } + }; + + // Loading state for initial connection check + if (isLoading) { + return ( +
+
+ +

GitHub Integration

+
+ +
+ ); + } + + // Error state for connection issues + if (error && !connection) { + return ( +
+
+ +

GitHub Integration

+
+ window.location.reload()} + retryLabel="Reload Page" + /> +
+ ); + } + + // Not connected state + if (!isConnected || !connection) { + return ( +
+
+ +

GitHub Integration

+
+

+ Connect your GitHub account to enable advanced repository management features, statistics, and seamless + integration. +

+ +
+ ); + } + + return ( + +
+ {/* Header */} + +
+ +

+ GitHub Integration +

+
+
+ {connection?.rateLimit && ( +
+
+ + API: {connection.rateLimit.remaining}/{connection.rateLimit.limit} + +
+ )} +
+ + +

+ Manage your GitHub integration with advanced repository features and comprehensive statistics +

+ + {/* Connection Test Results */} + + + {/* Connection Component */} + + + {/* User Profile */} + {connection.user && } + + {/* Stats Section */} + + + {/* Repositories Section */} + {stats?.repos && stats.repos.length > 0 && ( + + + +
+
+
+ + All Repositories ({stats.repos.length}) + +
+ +
+ + + +
+
+ {(isReposExpanded ? stats.repos : stats.repos.slice(0, 12)).map((repo) => ( + window.open(repo.html_url, '_blank', 'noopener,noreferrer')} + /> + ))} +
+ + {stats.repos.length > 12 && !isReposExpanded && ( +
+ +
+ )} +
+
+ + + )} + + {/* Stats Error State */} + {statsError && !stats && ( + window.location.reload()} + retryLabel="Retry" + /> + )} + + {/* Stats Loading State */} + {isStatsLoading && !stats && ( + +
+ + )} + + {/* Cache Management Section - Only show when connected */} + {isConnected && connection && ( +
+ +
+ )} +
+ + ); +} diff --git a/app/components/@settings/tabs/github/GitHubTabRefactored.tsx b/app/components/@settings/tabs/github/GitHubTabRefactored.tsx new file mode 100644 index 0000000000..9a3bf1002a --- /dev/null +++ b/app/components/@settings/tabs/github/GitHubTabRefactored.tsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useGitHubConnection, useGitHubStats } from '~/lib/hooks'; +import { GitHubConnection } from './components/GitHubConnection'; +import { GitHubUserProfile } from './components/GitHubUserProfile'; +import { GitHubStats } from './components/GitHubStats'; +import { GitHubRepositories } from './components/GitHubRepositories'; + +interface ConnectionTestResult { + status: 'success' | 'error' | 'testing'; + message: string; + timestamp?: number; +} + +// GitHub logo SVG component +const GithubLogo = () => ( + + + +); + +export default function GitHubTab() { + const { connection, isConnected, testConnection } = useGitHubConnection(); + const { stats } = useGitHubStats(connection, { autoFetch: true }); + + const [connectionTest, setConnectionTest] = useState(null); + const [isStatsExpanded, setIsStatsExpanded] = useState(false); + const [isReposExpanded, setIsReposExpanded] = useState(false); + + const handleTestConnection = async () => { + if (!connection?.user) { + setConnectionTest({ + status: 'error', + message: 'No connection established', + timestamp: Date.now(), + }); + return; + } + + setConnectionTest({ + status: 'testing', + message: 'Testing connection...', + }); + + try { + const isValid = await testConnection(); + + if (isValid) { + setConnectionTest({ + status: 'success', + message: `Connected successfully as ${connection.user.login}`, + timestamp: Date.now(), + }); + } else { + setConnectionTest({ + status: 'error', + message: 'Connection test failed', + timestamp: Date.now(), + }); + } + } catch (error) { + setConnectionTest({ + status: 'error', + message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + timestamp: Date.now(), + }); + } + }; + + return ( +
+ {/* Header */} + +
+ +

+ GitHub Integration +

+
+
+ {connection?.rateLimit && ( +
+
+ + API: {connection.rateLimit.remaining}/{connection.rateLimit.limit} + +
+ )} +
+ + +

+ Connect and manage your GitHub integration with advanced repository management features +

+ + {/* Connection Test Results */} + {connectionTest && ( + +
+ {connectionTest.status === 'success' && ( +
+ )} + {connectionTest.status === 'error' && ( +
+ )} + {connectionTest.status === 'testing' && ( +
+ )} + + {connectionTest.message} + +
+ {connectionTest.timestamp && ( +

{new Date(connectionTest.timestamp).toLocaleString()}

+ )} + + )} + + {/* Connection Component */} + + + {/* User Profile & Stats */} + {isConnected && connection?.user && ( + <> + + + + + {/* Repositories Section */} + {stats?.repos && stats.repos.length > 0 && ( + + )} + + )} +
+ ); +} diff --git a/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx new file mode 100644 index 0000000000..0496929e32 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx @@ -0,0 +1,367 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; +import { Database, Trash2, RefreshCw, Clock, HardDrive, CheckCircle } from 'lucide-react'; + +interface CacheEntry { + key: string; + size: number; + timestamp: number; + lastAccessed: number; + data: any; +} + +interface CacheStats { + totalSize: number; + totalEntries: number; + oldestEntry: number; + newestEntry: number; + hitRate?: number; +} + +interface GitHubCacheManagerProps { + className?: string; + showStats?: boolean; +} + +// Cache management utilities +class CacheManagerService { + private static readonly _cachePrefix = 'github_'; + private static readonly _cacheKeys = [ + 'github_connection', + 'github_stats_cache', + 'github_repositories_cache', + 'github_user_cache', + 'github_rate_limits', + ]; + + static getCacheEntries(): CacheEntry[] { + const entries: CacheEntry[] = []; + + for (const key of this._cacheKeys) { + try { + const data = localStorage.getItem(key); + + if (data) { + const parsed = JSON.parse(data); + entries.push({ + key, + size: new Blob([data]).size, + timestamp: parsed.timestamp || Date.now(), + lastAccessed: parsed.lastAccessed || Date.now(), + data: parsed, + }); + } + } catch (error) { + console.warn(`Failed to parse cache entry: ${key}`, error); + } + } + + return entries.sort((a, b) => b.lastAccessed - a.lastAccessed); + } + + static getCacheStats(): CacheStats { + const entries = this.getCacheEntries(); + + if (entries.length === 0) { + return { + totalSize: 0, + totalEntries: 0, + oldestEntry: 0, + newestEntry: 0, + }; + } + + const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0); + const timestamps = entries.map((e) => e.timestamp); + + return { + totalSize, + totalEntries: entries.length, + oldestEntry: Math.min(...timestamps), + newestEntry: Math.max(...timestamps), + }; + } + + static clearCache(keys?: string[]): void { + const keysToRemove = keys || this._cacheKeys; + + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + } + + static clearExpiredCache(maxAge: number = 24 * 60 * 60 * 1000): number { + const entries = this.getCacheEntries(); + const now = Date.now(); + let removedCount = 0; + + for (const entry of entries) { + if (now - entry.timestamp > maxAge) { + localStorage.removeItem(entry.key); + removedCount++; + } + } + + return removedCount; + } + + static compactCache(): void { + const entries = this.getCacheEntries(); + + for (const entry of entries) { + try { + // Re-serialize with minimal data + const compacted = { + ...entry.data, + lastAccessed: Date.now(), + }; + localStorage.setItem(entry.key, JSON.stringify(compacted)); + } catch (error) { + console.warn(`Failed to compact cache entry: ${entry.key}`, error); + } + } + } + + static formatSize(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } +} + +export function GitHubCacheManager({ className = '', showStats = true }: GitHubCacheManagerProps) { + const [cacheEntries, setCacheEntries] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [lastClearTime, setLastClearTime] = useState(null); + + const refreshCacheData = useCallback(() => { + setCacheEntries(CacheManagerService.getCacheEntries()); + }, []); + + useEffect(() => { + refreshCacheData(); + }, [refreshCacheData]); + + const cacheStats = useMemo(() => CacheManagerService.getCacheStats(), [cacheEntries]); + + const handleClearAll = useCallback(async () => { + setIsLoading(true); + + try { + CacheManagerService.clearCache(); + setLastClearTime(Date.now()); + refreshCacheData(); + + // Trigger a page refresh to update all components + setTimeout(() => { + window.location.reload(); + }, 1000); + } catch (error) { + console.error('Failed to clear cache:', error); + } finally { + setIsLoading(false); + } + }, [refreshCacheData]); + + const handleClearExpired = useCallback(() => { + setIsLoading(true); + + try { + const removedCount = CacheManagerService.clearExpiredCache(); + refreshCacheData(); + + if (removedCount > 0) { + // Show success message or trigger update + console.log(`Removed ${removedCount} expired cache entries`); + } + } catch (error) { + console.error('Failed to clear expired cache:', error); + } finally { + setIsLoading(false); + } + }, [refreshCacheData]); + + const handleCompactCache = useCallback(() => { + setIsLoading(true); + + try { + CacheManagerService.compactCache(); + refreshCacheData(); + } catch (error) { + console.error('Failed to compact cache:', error); + } finally { + setIsLoading(false); + } + }, [refreshCacheData]); + + const handleClearSpecific = useCallback( + (key: string) => { + setIsLoading(true); + + try { + CacheManagerService.clearCache([key]); + refreshCacheData(); + } catch (error) { + console.error(`Failed to clear cache key: ${key}`, error); + } finally { + setIsLoading(false); + } + }, + [refreshCacheData], + ); + + if (!showStats && cacheEntries.length === 0) { + return null; + } + + return ( +
+
+
+ +

GitHub Cache Management

+
+ +
+ +
+
+ + {showStats && ( +
+
+
+ + Total Size +
+

+ {CacheManagerService.formatSize(cacheStats.totalSize)} +

+
+ +
+
+ + Entries +
+

{cacheStats.totalEntries}

+
+ +
+
+ + Oldest +
+

+ {cacheStats.oldestEntry ? new Date(cacheStats.oldestEntry).toLocaleDateString() : 'N/A'} +

+
+ +
+
+ + Status +
+

+ {cacheStats.totalEntries > 0 ? 'Active' : 'Empty'} +

+
+
+ )} + + {cacheEntries.length > 0 && ( +
+

+ Cache Entries ({cacheEntries.length}) +

+ +
+ {cacheEntries.map((entry) => ( +
+
+

+ {entry.key.replace('github_', '')} +

+

+ {CacheManagerService.formatSize(entry.size)} • {new Date(entry.lastAccessed).toLocaleString()} +

+
+ + +
+ ))} +
+
+ )} + +
+ + + + + {cacheEntries.length > 0 && ( + + )} +
+ + {lastClearTime && ( +
+ + Cache cleared successfully at {new Date(lastClearTime).toLocaleTimeString()} +
+ )} +
+ ); +} diff --git a/app/components/@settings/tabs/github/components/GitHubConnection.tsx b/app/components/@settings/tabs/github/components/GitHubConnection.tsx new file mode 100644 index 0000000000..f82406f3a9 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubConnection.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; +import { useGitHubConnection } from '~/lib/hooks'; + +interface ConnectionTestResult { + status: 'success' | 'error' | 'testing'; + message: string; + timestamp?: number; +} + +interface GitHubConnectionProps { + connectionTest: ConnectionTestResult | null; + onTestConnection: () => void; +} + +export function GitHubConnection({ connectionTest, onTestConnection }: GitHubConnectionProps) { + const { isConnected, isLoading, isConnecting, connect, disconnect, error } = useGitHubConnection(); + + const [token, setToken] = React.useState(''); + const [tokenType, setTokenType] = React.useState<'classic' | 'fine-grained'>('classic'); + + const handleConnect = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!token.trim()) { + return; + } + + try { + await connect(token, tokenType); + setToken(''); // Clear token on successful connection + } catch { + // Error handling is done in the hook + } + }; + + if (isLoading) { + return ( +
+
+
+ Loading connection... +
+
+ ); + } + + return ( + +
+ {!isConnected && ( +
+

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

+
+ )} + +
+
+
+ + +
+ +
+ + setToken(e.target.value)} + disabled={isConnecting || isConnected} + placeholder={`Enter your GitHub ${ + 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:{' '} + {tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'} + +
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {!isConnected ? ( + + ) : ( +
+
+ + +
+ Connected to GitHub + +
+
+ + +
+
+ )} +
+ +
+ + ); +} diff --git a/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx new file mode 100644 index 0000000000..531f682ee3 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx @@ -0,0 +1,105 @@ +import React, { Component } from 'react'; +import type { ReactNode, ErrorInfo } from 'react'; +import { Button } from '~/components/ui/Button'; +import { AlertTriangle } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class GitHubErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('GitHub Error Boundary caught an error:', error, errorInfo); + + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ +
+ +
+

GitHub Integration Error

+

+ Something went wrong while loading GitHub data. This could be due to network issues, API limits, or a + temporary problem. +

+ + {this.state.error && ( +
+ Show error details +
+                  {this.state.error.message}
+                
+
+ )} +
+ +
+ + +
+
+ ); + } + + return this.props.children; + } +} + +// Higher-order component for wrapping components with error boundary +export function withGitHubErrorBoundary

(component: React.ComponentType

) { + return function WrappedComponent(props: P) { + return {React.createElement(component, props)}; + }; +} + +// Hook for handling async errors in GitHub operations +export function useGitHubErrorHandler() { + const handleError = React.useCallback((error: unknown, context?: string) => { + console.error(`GitHub Error ${context ? `(${context})` : ''}:`, error); + + /* + * You could integrate with error tracking services here + * For example: Sentry, LogRocket, etc. + */ + + return error instanceof Error ? error.message : 'An unknown error occurred'; + }, []); + + return { handleError }; +} diff --git a/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx new file mode 100644 index 0000000000..7f28ee16e0 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx @@ -0,0 +1,266 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; +import { Loader2, ChevronDown, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'; + +interface ProgressiveLoaderProps { + isLoading: boolean; + isRefreshing?: boolean; + error?: string | null; + onRetry?: () => void; + onRefresh?: () => void; + children: React.ReactNode; + className?: string; + loadingMessage?: string; + refreshingMessage?: string; + showProgress?: boolean; + progressSteps?: Array<{ + key: string; + label: string; + completed: boolean; + loading?: boolean; + error?: boolean; + }>; +} + +export function GitHubProgressiveLoader({ + isLoading, + isRefreshing = false, + error, + onRetry, + onRefresh, + children, + className = '', + loadingMessage = 'Loading...', + refreshingMessage = 'Refreshing...', + showProgress = false, + progressSteps = [], +}: ProgressiveLoaderProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Calculate progress percentage + const progress = useMemo(() => { + if (!showProgress || progressSteps.length === 0) { + return 0; + } + + const completed = progressSteps.filter((step) => step.completed).length; + + return Math.round((completed / progressSteps.length) * 100); + }, [showProgress, progressSteps]); + + const handleToggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + // Loading state with progressive steps + if (isLoading) { + return ( +

+
+ + {showProgress && progress > 0 && ( +
+ {progress}% +
+ )} +
+ +
+

{loadingMessage}

+ + {showProgress && progressSteps.length > 0 && ( +
+ {/* Progress bar */} +
+ +
+ + {/* Steps toggle */} + + + {/* Progress steps */} + + {isExpanded && ( + + {progressSteps.map((step) => ( +
+ {step.error ? ( + + ) : step.completed ? ( + + ) : step.loading ? ( + + ) : ( +
+ )} + + {step.label} + +
+ ))} + + )} + +
+ )} +
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+ +
+ +
+

Failed to Load

+

{error}

+
+ +
+ {onRetry && ( + + )} + {onRefresh && ( + + )} +
+
+ ); + } + + // Success state - render children with optional refresh indicator + return ( +
+ {isRefreshing && ( +
+
+ + {refreshingMessage} +
+
+ )} + + {children} +
+ ); +} + +// Hook for managing progressive loading steps +export function useProgressiveLoader() { + const [steps, setSteps] = useState< + Array<{ + key: string; + label: string; + completed: boolean; + loading?: boolean; + error?: boolean; + }> + >([]); + + const addStep = useCallback((key: string, label: string) => { + setSteps((prev) => [ + ...prev.filter((step) => step.key !== key), + { key, label, completed: false, loading: false, error: false }, + ]); + }, []); + + const updateStep = useCallback( + ( + key: string, + updates: { + completed?: boolean; + loading?: boolean; + error?: boolean; + label?: string; + }, + ) => { + setSteps((prev) => prev.map((step) => (step.key === key ? { ...step, ...updates } : step))); + }, + [], + ); + + const removeStep = useCallback((key: string) => { + setSteps((prev) => prev.filter((step) => step.key !== key)); + }, []); + + const clearSteps = useCallback(() => { + setSteps([]); + }, []); + + const startStep = useCallback( + (key: string) => { + updateStep(key, { loading: true, error: false }); + }, + [updateStep], + ); + + const completeStep = useCallback( + (key: string) => { + updateStep(key, { completed: true, loading: false, error: false }); + }, + [updateStep], + ); + + const errorStep = useCallback( + (key: string) => { + updateStep(key, { error: true, loading: false }); + }, + [updateStep], + ); + + return { + steps, + addStep, + updateStep, + removeStep, + clearSteps, + startStep, + completeStep, + errorStep, + }; +} diff --git a/app/components/@settings/tabs/github/components/GitHubRepositories.tsx b/app/components/@settings/tabs/github/components/GitHubRepositories.tsx new file mode 100644 index 0000000000..b2fc4b51a9 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubRepositories.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import type { GitHubRepoInfo } from '~/types/GitHub'; + +interface GitHubRepositoriesProps { + repositories: GitHubRepoInfo[]; + isExpanded: boolean; + onToggleExpanded: (expanded: boolean) => void; +} + +export function GitHubRepositories({ repositories, isExpanded, onToggleExpanded }: GitHubRepositoriesProps) { + if (!repositories || repositories.length === 0) { + return null; + } + + const displayedRepos = isExpanded ? repositories : repositories.slice(0, 12); + + return ( +
+
+

All Repositories ({repositories.length})

+ {repositories.length > 12 && ( + + )} +
+
+ {displayedRepos.map((repo) => ( + + ))} +
+
+ ); +} + +interface RepositoryCardProps { + repo: GitHubRepoInfo; +} + +function RepositoryCard({ repo }: RepositoryCardProps) { + const daysSinceUpdate = Math.floor((Date.now() - new Date(repo.updated_at).getTime()) / (1000 * 60 * 60 * 24)); + + const isHealthy = daysSinceUpdate < 30 && !repo.archived && repo.stargazers_count > 0; + const isActive = daysSinceUpdate < 7; + const healthColor = repo.archived + ? 'bg-gray-500' + : isActive + ? 'bg-green-500' + : isHealthy + ? 'bg-blue-500' + : 'bg-yellow-500'; + const healthTitle = repo.archived ? 'Archived' : isActive ? 'Very Active' : isHealthy ? 'Healthy' : 'Needs Attention'; + + const formatTimeAgo = () => { + if (daysSinceUpdate === 0) { + return 'Today'; + } + + if (daysSinceUpdate === 1) { + return '1 day ago'; + } + + if (daysSinceUpdate < 7) { + return `${daysSinceUpdate} days ago`; + } + + if (daysSinceUpdate < 30) { + return `${Math.floor(daysSinceUpdate / 7)} weeks ago`; + } + + return new Date(repo.updated_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const calculateHealthScore = () => { + const hasStars = repo.stargazers_count > 0; + const hasRecentActivity = daysSinceUpdate < 30; + const hasContributors = (repo.contributors_count || 0) > 1; + const hasDescription = !!repo.description; + const hasTopics = (repo.topics || []).length > 0; + const hasLicense = !!repo.license; + + const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter( + Boolean, + ).length; + + const maxScore = 6; + const percentage = Math.round((healthScore / maxScore) * 100); + + const getScoreColor = (score: number) => { + if (score >= 5) { + return 'text-green-500'; + } + + if (score >= 3) { + return 'text-yellow-500'; + } + + return 'text-red-500'; + }; + + return { + percentage, + color: getScoreColor(healthScore), + score: healthScore, + maxScore, + }; + }; + + const health = calculateHealthScore(); + + return ( + + {/* Repository Health Indicator */} +
+ +
+
+
+
+
+ {repo.name} +
+ {repo.fork && ( +
+ )} + {repo.archived && ( +
+ )} +
+
+ +
+ {repo.stargazers_count.toLocaleString()} + + +
+ {repo.forks_count.toLocaleString()} + + {repo.issues_count !== undefined && ( + +
+ {repo.issues_count} + + )} + {repo.pull_requests_count !== undefined && ( + +
+ {repo.pull_requests_count} + + )} +
+
+ +
+ {repo.description && ( +

{repo.description}

+ )} + + {/* Repository metrics bar */} +
+ {repo.license && ( + + {repo.license.spdx_id || repo.license.name} + + )} + {repo.topics && + repo.topics.slice(0, 2).map((topic) => ( + + {topic} + + ))} + {repo.archived && ( + + Archived + + )} + {repo.fork && ( + + Fork + + )} +
+
+ +
+
+ +
+ {repo.default_branch} + + {repo.branches_count && ( + +
+ {repo.branches_count} + + )} + {repo.contributors_count && ( + +
+ {repo.contributors_count} + + )} + {repo.size && ( + +
+ {(repo.size / 1024).toFixed(1)}MB + + )} + +
+ {formatTimeAgo()} + + {repo.topics && repo.topics.length > 0 && ( + +
+ {repo.topics.length} + + )} +
+ + {/* Repository Health Score */} +
+
+
+ {health.percentage}% +
+ + +
+ View + +
+
+
+ + ); +} diff --git a/app/components/@settings/tabs/github/components/GitHubStats.tsx b/app/components/@settings/tabs/github/components/GitHubStats.tsx new file mode 100644 index 0000000000..4b7d8fbf72 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubStats.tsx @@ -0,0 +1,291 @@ +import React from 'react'; +import { Button } from '~/components/ui/Button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { classNames } from '~/utils/classNames'; +import { useGitHubStats } from '~/lib/hooks'; +import type { GitHubConnection, GitHubStats as GitHubStatsType } from '~/types/GitHub'; +import { GitHubErrorBoundary } from './GitHubErrorBoundary'; + +interface GitHubStatsProps { + connection: GitHubConnection; + isExpanded: boolean; + onToggleExpanded: (expanded: boolean) => void; +} + +export function GitHubStats({ connection, isExpanded, onToggleExpanded }: GitHubStatsProps) { + const { stats, isLoading, isRefreshing, refreshStats, isStale } = useGitHubStats( + connection, + { + autoFetch: true, + cacheTimeout: 30 * 60 * 1000, // 30 minutes + }, + !connection?.token, + ); // Use server-side if no token + + return ( + + + + ); +} + +function GitHubStatsContent({ + stats, + isLoading, + isRefreshing, + refreshStats, + isStale, + isExpanded, + onToggleExpanded, +}: { + stats: GitHubStatsType | null; + isLoading: boolean; + isRefreshing: boolean; + refreshStats: () => Promise; + isStale: boolean; + isExpanded: boolean; + onToggleExpanded: (expanded: boolean) => void; +}) { + if (!stats) { + return ( +
+
+
+ {isLoading ? ( + <> +
+ Loading GitHub stats... + + ) : ( + No stats available + )} +
+
+
+ ); + } + + return ( +
+ + +
+
+
+ + GitHub Stats + {isStale && (Stale)} + +
+
+ +
+
+
+ + + +
+ {/* Languages Section */} +
+

Top Languages

+ {stats.mostUsedLanguages && stats.mostUsedLanguages.length > 0 ? ( +
+
+ {stats.mostUsedLanguages.slice(0, 15).map(({ language, bytes, repos }) => ( + + {language} ({repos}) + + ))} +
+
+ Based on actual codebase size across repositories +
+
+ ) : ( +
+ {Object.entries(stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+ )} +
+ + {/* GitHub Overview Summary */} +
+

GitHub Overview

+
+
+
+ {(stats.publicRepos || 0) + (stats.privateRepos || 0)} +
+
Total Repositories
+
+
+
{stats.totalBranches || 0}
+
Total Branches
+
+
+
+ {stats.organizations?.length || 0} +
+
Organizations
+
+
+
+ {Object.keys(stats.languages).length} +
+
Languages Used
+
+
+
+ + {/* Activity Summary */} +
+
Activity Summary
+
+ {[ + { + label: 'Total Branches', + value: stats.totalBranches || 0, + icon: 'i-ph:git-branch', + iconColor: 'text-bolt-elements-icon-info', + }, + { + label: 'Contributors', + value: stats.totalContributors || 0, + icon: 'i-ph:users', + iconColor: 'text-bolt-elements-icon-success', + }, + { + label: 'Issues', + value: stats.totalIssues || 0, + icon: 'i-ph:circle', + iconColor: 'text-bolt-elements-icon-warning', + }, + { + label: 'Pull Requests', + value: stats.totalPullRequests || 0, + icon: 'i-ph:git-pull-request', + iconColor: 'text-bolt-elements-icon-accent', + }, + ].map((stat, index) => ( +
+ {stat.label} + +
+ {stat.value.toLocaleString()} + +
+ ))} +
+
+ + {/* Organizations Section */} + {stats.organizations && stats.organizations.length > 0 && ( +
+
Organizations
+
+ {stats.organizations.map((org) => ( + + {org.login} +
+
+ {org.name || org.login} +
+

{org.login}

+ {org.description && ( +

{org.description}

+ )} +
+
+ )} + + {/* Last Updated */} +
+ + Last updated: {stats.lastUpdated ? new Date(stats.lastUpdated).toLocaleString() : 'Never'} + +
+
+ + +
+ ); +} diff --git a/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx new file mode 100644 index 0000000000..fd56860080 --- /dev/null +++ b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { GitHubUserResponse } from '~/types/GitHub'; + +interface GitHubUserProfileProps { + user: GitHubUserResponse; + className?: string; +} + +export function GitHubUserProfile({ user, className = '' }: GitHubUserProfileProps) { + return ( +
+ {user.login} +
+

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

+

@{user.login}

+ {user.bio && ( +

+ {user.bio} +

+ )} +
+ +
+ {user.followers} followers + + +
+ {user.public_repos} public repos + + +
+ {user.public_gists} gists + +
+
+
+ ); +} diff --git a/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx b/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx new file mode 100644 index 0000000000..a827beb9de --- /dev/null +++ b/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { Input } from '~/components/ui'; +import { classNames } from '~/utils/classNames'; +import { Plus, Lock, Globe } from 'lucide-react'; + +interface NewRepositoryFormProps { + repoName: string; + onRepoNameChange: (name: string) => void; + isPrivate: boolean; + onPrivateChange: (isPrivate: boolean) => void; + description?: string; + onDescriptionChange?: (description: string) => void; + isSubmitting?: boolean; + onSubmit: (e: React.FormEvent) => void; + error?: string; + className?: string; +} + +export function NewRepositoryForm({ + repoName, + onRepoNameChange, + isPrivate, + onPrivateChange, + description, + onDescriptionChange, + isSubmitting = false, + onSubmit, + error, + className = '', +}: NewRepositoryFormProps) { + /* + * Validation function for future use + * const isValidRepoName = (name: string) => { + * if (!name) { + * return false; + * } + * if (name.length < 1 || name.length > 100) { + * return false; + * } + * // GitHub repository name validation + * return /^[a-zA-Z0-9._-]+$/.test(name); + * }; + */ + + const validateRepoName = (name: string) => { + if (!name) { + return 'Repository name is required'; + } + + if (name.length < 1) { + return 'Repository name cannot be empty'; + } + + if (name.length > 100) { + return 'Repository name cannot exceed 100 characters'; + } + + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + return 'Repository name can only contain alphanumeric characters, periods, hyphens, and underscores'; + } + + if (name.startsWith('.') || name.endsWith('.')) { + return 'Repository name cannot start or end with a period'; + } + + if (name.startsWith('-') || name.endsWith('-')) { + return 'Repository name cannot start or end with a hyphen'; + } + + return null; + }; + + const nameError = validateRepoName(repoName); + const canSubmit = !nameError && repoName.trim() !== '' && !isSubmitting; + + return ( +
+ {error && ( +
+

{error}

+
+ )} + +
+ + onRepoNameChange(e.target.value)} + placeholder="my-awesome-project" + disabled={isSubmitting} + className={classNames(nameError && repoName ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : '')} + /> + {nameError && repoName &&

{nameError}

} + {!nameError && repoName && ( +

✓ Valid repository name

+ )} +
+ + {onDescriptionChange && ( +
+ + onDescriptionChange(e.target.value)} + placeholder="A brief description of your project" + disabled={isSubmitting} + /> +
+ )} + +
+ +
+ + + +
+
+ + +
+ ); +} diff --git a/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx b/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx new file mode 100644 index 0000000000..e471e9e524 --- /dev/null +++ b/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { motion } from 'framer-motion'; +import { Button } from '~/components/ui/Button'; +import { formatSize } from '~/utils/formatSize'; +import { CheckCircle, ExternalLink, Copy, Files } from 'lucide-react'; +import { toast } from 'react-toastify'; + +interface PushedFile { + path: string; + size: number; +} + +interface PushSuccessDialogProps { + isOpen: boolean; + onClose: () => void; + repositoryUrl: string; + repositoryName?: string; + pushedFiles: PushedFile[]; +} + +export function PushSuccessDialog({ + isOpen, + onClose, + repositoryUrl, + repositoryName, + pushedFiles, +}: PushSuccessDialogProps) { + const totalFiles = pushedFiles.length; + const totalSize = pushedFiles.reduce((sum, file) => sum + file.size, 0); + + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(repositoryUrl); + toast.success('Repository URL copied to clipboard'); + } catch (error) { + console.error('Failed to copy URL:', error); + toast.error('Failed to copy URL'); + } + }; + + const handleOpenRepository = () => { + window.open(repositoryUrl, '_blank', 'noopener,noreferrer'); + }; + + return ( + !open && onClose()}> + + +
+ + + {/* Header */} +
+
+
+ +
+
+ + Successfully Pushed to GitHub! + + + Your project has been pushed to{' '} + {repositoryName && {repositoryName}} + +
+
+
+ + {/* Repository URL */} +
+
+ + {repositoryUrl} + +
+
+ + {/* Statistics */} +
+
+

+ + Push Summary +

+
+
+
{totalFiles}
+
Files Pushed
+
+
+
{formatSize(totalSize)}
+
Total Size
+
+
+
+
+ + {/* File List */} + {pushedFiles.length > 0 && ( +
+

+ Pushed Files ({pushedFiles.length}) +

+
+ {pushedFiles.slice(0, 10).map((file, index) => ( +
+ + {file.path} + + + {formatSize(file.size)} + +
+ ))} + {pushedFiles.length > 10 && ( +
+ ... and {pushedFiles.length - 10} more files +
+ )} +
+
+ )} + + {/* Actions */} +
+
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx b/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx new file mode 100644 index 0000000000..f932c96701 --- /dev/null +++ b/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx @@ -0,0 +1,263 @@ +import React, { useState, useEffect } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { motion } from 'framer-motion'; +import { X, Github, Plus, Folder } from 'lucide-react'; +import { useGitHubConnection, useRepositoryPush } from '~/lib/hooks'; +import { RepositorySelector } from './RepositorySelector'; +import { NewRepositoryForm } from './NewRepositoryForm'; +import { PushSuccessDialog } from './PushSuccessDialog'; +import { classNames } from '~/utils/classNames'; +import type { GitHubRepoInfo } from '~/types/GitHub'; + +interface PushToGitHubDialogProps { + isOpen: boolean; + onClose: () => void; + onPush?: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise; +} + +type TabType = 'existing' | 'new'; + +export function PushToGitHubDialogRefactored({ isOpen, onClose, onPush }: PushToGitHubDialogProps) { + const { connection, isConnected } = useGitHubConnection(); + const { + recentRepos, + filteredRepos, + isFetchingRepos, + isLoading, + error, + showSuccessDialog, + createdRepoUrl, + pushedFiles, + pushToRepository, + pushToExistingRepository, + filterRepos, + closeSuccessDialog, + resetState, + } = useRepositoryPush(connection); + + const [activeTab, setActiveTab] = useState('existing'); + const [repoName, setRepoName] = useState(''); + const [description, setDescription] = useState(''); + const [isPrivate, setIsPrivate] = useState(false); + + // Reset form when dialog closes + useEffect(() => { + if (!isOpen) { + setRepoName(''); + setDescription(''); + setIsPrivate(false); + resetState(); + } + }, [isOpen, resetState]); + + // Switch to new repo tab if no repositories available + useEffect(() => { + if (!isFetchingRepos && recentRepos.length === 0 && activeTab === 'existing') { + setActiveTab('new'); + } + }, [isFetchingRepos, recentRepos.length, activeTab]); + + const handleCreateRepository = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!repoName.trim()) { + return; + } + + try { + await pushToRepository({ + repoName: repoName.trim(), + isPrivate, + description: description.trim() || undefined, + }); + + // Call the optional onPush callback for backward compatibility + if (onPush) { + await onPush(repoName, connection?.user?.login, connection?.token, isPrivate); + } + } catch { + // Error handling is done in the hook + } + }; + + const handleExistingRepoSelect = async (repo: GitHubRepoInfo) => { + try { + await pushToExistingRepository(repo); + + // Call the optional onPush callback for backward compatibility + if (onPush) { + await onPush(repo.name, connection?.user?.login, connection?.token, repo.private); + } + } catch { + // Error handling is done in the hook + } + }; + + const handleDialogClose = () => { + if (!isLoading) { + onClose(); + } + }; + + if (!isConnected || !connection) { + return ( + + + +
+ +
+ +

GitHub Connection Required

+

+ Please connect your GitHub account to push repositories. +

+ +
+
+
+
+
+ ); + } + + const repositoryName = createdRepoUrl ? createdRepoUrl.split('/').pop() : undefined; + + return ( + <> + + + +
+ + + {/* Header */} +
+
+ +
+ + Push to GitHub + + + Push your project to a GitHub repository + +
+
+ + + +
+ + {/* Tab Navigation */} +
+ + +
+ + {/* Content */} +
+
+ {activeTab === 'existing' && ( + + )} + + {activeTab === 'new' && ( + + )} +
+
+ + {/* Footer info */} +
+
+ + Connected as {connection.user?.login} + {connection.rateLimit && ( + <> + + + API: {connection.rateLimit.remaining}/{connection.rateLimit.limit} + + + )} +
+
+
+
+
+
+
+ + {/* Success Dialog */} + + + ); +} diff --git a/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx b/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx new file mode 100644 index 0000000000..9fb5d326e6 --- /dev/null +++ b/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { motion } from 'framer-motion'; +import { X, Github, Folder, Search, Link, ArrowLeft } from 'lucide-react'; +import { Button } from '~/components/ui/Button'; +import { Input } from '~/components/ui'; +import { useGitHubConnection, useRepositorySearch } from '~/lib/hooks'; +import { RepositorySelector } from './RepositorySelector'; +import { classNames } from '~/utils/classNames'; +import type { GitHubRepoInfo } from '~/types/GitHub'; + +interface RepositorySelectionDialogProps { + isOpen: boolean; + onClose: () => void; + onSelect: (url: string) => void; +} + +export function RepositorySelectionDialogRefactored({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { + const { connection, isConnected } = useGitHubConnection(); + const { + myRepositories, + searchResults, + selectedRepository, + searchQuery, + activeTab, + branches, + selectedBranch, + customUrl, + isLoadingRepos, + isSearching, + isLoadingBranches, + error, + filteredRepositories, + canSelectRepository, + setSearchQuery, + setActiveTab, + setCustomUrl, + setSelectedBranch, + selectRepository, + searchRepositories, + clearSelection, + } = useRepositorySearch(connection); + + const handleRepositorySelect = async (repo: GitHubRepoInfo) => { + await selectRepository(repo); + }; + + const handleSearch = async () => { + if (searchQuery.trim()) { + await searchRepositories(); + } + }; + + const handleConfirmSelection = () => { + if (activeTab === 'url') { + // Convert GitHub web URL to Git clone URL if needed + let url = customUrl; + + if (url.includes('github.com') && url.includes('/tree/')) { + // Extract owner/repo from URL like: https://github.com/owner/repo/tree/branch + const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/); + + if (match) { + url = `https://github.com/${match[1]}.git`; + } + } else if (url.includes('github.com') && !url.endsWith('.git')) { + // Add .git suffix if missing + url = url.replace(/\/$/, '') + '.git'; + } + + onSelect(url); + + return; + } + + if (selectedRepository && selectedBranch) { + console.log('Selected repository data:', selectedRepository); + console.log('Selected branch:', selectedBranch); + + // Use clone_url which is the proper Git URL, or construct from html_url as fallback + let url = selectedRepository.clone_url; + + if (!url) { + // Fallback: construct clone URL from html_url + url = selectedRepository.html_url.replace(/\/$/, '') + '.git'; + console.log('No clone_url found, constructed URL:', url); + } else { + console.log('Using clone_url:', url); + } + + console.log('Final URL to select:', url); + onSelect(url); + } else { + console.log('Missing repository or branch:', { selectedRepository, selectedBranch }); + } + }; + + const handleDialogClose = () => { + clearSelection(); + onClose(); + }; + + if (!isConnected || !connection) { + return ( + + + +
+ +
+ +

GitHub Connection Required

+

+ Please connect your GitHub account to browse repositories. +

+ +
+
+
+
+
+ ); + } + + return ( + + + +
+ + + {/* Header */} +
+
+ +
+ + Import from GitHub + + + Select a repository to import into your workspace + +
+
+ + + +
+ + {/* Tab Navigation */} +
+ + + +
+ + {/* Content */} +
+
+ {/* Repository List */} +
+
+ {activeTab === 'search' && ( +
+ setSearchQuery(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+ )} + + {activeTab === 'url' && ( + setCustomUrl(e.target.value)} + /> + )} +
+ +
+ {activeTab !== 'url' && ( + undefined} // Search is handled above + /> + )} + + {activeTab === 'url' && ( +
+ +

+ Import from Custom URL +

+

+ Enter a GitHub repository URL to import it directly +

+
+ )} +
+
+ + {/* Repository Details */} + {(selectedRepository || (activeTab === 'url' && customUrl)) && ( +
+ {selectedRepository && ( + <> +
+ +

Repository Details

+
+ +
+
+

+ {selectedRepository.name} +

+

{selectedRepository.full_name}

+ {selectedRepository.description && ( +

+ {selectedRepository.description} +

+ )} +
+ +
+ + {isLoadingBranches ? ( +
Loading branches...
+ ) : ( + + )} +
+
+ + )} + + {activeTab === 'url' && customUrl && ( +
+

Custom URL Import

+
+

URL: {customUrl}

+
+
+ )} +
+ )} +
+
+ + {/* Footer */} +
+
+
Connected as {connection.user?.login}
+
+ + +
+
+
+ + {error && ( +
+

{error}

+
+ )} +
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/github/components/RepositorySelector.tsx b/app/components/@settings/tabs/github/components/RepositorySelector.tsx new file mode 100644 index 0000000000..cfb19fa0a1 --- /dev/null +++ b/app/components/@settings/tabs/github/components/RepositorySelector.tsx @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import { SearchInput, Badge, EmptyState, StatusIndicator } from '~/components/ui'; +import { Search, Folder, Star, GitFork, Clock, Lock } from 'lucide-react'; +import type { GitHubRepoInfo } from '~/types/GitHub'; +import { formatSize } from '~/utils/formatSize'; + +interface RepositorySelectorProps { + repositories: GitHubRepoInfo[]; + filteredRepositories: GitHubRepoInfo[]; + isLoading: boolean; + onRepositorySelect: (repo: GitHubRepoInfo) => void; + onSearch: (query: string) => void; + className?: string; +} + +export function RepositorySelector({ + repositories, + filteredRepositories, + isLoading, + onRepositorySelect, + onSearch, + className = '', +}: RepositorySelectorProps) { + const [searchQuery, setSearchQuery] = useState(''); + + const handleSearch = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchQuery(value); + onSearch(value); + }; + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (repositories.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {filteredRepositories.length === 0 ? ( + + ) : ( +
+ {filteredRepositories.map((repo) => ( + onRepositorySelect(repo)} /> + ))} +
+ )} + +
+ Showing {filteredRepositories.length} of {repositories.length} repositories +
+
+ ); +} + +interface RepositoryItemProps { + repository: GitHubRepoInfo; + onSelect: () => void; +} + +function RepositoryItem({ repository, onSelect }: RepositoryItemProps) { + const formatTimeAgo = (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 <= 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', + }); + }; + + return ( + + ); +} diff --git a/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx b/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx new file mode 100644 index 0000000000..f7660b52ed --- /dev/null +++ b/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { motion } from 'framer-motion'; +import { X, Github } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +interface GitHubDialogProps { + isOpen: boolean; + onClose: () => void; + title: string; + description?: string; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; + footer?: React.ReactNode; + preventClose?: boolean; + className?: string; +} + +const sizeClasses = { + sm: 'max-w-md', + md: 'max-w-2xl', + lg: 'max-w-4xl', + xl: 'max-w-6xl', +}; + +export function GitHubDialog({ + isOpen, + onClose, + title, + description, + children, + size = 'md', + footer, + preventClose = false, + className = '', +}: GitHubDialogProps) { + const handleClose = () => { + if (!preventClose) { + onClose(); + } + }; + + return ( + + + +
+ + + {/* Header */} +
+
+ +
+ {title} + {description && ( + + {description} + + )} +
+
+ {!preventClose && ( + + + + )} +
+ + {/* Content */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+
+
+
+ ); +} + +interface GitHubDialogContentProps { + children: React.ReactNode; + className?: string; +} + +export function GitHubDialogContent({ children, className = '' }: GitHubDialogContentProps) { + return
{children}
; +} + +interface GitHubDialogFooterProps { + children: React.ReactNode; + className?: string; +} + +export function GitHubDialogFooter({ children, className = '' }: GitHubDialogFooterProps) { + return
{children}
; +} diff --git a/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx new file mode 100644 index 0000000000..c36fa09c0f --- /dev/null +++ b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { Loader2, AlertCircle, CheckCircle, Info, Github } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +interface LoadingStateProps { + message?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function LoadingState({ message = 'Loading...', size = 'md', className = '' }: LoadingStateProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + const textSizeClasses = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }; + + return ( +
+ +

{message}

+
+ ); +} + +interface ErrorStateProps { + title?: string; + message: string; + onRetry?: () => void; + retryLabel?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function ErrorState({ + title = 'Error', + message, + onRetry, + retryLabel = 'Try Again', + size = 'md', + className = '', +}: ErrorStateProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + const textSizeClasses = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }; + + return ( +
+ +

{title}

+

{message}

+ {onRetry && ( + + )} +
+ ); +} + +interface SuccessStateProps { + title?: string; + message: string; + onAction?: () => void; + actionLabel?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function SuccessState({ + title = 'Success', + message, + onAction, + actionLabel = 'Continue', + size = 'md', + className = '', +}: SuccessStateProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + const textSizeClasses = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }; + + return ( +
+ +

{title}

+

{message}

+ {onAction && ( + + )} +
+ ); +} + +interface GitHubConnectionRequiredProps { + onConnect?: () => void; + className?: string; +} + +export function GitHubConnectionRequired({ onConnect, className = '' }: GitHubConnectionRequiredProps) { + return ( +
+ +

GitHub Connection Required

+

+ Please connect your GitHub account to access this feature. You'll be able to browse repositories, push code, and + manage your GitHub integration. +

+ {onConnect && ( + + )} +
+ ); +} + +interface InformationStateProps { + title: string; + message: string; + icon?: React.ComponentType<{ className?: string }>; + onAction?: () => void; + actionLabel?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function InformationState({ + title, + message, + icon = Info, + onAction, + actionLabel = 'Got it', + size = 'md', + className = '', +}: InformationStateProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + const textSizeClasses = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }; + + return ( +
+ {React.createElement(icon, { className: classNames('text-blue-500 mb-2', sizeClasses[size]) })} +

{title}

+

{message}

+ {onAction && ( + + )} +
+ ); +} + +interface ConnectionTestIndicatorProps { + status: 'success' | 'error' | 'testing' | null; + message?: string; + timestamp?: number; + className?: string; +} + +export function ConnectionTestIndicator({ status, message, timestamp, className = '' }: ConnectionTestIndicatorProps) { + if (!status) { + return null; + } + + const getStatusColor = () => { + switch (status) { + case 'success': + return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700'; + case 'error': + return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700'; + case 'testing': + return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700'; + default: + return 'bg-gray-50 border-gray-200 dark:bg-gray-900/20 dark:border-gray-700'; + } + }; + + const getStatusIcon = () => { + switch (status) { + case 'success': + return ; + case 'error': + return ; + case 'testing': + return ; + default: + return ; + } + }; + + const getStatusTextColor = () => { + switch (status) { + case 'success': + return 'text-green-800 dark:text-green-200'; + case 'error': + return 'text-red-800 dark:text-red-200'; + case 'testing': + return 'text-blue-800 dark:text-blue-200'; + default: + return 'text-gray-800 dark:text-gray-200'; + } + }; + + return ( +
+
+ {getStatusIcon()} + {message || status} +
+ {timestamp &&

{new Date(timestamp).toLocaleString()}

} +
+ ); +} diff --git a/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx new file mode 100644 index 0000000000..f0ff7fa130 --- /dev/null +++ b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx @@ -0,0 +1,361 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; +import { formatSize } from '~/utils/formatSize'; +import type { GitHubRepoInfo } from '~/types/GitHub'; +import { + Star, + GitFork, + Clock, + Lock, + Archive, + GitBranch, + Users, + Database, + Tag, + Heart, + ExternalLink, + Circle, + GitPullRequest, +} from 'lucide-react'; + +interface RepositoryCardProps { + repository: GitHubRepoInfo; + variant?: 'default' | 'compact' | 'detailed'; + onSelect?: () => void; + showHealthScore?: boolean; + showExtendedMetrics?: boolean; + className?: string; +} + +export function RepositoryCard({ + repository, + variant = 'default', + onSelect, + showHealthScore = false, + showExtendedMetrics = false, + className = '', +}: RepositoryCardProps) { + const daysSinceUpdate = Math.floor((Date.now() - new Date(repository.updated_at).getTime()) / (1000 * 60 * 60 * 24)); + + const formatTimeAgo = () => { + if (daysSinceUpdate === 0) { + return 'Today'; + } + + if (daysSinceUpdate === 1) { + return '1 day ago'; + } + + if (daysSinceUpdate < 7) { + return `${daysSinceUpdate} days ago`; + } + + if (daysSinceUpdate < 30) { + return `${Math.floor(daysSinceUpdate / 7)} weeks ago`; + } + + return new Date(repository.updated_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const calculateHealthScore = () => { + const hasStars = repository.stargazers_count > 0; + const hasRecentActivity = daysSinceUpdate < 30; + const hasContributors = (repository.contributors_count || 0) > 1; + const hasDescription = !!repository.description; + const hasTopics = (repository.topics || []).length > 0; + const hasLicense = !!repository.license; + + const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter( + Boolean, + ).length; + + const maxScore = 6; + const percentage = Math.round((healthScore / maxScore) * 100); + + const getScoreColor = (score: number) => { + if (score >= 5) { + return 'text-green-500'; + } + + if (score >= 3) { + return 'text-yellow-500'; + } + + return 'text-red-500'; + }; + + return { + percentage, + color: getScoreColor(healthScore), + score: healthScore, + maxScore, + }; + }; + + const getHealthIndicatorColor = () => { + const isActive = daysSinceUpdate < 7; + const isHealthy = daysSinceUpdate < 30 && !repository.archived && repository.stargazers_count > 0; + + if (repository.archived) { + return 'bg-gray-500'; + } + + if (isActive) { + return 'bg-green-500'; + } + + if (isHealthy) { + return 'bg-blue-500'; + } + + return 'bg-yellow-500'; + }; + + const getHealthTitle = () => { + if (repository.archived) { + return 'Archived'; + } + + if (daysSinceUpdate < 7) { + return 'Very Active'; + } + + if (daysSinceUpdate < 30 && repository.stargazers_count > 0) { + return 'Healthy'; + } + + return 'Needs Attention'; + }; + + const health = showHealthScore ? calculateHealthScore() : null; + + if (variant === 'compact') { + return ( + + ); + } + + const Component = onSelect ? 'button' : 'div'; + const interactiveProps = onSelect + ? { + onClick: onSelect, + className: classNames( + 'group cursor-pointer hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200', + className, + ), + } + : { className }; + + return ( + + {/* Repository Health Indicator */} + {variant === 'detailed' && ( +
+ )} + +
+
+
+ +
+ {repository.name} +
+ {repository.fork && ( + + + + )} + {repository.archived && ( + + + + )} +
+
+ + + {repository.stargazers_count.toLocaleString()} + + + + {repository.forks_count.toLocaleString()} + + {showExtendedMetrics && repository.issues_count !== undefined && ( + + + {repository.issues_count} + + )} + {showExtendedMetrics && repository.pull_requests_count !== undefined && ( + + + {repository.pull_requests_count} + + )} +
+
+ +
+ {repository.description && ( +

{repository.description}

+ )} + + {/* Repository metrics bar */} +
+ {repository.license && ( + + {repository.license.spdx_id || repository.license.name} + + )} + {repository.topics && + repository.topics.slice(0, 2).map((topic) => ( + + {topic} + + ))} + {repository.archived && ( + + Archived + + )} + {repository.fork && ( + + Fork + + )} +
+
+ +
+
+ + + {repository.default_branch} + + {showExtendedMetrics && repository.branches_count && ( + + + {repository.branches_count} + + )} + {showExtendedMetrics && repository.contributors_count && ( + + + {repository.contributors_count} + + )} + {repository.size && ( + + + {(repository.size / 1024).toFixed(1)}MB + + )} + + + {formatTimeAgo()} + + {repository.topics && repository.topics.length > 0 && ( + + + {repository.topics.length} + + )} +
+ +
+ {/* Repository Health Score */} + {health && ( +
+ + {health.percentage}% +
+ )} + + {onSelect && ( + + + View + + )} +
+
+
+ + ); +} diff --git a/app/components/@settings/tabs/github/components/shared/index.ts b/app/components/@settings/tabs/github/components/shared/index.ts new file mode 100644 index 0000000000..df3ffa33f7 --- /dev/null +++ b/app/components/@settings/tabs/github/components/shared/index.ts @@ -0,0 +1,10 @@ +export { RepositoryCard } from './RepositoryCard'; +export { GitHubDialog, GitHubDialogContent, GitHubDialogFooter } from './GitHubDialog'; +export { + LoadingState, + ErrorState, + SuccessState, + GitHubConnectionRequired, + InformationState, + ConnectionTestIndicator, +} from './GitHubStateIndicators'; diff --git a/app/components/@settings/tabs/gitlab/GitLabTab.tsx b/app/components/@settings/tabs/gitlab/GitLabTab.tsx new file mode 100644 index 0000000000..ff992c13b7 --- /dev/null +++ b/app/components/@settings/tabs/gitlab/GitLabTab.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useGitLabConnection } from '~/lib/stores/gitlabConnection'; +import GitLabConnection from '~/components/@settings/tabs/connections/gitlab/GitLabConnection'; +import { Button } from '~/components/ui/Button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { classNames } from '~/utils/classNames'; +import { ChevronDown } from 'lucide-react'; + +// GitLab logo SVG component +const GitLabLogo = () => ( + + + +); + +export default function GitLabTab() { + const { connection, isConnected, stats } = useGitLabConnection(); + const [isReposExpanded, setIsReposExpanded] = useState(false); + + // Loading state for initial connection check + if (!connection && !isConnected) { + return ( +
+
+ +

GitLab Integration

+
+
+
+
+ Loading... +
+
+
+ ); + } + + return ( +
+ {/* Header */} + +
+ +

+ GitLab Integration +

+
+
+ {connection.get()?.rateLimit && ( +
+
+ + API: {connection.get()?.rateLimit?.remaining}/{connection.get()?.rateLimit?.limit} + +
+ )} +
+ + +

+ Manage your GitLab integration with advanced repository features and comprehensive statistics +

+ + {/* Connection Test Results */} + {/* connectionTest && ( +
+
+
+ {connectionTest.status === 'success' ? ( +
+ ) : connectionTest.status === 'error' ? ( +
+ ) : ( +
+ )} +
+ + {connectionTest.message} + +
+
+ ) */} + + {/* GitLab Connection Component */} + + + {/* Repositories Section */} + {isConnected && stats.get() && stats.get()?.projects && stats.get()!.projects!.length > 0 && ( + + + +
+
+
+ + All Repositories ({stats.get()?.projects?.length}) + +
+ +
+ + + +
+
+ {(isReposExpanded ? stats.get()?.projects : stats.get()?.projects?.slice(0, 12))?.map((repo: any) => ( +
+
+
+

{repo.name}

+

{repo.path_with_namespace}

+ {repo.description && ( +

+ {repo.description} +

+ )} +
+
+
+
+ +
+ {repo.star_count} + + +
+ {repo.forks_count} + +
+ +
+
+ ))} +
+ + {stats.get()?.projects && stats.get()!.projects!.length > 12 && !isReposExpanded && ( +
+ +
+ )} +
+ + + + )} +
+ ); +} diff --git a/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx b/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx new file mode 100644 index 0000000000..3cfe2adffb --- /dev/null +++ b/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx @@ -0,0 +1,865 @@ +import { useState } from 'react'; +import { Switch } from '~/components/ui/Switch'; +import { Button } from '~/components/ui/Button'; + +// Helper function to sanitize config for UI display +function sanitizeConfigForDisplay(config: any): any { + const sanitized = { ...config }; + + // Sanitize environment variables + if (sanitized.env) { + sanitized.env = {}; + + for (const [key, value] of Object.entries(config.env)) { + if (isSensitiveEnvVar(key)) { + sanitized.env[key] = '[REDACTED]'; + } else { + sanitized.env[key] = value; + } + } + } + + // Sanitize headers + if (sanitized.headers) { + sanitized.headers = {}; + + for (const [key, value] of Object.entries(config.headers)) { + if (isSensitiveHeader(key)) { + sanitized.headers[key] = '[REDACTED]'; + } else { + sanitized.headers[key] = value; + } + } + } + + return sanitized; +} + +function isSensitiveEnvVar(key: string): boolean { + const sensitivePatterns = [ + /api[_-]?key/i, + /secret/i, + /token/i, + /password/i, + /auth/i, + /credential/i, + /bearer/i, + /authorization/i, + /private[_-]?key/i, + /access[_-]?token/i, + /refresh[_-]?token/i, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); +} + +function isSensitiveHeader(key: string): boolean { + const sensitivePatterns = [/authorization/i, /api[_-]?key/i, /bearer/i, /token/i, /secret/i]; + return sensitivePatterns.some((pattern) => pattern.test(key)); +} +import { Badge } from '~/components/ui/Badge'; +import { Input } from '~/components/ui/Input'; +import { Label } from '~/components/ui/Label'; +import { Textarea } from '~/components/ui/Textarea'; +import { ConfirmationDialog, Dialog, DialogTitle } from '~/components/ui/Dialog'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import McpStatusBadge from './McpStatusBadge'; +import McpServerListItem from './McpServerListItem'; +import { classNames } from '~/utils/classNames'; +import { ChevronDown, ChevronRight, Trash2, Settings, Play, Pause, AlertTriangle } from 'lucide-react'; +import type { MCPServer } from '~/lib/services/mcpService'; + +// Helper function to detect server type based on name and config +const detectServerType = (serverName: string, config: any): string | null => { + // Try to match by server name first + const lowerName = serverName.toLowerCase(); + + if (lowerName.includes('github')) { + return 'github'; + } + + if (lowerName.includes('slack')) { + return 'slack'; + } + + if (lowerName.includes('gdrive') || lowerName.includes('google-drive')) { + return 'gdrive'; + } + + if (lowerName.includes('postgres')) { + return 'postgres'; + } + + if (lowerName.includes('brave')) { + return 'brave-search'; + } + + if (lowerName.includes('kubernetes') || lowerName.includes('k8s')) { + return 'kubernetes'; + } + + if (lowerName.includes('elasticsearch') || lowerName.includes('elastic')) { + return 'elasticsearch'; + } + + if (lowerName.includes('weather')) { + return 'weather'; + } + + if (lowerName.includes('sentry')) { + return 'sentry'; + } + + if (lowerName.includes('twitter') || lowerName.includes('x-')) { + return 'twitter'; + } + + if (lowerName.includes('reddit')) { + return 'reddit'; + } + + if (lowerName.includes('youtube') || lowerName.includes('yt-')) { + return 'youtube'; + } + + if (lowerName.includes('openai') || lowerName.includes('gpt')) { + return 'openai'; + } + + if (lowerName.includes('anthropic') || lowerName.includes('claude')) { + return 'anthropic'; + } + + if (lowerName.includes('perplexity')) { + return 'perplexity'; + } + + if (lowerName.includes('replicate')) { + return 'replicate'; + } + + if (lowerName.includes('mem0') || lowerName.includes('coding')) { + return 'mem0-coding'; + } + + if (lowerName.includes('devstandards') || lowerName.includes('standards')) { + return 'devstandards'; + } + + if (lowerName.includes('code-runner') || lowerName.includes('coderunner')) { + return 'code-runner'; + } + + if (lowerName.includes('vscode')) { + return 'vscode-mcp'; + } + + if (lowerName.includes('xcode')) { + return 'xcode-mcp'; + } + + if (lowerName.includes('context7') || lowerName.includes('documentation')) { + return 'context7-docs'; + } + + if (lowerName.includes('shrimp') || lowerName.includes('task-manager')) { + return 'shrimp-task-manager'; + } + + if (lowerName.includes('sonarqube') || lowerName.includes('sonar')) { + return 'sonarqube'; + } + + if (lowerName.includes('semgrep') || lowerName.includes('security')) { + return 'semgrep-security'; + } + + if (lowerName.includes('jupyter') || lowerName.includes('notebook')) { + return 'jupyter-mcp'; + } + + if (lowerName.includes('git-ingest') || lowerName.includes('git-analyzer')) { + return 'mcp-git-ingest'; + } + + // Try to match by command args for STDIO servers + if (config.type === 'stdio' && config.args) { + const argsStr = config.args.join(' ').toLowerCase(); + + if (argsStr.includes('server-github')) { + return 'github'; + } + + if (argsStr.includes('server-slack')) { + return 'slack'; + } + + if (argsStr.includes('server-gdrive')) { + return 'gdrive'; + } + + if (argsStr.includes('server-postgres')) { + return 'postgres'; + } + + if (argsStr.includes('server-sqlite')) { + return 'sqlite'; + } + + if (argsStr.includes('server-git')) { + return 'git'; + } + + if (argsStr.includes('hacker-news-mcp')) { + return 'hacker-news'; + } + + if (argsStr.includes('qdrant')) { + return 'qdrant'; + } + + if (argsStr.includes('@mem0ai/mem0-mcp')) { + return 'mem0-coding'; + } + + if (argsStr.includes('@ivangrynenko/devstandards_mcp')) { + return 'devstandards'; + } + + if (argsStr.includes('@axliupore/mcp-code-runner')) { + return 'code-runner'; + } + + if (argsStr.includes('@juehang/vscode-mcp-server')) { + return 'vscode-mcp'; + } + + if (argsStr.includes('@r-huijts/xcode-mcp-server')) { + return 'xcode-mcp'; + } + + if (argsStr.includes('@context7/server')) { + return 'context7-docs'; + } + + if (argsStr.includes('@cjo4m06/mcp-shrimp-task-manager')) { + return 'shrimp-task-manager'; + } + + if (argsStr.includes('@sonarsource/sonarqube-mcp')) { + return 'sonarqube'; + } + + if (argsStr.includes('@semgrep/mcp-server')) { + return 'semgrep-security'; + } + + if (argsStr.includes('@datalayer/jupyter-mcp-server')) { + return 'jupyter-mcp'; + } + + if (argsStr.includes('@adhikasp/mcp-git-ingest')) { + return 'mcp-git-ingest'; + } + + if (argsStr.includes('@modelcontextprotocol/server-brave-search')) { + return 'brave-search'; + } + } + + // Try to match by URL for HTTP servers + if (config.url) { + // Add URL-based detections here if needed + } + + return null; +}; + +// API key configurations (should match McpServerWizard) +const SERVER_API_CONFIGS: Record< + string, + Record +> = { + github: { + GITHUB_PERSONAL_ACCESS_TOKEN: { + label: 'GitHub Personal Access Token', + placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx', + description: 'Create at github.com/settings/tokens with repo and read:user scopes', + required: true, + }, + }, + slack: { + SLACK_BOT_TOKEN: { + label: 'Slack Bot Token', + placeholder: 'xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from your Slack app settings (OAuth & Permissions)', + required: true, + }, + }, + gdrive: { + GOOGLE_CLIENT_ID: { + label: 'Google Client ID', + placeholder: 'xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com', + description: 'From Google Cloud Console OAuth 2.0 credentials', + required: true, + }, + GOOGLE_CLIENT_SECRET: { + label: 'Google Client Secret', + placeholder: 'GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'From Google Cloud Console OAuth 2.0 credentials', + required: true, + }, + }, + postgres: { + DATABASE_URL: { + label: 'Database URL', + placeholder: 'postgresql://username:password@hostname:port/database', + description: 'PostgreSQL connection string', + required: true, + }, + }, + 'brave-search': { + BRAVE_API_KEY: { + label: 'Brave Search API Key', + placeholder: 'BSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get your API key from https://api.search.brave.com/app/keys', + required: true, + }, + }, + kubernetes: { + KUBECONFIG: { + label: 'Kubeconfig Path', + placeholder: '~/.kube/config', + description: 'Path to your Kubernetes configuration file', + required: true, + }, + }, + elasticsearch: { + ELASTICSEARCH_URL: { + label: 'Elasticsearch URL', + placeholder: 'http://localhost:9200', + description: 'Elasticsearch cluster URL', + required: true, + }, + ELASTICSEARCH_API_KEY: { + label: 'Elasticsearch API Key', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Elasticsearch API key for authentication', + required: true, + }, + }, + weather: { + OPENWEATHER_API_KEY: { + label: 'OpenWeatherMap API Key', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get your API key from openweathermap.org', + required: true, + }, + }, + sentry: { + SENTRY_AUTH_TOKEN: { + label: 'Sentry Auth Token', + placeholder: 'sntrys_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Create at sentry.io/settings/account/api/auth-tokens', + required: true, + }, + }, + twitter: { + TWITTER_BEARER_TOKEN: { + label: 'Twitter Bearer Token', + placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from Twitter Developer Portal (API v2)', + required: true, + }, + }, + reddit: { + REDDIT_CLIENT_ID: { + label: 'Reddit Client ID', + placeholder: 'your_client_id_here', + description: 'From Reddit App Settings', + required: true, + }, + REDDIT_CLIENT_SECRET: { + label: 'Reddit Client Secret', + placeholder: 'your_client_secret_here', + description: 'From Reddit App Settings', + required: true, + }, + }, + youtube: { + YOUTUBE_API_KEY: { + label: 'YouTube API Key', + placeholder: 'AIzaSyAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from Google Cloud Console (YouTube Data API v3)', + required: true, + }, + }, + openai: { + OPENAI_API_KEY: { + label: 'OpenAI API Key', + placeholder: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from OpenAI Platform (platform.openai.com/api-keys)', + required: true, + }, + }, + anthropic: { + ANTHROPIC_API_KEY: { + label: 'Anthropic API Key', + placeholder: 'sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from Anthropic Console (console.anthropic.com/)', + required: true, + }, + }, + perplexity: { + PERPLEXITY_API_KEY: { + label: 'Perplexity API Key', + placeholder: 'pplx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from Perplexity API dashboard', + required: true, + }, + }, + replicate: { + REPLICATE_API_TOKEN: { + label: 'Replicate API Token', + placeholder: 'r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Get from Replicate account settings', + required: true, + }, + }, + sonarqube: { + SONARQUBE_URL: { + label: 'SonarQube Server URL', + placeholder: 'http://localhost:9000', + description: 'URL of your SonarQube server', + required: true, + }, + SONARQUBE_TOKEN: { + label: 'SonarQube Authentication Token', + placeholder: 'squ_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Generate from SonarQube: Administration > Security > Users', + required: true, + }, + }, +}; + +interface EnhancedMcpServerListItemProps { + serverName: string; + mcpServer: MCPServer; + isEnabled: boolean; + isExpanded: boolean; + isCheckingServers: boolean; + onToggleExpanded: (serverName: string) => void; + onToggleEnabled: (serverName: string, enabled: boolean) => void; + onRemoveServer: (serverName: string) => void; + onEditServer?: (serverName: string, config: any) => void; +} + +export default function EnhancedMcpServerListItem({ + serverName, + mcpServer, + isEnabled, + isExpanded, + isCheckingServers, + onToggleExpanded, + onToggleEnabled, + onRemoveServer, + onEditServer, +}: EnhancedMcpServerListItemProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [isToggling, setIsToggling] = useState(false); + const [editedConfig, setEditedConfig] = useState(mcpServer.config); + const [apiKeys, setApiKeys] = useState>({}); + + const isAvailable = mcpServer.status === 'available'; + const serverTools = isAvailable ? Object.entries(mcpServer.tools) : []; + const canConnect = + mcpServer.config.type === 'streamable-http' || mcpServer.config.type === 'sse' + ? !!mcpServer.config.url + : !!mcpServer.config.command; + + const handleToggleEnabled = async (enabled: boolean) => { + if (isToggling) { + return; + } + + setIsToggling(true); + + try { + await onToggleEnabled(serverName, enabled); + } finally { + setIsToggling(false); + } + }; + + const handleRemoveServer = () => { + onRemoveServer(serverName); + setShowDeleteConfirm(false); + }; + + const handleEditServer = () => { + setEditedConfig(mcpServer.config); + + // Extract API keys from existing config + const existingKeys: Record = {}; + + if (mcpServer.config.type === 'stdio') { + const stdioConfig = mcpServer.config as { type: 'stdio'; env?: Record }; + const serverType = detectServerType(serverName, mcpServer.config); + + if (serverType && SERVER_API_CONFIGS[serverType] && stdioConfig.env) { + Object.keys(SERVER_API_CONFIGS[serverType]).forEach((key) => { + if (stdioConfig.env && stdioConfig.env[key]) { + existingKeys[key] = stdioConfig.env[key]; + } + }); + } + } + + setApiKeys(existingKeys); + + setShowEditDialog(true); + }; + + const handleSaveConfig = () => { + if (onEditServer) { + // Merge API keys into config if it's a STDIO server + let finalConfig = { ...editedConfig }; + const serverType = detectServerType(serverName, editedConfig); + + if ( + editedConfig.type === 'stdio' && + serverType && + SERVER_API_CONFIGS[serverType] && + Object.keys(apiKeys).length > 0 + ) { + const stdioConfig = editedConfig as { + type: 'stdio'; + command: string; + env?: Record; + cwd?: string; + args?: string[]; + }; + finalConfig = { + ...stdioConfig, + env: { + ...stdioConfig.env, + ...Object.fromEntries(Object.entries(apiKeys).filter(([_, value]) => value.trim() !== '')), + }, + }; + } + + onEditServer(serverName, finalConfig); + } + + setShowEditDialog(false); + }; + + const handleApiKeyChange = (key: string, value: string) => { + setApiKeys((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const handleConfigChange = (field: string, value: any) => { + setEditedConfig((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const getServerTypeInfo = () => { + const { type } = mcpServer.config; + const typeColors = { + stdio: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20', + sse: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/20', + 'streamable-http': 'bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20', + }; + + return { + color: + typeColors[type] || + 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor', + label: type.toUpperCase(), + }; + }; + + const typeInfo = getServerTypeInfo(); + + return ( +
+
+ {/* Header Row */} +
+
+ {/* Expand/Collapse Button */} + + + {/* Server Name & Info */} +
+
+

{serverName}

+ + {typeInfo.label} + + {!canConnect && ( + + + Config Error + + )} +
+ +
+ {mcpServer.config.type === 'stdio' ? ( + + {mcpServer.config.command} {mcpServer.config.args?.join(' ')} + + ) : ( + {mcpServer.config.url} + )} +
+
+
+ + {/* Controls */} +
+ {/* Status Badge */} + {isCheckingServers ? ( + + ) : ( + + )} + + {/* Tool Count */} + {isAvailable && ( + + {serverTools.length} tool{serverTools.length !== 1 ? 's' : ''} + + )} + + {/* Enable/Disable Toggle */} +
+ + {isEnabled ? : } +
+ + {/* Actions */} +
+ + +
+
+
+ + {/* Error Message */} + {!isAvailable && mcpServer.error && ( +
+

+ Error: {mcpServer.error} +

+
+ )} + + {/* Expanded Content */} + {isExpanded && ( +
+ {isAvailable ? ( +
+

+ Available Tools ({serverTools.length}) +

+ {serverTools.length === 0 ? ( +

No tools available from this server

+ ) : ( +
+ {serverTools.map(([toolName, toolSchema]) => ( + + ))} +
+ )} +
+ ) : ( +
+

+ Server is not available. Check the configuration and ensure the server is running. +

+
+ )} + + {/* Server Configuration Preview */} +
+ + View Configuration + +
+
+                  {JSON.stringify(sanitizeConfigForDisplay(mcpServer.config), null, 2)}
+                
+
+
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + setShowDeleteConfirm(false)} + onConfirm={handleRemoveServer} + title={`Remove ${serverName}?`} + description="This will permanently remove the MCP server configuration. This action cannot be undone." + confirmLabel="Remove Server" + variant="destructive" + /> + + {/* Edit Server Dialog */} + + +
+ + + Edit Server: {serverName} + + +
+ {editedConfig.type === 'stdio' ? ( + <> +
+ + handleConfigChange('command', e.target.value)} + placeholder="e.g., npx, node, python" + /> +
+
+ +