Compare commits
1 Commits
slate-rebr
...
revert-133
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67702202af |
157
.cursorrules
Normal file
157
.cursorrules
Normal file
@@ -0,0 +1,157 @@
|
||||
# Project Overview
|
||||
|
||||
bolt.diy (previously oTToDev) is an open-source AI-powered full-stack web development platform that allows users to choose different LLM providers for coding assistance. The project supports multiple AI providers including OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, and Groq.
|
||||
|
||||
# Personality
|
||||
|
||||
- Professional and technically precise
|
||||
- Focus on best practices and clean code
|
||||
- Provide clear explanations for code changes
|
||||
- Maintain consistent code style with the existing codebase
|
||||
|
||||
# Techstack
|
||||
|
||||
- Framework: Remix
|
||||
- Runtime: Node.js (>=18.18.0)
|
||||
- Package Manager: pnpm
|
||||
- UI: React with TypeScript
|
||||
- Styling: UnoCSS
|
||||
- Development Environment: Vite
|
||||
- Testing: Vitest
|
||||
- Deployment: Cloudflare Pages
|
||||
- Containerization: Docker
|
||||
- Code Quality: ESLint, Prettier, TypeScript
|
||||
|
||||
# our .env file
|
||||
|
||||
- Follow .env.example for required environment variables
|
||||
- Keep API keys and sensitive data in .env.local
|
||||
- Never commit .env files (they are gitignored)
|
||||
|
||||
# Error Fixing Process
|
||||
|
||||
1. Identify the root cause through error messages and logs
|
||||
2. Check relevant components and dependencies
|
||||
3. Verify type safety and TypeScript compliance
|
||||
4. Test changes locally before committing
|
||||
5. Follow existing error handling patterns
|
||||
|
||||
# Our Codebase
|
||||
|
||||
- Main application code in /app directory
|
||||
- Components follow a modular structure
|
||||
- Server-side code in app/lib/.server
|
||||
- Client-side utilities in app/lib/
|
||||
- Type definitions in types/ directory
|
||||
- Documentation in docs/ directory
|
||||
|
||||
# Current File Structure
|
||||
|
||||
- /app - Main application code
|
||||
- /docs - Documentation
|
||||
- /functions - Serverless functions
|
||||
- /public - Static assets
|
||||
- /scripts - Build and utility scripts
|
||||
- /types - TypeScript definitions
|
||||
- /icons - SVG icons and assets
|
||||
|
||||
# github upload process
|
||||
|
||||
1. Follow conventional commit messages
|
||||
2. Run linting and tests before committing
|
||||
3. Create feature branches for new work
|
||||
4. Submit PRs with clear descriptions
|
||||
5. Ensure CI/CD checks pass
|
||||
|
||||
# Important
|
||||
|
||||
- Keep dependencies updated
|
||||
- Follow TypeScript strict mode
|
||||
- Maintain backward compatibility
|
||||
- Document API changes
|
||||
- Test cross-browser compatibility
|
||||
|
||||
# comments
|
||||
|
||||
- Use JSDoc for function documentation
|
||||
- Keep comments clear and concise
|
||||
- Document complex logic and business rules
|
||||
- Update comments when changing code
|
||||
- Remove redundant comments
|
||||
- Always write comments that are relevant to the code they describe
|
||||
- Ensure comments explain the "why" not just the "what"
|
||||
|
||||
# code review
|
||||
|
||||
- Check for type safety
|
||||
- Verify error handling
|
||||
- Ensure code follows project patterns
|
||||
- Look for performance implications
|
||||
- Validate accessibility standards
|
||||
|
||||
# code writing
|
||||
|
||||
- Follow TypeScript best practices
|
||||
- Use functional components for React
|
||||
- Implement proper error boundaries
|
||||
- Write testable code
|
||||
- Follow the DRY principle
|
||||
|
||||
# code refactoring
|
||||
|
||||
- Maintain backward compatibility
|
||||
- Update tests alongside changes
|
||||
- Document breaking changes
|
||||
- Follow the project's type system
|
||||
- Keep components modular and reusable
|
||||
|
||||
# Development Process
|
||||
|
||||
- Write 3 reasoning paragraphs before implementing solutions
|
||||
- Analyze the problem space thoroughly before jumping to conclusions
|
||||
- Consider all edge cases and potential impacts
|
||||
- Process tasks with a Senior Developer mindset
|
||||
- Continue working until the solution is complete and verified
|
||||
- Remember and consider the full commit/change history when working
|
||||
|
||||
# Code Quality Guidelines
|
||||
|
||||
- Fewer lines of code is better, but not at the expense of readability
|
||||
- Preserve existing comments and documentation
|
||||
- Add meaningful comments explaining complex logic or business rules
|
||||
- Follow the principle of "Clean Code, Clear Intent"
|
||||
- Balance between conciseness and maintainability
|
||||
- Think twice, code once - avoid premature optimization
|
||||
- Never add comments just for the sake of commenting - ensure they add value
|
||||
|
||||
# Problem Solving Approach
|
||||
|
||||
1. Understand the context fully before making changes
|
||||
2. Document your reasoning and assumptions
|
||||
3. Consider alternative approaches and their trade-offs
|
||||
4. Validate your solution against existing patterns
|
||||
5. Test thoroughly before considering work complete
|
||||
6. Review impact on related components
|
||||
|
||||
# UI GUIDELINES
|
||||
|
||||
- Use consistent colors and typography
|
||||
- Ensure UI is responsive and accessible
|
||||
- Provide clear feedback for user actions
|
||||
- Use meaningful icons and labels
|
||||
- Keep UI clean and organized
|
||||
- Use consistent spacing and alignment
|
||||
- Use consistent naming conventions for components and variables
|
||||
- Use consistent file and folder structure
|
||||
- Use consistent naming conventions for components and variables
|
||||
- Use consistent file and folder structure
|
||||
|
||||
# Style Guide
|
||||
|
||||
- Use consistent naming conventions for components and variables
|
||||
- Use consistent file and folder structure
|
||||
- Respect the Light/Dark mode
|
||||
- Don't use white background for dark mode
|
||||
- Don't use white text on white background for dark mode
|
||||
- Match the style of the existing codebase
|
||||
- Use consistent naming conventions for components and variables
|
||||
16
.env.example
16
.env.example
@@ -97,22 +97,6 @@ AWS_BEDROCK_CONFIG=
|
||||
# Include this environment variable if you want more logging for debugging locally
|
||||
VITE_LOG_LEVEL=debug
|
||||
|
||||
# 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=
|
||||
|
||||
# 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=classic
|
||||
|
||||
# Example Context Values for qwen2.5-coder:32b
|
||||
#
|
||||
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
||||
|
||||
115
.env.production
115
.env.production
@@ -1,115 +0,0 @@
|
||||
# Rename this file to .env once you have filled in the below environment variables!
|
||||
|
||||
# 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=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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
|
||||
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=
|
||||
|
||||
# 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_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=
|
||||
|
||||
# You only need this environment variable set if you want to use OpenAI Like models
|
||||
OPENAI_LIKE_API_BASE_URL=
|
||||
|
||||
# You only need this environment variable set if you want to use Together AI models
|
||||
TOGETHER_API_BASE_URL=
|
||||
|
||||
# You only need this environment variable set if you want to use DeepSeek models through their API
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# Get your OpenAI Like API Key
|
||||
OPENAI_LIKE_API_KEY=
|
||||
|
||||
# Get your Together API Key
|
||||
TOGETHER_API_KEY=
|
||||
|
||||
# You only need this environment variable set if you want to use Hyperbolic models
|
||||
HYPERBOLIC_API_KEY=
|
||||
HYPERBOLIC_API_BASE_URL=
|
||||
|
||||
# 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_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_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
|
||||
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=
|
||||
|
||||
# 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=
|
||||
|
||||
# Get your AWS configuration
|
||||
# https://console.aws.amazon.com/iam/home
|
||||
AWS_BEDROCK_CONFIG=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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=
|
||||
|
||||
# Netlify Authentication
|
||||
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=
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"rules": {
|
||||
// example: turn off console warnings
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
|
||||
75
.github/workflows/docker.yaml
vendored
75
.github/workflows/docker.yaml
vendored
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, stable]
|
||||
tags: ['v*', '*.*.*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- v*
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -16,47 +16,66 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKER_IMAGE: ghcr.io/${{ github.repository }}
|
||||
BUILD_TARGET: bolt-ai-production # bolt-ai-development
|
||||
|
||||
jobs:
|
||||
docker-build-publish:
|
||||
runs-on: ubuntu-latest
|
||||
# timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- id: string
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
with:
|
||||
string: ${{ env.DOCKER_IMAGE }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: crazy-max/ghaction-docker-meta@v5
|
||||
with:
|
||||
images: ${{ steps.string.outputs.lowercase }}
|
||||
flavor: |
|
||||
latest=true
|
||||
prefix=
|
||||
suffix=
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=pep440,pattern={{version}}
|
||||
type=ref,event=tag
|
||||
type=raw,value={{sha}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }}
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ env.BUILD_TARGET }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: bolt-ai-production
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Check manifest
|
||||
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Dump context
|
||||
if: always()
|
||||
uses: crazy-max/ghaction-dump-context@v2
|
||||
|
||||
98
.github/workflows/electron.yml
vendored
98
.github/workflows/electron.yml
vendored
@@ -1,98 +0,0 @@
|
||||
name: Electron Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag for the release (e.g., v1.0.0). Leave empty if not applicable.'
|
||||
required: false
|
||||
push:
|
||||
branches:
|
||||
- electron
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest] # Use unsigned macOS builds for now
|
||||
node-version: [18.18.0]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.14.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
# Install Linux dependencies
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm
|
||||
|
||||
# Build
|
||||
- name: Build Electron app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
pnpm run electron:build:win
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
pnpm run electron:build:mac
|
||||
else
|
||||
pnpm run electron:build:linux
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
# Create Release
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Use the workflow_dispatch input tag if available, else use the Git ref name.
|
||||
tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
# Only branch pushes remain drafts. For workflow_dispatch and tag pushes the release is published.
|
||||
draft: ${{ github.event_name != 'workflow_dispatch' && github.ref_type == 'branch' }}
|
||||
# For tag pushes, name the release as "Release <tagname>", otherwise "Electron Release".
|
||||
name: ${{ (github.event_name == 'push' && github.ref_type == 'tag') && format('Release {0}', github.ref_name) || 'Electron Release' }}
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.dmg
|
||||
dist/*.deb
|
||||
dist/*.AppImage
|
||||
dist/*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/update-stable.yml
vendored
4
.github/workflows/update-stable.yml
vendored
@@ -119,9 +119,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="v${{ steps.bump_version.outputs.new_version }}"
|
||||
# Save changelog to a file
|
||||
echo "${{ steps.changelog.outputs.content }}" > release_notes.md
|
||||
gh release create "$VERSION" \
|
||||
--title "Release $VERSION" \
|
||||
--notes-file release_notes.md \
|
||||
--notes "${{ steps.changelog.outputs.content }}" \
|
||||
--target stable
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,7 +25,6 @@ dist-ssr
|
||||
/.history
|
||||
/.cache
|
||||
/build
|
||||
functions/build/
|
||||
.env.local
|
||||
.env
|
||||
.dev.vars
|
||||
@@ -44,5 +43,5 @@ app/commit.json
|
||||
changelogUI.md
|
||||
docs/instructions/Roadmap.md
|
||||
.cursorrules
|
||||
.cursorrules
|
||||
*.md
|
||||
.qodo
|
||||
|
||||
2
.tool-versions
Normal file
2
.tool-versions
Normal file
@@ -0,0 +1,2 @@
|
||||
nodejs 20.15.1
|
||||
pnpm 9.4.0
|
||||
92
CHANGES.md
92
CHANGES.md
@@ -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
|
||||
@@ -6,10 +6,9 @@ WORKDIR /app
|
||||
# Install dependencies (this step is cached as long as the dependencies don't change)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
#RUN npm install -g corepack@latest
|
||||
RUN npm install -g corepack@latest
|
||||
|
||||
#RUN corepack enable pnpm && pnpm install
|
||||
RUN npm install -g pnpm && pnpm install --prod=false
|
||||
RUN corepack enable pnpm && pnpm install
|
||||
|
||||
# Copy the rest of your app's source code
|
||||
COPY . .
|
||||
|
||||
56
README.md
56
README.md
@@ -1,13 +1,15 @@
|
||||
# bolt.diy
|
||||
# bolt.diy (Previously oTToDev)
|
||||
|
||||
[](https://bolt.diy)
|
||||
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
|
||||
|
||||
---
|
||||
|
||||
-----
|
||||
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
|
||||
|
||||
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).
|
||||
@@ -79,12 +81,10 @@ project, please check the [project management guide](./PROJECT.md) to get starte
|
||||
- ✅ Add Starter Template Options (@thecodacus)
|
||||
- ✅ Perplexity Integration (@meetpateltech)
|
||||
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
||||
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
|
||||
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
|
||||
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
||||
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||
- ✅ Deploy directly to Netlify (@xKevIsDev)
|
||||
- ⬜ Supabase Integration
|
||||
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms
|
||||
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
||||
- ⬜ VSCode Integration with git-like confirmations
|
||||
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
||||
@@ -102,9 +102,8 @@ project, please check the [project management guide](./PROJECT.md) to get starte
|
||||
- **Attach images to prompts** for better contextual understanding.
|
||||
- **Integrated terminal** to view output of LLM-run commands.
|
||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
||||
- **Download projects as ZIP** for easy portability Sync to a folder on the host.
|
||||
- **Download projects as ZIP** for easy portability.
|
||||
- **Integration-ready Docker support** for a hassle-free setup.
|
||||
- **Deploy** directly to **Netlify**
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -243,7 +242,8 @@ This method is recommended for developers who want to:
|
||||
1. **Clone the Repository**:
|
||||
|
||||
```bash
|
||||
git clone -b stable https://github.com/stackblitz-labs/bolt.diy.git
|
||||
# Using HTTPS
|
||||
git clone https://github.com/stackblitz-labs/bolt.diy.git
|
||||
```
|
||||
|
||||
2. **Navigate to Project Directory**:
|
||||
@@ -252,30 +252,20 @@ This method is recommended for developers who want to:
|
||||
cd bolt.diy
|
||||
```
|
||||
|
||||
3. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Start the Development Server**:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch:
|
||||
3. **Switch to the Main Branch**:
|
||||
```bash
|
||||
git checkout main
|
||||
```
|
||||
4. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
5. **Start the Development Server**:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
|
||||
|
||||
>**Open the WebUI to test (Default: http://localhost:5173)**
|
||||
> - Beginngers:
|
||||
> - Try to use a sophisticated Provider/Model like Anthropic with Claude Sonnet 3.x Models to get best results
|
||||
> - Explanation: The System Prompt currently implemented in bolt.diy cant cover the best performance for all providers and models out there. So it works better with some models, then other, even if the models itself are perfect for >programming
|
||||
> - Future: Planned is a Plugin/Extentions-Library so there can be different System Prompts for different Models, which will help to get better results
|
||||
|
||||
#### Staying Updated
|
||||
|
||||
@@ -290,7 +280,7 @@ To get the latest changes from the repository:
|
||||
2. **Pull Latest Updates**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
3. **Update Dependencies**:
|
||||
@@ -360,9 +350,3 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
|
||||
## FAQ
|
||||
|
||||
For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).
|
||||
|
||||
|
||||
# Licensing
|
||||
**Who needs a commercial WebContainer API license?**
|
||||
|
||||
bolt.diy source code is distributed as MIT, but it uses WebContainers API that [requires licensing](https://webcontainers.io/enterprise) for production usage in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
|
||||
|
||||
@@ -29,7 +29,7 @@ import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
||||
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
||||
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
||||
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
||||
import { DataTab } from '~/components/@settings/tabs/data/DataTab';
|
||||
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
||||
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
||||
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
||||
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
||||
@@ -413,10 +413,10 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Badge } from '~/components/ui/Badge';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
|
||||
import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
// Helper function to safely parse JSON
|
||||
const safeJsonParse = (item: string | null) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(item);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON from localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A diagnostics component to help troubleshoot connection issues
|
||||
*/
|
||||
export default function ConnectionDiagnostics() {
|
||||
const [diagnosticResults, setDiagnosticResults] = useState<any>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Run diagnostics when requested
|
||||
const runDiagnostics = async () => {
|
||||
try {
|
||||
setIsRunning(true);
|
||||
setDiagnosticResults(null);
|
||||
|
||||
// Check browser-side storage
|
||||
const localStorageChecks = {
|
||||
githubConnection: localStorage.getItem('github_connection'),
|
||||
netlifyConnection: localStorage.getItem('netlify_connection'),
|
||||
vercelConnection: localStorage.getItem('vercel_connection'),
|
||||
supabaseConnection: localStorage.getItem('supabase_connection'),
|
||||
};
|
||||
|
||||
// Get diagnostic data from server
|
||||
const response = await fetch('/api/system/diagnostics');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Diagnostics API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const serverDiagnostics = await response.json();
|
||||
|
||||
// === GitHub Checks ===
|
||||
const githubConnectionParsed = safeJsonParse(localStorageChecks.githubConnection);
|
||||
const githubToken = githubConnectionParsed?.token;
|
||||
const githubAuthHeaders = {
|
||||
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
|
||||
|
||||
const githubEndpoints = [
|
||||
{ name: 'User', url: '/api/system/git-info?action=getUser' },
|
||||
{ name: 'Repos', url: '/api/system/git-info?action=getRepos' },
|
||||
{ name: 'Default', url: '/api/system/git-info' },
|
||||
];
|
||||
const githubResults = await Promise.all(
|
||||
githubEndpoints.map(async (endpoint) => {
|
||||
try {
|
||||
const resp = await fetch(endpoint.url, { headers: githubAuthHeaders });
|
||||
return { endpoint: endpoint.name, status: resp.status, ok: resp.ok };
|
||||
} catch (error) {
|
||||
return {
|
||||
endpoint: endpoint.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// === Netlify Checks ===
|
||||
const netlifyConnectionParsed = safeJsonParse(localStorageChecks.netlifyConnection);
|
||||
const netlifyToken = netlifyConnectionParsed?.token;
|
||||
let netlifyUserCheck = null;
|
||||
|
||||
if (netlifyToken) {
|
||||
try {
|
||||
const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: { Authorization: `Bearer ${netlifyToken}` },
|
||||
});
|
||||
netlifyUserCheck = { status: netlifyResp.status, ok: netlifyResp.ok };
|
||||
} catch (error) {
|
||||
netlifyUserCheck = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === Vercel Checks ===
|
||||
const vercelConnectionParsed = safeJsonParse(localStorageChecks.vercelConnection);
|
||||
const vercelToken = vercelConnectionParsed?.token;
|
||||
let vercelUserCheck = null;
|
||||
|
||||
if (vercelToken) {
|
||||
try {
|
||||
const vercelResp = await fetch('https://api.vercel.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${vercelToken}` },
|
||||
});
|
||||
vercelUserCheck = { status: vercelResp.status, ok: vercelResp.ok };
|
||||
} catch (error) {
|
||||
vercelUserCheck = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === Supabase Checks ===
|
||||
const supabaseConnectionParsed = safeJsonParse(localStorageChecks.supabaseConnection);
|
||||
const supabaseUrl = supabaseConnectionParsed?.projectUrl;
|
||||
const supabaseAnonKey = supabaseConnectionParsed?.anonKey;
|
||||
let supabaseCheck = null;
|
||||
|
||||
if (supabaseUrl && supabaseAnonKey) {
|
||||
supabaseCheck = { ok: true, status: 200, message: 'URL and Key present in localStorage' };
|
||||
} else {
|
||||
supabaseCheck = { ok: false, message: 'URL or Key missing in localStorage' };
|
||||
}
|
||||
|
||||
// Compile results
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
localStorage: {
|
||||
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
|
||||
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
|
||||
hasVercelConnection: Boolean(localStorageChecks.vercelConnection),
|
||||
hasSupabaseConnection: Boolean(localStorageChecks.supabaseConnection),
|
||||
githubConnectionParsed,
|
||||
netlifyConnectionParsed,
|
||||
vercelConnectionParsed,
|
||||
supabaseConnectionParsed,
|
||||
},
|
||||
apiEndpoints: {
|
||||
github: githubResults,
|
||||
netlify: netlifyUserCheck,
|
||||
vercel: vercelUserCheck,
|
||||
supabase: supabaseCheck,
|
||||
},
|
||||
serverDiagnostics,
|
||||
};
|
||||
|
||||
setDiagnosticResults(results);
|
||||
|
||||
// Display simple results
|
||||
if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) {
|
||||
toast.error('GitHub API connections are failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) {
|
||||
toast.error('Netlify API connection is failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasVercelConnection && vercelUserCheck && !vercelUserCheck.ok) {
|
||||
toast.error('Vercel API connection is failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasSupabaseConnection && supabaseCheck && !supabaseCheck.ok) {
|
||||
toast.warning('Supabase connection check failed or missing details. Verify settings.');
|
||||
}
|
||||
|
||||
if (
|
||||
!results.localStorage.hasGithubConnection &&
|
||||
!results.localStorage.hasNetlifyConnection &&
|
||||
!results.localStorage.hasVercelConnection &&
|
||||
!results.localStorage.hasSupabaseConnection
|
||||
) {
|
||||
toast.info('No connection data found in browser storage.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Diagnostics error:', error);
|
||||
toast.error('Error running diagnostics');
|
||||
setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset GitHub connection
|
||||
const resetGitHubConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('github_connection');
|
||||
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
toast.success('GitHub connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing GitHub data:', error);
|
||||
toast.error('Failed to clear GitHub connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Netlify connection
|
||||
const resetNetlifyConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('netlify_connection');
|
||||
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
toast.success('Netlify connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Netlify data:', error);
|
||||
toast.error('Failed to clear Netlify connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Vercel connection
|
||||
const resetVercelConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('vercel_connection');
|
||||
toast.success('Vercel connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Vercel data:', error);
|
||||
toast.error('Failed to clear Vercel connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Supabase connection
|
||||
const resetSupabaseConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('supabase_connection');
|
||||
toast.success('Supabase connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Supabase data:', error);
|
||||
toast.error('Failed to clear Supabase connection data');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Connection Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* GitHub Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitHub Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasGithubConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasGithubConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={
|
||||
diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok)
|
||||
? 'default'
|
||||
: 'destructive'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasGithubConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Netlify Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-bolt:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Netlify Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasNetlifyConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasNetlifyConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User:{' '}
|
||||
{diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name ||
|
||||
diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email ||
|
||||
'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.netlify?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasNetlifyConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vercel Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-si:vercel text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Vercel Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasVercelConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasVercelConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasVercelConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User:{' '}
|
||||
{diagnosticResults.localStorage.vercelConnectionParsed?.user?.username ||
|
||||
diagnosticResults.localStorage.vercelConnectionParsed?.user?.user?.username ||
|
||||
'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.vercel?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.vercel?.ok ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasVercelConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supabase Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-si:supabase text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Supabase Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasSupabaseConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasSupabaseConnection ? 'Configured' : 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasSupabaseConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5 truncate">
|
||||
<div className="i-ph:link w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent flex-shrink-0" />
|
||||
Project URL: {diagnosticResults.localStorage.supabaseConnectionParsed?.projectUrl || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Config Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.supabase?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.supabase?.ok ? 'OK' : 'Check Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasSupabaseConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Configure Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button
|
||||
onClick={runDiagnostics}
|
||||
disabled={isRunning}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<div className="i-ph:activity w-4 h-4" />
|
||||
)}
|
||||
{isRunning ? 'Running Diagnostics...' : 'Run Diagnostics'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetGitHubConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasGithubConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-ph:github-logo w-4 h-4" />
|
||||
Reset GitHub
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetNetlifyConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasNetlifyConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:netlify w-4 h-4" />
|
||||
Reset Netlify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetVercelConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasVercelConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:vercel w-4 h-4" />
|
||||
Reset Vercel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetSupabaseConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasSupabaseConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:supabase w-4 h-4" />
|
||||
Reset Supabase
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Details Panel */}
|
||||
{diagnosticResults && (
|
||||
<div className="mt-4">
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="w-full">
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeBracketIcon className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Diagnostic Details
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={classNames(
|
||||
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary',
|
||||
showDetails ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="p-4 mt-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
|
||||
<pre className="text-xs overflow-auto max-h-96 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{JSON.stringify(diagnosticResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +1,614 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import ConnectionDiagnostics from './ConnectionDiagnostics';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import VercelConnection from './VercelConnection';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// Use React.lazy for dynamic imports
|
||||
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
|
||||
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
<div className="p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
|
||||
<div className="flex items-center justify-center gap-2 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
<span>Loading connection...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
totalStars: number;
|
||||
totalForks: number;
|
||||
organizations: GitHubOrganization[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
}
|
||||
|
||||
export default function ConnectionsTab() {
|
||||
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
|
||||
// Load saved connection on mount
|
||||
useEffect(() => {
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
// Ensure backward compatibility with existing connections
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
setConnection(parsed);
|
||||
|
||||
if (parsed.user && parsed.token) {
|
||||
fetchGitHubStats(parsed.token);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
try {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
// Fetch repositories - only owned by the authenticated user
|
||||
const reposResponse = await fetch(
|
||||
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error('Failed to fetch repositories');
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
||||
|
||||
// Fetch organizations
|
||||
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgsResponse.ok) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
}
|
||||
|
||||
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
||||
|
||||
// Fetch recent activity
|
||||
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error('Failed to fetch events');
|
||||
}
|
||||
|
||||
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
||||
|
||||
// Fetch languages for each repository
|
||||
const languagePromises = repos.map((repo) =>
|
||||
fetch(repo.languages_url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then((res) => res.json() as Promise<Record<string, number>>),
|
||||
);
|
||||
|
||||
const repoLanguages = await Promise.all(languagePromises);
|
||||
const languages: GitHubLanguageStats = {};
|
||||
|
||||
repoLanguages.forEach((repoLang) => {
|
||||
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
||||
languages[lang] = (languages[lang] || 0) + bytes;
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate total stats
|
||||
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
||||
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
||||
const totalGists = connection.user?.public_gists || 0;
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
stats: {
|
||||
repos,
|
||||
totalStars,
|
||||
totalForks,
|
||||
organizations,
|
||||
recentActivity,
|
||||
languages,
|
||||
totalGists,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to fetch GitHub stats', { error });
|
||||
toast.error('Failed to fetch GitHub statistics');
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubUserResponse;
|
||||
const newConnection: GitHubConnection = {
|
||||
user: data,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
};
|
||||
|
||||
// Save connection
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
setConnection(newConnection);
|
||||
|
||||
// Fetch additional stats
|
||||
await fetchGitHubStats(token);
|
||||
|
||||
toast.success('Successfully connected to GitHub');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to authenticate with GitHub', { error });
|
||||
toast.error('Failed to connect to GitHub');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
await fetchGithubUser(connection.token);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('Disconnected from GitHub');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between gap-2"
|
||||
className="flex items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Connection Settings
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowDiagnostics(!showDiagnostics)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{showDiagnostics ? (
|
||||
<>
|
||||
<div className="i-ph:eye-slash w-4 h-4" />
|
||||
Hide Diagnostics
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:wrench w-4 h-4" />
|
||||
Troubleshoot Connections
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
||||
</motion.div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
||||
Manage your external service connections and integrations
|
||||
</p>
|
||||
|
||||
{/* Diagnostics Tool - Conditionally rendered */}
|
||||
{showDiagnostics && <ConnectionDiagnostics />}
|
||||
|
||||
{/* Environment Variables Info - Collapsible */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* GitHub Connection */}
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6">
|
||||
<button
|
||||
onClick={() => setIsEnvVarsExpanded(!isEnvVarsExpanded)}
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) =>
|
||||
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
||||
}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
className={classNames(
|
||||
'w-full bg-transparent flex items-center justify-between',
|
||||
'hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary',
|
||||
'dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary',
|
||||
'rounded-md p-2 -m-2 transition-colors',
|
||||
'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',
|
||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:info w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Environment Variables
|
||||
</h3>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`}
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary transition-transform',
|
||||
isEnvVarsExpanded ? 'rotate-180' : '',
|
||||
'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-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-10 h-5" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEnvVarsExpanded && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
You can configure connections using environment variables in your{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
.env.local
|
||||
</code>{' '}
|
||||
file:
|
||||
<div className="flex items-center gap-3">
|
||||
{!connection.user ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-purple-500 text-white',
|
||||
'hover:bg-purple-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug-x w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
{connection.user && (
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && (
|
||||
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingStats ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching GitHub stats...
|
||||
</div>
|
||||
) : (
|
||||
connection.stats && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user.public_repos}
|
||||
</p>
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-3 rounded-md text-xs font-mono overflow-x-auto">
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
# GitHub Authentication
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_GITHUB_ACCESS_TOKEN=your_token_here
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
# Optional: Specify token type (defaults to 'classic' if not specified)
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_GITHUB_TOKEN_TYPE=classic|fine-grained
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2">
|
||||
# Netlify Authentication
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_NETLIFY_ACCESS_TOKEN=your_token_here
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary space-y-1">
|
||||
<p>
|
||||
<span className="font-medium">Token types:</span>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalStars}
|
||||
</p>
|
||||
<ul className="list-disc list-inside pl-2 space-y-1">
|
||||
<li>
|
||||
<span className="font-medium">classic</span> - Personal Access Token with{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
repo, read:org, read:user
|
||||
</code>{' '}
|
||||
scopes
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">fine-grained</span> - Fine-grained token with Repository and
|
||||
Organization access
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
When set, these variables will be used automatically without requiring manual connection.
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalForks}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h3>
|
||||
{connection.user.bio && (
|
||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:users w-4 h-4" />
|
||||
{connection.user.followers} followers
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-4 h-4" />
|
||||
{connection.stats.totalStars} stars
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-4 h-4" />
|
||||
{connection.stats.totalForks} forks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations Section */}
|
||||
{connection.stats.organizations.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{connection.stats.organizations.map((org) => (
|
||||
<a
|
||||
key={org.login}
|
||||
href={org.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
||||
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.recentActivity.map((event) => (
|
||||
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
||||
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
||||
<span>on</span>
|
||||
<a
|
||||
href={`https://github.com/${event.repo.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline"
|
||||
>
|
||||
{event.repo.name}
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
||||
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{new Date(connection.user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalGists}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.organizations.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{Object.keys(connection.stats.languages).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing repositories section */}
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
{repo.name}
|
||||
</h5>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-branch w-3 h-3" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<GitHubConnection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<NetlifyConnection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<VercelConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Additional help text */}
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<p className="flex items-center gap-1 mb-2">
|
||||
<span className="i-ph:lightbulb w-4 h-4 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Troubleshooting Tip:</span>
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can
|
||||
help diagnose and fix common connection issues.
|
||||
</p>
|
||||
<p>For persistent issues:</p>
|
||||
<ol className="list-decimal list-inside pl-4 mt-1">
|
||||
<li>Check your browser console for errors</li>
|
||||
<li>Verify that your tokens have the correct permissions</li>
|
||||
<li>Try clearing your browser cache and cookies</li>
|
||||
<li>Ensure your browser allows third-party cookies if using integrations</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,980 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import Cookies from 'js-cookie';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
publicRepos: number;
|
||||
privateRepos: number;
|
||||
stars: number;
|
||||
forks: number;
|
||||
followers: number;
|
||||
publicGists: number;
|
||||
privateGists: number;
|
||||
lastUpdated: string;
|
||||
|
||||
// Keep these for backward compatibility
|
||||
totalStars?: number;
|
||||
totalForks?: number;
|
||||
organizations?: GitHubOrganization[];
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
rateLimit?: {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Add the GitHub logo SVG component
|
||||
const GithubLogo = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function GitHubConnection() {
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
||||
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
console.log('Fetching GitHub user with token:', token.substring(0, 5) + '...');
|
||||
|
||||
// Use server-side API endpoint instead of direct GitHub API call
|
||||
const response = await fetch(`/api/system/git-info?action=getUser`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`, // Include token in headers for validation
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Error fetching GitHub user. Status:', response.status);
|
||||
throw new Error(`Error: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get rate limit information from headers
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
console.log('GitHub user API response:', data);
|
||||
|
||||
const { user } = data as { user: GitHubUserResponse };
|
||||
|
||||
// Validate that we received a user object
|
||||
if (!user || !user.login) {
|
||||
console.error('Invalid user data received:', user);
|
||||
throw new Error('Invalid user data received');
|
||||
}
|
||||
|
||||
// Use the response data
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
rateLimit,
|
||||
}));
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('githubUsername', user.login);
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('Connected to GitHub', {
|
||||
type: 'system',
|
||||
message: `Connected to GitHub as ${user.login}`,
|
||||
});
|
||||
|
||||
// Fetch additional GitHub stats
|
||||
fetchGitHubStats(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub user:', error);
|
||||
logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'GitHub authentication failed',
|
||||
});
|
||||
|
||||
toast.error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error; // Rethrow to allow handling in the calling function
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
try {
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
if (userResponse.status === 401) {
|
||||
toast.error('Your GitHub token has expired. Please reconnect your account.');
|
||||
handleDisconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
|
||||
}
|
||||
|
||||
const userData = (await userResponse.json()) as any;
|
||||
|
||||
// Fetch repositories with pagination
|
||||
let allRepos: any[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as any[];
|
||||
allRepos = [...allRepos, ...repos];
|
||||
|
||||
// Check if there are more pages
|
||||
const linkHeader = reposResponse.headers.get('Link');
|
||||
hasMore = linkHeader?.includes('rel="next"') ?? false;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const repoStats = calculateRepoStats(allRepos);
|
||||
|
||||
// Fetch recent activity
|
||||
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const events = (await eventsResponse.json()) as any[];
|
||||
const recentActivity = events.slice(0, 5).map((event: any) => ({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
repo: event.repo.name,
|
||||
created_at: event.created_at,
|
||||
}));
|
||||
|
||||
// Calculate total stars and forks
|
||||
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
|
||||
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
|
||||
|
||||
// Update the stats in the store
|
||||
const stats: GitHubStats = {
|
||||
repos: repoStats.repos,
|
||||
recentActivity,
|
||||
languages: repoStats.languages || {},
|
||||
totalGists: repoStats.totalGists || 0,
|
||||
publicRepos: userData.public_repos || 0,
|
||||
privateRepos: privateRepos || 0,
|
||||
stars: totalStars || 0,
|
||||
forks: totalForks || 0,
|
||||
followers: userData.followers || 0,
|
||||
publicGists: userData.public_gists || 0,
|
||||
privateGists: userData.private_gists || 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
|
||||
// For backward compatibility
|
||||
totalStars: totalStars || 0,
|
||||
totalForks: totalForks || 0,
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
|
||||
const currentUser = currentConnection.user || connection.user;
|
||||
|
||||
// Update connection with stats
|
||||
const updatedConnection: GitHubConnection = {
|
||||
user: currentUser,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
stats,
|
||||
rateLimit: connection.rateLimit,
|
||||
};
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
// Update state
|
||||
setConnection(updatedConnection);
|
||||
|
||||
toast.success('GitHub stats refreshed');
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error);
|
||||
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRepoStats = (repos: any[]) => {
|
||||
const repoStats = {
|
||||
repos: repos.map((repo: any) => ({
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
html_url: repo.html_url,
|
||||
description: repo.description,
|
||||
stargazers_count: repo.stargazers_count,
|
||||
forks_count: repo.forks_count,
|
||||
default_branch: repo.default_branch,
|
||||
updated_at: repo.updated_at,
|
||||
languages_url: repo.languages_url,
|
||||
})),
|
||||
|
||||
languages: {} as Record<string, number>,
|
||||
totalGists: 0,
|
||||
};
|
||||
|
||||
repos.forEach((repo: any) => {
|
||||
fetch(repo.languages_url)
|
||||
.then((response) => response.json())
|
||||
.then((languages: any) => {
|
||||
const typedLanguages = languages as Record<string, number>;
|
||||
Object.keys(typedLanguages).forEach((language) => {
|
||||
if (!repoStats.languages[language]) {
|
||||
repoStats.languages[language] = 0;
|
||||
}
|
||||
|
||||
repoStats.languages[language] += 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return repoStats;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedConnection = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
// Update the ref with the parsed token type
|
||||
tokenTypeRef.current = parsed.tokenType;
|
||||
|
||||
// Set the connection
|
||||
setConnection(parsed);
|
||||
|
||||
// If we have a token but no stats or incomplete stats, fetch them
|
||||
if (
|
||||
parsed.user &&
|
||||
parsed.token &&
|
||||
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
|
||||
) {
|
||||
console.log('Fetching missing GitHub stats for saved connection');
|
||||
await fetchGitHubStats(parsed.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved GitHub connection:', error);
|
||||
localStorage.removeItem('github_connection');
|
||||
}
|
||||
} else {
|
||||
// Check for environment variable token
|
||||
const envToken = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
|
||||
|
||||
if (envToken) {
|
||||
// Check if token type is specified in environment variables
|
||||
const envTokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE;
|
||||
console.log('Environment token type:', envTokenType);
|
||||
|
||||
const tokenType =
|
||||
envTokenType === 'classic' || envTokenType === 'fine-grained'
|
||||
? (envTokenType as 'classic' | 'fine-grained')
|
||||
: 'classic';
|
||||
|
||||
console.log('Using token type:', tokenType);
|
||||
|
||||
// Update both the state and the ref
|
||||
tokenTypeRef.current = tokenType;
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
tokenType,
|
||||
}));
|
||||
|
||||
try {
|
||||
// Fetch user data with the environment token
|
||||
await fetchGithubUser(envToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect with environment token:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadSavedConnection();
|
||||
}, []);
|
||||
|
||||
// Ensure cookies are updated when connection changes
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = connection.token;
|
||||
const data = connection.user;
|
||||
|
||||
if (token) {
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
}
|
||||
|
||||
if (data) {
|
||||
Cookies.set('githubUsername', data.login);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// Add function to update rate limits
|
||||
const updateRateLimits = async (token: string) => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/rate_limit', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
rateLimit,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rate limits:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to update rate limits periodically
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (connection.token && connection.user) {
|
||||
updateRateLimits(connection.token);
|
||||
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [connection.token, connection.user]);
|
||||
|
||||
if (isLoading || isConnecting || isFetchingStats) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Update the ref with the current state value before connecting
|
||||
tokenTypeRef.current = connection.tokenType;
|
||||
|
||||
/*
|
||||
* Save token type to localStorage even before connecting
|
||||
* This ensures the token type is persisted even if connection fails
|
||||
*/
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user: null,
|
||||
token: connection.token,
|
||||
tokenType: connection.tokenType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Attempt to fetch the user info which validates the token
|
||||
await fetchGithubUser(connection.token);
|
||||
|
||||
toast.success('Connected to GitHub successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitHub:', error);
|
||||
|
||||
// Reset connection state on failure
|
||||
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
|
||||
|
||||
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
|
||||
// Remove all GitHub-related cookies
|
||||
Cookies.remove('githubToken');
|
||||
Cookies.remove('githubUsername');
|
||||
Cookies.remove('git:github.com');
|
||||
|
||||
// Reset the token type ref
|
||||
tokenTypeRef.current = 'classic';
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('Disconnected from GitHub');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitHub Connection
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set the{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_ACCESS_TOKEN
|
||||
</code>{' '}
|
||||
environment variable to connect automatically.
|
||||
</p>
|
||||
<p>
|
||||
For fine-grained tokens, also set{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_TOKEN_TYPE=fine-grained
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
Token Type
|
||||
</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) => {
|
||||
const newTokenType = e.target.value as 'classic' | 'fine-grained';
|
||||
tokenTypeRef.current = newTokenType;
|
||||
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
|
||||
}}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`Enter your GitHub ${
|
||||
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
||||
}`}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!connection.user ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:layout w-4 h-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fetchGitHubStats(connection.token);
|
||||
updateRateLimits(connection.token);
|
||||
}}
|
||||
disabled={isFetchingStats}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isFetchingStats ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:arrows-clockwise w-4 h-4" />
|
||||
Refresh Stats
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg mb-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent dark:border-bolt-elements-item-contentAccent"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{connection.user.login}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Stats</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||
isStatsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{
|
||||
label: 'Member Since',
|
||||
value: new Date(connection.user.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: 'Public Gists',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
|
||||
},
|
||||
{
|
||||
label: 'Languages',
|
||||
value: Object.keys(connection.stats.languages).length,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public Repos',
|
||||
value: connection.stats.publicRepos,
|
||||
},
|
||||
{
|
||||
label: 'Private Repos',
|
||||
value: connection.stats.privateRepos,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Stars',
|
||||
value: connection.stats.stars || 0,
|
||||
icon: 'i-ph:star',
|
||||
iconColor: 'text-bolt-elements-icon-warning',
|
||||
},
|
||||
{
|
||||
label: 'Forks',
|
||||
value: connection.stats.forks || 0,
|
||||
icon: 'i-ph:git-fork',
|
||||
iconColor: 'text-bolt-elements-icon-info',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: connection.stats.followers || 0,
|
||||
icon: 'i-ph:users',
|
||||
iconColor: 'text-bolt-elements-icon-success',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Gists</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Private',
|
||||
value: connection.stats.privateGists || 0,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-bolt-elements-borderColor">
|
||||
<span className="text-xs text-bolt-elements-textSecondary">
|
||||
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Recent Repositories</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-tertiary" />
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 ml-auto group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
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';
|
||||
|
||||
// Add the Netlify logo SVG component at the top of the file
|
||||
const NetlifyLogo = () => (
|
||||
<svg viewBox="0 0 40 40" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M28.589 14.135l-.014-.006c-.008-.003-.016-.006-.023-.013a.11.11 0 0 1-.028-.093l.773-4.726 3.625 3.626-3.77 1.604a.083.083 0 0 1-.033.006h-.015c-.005-.003-.01-.007-.02-.017a1.716 1.716 0 0 0-.495-.381zm5.258-.288l3.876 3.876c.805.806 1.208 1.208 1.674 1.355a2 2 0 0 1 1.206 0c.466-.148.869-.55 1.674-1.356L8.73 28.73l2.349-3.643c.011-.018.022-.034.04-.047.025-.018.061-.01.091 0a2.434 2.434 0 0 0 1.638-.083c.027-.01.054-.017.075.002a.19.19 0 0 1 .028.032L21.95 38.05zM7.863 27.863L5.8 25.8l4.074-1.738a.084.084 0 0 1 .033-.007c.034 0 .054.034.072.065a2.91 2.91 0 0 0 .13.184l.013.016c.012.017.004.034-.008.05l-2.25 3.493zm-2.976-2.976l-2.61-2.61c-.444-.444-.766-.766-.99-1.043l7.936 1.646a.84.84 0 0 0 .03.005c.049.008.103.017.103.063 0 .05-.059.073-.109.092l-.023.01-4.337 1.837zM.831 19.892a2 2 0 0 1 .09-.495c.148-.466.55-.868 1.356-1.674l3.34-3.34a2175.525 2175.525 0 0 0 4.626 6.687c.027.036.057.076.026.106-.146.161-.292.337-.395.528a.16.16 0 0 1-.05.062c-.013.008-.027.005-.042.002H9.78L.831 19.892zm5.68-6.403l4.491-4.491c.422.185 1.958.834 3.332 1.414 1.04.44 1.988.84 2.286.97.03.012.057.024.07.054.008.018.004.041 0 .06a2.003 2.003 0 0 0 .523 1.828c.03.03 0 .073-.026.11l-.014.021-4.56 7.063c-.012.02-.023.037-.043.05-.024.015-.058.008-.086.001a2.274 2.274 0 0 0-.543-.074c-.164 0-.342.03-.522.063h-.001c-.02.003-.038.007-.054-.005a.21.21 0 0 1-.045-.051l-4.808-7.013zm5.398-5.398l5.814-5.814c.805-.805 1.208-1.208 1.674-1.355a2 2 0 0 1 1.206 0c.466.147.869.55 1.674 1.355l1.26 1.26-4.135 6.404a.155.155 0 0 1-.041.048c-.025.017-.06.01-.09 0a2.097 2.097 0 0 0-1.92.37c-.027.028-.067.012-.101-.003-.54-.235-4.74-2.01-5.341-2.265zm12.506-3.676l3.818 3.818-.92 5.698v.015a.135.135 0 0 1-.008.038c-.01.02-.03.024-.05.03a1.83 1.83 0 0 0-.548.273.154.154 0 0 0-.02.017c-.011.012-.022.023-.04.025a.114.114 0 0 1-.043-.007l-5.818-2.472-.011-.005c-.037-.015-.081-.033-.081-.071a2.198 2.198 0 0 0-.31-.915c-.028-.046-.059-.094-.035-.141l4.066-6.303zm-3.932 8.606l5.454 2.31c.03.014.063.027.076.058a.106.106 0 0 1 0 .057c-.016.08-.03.171-.03.263v.153c0 .038-.039.054-.075.069l-.011.004c-.864.369-12.13 5.173-12.147 5.173-.017 0-.035 0-.052-.017-.03-.03 0-.072.027-.11a.76.76 0 0 0 .014-.02l4.482-6.94.008-.012c.026-.042.056-.089.104-.089l.045.007c.102.014.192.027.283.027.68 0 1.31-.331 1.69-.897a.16.16 0 0 1 .034-.04c.027-.02.067-.01.098.004zm-6.246 9.185l12.28-5.237s.018 0 .035.017c.067.067.124.112.179.154l.027.017c.025.014.05.03.052.056 0 .01 0 .016-.002.025L25.756 23.7l-.004.026c-.007.05-.014.107-.061.107a1.729 1.729 0 0 0-1.373.847l-.005.008c-.014.023-.027.045-.05.057-.021.01-.048.006-.07.001l-9.793-2.02c-.01-.002-.152-.519-.163-.52z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Add new interface for site actions
|
||||
interface SiteAction {
|
||||
name: string;
|
||||
icon: React.ComponentType<any>;
|
||||
action: (siteId: string) => Promise<void>;
|
||||
requiresConfirmation?: boolean;
|
||||
variant?: 'default' | 'destructive' | 'outline';
|
||||
}
|
||||
|
||||
export default function NetlifyConnection() {
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [fetchingStats, setFetchingStats] = useState(false);
|
||||
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||
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(() => {
|
||||
// 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 (
|
||||
<div className="mt-6">
|
||||
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Netlify Stats
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||
isStatsOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||
<span>{connection.stats.totalSites} Sites</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<RocketLaunchIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||
<span>{deploymentCount} Deployments</span>
|
||||
</Badge>
|
||||
{lastUpdated && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||
<span>Updated {formatDistanceToNow(new Date(lastUpdated))} ago</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{sites.length > 0 && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Your Sites
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchNetlifyStats(connection.token)}
|
||||
disabled={fetchingStats}
|
||||
className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={classNames(
|
||||
'h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent',
|
||||
{ 'animate-spin': fetchingStats },
|
||||
)}
|
||||
/>
|
||||
{fetchingStats ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sites.map((site, index) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border rounded-lg p-4 transition-all',
|
||||
activeSiteIndex === index
|
||||
? 'border-bolt-elements-item-contentAccent bg-bolt-elements-item-backgroundActive/10'
|
||||
: 'border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveSiteIndex(index);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudIcon className="h-5 w-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{site.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
{site.published_deploy?.state === 'ready' ? (
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{site.published_deploy?.state || 'Unknown'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
href={site.ssl_url || site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">
|
||||
{site.ssl_url || site.url}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{activeSiteIndex === index && (
|
||||
<>
|
||||
<div className="mt-4 pt-3 border-t border-bolt-elements-borderColor">
|
||||
<div className="flex items-center gap-2">
|
||||
{siteActions.map((action) => (
|
||||
<Button
|
||||
key={action.name}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (action.requiresConfirmation) {
|
||||
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsActionLoading(true);
|
||||
await action.action(site.id);
|
||||
setIsActionLoading(false);
|
||||
}}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<action.icon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{site.published_deploy && (
|
||||
<div className="mt-3 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
{site.published_deploy.branch && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Branch: {site.published_deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeSiteIndex !== -1 && deploys.length > 0 && (
|
||||
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Recent Deployments
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deploys.map((deploy) => (
|
||||
<div
|
||||
key={deploy.id}
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
deploy.state === 'ready'
|
||||
? 'default'
|
||||
: deploy.state === 'error'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{deploy.state === 'ready' ? (
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
) : deploy.state === 'error' ? (
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||
)}
|
||||
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{deploy.state}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(deploy.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
{deploy.branch && (
|
||||
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<CodeBracketIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Branch: {deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deploy.deploy_url && (
|
||||
<div className="mt-2 text-xs">
|
||||
<a
|
||||
href={deploy.deploy_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Publish
|
||||
</Button>
|
||||
{deploy.state === 'ready' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<LockClosedIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Lock
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<LockOpenIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Unlock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSiteIndex !== -1 && builds.length > 0 && (
|
||||
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Recent Builds
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{builds.map((build) => (
|
||||
<div
|
||||
key={build.id}
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{build.done && !build.error ? (
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
) : build.error ? (
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CodeBracketIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(build.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
{build.error && (
|
||||
<div className="mt-2 text-xs text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive flex items-center gap-1">
|
||||
<XCircleIcon className="h-3 w-3 text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive" />
|
||||
Error: {build.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[#00AD9F]">
|
||||
<NetlifyLogo />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Netlify Connection</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user ? (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
API Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full gap-4 mt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to Netlify
|
||||
</span>
|
||||
</div>
|
||||
{renderStats()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import React, { useEffect, useState } 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,
|
||||
} from '~/lib/stores/vercel';
|
||||
|
||||
export default function VercelConnection() {
|
||||
const connection = useStore(vercelConnection);
|
||||
const connecting = useStore(isConnecting);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
if (connection.user && connection.token) {
|
||||
await fetchVercelStats(connection.token);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, [connection.user, connection.token]);
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="w-5 h-5 dark:invert"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src={`https://cdn.simpleicons.org/vercel/black`}
|
||||
/>
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel Connection</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href="https://vercel.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to Vercel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
{/* Debug output */}
|
||||
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
|
||||
|
||||
<img
|
||||
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
alt="User Avatar"
|
||||
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
{connection.user?.email || connection.user?.user?.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fetchingStats ? (
|
||||
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching Vercel projects...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
||||
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:buildings w-4 h-4" />
|
||||
Your Projects ({connection.stats?.totalProjects || 0})
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
||||
isProjectsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isProjectsExpanded && connection.stats?.projects?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{connection.stats.projects.map((project) => (
|
||||
<a
|
||||
key={project.id}
|
||||
href={`https://vercel.com/dashboard/${project.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:globe w-4 h-4 text-bolt-elements-borderColorActive" />
|
||||
{project.name}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.targets.production.alias.find((a: string) => 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]}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock w-3 h-3" />
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.latestDeployments[0].url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-bolt-elements-borderColorActive"
|
||||
>
|
||||
{project.latestDeployments[0].url}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock w-3 h-3" />
|
||||
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{project.framework && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:code w-3 h-3" />
|
||||
{project.framework}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : isProjectsExpanded ? (
|
||||
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
No projects found in your Vercel account
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface ConnectionFormProps {
|
||||
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
|
||||
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
|
||||
|
||||
if (savedToken && !authState.tokenInfo?.token) {
|
||||
setAuthState((prev: GitHubAuthState) => ({
|
||||
@@ -30,9 +30,6 @@ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }
|
||||
followers: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
// Ensure the token is also saved with the correct key for API requests
|
||||
Cookies.set('githubToken', savedToken);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import Cookies from 'js-cookie';
|
||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||
|
||||
interface GitHubAuthDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = (await response.json()) as GitHubUserResponse;
|
||||
|
||||
// Save connection data
|
||||
const connectionData = {
|
||||
token,
|
||||
tokenType,
|
||||
user: {
|
||||
login: userData.login,
|
||||
avatar_url: userData.avatar_url,
|
||||
name: userData.name || userData.login,
|
||||
},
|
||||
connected_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(connectionData));
|
||||
|
||||
// Set cookies for API requests
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', userData.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
toast.success(`Successfully connected as ${userData.login}`);
|
||||
setToken('');
|
||||
onClose();
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
toast.error('Invalid GitHub token. Please check and try again.');
|
||||
} else {
|
||||
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting to GitHub:', error);
|
||||
toast.error('Failed to connect to GitHub. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
|
||||
|
||||
<p className="text-sm text-[#666666] dark:text-[#999999]">
|
||||
To access private repositories, you need to connect your GitHub account by providing a personal access
|
||||
token.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
|
||||
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
|
||||
GitHub Personal Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
|
||||
Get your token at{' '}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={tokenType === 'classic'}
|
||||
onChange={() => setTokenType('classic')}
|
||||
className="w-3.5 h-3.5 accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={tokenType === 'fine-grained'}
|
||||
onChange={() => setTokenType('fine-grained')}
|
||||
className="w-3.5 h-3.5 accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
|
||||
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
|
||||
<span className="i-ph:warning-circle w-4 h-4" />
|
||||
Accessing Private Repositories
|
||||
</h3>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Important things to know about accessing private repositories:
|
||||
</p>
|
||||
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
|
||||
<li>You must be granted access to the repository by its owner</li>
|
||||
<li>Your GitHub token must have the 'repo' scope</li>
|
||||
<li>For organization repositories, you may need additional permissions</li>
|
||||
<li>No token can give you access to repositories you don't have permission for</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-transparent bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
// Internal imports
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||
@@ -13,9 +10,7 @@ import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { extractRelativePath } from '~/utils/diff';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
import type { FileMap, File } from '~/lib/stores/files';
|
||||
|
||||
// UI Components
|
||||
import { Badge, EmptyState, StatusIndicator, SearchInput } from '~/components/ui';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
interface PushToGitHubDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -42,8 +37,6 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
||||
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
||||
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
|
||||
const [repoSearchQuery, setRepoSearchQuery] = useState('');
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
||||
@@ -65,34 +58,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/*
|
||||
* Filter repositories based on search query
|
||||
* const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300);
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
if (recentRepos.length === 0) {
|
||||
setFilteredRepos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repoSearchQuery.trim()) {
|
||||
setFilteredRepos(recentRepos);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = repoSearchQuery.toLowerCase().trim();
|
||||
const filtered = recentRepos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(query) ||
|
||||
(repo.description && repo.description.toLowerCase().includes(query)) ||
|
||||
(repo.language && repo.language.toLowerCase().includes(query)),
|
||||
);
|
||||
|
||||
setFilteredRepos(filtered);
|
||||
}, [recentRepos, repoSearchQuery]);
|
||||
|
||||
const fetchRecentRepos = useCallback(async (token: string) => {
|
||||
const fetchRecentRepos = async (token: string) => {
|
||||
if (!token) {
|
||||
logStore.logError('No GitHub token available');
|
||||
toast.error('GitHub authentication required');
|
||||
@@ -102,32 +68,19 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
|
||||
try {
|
||||
setIsFetchingRepos(true);
|
||||
console.log('Fetching GitHub repositories with token:', token.substring(0, 5) + '...');
|
||||
|
||||
// Fetch ALL repos by paginating through all pages
|
||||
let allRepos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const requestUrl = `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,organization_member`;
|
||||
const response = await fetch(requestUrl, {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: { message?: string } = {};
|
||||
|
||||
try {
|
||||
errorData = await response.json();
|
||||
console.error('Error response data:', errorData);
|
||||
} catch (e) {
|
||||
errorData = { message: 'Could not parse error response' };
|
||||
console.error('Could not parse error response:', e);
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
toast.error('GitHub token expired. Please reconnect your account.');
|
||||
@@ -139,52 +92,29 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
localStorage.removeItem('github_connection');
|
||||
setUser(null);
|
||||
}
|
||||
} else if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
|
||||
// Rate limit exceeded
|
||||
const resetTime = response.headers.get('x-ratelimit-reset');
|
||||
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'soon';
|
||||
toast.error(`GitHub API rate limit exceeded. Limit resets at ${resetDate}`);
|
||||
} else {
|
||||
logStore.logError('Failed to fetch GitHub repositories', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
});
|
||||
toast.error(`Failed to fetch repositories: ${errorData.message || response.statusText}`);
|
||||
toast.error(`Failed to fetch repositories: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repos = (await response.json()) as GitHubRepo[];
|
||||
allRepos = allRepos.concat(repos);
|
||||
|
||||
if (repos.length < 100) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page += 1;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing JSON response:', parseError);
|
||||
logStore.logError('Failed to parse GitHub repositories response', { parseError });
|
||||
toast.error('Failed to parse repository data');
|
||||
setRecentRepos([]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRecentRepos(allRepos);
|
||||
setRecentRepos(repos);
|
||||
} catch (error) {
|
||||
console.error('Exception while fetching GitHub repositories:', error);
|
||||
logStore.logError('Failed to fetch GitHub repositories', { error });
|
||||
toast.error('Failed to fetch recent repositories');
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const connection = getLocalStorage('github_connection');
|
||||
@@ -206,24 +136,15 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
const octokit = new Octokit({ auth: connection.token });
|
||||
|
||||
try {
|
||||
const { data: existingRepo } = await octokit.repos.get({
|
||||
await octokit.repos.get({
|
||||
owner: connection.user.login,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
// If we get here, the repo exists
|
||||
let confirmMessage = `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`;
|
||||
|
||||
// Add visibility change warning if needed
|
||||
if (existingRepo.private !== isPrivate) {
|
||||
const visibilityChange = isPrivate
|
||||
? 'This will also change the repository from public to private.'
|
||||
: 'This will also change the repository from private to public.';
|
||||
|
||||
confirmMessage += `\n\n${visibilityChange}`;
|
||||
}
|
||||
|
||||
const confirmOverwrite = window.confirm(confirmMessage);
|
||||
const confirmOverwrite = window.confirm(
|
||||
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
|
||||
);
|
||||
|
||||
if (!confirmOverwrite) {
|
||||
setIsLoading(false);
|
||||
@@ -256,7 +177,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRepoName('');
|
||||
@@ -280,46 +201,27 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<Dialog.Content
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||
aria-describedby="success-dialog-description"
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500">
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<div className="i-ph:check-circle w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Successfully pushed to GitHub
|
||||
</h3>
|
||||
<p
|
||||
id="success-dialog-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
Your code is now available on GitHub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
<Dialog.Close
|
||||
onClick={handleClose}
|
||||
className="p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
>
|
||||
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Close dialog</span>
|
||||
</button>
|
||||
<div className="i-ph:x w-5 h-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 text-left border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
|
||||
<span className="i-ph:github-logo w-4 h-4 text-purple-500" />
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
|
||||
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
Repository URL
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
||||
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
||||
{createdRepoUrl}
|
||||
</code>
|
||||
<motion.button
|
||||
@@ -327,28 +229,27 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL copied to clipboard');
|
||||
}}
|
||||
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className="i-ph:copy w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
|
||||
<span className="i-ph:files w-4 h-4 text-purple-500" />
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
|
||||
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
Pushed Files ({pushedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar pr-2">
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{pushedFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between py-1.5 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark border-b border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 last:border-0"
|
||||
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||
>
|
||||
<span className="font-mono truncate flex-1 text-xs">{file.path}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
||||
<span className="font-mono truncate flex-1">{file.path}</span>
|
||||
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -373,7 +274,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL copied to clipboard');
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
@@ -382,7 +283,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
@@ -411,57 +312,29 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||
aria-describedby="connection-required-description"
|
||||
>
|
||||
<div className="relative text-center space-y-4">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-0 top-0 p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||
>
|
||||
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Close dialog</span>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
||||
<div className="text-center space-y-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:github-logo w-8 h-8" />
|
||||
<div className="i-ph:github-logo w-6 h-6" />
|
||||
</motion.div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
GitHub Connection Required
|
||||
</h3>
|
||||
<p
|
||||
id="connection-required-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark max-w-md mx-auto"
|
||||
>
|
||||
To push your code to GitHub, you need to connect your GitHub account in Settings {'>'} Connections
|
||||
first.
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
|
||||
</p>
|
||||
<div className="pt-2 flex justify-center gap-3">
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-sm hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x-circle" />
|
||||
Close
|
||||
</motion.button>
|
||||
<motion.a
|
||||
href="/settings/connections"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:gear" />
|
||||
Go to Settings
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
@@ -483,10 +356,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||
aria-describedby="push-dialog-description"
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<motion.div
|
||||
@@ -495,189 +365,130 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
transition={{ delay: 0.1 }}
|
||||
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:github-logo w-5 h-5" />
|
||||
<div className="i-ph:git-branch w-5 h-5" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Push to GitHub
|
||||
</Dialog.Title>
|
||||
<p
|
||||
id="push-dialog-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Push your code to a new or existing GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
<Dialog.Close
|
||||
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
onClick={handleClose}
|
||||
className="ml-auto p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||
>
|
||||
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Close dialog</span>
|
||||
</button>
|
||||
<div className="i-ph:x w-5 h-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 p-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
|
||||
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center text-white">
|
||||
<div className="i-ph:github-logo w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
{user.name || user.login}
|
||||
</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
@{user.login}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="repoName"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Repository Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:git-branch w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
id="repoName"
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
placeholder="my-awesome-project"
|
||||
className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentRepos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Recent Repositories
|
||||
</label>
|
||||
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
{filteredRepos.length} of {recentRepos.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<SearchInput
|
||||
placeholder="Search repositories..."
|
||||
value={repoSearchQuery}
|
||||
onChange={(e) => setRepoSearchQuery(e.target.value)}
|
||||
onClear={() => setRepoSearchQuery('')}
|
||||
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recentRepos.length === 0 && !isFetchingRepos ? (
|
||||
<EmptyState
|
||||
icon="i-ph:github-logo"
|
||||
title="No repositories found"
|
||||
description="We couldn't find any repositories in your GitHub account."
|
||||
variant="compact"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? (
|
||||
<EmptyState
|
||||
icon="i-ph:magnifying-glass"
|
||||
title="No matching repositories"
|
||||
description="Try a different search term"
|
||||
variant="compact"
|
||||
/>
|
||||
) : (
|
||||
filteredRepos.map((repo) => (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
|
||||
<div className="space-y-2">
|
||||
{recentRepos.map((repo) => (
|
||||
<motion.button
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => setRepoName(repo.name)}
|
||||
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30"
|
||||
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-purple-500">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
||||
{repo.name}
|
||||
</span>
|
||||
</div>
|
||||
{repo.private && (
|
||||
<Badge variant="primary" size="sm" icon="i-ph:lock w-3 h-3">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
||||
Private
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-1 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
{repo.language && (
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:code w-3 h-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:code w-3 h-3" />
|
||||
{repo.language}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:star w-3 h-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:git-fork w-3 h-3">
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:clock w-3 h-3">
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock w-3 h-3" />
|
||||
{new Date(repo.updated_at).toLocaleDateString()}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFetchingRepos && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<StatusIndicator status="loading" pulse={true} label="Loading repositories..." />
|
||||
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
||||
Loading repositories...
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="private"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3"
|
||||
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="private"
|
||||
className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||
>
|
||||
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Make repository private
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark mt-2 ml-6">
|
||||
Private repositories are only visible to you and people you share them with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
@@ -695,12 +506,12 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
Pushing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:github-logo w-4 h-4" />
|
||||
<div className="i-ph:git-branch w-4 h-4" />
|
||||
Push to GitHub
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||
|
||||
interface RepositoryCardProps {
|
||||
repo: GitHubRepoInfo;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function RepositoryCard({ repo, onSelect }: RepositoryCardProps) {
|
||||
// Use a consistent styling for all repository cards
|
||||
const getCardStyle = () => {
|
||||
return 'from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark';
|
||||
};
|
||||
|
||||
// Format the date in a more readable format
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 1) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
if (diffDays <= 2) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
if (diffDays <= 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
if (diffDays <= 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const cardStyle = useMemo(() => getCardStyle(), []);
|
||||
|
||||
// const formattedDate = useMemo(() => formatDate(repo.updated_at), [repo.updated_at]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`p-5 rounded-xl bg-gradient-to-br ${cardStyle} border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md`}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
y: -2,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80 backdrop-blur-sm flex items-center justify-center text-purple-500 shadow-sm">
|
||||
<span className="i-ph:git-branch w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
|
||||
{repo.name}
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||
<span className="i-ph:user w-3 h-3" />
|
||||
{repo.full_name.split('/')[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={onSelect}
|
||||
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-3.5 h-3.5" />
|
||||
Import
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{repo.private && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-purple-500/10 text-purple-600 dark:text-purple-400 text-xs">
|
||||
<span className="i-ph:lock w-3 h-3" />
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:code w-3 h-3" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
{repo.forks_count > 0 && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:clock w-3 h-3" />
|
||||
Updated {formatDate(repo.updated_at)}
|
||||
</span>
|
||||
|
||||
{repo.topics && repo.topics.length > 0 && (
|
||||
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
{repo.topics.slice(0, 1).map((topic) => (
|
||||
<span
|
||||
key={topic}
|
||||
className="px-1.5 py-0.5 rounded-full bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 text-xs"
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
{repo.topics.length > 1 && <span className="ml-1">+{repo.topics.length - 1}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Create a context to share the setShowAuthDialog function with child components
|
||||
export interface RepositoryDialogContextType {
|
||||
setShowAuthDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// Default context value with a no-op function
|
||||
export const RepositoryDialogContext = createContext<RepositoryDialogContextType>({
|
||||
// This is intentionally empty as it will be overridden by the provider
|
||||
setShowAuthDialog: () => {
|
||||
// No operation
|
||||
},
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||
import { EmptyState, StatusIndicator } from '~/components/ui';
|
||||
import { RepositoryCard } from './RepositoryCard';
|
||||
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||
|
||||
interface RepositoryListProps {
|
||||
repos: GitHubRepoInfo[];
|
||||
isLoading: boolean;
|
||||
onSelect: (repo: GitHubRepoInfo) => void;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
export function RepositoryList({ repos, isLoading, onSelect, activeTab }: RepositoryListProps) {
|
||||
// Access the parent component's setShowAuthDialog function
|
||||
const { setShowAuthDialog } = useContext(RepositoryDialogContext);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<StatusIndicator status="loading" pulse={true} size="lg" label="Loading repositories..." className="mb-2" />
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
This may take a moment
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (repos.length === 0) {
|
||||
if (activeTab === 'my-repos') {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="i-ph:folder-simple-dashed"
|
||||
title="No repositories found"
|
||||
description="Connect your GitHub account or create a new repository to get started"
|
||||
actionLabel="Connect GitHub Account"
|
||||
onAction={() => setShowAuthDialog(true)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="i-ph:magnifying-glass"
|
||||
title="No repositories found"
|
||||
description="Try searching with different keywords or filters"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
|
||||
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
// Import UI components
|
||||
import { Input, SearchInput, Badge, FilterChip } from '~/components/ui';
|
||||
|
||||
// Import the components we've extracted
|
||||
import { RepositoryList } from './RepositoryList';
|
||||
import { StatsDialog } from './StatsDialog';
|
||||
import { GitHubAuthDialog } from './GitHubAuthDialog';
|
||||
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
|
||||
interface GitHubTreeResponse {
|
||||
tree: Array<{
|
||||
@@ -36,6 +28,100 @@ interface SearchFilters {
|
||||
forks?: number;
|
||||
}
|
||||
|
||||
interface StatsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
stats: RepositoryStats;
|
||||
isLargeRepo?: boolean;
|
||||
}
|
||||
|
||||
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
|
||||
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
||||
<span>Total Files: {stats.totalFiles}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
||||
<span>Total Size: {formatSize(stats.totalSize)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
||||
<span>
|
||||
Languages:{' '}
|
||||
{Object.entries(stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 3)
|
||||
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
{stats.hasPackageJson && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:package text-purple-500 w-4 h-4" />
|
||||
<span>Has package.json</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.hasDependencies && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
|
||||
<span>Has dependencies</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLargeRepo && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
||||
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-yellow-800 dark:text-yellow-500">
|
||||
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
||||
and could impact performance.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -47,78 +133,13 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState('');
|
||||
const [filters, setFilters] = useState<SearchFilters>({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [stats, setStats] = useState<RepositoryStats | null>(null);
|
||||
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
||||
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
||||
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
|
||||
// Handle GitHub auth dialog close and refresh repositories
|
||||
const handleAuthDialogClose = () => {
|
||||
setShowAuthDialog(false);
|
||||
|
||||
// If we're on the my-repos tab, refresh the repository list
|
||||
if (activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize GitHub connection and fetch repositories
|
||||
useEffect(() => {
|
||||
const savedConnection = getLocalStorage('github_connection');
|
||||
|
||||
// If no connection exists but environment variables are set, create a connection
|
||||
if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
|
||||
const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
|
||||
const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic';
|
||||
|
||||
// Fetch GitHub user info to initialize the connection
|
||||
fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then((data: unknown) => {
|
||||
const userData = data as GitHubUserResponse;
|
||||
|
||||
// Save connection to local storage
|
||||
const newConnection = {
|
||||
token,
|
||||
tokenType,
|
||||
user: {
|
||||
login: userData.login,
|
||||
avatar_url: userData.avatar_url,
|
||||
name: userData.name || userData.login,
|
||||
},
|
||||
connected_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
|
||||
// Also save as cookies for API requests
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', userData.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Refresh repositories after connection is established
|
||||
if (isOpen && activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to initialize GitHub connection from environment variables:', error);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch repositories when dialog opens or tab changes
|
||||
// Fetch user's repositories when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
@@ -138,7 +159,6 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
},
|
||||
});
|
||||
@@ -218,15 +238,10 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
const headers: HeadersInit = connection?.token
|
||||
? {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
}
|
||||
: {};
|
||||
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -270,97 +285,21 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
|
||||
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
||||
try {
|
||||
// Extract branch from URL if present (format: url#branch)
|
||||
let branch: string | null = null;
|
||||
let cleanUrl = repoUrl;
|
||||
|
||||
if (repoUrl.includes('#')) {
|
||||
const parts = repoUrl.split('#');
|
||||
cleanUrl = parts[0];
|
||||
branch = parts[1];
|
||||
}
|
||||
|
||||
const [owner, repo] = cleanUrl
|
||||
const [owner, repo] = repoUrl
|
||||
.replace(/\.git$/, '')
|
||||
.split('/')
|
||||
.slice(-2);
|
||||
|
||||
// Try to get token from local storage first
|
||||
const connection = getLocalStorage('github_connection');
|
||||
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
||||
|
||||
// If no connection in local storage, check environment variables
|
||||
let headers: HeadersInit = {};
|
||||
|
||||
if (connection?.token) {
|
||||
headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
};
|
||||
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
|
||||
// Use token from environment variables
|
||||
headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`,
|
||||
};
|
||||
}
|
||||
|
||||
// First, get the repository info to determine the default branch
|
||||
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
// Fetch repository tree
|
||||
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!repoInfoResponse.ok) {
|
||||
if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) {
|
||||
throw new Error(
|
||||
`Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`,
|
||||
);
|
||||
} else if (repoInfoResponse.status === 404) {
|
||||
throw new Error(
|
||||
`Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const repoInfo = (await repoInfoResponse.json()) as { default_branch: string };
|
||||
let defaultBranch = repoInfo.default_branch || 'main';
|
||||
|
||||
// If a branch was specified in the URL, use that instead of the default
|
||||
if (branch) {
|
||||
defaultBranch = branch;
|
||||
}
|
||||
|
||||
// Try to fetch the repository tree using the selected branch
|
||||
let treeResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
// If the selected branch doesn't work, try common branch names
|
||||
if (!treeResponse.ok) {
|
||||
// Try 'master' branch if default branch failed
|
||||
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// If master also fails, try 'main' branch
|
||||
if (!treeResponse.ok) {
|
||||
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// If all common branches fail, throw an error
|
||||
if (!treeResponse.ok) {
|
||||
throw new Error(
|
||||
'Failed to fetch repository structure. Please check the repository URL and your access permissions.',
|
||||
);
|
||||
}
|
||||
throw new Error('Failed to fetch repository structure');
|
||||
}
|
||||
|
||||
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
|
||||
@@ -417,27 +356,12 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
hasDependencies,
|
||||
};
|
||||
|
||||
setStats(stats);
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Error verifying repository:', error);
|
||||
|
||||
// Check if it's an authentication error and show the auth dialog
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository';
|
||||
|
||||
if (
|
||||
errorMessage.includes('Authentication failed') ||
|
||||
errorMessage.includes('may be private') ||
|
||||
errorMessage.includes('Repository not found or is private') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('403') ||
|
||||
errorMessage.includes('404') ||
|
||||
errorMessage.includes('access permissions')
|
||||
) {
|
||||
setShowAuthDialog(true);
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
toast.error('Failed to verify repository');
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -471,36 +395,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
setShowStatsDialog(true);
|
||||
} catch (error) {
|
||||
console.error('Error preparing repository:', error);
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.';
|
||||
|
||||
// Show the GitHub auth dialog for any authentication or permission errors
|
||||
if (
|
||||
errorMessage.includes('Authentication failed') ||
|
||||
errorMessage.includes('may be private') ||
|
||||
errorMessage.includes('Repository not found or is private') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('403') ||
|
||||
errorMessage.includes('404') ||
|
||||
errorMessage.includes('access permissions')
|
||||
) {
|
||||
// Directly show the auth dialog instead of just showing a toast
|
||||
setShowAuthDialog(true);
|
||||
|
||||
toast.error(
|
||||
<div className="space-y-2">
|
||||
<p>{errorMessage}</p>
|
||||
<button onClick={() => setShowAuthDialog(true)} className="underline font-medium block text-purple-500">
|
||||
Learn how to access private repositories
|
||||
</button>
|
||||
</div>,
|
||||
{ autoClose: 10000 }, // Keep the toast visible longer
|
||||
);
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
toast.error('Failed to prepare repository. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -533,7 +428,6 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
};
|
||||
|
||||
return (
|
||||
<RepositoryDialogContext.Provider value={{ setShowAuthDialog }}>
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -544,26 +438,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
||||
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[650px] max-h-[85vh] overflow-hidden bg-white dark:bg-bolt-elements-background-depth-1 rounded-xl shadow-xl z-[51] border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/10 flex items-center justify-center text-purple-500 shadow-sm">
|
||||
<span className="i-ph:github-logo w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
||||
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
||||
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Import GitHub Repository
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Clone a repository from GitHub to your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
onClick={handleClose}
|
||||
className={classNames(
|
||||
'p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent',
|
||||
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
||||
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
||||
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
||||
@@ -575,389 +458,127 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Auth Info Banner */}
|
||||
<div className="p-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between bg-gradient-to-r from-bolt-elements-background-depth-2 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-3 dark:to-bolt-elements-background-depth-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:info text-blue-500" />
|
||||
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Need to access private repositories?
|
||||
</span>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => setShowAuthDialog(true)}
|
||||
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5 shadow-sm"
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 4px 8px rgba(124, 58, 237, 0.2)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="i-ph:github-logo w-4 h-4" />
|
||||
Connect GitHub Account
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="bg-[#f0f0f0] dark:bg-[#1e1e1e] rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('my-repos')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'my-repos'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
||||
<span className="i-ph:book-bookmark" />
|
||||
My Repos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'search'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
||||
<span className="i-ph:magnifying-glass" />
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('url')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'url'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
From URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
||||
<span className="i-ph:link" />
|
||||
URL
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{activeTab === 'url' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="bg-gradient-to-br from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||
<span className="i-ph:link-simple w-4 h-4 text-purple-500" />
|
||||
Repository URL
|
||||
</h3>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-purple-500">
|
||||
<span className="i-ph:github-logo w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter GitHub repository URL (e.g., https://github.com/user/repo)"
|
||||
placeholder="Enter repository URL"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
className="w-full pl-10 py-3 border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
className={classNames('w-full', {
|
||||
'border-red-500': false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||
<span>
|
||||
You can paste any GitHub repository URL, including specific branches or tags.
|
||||
<br />
|
||||
<span className="text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
Example: https://github.com/username/repository/tree/branch-name
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||
<span>Ready to import?</span>
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!customUrl}
|
||||
className={classNames(
|
||||
'w-full h-12 px-4 py-2 rounded-xl text-white transition-all duration-200 flex items-center gap-2 justify-center',
|
||||
customUrl
|
||||
? 'bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 shadow-md'
|
||||
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
|
||||
)}
|
||||
whileHover={customUrl ? { scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' } : {}}
|
||||
whileTap={customUrl ? { scale: 0.98 } : {}}
|
||||
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||
Import Repository
|
||||
</motion.button>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'search' && (
|
||||
<div className="space-y-5 mb-5">
|
||||
<div className="bg-gradient-to-br from-blue-500/5 to-cyan-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||
<span className="i-ph:magnifying-glass w-4 h-4 text-blue-500" />
|
||||
Search GitHub
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<SearchInput
|
||||
placeholder="Search GitHub repositories..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (e.target.value.length > 2) {
|
||||
handleSearch(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
iconClassName="text-blue-500"
|
||||
className="py-3 bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm"
|
||||
loading={isLoading}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
||||
/>
|
||||
</div>
|
||||
<motion.button
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
className="px-3 py-2 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title="Clear filters"
|
||||
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
>
|
||||
<span className="i-ph:funnel-simple w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
Filters
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{(filters.language || filters.stars || filters.forks) && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<AnimatePresence>
|
||||
{filters.language && (
|
||||
<FilterChip
|
||||
label="Language"
|
||||
value={filters.language}
|
||||
icon="i-ph:code"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.language;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filters.stars && (
|
||||
<FilterChip
|
||||
label="Stars"
|
||||
value={`>${filters.stars}`}
|
||||
icon="i-ph:star"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.stars;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filters.forks && (
|
||||
<FilterChip
|
||||
label="Forks"
|
||||
value={`>${filters.forks}`}
|
||||
icon="i-ph:git-fork"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.forks;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="relative col-span-3 md:col-span-1">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:code w-3.5 h-3.5" />
|
||||
<span className="i-ph:funnel-simple" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Language (e.g., javascript)"
|
||||
placeholder="Filter by language..."
|
||||
value={filters.language || ''}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, language: e.target.value });
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:star w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min stars"
|
||||
placeholder="Min stars..."
|
||||
value={filters.stars || ''}
|
||||
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:git-fork w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min forks"
|
||||
placeholder="Min forks..."
|
||||
value={filters.forks || ''}
|
||||
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||
<span>
|
||||
Search for repositories by name, description, or topics. Use filters to narrow down
|
||||
results.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{selectedRepository ? (
|
||||
<div className="space-y-5 bg-gradient-to-br from-purple-500/5 to-blue-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.button
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedRepository(null)}
|
||||
className="p-2 rounded-lg hover:bg-white dark:hover:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary shadow-sm"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
||||
>
|
||||
<span className="i-ph:arrow-left w-4 h-4" />
|
||||
</motion.button>
|
||||
<div>
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-lg">
|
||||
{selectedRepository.name}
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||
<span className="i-ph:user w-3 h-3" />
|
||||
{selectedRepository.full_name.split('/')[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRepository.private && (
|
||||
<Badge variant="primary" size="md" icon="i-ph:lock w-3 h-3">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRepository.description && (
|
||||
<div className="bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
{selectedRepository.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedRepository.language && (
|
||||
<Badge variant="subtle" size="md" icon="i-ph:code w-3 h-3">
|
||||
{selectedRepository.language}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="subtle" size="md" icon="i-ph:star w-3 h-3">
|
||||
{selectedRepository.stargazers_count.toLocaleString()}
|
||||
</Badge>
|
||||
{selectedRepository.forks_count > 0 && (
|
||||
<Badge variant="subtle" size="md" icon="i-ph:git-fork w-3 h-3">
|
||||
{selectedRepository.forks_count.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Select Branch
|
||||
</label>
|
||||
</button>
|
||||
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
||||
className="w-full px-3 py-3 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-purple-500 shadow-sm"
|
||||
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||
>
|
||||
{branches.map((branch) => (
|
||||
<option
|
||||
key={branch.name}
|
||||
value={branch.name}
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||
>
|
||||
{branch.name} {branch.default ? '(default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||
<span>Ready to import?</span>
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="w-full h-12 px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white transition-all duration-200 flex items-center gap-2 justify-center shadow-md"
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||
Import {selectedRepository.name}
|
||||
</motion.button>
|
||||
Import Selected Branch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RepositoryList
|
||||
@@ -973,21 +594,100 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
|
||||
{/* GitHub Auth Dialog */}
|
||||
<GitHubAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
|
||||
|
||||
{/* Repository Stats Dialog */}
|
||||
{currentStats && (
|
||||
<StatsDialog
|
||||
isOpen={showStatsDialog}
|
||||
onClose={() => setShowStatsDialog(false)}
|
||||
onClose={handleStatsConfirm}
|
||||
onConfirm={handleStatsConfirm}
|
||||
stats={currentStats}
|
||||
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
|
||||
/>
|
||||
)}
|
||||
</Dialog.Root>
|
||||
</RepositoryDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
|
||||
active
|
||||
? 'bg-purple-500 text-white hover:bg-purple-600'
|
||||
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryList({
|
||||
repos,
|
||||
isLoading,
|
||||
onSelect,
|
||||
activeTab,
|
||||
}: {
|
||||
repos: GitHubRepoInfo[];
|
||||
isLoading: boolean;
|
||||
onSelect: (repo: GitHubRepoInfo) => void;
|
||||
activeTab: string;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:spinner animate-spin mr-2" />
|
||||
Loading repositories...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (repos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
|
||||
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
|
||||
}
|
||||
|
||||
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
|
||||
>
|
||||
<span className="i-ph:download-simple w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
|
||||
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="i-ph:code" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="i-ph:star" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="i-ph:clock" />
|
||||
{new Date(repo.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { RepositoryStats } from '~/types/GitHub';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
import { RepositoryStats as RepoStats } from '~/components/ui';
|
||||
|
||||
interface StatsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
stats: RepositoryStats;
|
||||
isLargeRepo?: boolean;
|
||||
}
|
||||
|
||||
export function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500">
|
||||
<span className="i-ph:git-branch w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Repository Overview
|
||||
</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Review repository details before importing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 p-4 rounded-lg">
|
||||
<RepoStats stats={stats} />
|
||||
</div>
|
||||
|
||||
{isLargeRepo && (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
||||
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-yellow-800 dark:text-yellow-500">
|
||||
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
||||
and could impact performance.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark p-4 flex justify-end gap-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-b-lg">
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
Import Repository
|
||||
</motion.button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,384 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
} from 'chart.js';
|
||||
import { Bar, Pie } from 'react-chartjs-2';
|
||||
import type { Chat } from '~/lib/persistence/chats';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Register ChartJS components
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
|
||||
|
||||
type DataVisualizationProps = {
|
||||
chats: Chat[];
|
||||
};
|
||||
|
||||
export function DataVisualization({ chats }: DataVisualizationProps) {
|
||||
const [chatsByDate, setChatsByDate] = useState<Record<string, number>>({});
|
||||
const [messagesByRole, setMessagesByRole] = useState<Record<string, number>>({});
|
||||
const [apiKeyUsage, setApiKeyUsage] = useState<Array<{ provider: string; count: number }>>([]);
|
||||
const [averageMessagesPerChat, setAverageMessagesPerChat] = useState<number>(0);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
setIsDarkMode(isDark);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chats || chats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process chat data
|
||||
const chatDates: Record<string, number> = {};
|
||||
const roleCounts: Record<string, number> = {};
|
||||
const apiUsage: Record<string, number> = {};
|
||||
let totalMessages = 0;
|
||||
|
||||
chats.forEach((chat) => {
|
||||
const date = new Date(chat.timestamp).toLocaleDateString();
|
||||
chatDates[date] = (chatDates[date] || 0) + 1;
|
||||
|
||||
chat.messages.forEach((message) => {
|
||||
roleCounts[message.role] = (roleCounts[message.role] || 0) + 1;
|
||||
totalMessages++;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const providerMatch = message.content.match(/provider:\s*([\w-]+)/i);
|
||||
const provider = providerMatch ? providerMatch[1] : 'unknown';
|
||||
apiUsage[provider] = (apiUsage[provider] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(chatDates).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
|
||||
const sortedChatsByDate: Record<string, number> = {};
|
||||
sortedDates.forEach((date) => {
|
||||
sortedChatsByDate[date] = chatDates[date];
|
||||
});
|
||||
|
||||
setChatsByDate(sortedChatsByDate);
|
||||
setMessagesByRole(roleCounts);
|
||||
setApiKeyUsage(Object.entries(apiUsage).map(([provider, count]) => ({ provider, count })));
|
||||
setAverageMessagesPerChat(totalMessages / chats.length);
|
||||
}, [chats]);
|
||||
|
||||
// Get theme colors from CSS variables to ensure theme consistency
|
||||
const getThemeColor = (varName: string): string => {
|
||||
// Get the CSS variable value from document root
|
||||
if (typeof document !== 'undefined') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
|
||||
// Fallback for SSR
|
||||
return isDarkMode ? '#FFFFFF' : '#000000';
|
||||
};
|
||||
|
||||
// Theme-aware chart colors with enhanced dark mode visibility using CSS variables
|
||||
const chartColors = {
|
||||
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
text: getThemeColor('--bolt-elements-textPrimary'),
|
||||
textSecondary: getThemeColor('--bolt-elements-textSecondary'),
|
||||
background: getThemeColor('--bolt-elements-bg-depth-1'),
|
||||
accent: getThemeColor('--bolt-elements-button-primary-text'),
|
||||
border: getThemeColor('--bolt-elements-borderColor'),
|
||||
};
|
||||
|
||||
const getChartColors = (index: number) => {
|
||||
// Define color palettes based on Bolt design tokens
|
||||
const baseColors = [
|
||||
// Indigo
|
||||
{
|
||||
base: getThemeColor('--bolt-elements-button-primary-text'),
|
||||
},
|
||||
|
||||
// Pink
|
||||
{
|
||||
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
|
||||
},
|
||||
|
||||
// Green
|
||||
{
|
||||
base: getThemeColor('--bolt-elements-icon-success'),
|
||||
},
|
||||
|
||||
// Yellow
|
||||
{
|
||||
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
|
||||
},
|
||||
|
||||
// Blue
|
||||
{
|
||||
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
|
||||
},
|
||||
];
|
||||
|
||||
// Get the base color for this index
|
||||
const color = baseColors[index % baseColors.length].base;
|
||||
|
||||
// Parse color and generate variations with appropriate opacity
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
// Handle rgb/rgba format
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
|
||||
|
||||
if (rgbMatch) {
|
||||
[, r, g, b] = rgbMatch.map(Number);
|
||||
} else if (rgbaMatch) {
|
||||
[, r, g, b] = rgbaMatch.map(Number);
|
||||
} else if (color.startsWith('#')) {
|
||||
// Handle hex format
|
||||
const hex = color.slice(1);
|
||||
const bigint = parseInt(hex, 16);
|
||||
r = (bigint >> 16) & 255;
|
||||
g = (bigint >> 8) & 255;
|
||||
b = bigint & 255;
|
||||
}
|
||||
|
||||
return {
|
||||
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
|
||||
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
|
||||
};
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
history: {
|
||||
labels: Object.keys(chatsByDate),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Chats Created',
|
||||
data: Object.values(chatsByDate),
|
||||
backgroundColor: getChartColors(0).bg,
|
||||
borderColor: getChartColors(0).border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
roles: {
|
||||
labels: Object.keys(messagesByRole),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Messages by Role',
|
||||
data: Object.values(messagesByRole),
|
||||
backgroundColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).bg),
|
||||
borderColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).border),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
apiUsage: {
|
||||
labels: apiKeyUsage.map((item) => item.provider),
|
||||
datasets: [
|
||||
{
|
||||
label: 'API Usage',
|
||||
data: apiKeyUsage.map((item) => item.count),
|
||||
backgroundColor: apiKeyUsage.map((_, i) => getChartColors(i).bg),
|
||||
borderColor: apiKeyUsage.map((_, i) => getChartColors(i).border),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const baseChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
color: chartColors.text,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
size: 12,
|
||||
},
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: 16,
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: chartColors.text,
|
||||
bodyColor: chartColors.text,
|
||||
backgroundColor: isDarkMode
|
||||
? 'rgba(23, 23, 23, 0.8)' // Dark bg using Tailwind gray-900
|
||||
: 'rgba(255, 255, 255, 0.8)', // Light bg
|
||||
borderColor: chartColors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Chat History',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pieOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Message Distribution',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
datalabels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (chats.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="i-ph-chart-line-duotone w-12 h-12 mx-auto mb-4 text-bolt-elements-textTertiary opacity-80" />
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Data Available</h3>
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
Start creating chats to see your usage statistics and data visualization.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-6 rounded-lg shadow-sm',
|
||||
'bg-bolt-elements-bg-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
);
|
||||
|
||||
const statClasses = classNames('text-3xl font-bold text-bolt-elements-textPrimary', 'flex items-center gap-3');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Chats</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chats-duotone w-8 h-8 text-indigo-500 dark:text-indigo-400" />
|
||||
<span>{chats.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Messages</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chat-text-duotone w-8 h-8 text-pink-500 dark:text-pink-400" />
|
||||
<span>{Object.values(messagesByRole).reduce((sum, count) => sum + count, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Avg. Messages/Chat</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chart-bar-duotone w-8 h-8 text-green-500 dark:text-green-400" />
|
||||
<span>{averageMessagesPerChat.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Chat History</h3>
|
||||
<div className="h-64">
|
||||
<Bar data={chartData.history} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Message Distribution</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={chartData.roles} options={pieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKeyUsage.length > 0 && (
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">API Usage by Provider</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={chartData.apiUsage} options={pieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -342,86 +342,24 @@ export default function DebugTab() {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, systemInfo: true }));
|
||||
|
||||
// Get better OS detection
|
||||
const userAgent = navigator.userAgent;
|
||||
let detectedOS = 'Unknown';
|
||||
let detectedArch = 'unknown';
|
||||
|
||||
// Improved OS detection
|
||||
if (userAgent.indexOf('Win') !== -1) {
|
||||
detectedOS = 'Windows';
|
||||
} else if (userAgent.indexOf('Mac') !== -1) {
|
||||
detectedOS = 'macOS';
|
||||
} else if (userAgent.indexOf('Linux') !== -1) {
|
||||
detectedOS = 'Linux';
|
||||
} else if (userAgent.indexOf('Android') !== -1) {
|
||||
detectedOS = 'Android';
|
||||
} else if (/iPhone|iPad|iPod/.test(userAgent)) {
|
||||
detectedOS = 'iOS';
|
||||
}
|
||||
|
||||
// Better architecture detection
|
||||
if (userAgent.indexOf('x86_64') !== -1 || userAgent.indexOf('x64') !== -1 || userAgent.indexOf('WOW64') !== -1) {
|
||||
detectedArch = 'x64';
|
||||
} else if (userAgent.indexOf('x86') !== -1 || userAgent.indexOf('i686') !== -1) {
|
||||
detectedArch = 'x86';
|
||||
} else if (userAgent.indexOf('arm64') !== -1 || userAgent.indexOf('aarch64') !== -1) {
|
||||
detectedArch = 'arm64';
|
||||
} else if (userAgent.indexOf('arm') !== -1) {
|
||||
detectedArch = 'arm';
|
||||
}
|
||||
|
||||
// Get browser info with improved detection
|
||||
const browserName = (() => {
|
||||
if (userAgent.indexOf('Edge') !== -1 || userAgent.indexOf('Edg/') !== -1) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Chrome') !== -1) {
|
||||
return 'Chrome';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Firefox') !== -1) {
|
||||
return 'Firefox';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Safari') !== -1) {
|
||||
return 'Safari';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
})();
|
||||
|
||||
const browserVersionMatch = userAgent.match(/(Edge|Edg|Chrome|Firefox|Safari)[\s/](\d+(\.\d+)*)/);
|
||||
const browserVersion = browserVersionMatch ? browserVersionMatch[2] : 'Unknown';
|
||||
// Get browser info
|
||||
const ua = navigator.userAgent;
|
||||
const browserName = ua.includes('Firefox')
|
||||
? 'Firefox'
|
||||
: ua.includes('Chrome')
|
||||
? 'Chrome'
|
||||
: ua.includes('Safari')
|
||||
? 'Safari'
|
||||
: ua.includes('Edge')
|
||||
? 'Edge'
|
||||
: 'Unknown';
|
||||
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
|
||||
|
||||
// Get performance metrics
|
||||
const memory = (performance as any).memory || {};
|
||||
const timing = performance.timing;
|
||||
const navigation = performance.navigation;
|
||||
const connection = (navigator as any).connection || {};
|
||||
|
||||
// Try to use Navigation Timing API Level 2 when available
|
||||
let loadTime = 0;
|
||||
let domReadyTime = 0;
|
||||
|
||||
try {
|
||||
const navEntries = performance.getEntriesByType('navigation');
|
||||
|
||||
if (navEntries.length > 0) {
|
||||
const navTiming = navEntries[0] as PerformanceNavigationTiming;
|
||||
loadTime = navTiming.loadEventEnd - navTiming.startTime;
|
||||
domReadyTime = navTiming.domContentLoadedEventEnd - navTiming.startTime;
|
||||
} else {
|
||||
// Fall back to older API
|
||||
loadTime = timing.loadEventEnd - timing.navigationStart;
|
||||
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to older API if Navigation Timing API Level 2 is not available
|
||||
loadTime = timing.loadEventEnd - timing.navigationStart;
|
||||
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
|
||||
}
|
||||
const connection = (navigator as any).connection;
|
||||
|
||||
// Get battery info
|
||||
let batteryInfo;
|
||||
@@ -467,9 +405,9 @@ export default function DebugTab() {
|
||||
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
|
||||
|
||||
const systemInfo: SystemInfo = {
|
||||
os: detectedOS,
|
||||
arch: detectedArch,
|
||||
platform: navigator.platform || 'unknown',
|
||||
os: navigator.platform,
|
||||
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
|
||||
platform: navigator.platform,
|
||||
cpus: navigator.hardwareConcurrency + ' cores',
|
||||
memory: {
|
||||
total: formatBytes(totalMemory),
|
||||
@@ -485,7 +423,7 @@ export default function DebugTab() {
|
||||
userAgent: navigator.userAgent,
|
||||
cookiesEnabled: navigator.cookieEnabled,
|
||||
online: navigator.onLine,
|
||||
platform: navigator.platform || 'unknown',
|
||||
platform: navigator.platform,
|
||||
cores: navigator.hardwareConcurrency,
|
||||
},
|
||||
screen: {
|
||||
@@ -507,8 +445,8 @@ export default function DebugTab() {
|
||||
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
|
||||
},
|
||||
timing: {
|
||||
loadTime,
|
||||
domReadyTime,
|
||||
loadTime: timing.loadEventEnd - timing.navigationStart,
|
||||
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
|
||||
readyStart: timing.fetchStart - timing.navigationStart,
|
||||
redirectTime: timing.redirectEnd - timing.redirectStart,
|
||||
appcacheTime: timing.domainLookupStart - timing.fetchStart,
|
||||
@@ -545,23 +483,6 @@ export default function DebugTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format bytes to human readable format with better precision
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
// Return with proper precision based on unit size
|
||||
if (i === 0) {
|
||||
return `${bytes} ${units[i]}`;
|
||||
}
|
||||
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const getWebAppInfo = async () => {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, webAppInfo: true }));
|
||||
@@ -599,6 +520,20 @@ export default function DebugTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format bytes to human readable format
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${Math.round(size)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const handleLogPerformance = () => {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, performance: true }));
|
||||
@@ -1142,7 +1077,7 @@ export default function DebugTab() {
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Export as JSON',
|
||||
icon: 'i-ph:file-js',
|
||||
icon: 'i-ph:file-json',
|
||||
handler: exportDebugInfo,
|
||||
},
|
||||
{
|
||||
@@ -1652,7 +1587,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:circuitry text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Architecture: </span>
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
|
||||
</div>
|
||||
@@ -1662,7 +1597,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:graph text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Node Version: </span>
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
|
||||
</div>
|
||||
@@ -1917,7 +1852,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:graph text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Node Version:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
|
||||
</div>
|
||||
@@ -1952,7 +1887,7 @@ export default function DebugTab() {
|
||||
<>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Repository:</span>
|
||||
<span className="text-bolt-elements-textPrimary">
|
||||
{webAppInfo.gitInfo.github.currentRepo.fullName}
|
||||
|
||||
@@ -763,7 +763,7 @@ export function EventLogsTab() {
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Export as JSON',
|
||||
icon: 'i-ph:file-js',
|
||||
icon: 'i-ph:file-json',
|
||||
handler: exportAsJSON,
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -455,7 +455,7 @@ const UpdateTab = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p>
|
||||
Updates are fetched from: <span className="font-mono">stackblitz-labs/bolt.diy</span> (Slate upstream) (
|
||||
Updates are fetched from: <span className="font-mono">stackblitz-labs/bolt.diy</span> (
|
||||
{isLatestBranch ? 'main' : 'stable'} branch)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
@@ -553,7 +553,7 @@ const UpdateTab = () => {
|
||||
<DialogDescription>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
A new version is available from <span className="font-mono">stackblitz-labs/bolt.diy</span> (Slate upstream) (
|
||||
A new version is available from <span className="font-mono">stackblitz-labs/bolt.diy</span> (
|
||||
{isLatestBranch ? 'main' : 'stable'} branch)
|
||||
</p>
|
||||
|
||||
|
||||
@@ -35,11 +35,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
|
||||
const actions = useStore(
|
||||
computed(artifact.runner.actions, (actions) => {
|
||||
// Filter out Supabase actions except for migrations
|
||||
return Object.values(actions).filter((action) => {
|
||||
// Exclude actions with type 'supabase' or actions that contain 'supabase' in their content
|
||||
return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase'));
|
||||
});
|
||||
return Object.values(actions);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -54,30 +50,15 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
}
|
||||
|
||||
if (actions.length !== 0 && artifact.type === 'bundled') {
|
||||
const finished = !actions.find(
|
||||
(action) => action.status !== 'complete' && !(action.type === 'start' && action.status === 'running'),
|
||||
);
|
||||
const finished = !actions.find((action) => action.status !== 'complete');
|
||||
|
||||
if (allActionFinished !== finished) {
|
||||
setAllActionFinished(finished);
|
||||
}
|
||||
}
|
||||
}, [actions, artifact.type, allActionFinished]);
|
||||
|
||||
// Determine the dynamic title based on state for bundled artifacts
|
||||
const dynamicTitle =
|
||||
artifact?.type === 'bundled'
|
||||
? allActionFinished
|
||||
? artifact.id === 'restored-project-setup'
|
||||
? 'Project Restored' // Title when restore is complete
|
||||
: 'Project Created' // Title when initial creation is complete
|
||||
: artifact.id === 'restored-project-setup'
|
||||
? 'Restoring Project...' // Title during restore
|
||||
: 'Creating Project...' // Title during initial creation
|
||||
: artifact?.title; // Fallback to original title for non-bundled or if artifact is missing
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
|
||||
<div className="flex">
|
||||
<button
|
||||
@@ -87,17 +68,24 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
workbenchStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
{artifact.type == 'bundled' && (
|
||||
<>
|
||||
<div className="p-4">
|
||||
{allActionFinished ? (
|
||||
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
||||
) : (
|
||||
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
</>
|
||||
)}
|
||||
<div className="px-5 p-3.5 w-full text-left">
|
||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
||||
{/* Use the dynamic title here */}
|
||||
{dynamicTitle}
|
||||
</div>
|
||||
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
|
||||
Click to open Workbench
|
||||
</div>
|
||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
|
||||
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
|
||||
</div>
|
||||
</button>
|
||||
{artifact.type !== 'bundled' && <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />}
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
<AnimatePresence>
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<motion.button
|
||||
@@ -115,25 +103,6 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{artifact.type === 'bundled' && (
|
||||
<div className="flex items-center gap-1.5 p-5 bg-bolt-elements-actions-background border-t border-bolt-elements-artifacts-borderColor">
|
||||
<div className={classNames('text-lg', getIconColor(allActionFinished ? 'complete' : 'running'))}>
|
||||
{allActionFinished ? (
|
||||
<div className="i-ph:check"></div>
|
||||
) : (
|
||||
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
||||
{/* This status text remains the same */}
|
||||
{allActionFinished
|
||||
? artifact.id === 'restored-project-setup'
|
||||
? 'Restore files from snapshot'
|
||||
: 'Initial files created'
|
||||
: 'Creating initial files'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
||||
<motion.div
|
||||
@@ -152,7 +121,6 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { memo, Fragment } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Markdown } from './Markdown';
|
||||
import type { JSONValue } from 'ai';
|
||||
import Popover from '~/components/ui/Popover';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
annotations?: JSONValue[];
|
||||
messageId?: string;
|
||||
onRewind?: (messageId: string) => void;
|
||||
onFork?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
function openArtifactInWorkbench(filePath: string) {
|
||||
@@ -38,7 +34,7 @@ function normalizedFilePath(path: string) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content, annotations, messageId, onRewind, onFork }: AssistantMessageProps) => {
|
||||
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
||||
@@ -82,7 +78,7 @@ export const AssistantMessage = memo(({ content, annotations, messageId, onRewin
|
||||
{codeContext.map((x) => {
|
||||
const normalized = normalizedFilePath(x);
|
||||
return (
|
||||
<Fragment key={normalized}>
|
||||
<>
|
||||
<code
|
||||
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
@@ -93,7 +89,7 @@ export const AssistantMessage = memo(({ content, annotations, messageId, onRewin
|
||||
>
|
||||
{normalized}
|
||||
</code>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -104,35 +100,11 @@ export const AssistantMessage = memo(({ content, annotations, messageId, onRewin
|
||||
<div className="context"></div>
|
||||
</Popover>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{usage && (
|
||||
<div>
|
||||
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
|
||||
</div>
|
||||
)}
|
||||
{(onRewind || onFork) && messageId && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row ml-auto">
|
||||
{onRewind && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => onRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className="i-ph:arrow-u-up-left text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
{onFork && (
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => onFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className="i-ph:git-fork text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<Markdown html>{content}</Markdown>
|
||||
|
||||
@@ -29,20 +29,12 @@ import type { ProviderInfo } from '~/types/model';
|
||||
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
||||
import { toast } from 'react-toastify';
|
||||
import StarterTemplates from './StarterTemplates';
|
||||
import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions';
|
||||
import DeployChatAlert from '~/components/deploy/DeployAlert';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import ChatAlert from './ChatAlert';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import ProgressCompilation from './ProgressCompilation';
|
||||
import type { ProgressAnnotation } from '~/types/context';
|
||||
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
||||
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
|
||||
import { SupabaseConnection } from './SupabaseConnection';
|
||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -53,7 +45,6 @@ interface BaseChatProps {
|
||||
showChat?: boolean;
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
onStreamingChange?: (streaming: boolean) => void;
|
||||
messages?: Message[];
|
||||
description?: string;
|
||||
enhancingPrompt?: boolean;
|
||||
@@ -76,22 +67,18 @@ interface BaseChatProps {
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
actionAlert?: ActionAlert;
|
||||
clearAlert?: () => void;
|
||||
supabaseAlert?: SupabaseAlert;
|
||||
clearSupabaseAlert?: () => void;
|
||||
deployAlert?: DeployAlert;
|
||||
clearDeployAlert?: () => void;
|
||||
data?: JSONValue[] | undefined;
|
||||
actionRunner?: ActionRunner;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
messageRef,
|
||||
scrollRef,
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
isStreaming = false,
|
||||
onStreamingChange,
|
||||
model,
|
||||
setModel,
|
||||
provider,
|
||||
@@ -114,12 +101,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
messages,
|
||||
actionAlert,
|
||||
clearAlert,
|
||||
deployAlert,
|
||||
clearDeployAlert,
|
||||
supabaseAlert,
|
||||
clearSupabaseAlert,
|
||||
data,
|
||||
actionRunner,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -132,15 +114,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
|
||||
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
||||
const expoUrl = useStore(expoUrlAtom);
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (expoUrl) {
|
||||
setQrModalOpen(true);
|
||||
}
|
||||
}, [expoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const progressList = data.filter(
|
||||
@@ -153,10 +126,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
console.log(transcript);
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@@ -347,52 +316,32 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<StickToBottom
|
||||
className={classNames('pt-6 px-2 sm:px-6 relative', {
|
||||
'h-full flex flex-col modern-scrollbar': chatStarted,
|
||||
<div
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
|
||||
})}
|
||||
resize="smooth"
|
||||
initial="smooth"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<StickToBottom.Content className="flex flex-col gap-4">
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
|
||||
<Messages
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
ref={messageRef}
|
||||
className="flex flex-col "
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
</StickToBottom.Content>
|
||||
<div
|
||||
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
|
||||
'sticky bottom-2': chatStarted,
|
||||
'position-absolute': chatStarted,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{deployAlert && (
|
||||
<DeployChatAlert
|
||||
alert={deployAlert}
|
||||
clearAlert={() => clearDeployAlert?.()}
|
||||
postMessage={(message: string | undefined) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{supabaseAlert && (
|
||||
<SupabaseChatAlert
|
||||
alert={supabaseAlert}
|
||||
clearAlert={() => clearSupabaseAlert?.()}
|
||||
postMessage={(message) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actionAlert && (
|
||||
<ChatAlert
|
||||
alert={actionAlert}
|
||||
@@ -403,12 +352,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScrollToBottom />
|
||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||
<div
|
||||
className={classNames(
|
||||
'relative bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
|
||||
/*
|
||||
* {
|
||||
@@ -458,9 +405,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
apiKeys={apiKeys}
|
||||
modelLoading={isModelLoading}
|
||||
/>
|
||||
{(providerList || []).length > 0 &&
|
||||
provider &&
|
||||
(!LOCAL_PROVIDERS.includes(provider.name) || 'OpenAILike') && (
|
||||
{(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider.name] || ''}
|
||||
@@ -563,7 +508,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
}}
|
||||
placeholder="How can Slate help you today?"
|
||||
placeholder="How can Bolt help you today?"
|
||||
translate="no"
|
||||
/>
|
||||
<ClientOnly>
|
||||
@@ -635,23 +580,21 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
a new line
|
||||
</div>
|
||||
) : null}
|
||||
<SupabaseConnection />
|
||||
<ExpoQrModal open={qrModalOpen} onClose={() => setQrModalOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StickToBottom>
|
||||
<div className="flex flex-col justify-center">
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<div className="flex flex-col justify-center mt-6 gap-5">
|
||||
<div className="flex justify-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} />
|
||||
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
</div>
|
||||
|
||||
{ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
@@ -659,19 +602,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
{!chatStarted && <StarterTemplates />}
|
||||
<StarterTemplates />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<Workbench
|
||||
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -679,19 +614,3 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
|
||||
function ScrollToBottom() {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<button
|
||||
className="absolute z-50 top-[0%] translate-y-[-100%] text-4xl rounded-lg left-[50%] translate-x-[-50%] px-1.5 py-0.5 flex items-center gap-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
|
||||
onClick={() => scrollToBottom()}
|
||||
>
|
||||
Go to last message
|
||||
<span className="i-ph:arrow-down animate-bounce" />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useChat } from 'ai/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
||||
import { description, useChatHistory } from '~/lib/persistence';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
@@ -24,9 +24,6 @@ import { useSearchParams } from '@remix-run/react';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { filesToArtifacts } from '~/utils/fileUtils';
|
||||
import { supabaseConnection } from '~/lib/stores/supabase';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -81,7 +78,6 @@ export function Chat() {
|
||||
position="bottom-right"
|
||||
pauseOnFocusLoss
|
||||
transition={toastAnimation}
|
||||
autoClose={3000}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -125,12 +121,6 @@ export const ChatImpl = memo(
|
||||
const [fakeLoading, setFakeLoading] = useState(false);
|
||||
const files = useStore(workbenchStore.files);
|
||||
const actionAlert = useStore(workbenchStore.alert);
|
||||
const deployAlert = useStore(workbenchStore.deployAlert);
|
||||
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
|
||||
const selectedProject = supabaseConn.stats?.projects?.find(
|
||||
(project) => project.id === supabaseConn.selectedProjectId,
|
||||
);
|
||||
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
|
||||
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
|
||||
|
||||
const [model, setModel] = useState(() => {
|
||||
@@ -168,14 +158,6 @@ export const ChatImpl = memo(
|
||||
files,
|
||||
promptId,
|
||||
contextOptimization: contextOptimizationEnabled,
|
||||
supabase: {
|
||||
isConnected: supabaseConn.isConnected,
|
||||
hasSelectedProject: !!selectedProject,
|
||||
credentials: {
|
||||
supabaseUrl: supabaseConn?.credentials?.supabaseUrl,
|
||||
anonKey: supabaseConn?.credentials?.anonKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
sendExtraMessageFields: true,
|
||||
onError: (e) => {
|
||||
@@ -310,9 +292,6 @@ export const ChatImpl = memo(
|
||||
return;
|
||||
}
|
||||
|
||||
// If no locked items, proceed normally with the original message
|
||||
const finalMessageContent = messageContent;
|
||||
|
||||
runAnimation();
|
||||
|
||||
if (!chatStarted) {
|
||||
@@ -320,7 +299,7 @@ export const ChatImpl = memo(
|
||||
|
||||
if (autoSelectTemplate) {
|
||||
const { template, title } = await selectStarterTemplate({
|
||||
message: finalMessageContent,
|
||||
message: messageContent,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
@@ -340,41 +319,23 @@ export const ChatImpl = memo(
|
||||
const { assistantMessage, userMessage } = temResp;
|
||||
setMessages([
|
||||
{
|
||||
id: `1-${new Date().getTime()}`,
|
||||
id: `${new Date().getTime()}`,
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
image: imageData,
|
||||
})),
|
||||
] as any,
|
||||
content: messageContent,
|
||||
},
|
||||
{
|
||||
id: `2-${new Date().getTime()}`,
|
||||
id: `${new Date().getTime()}`,
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
},
|
||||
{
|
||||
id: `3-${new Date().getTime()}`,
|
||||
id: `${new Date().getTime()}`,
|
||||
role: 'user',
|
||||
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
|
||||
annotations: ['hidden'],
|
||||
},
|
||||
]);
|
||||
reload();
|
||||
setInput('');
|
||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
setFakeLoading(false);
|
||||
|
||||
return;
|
||||
@@ -390,7 +351,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@@ -401,15 +362,6 @@ export const ChatImpl = memo(
|
||||
]);
|
||||
reload();
|
||||
setFakeLoading(false);
|
||||
setInput('');
|
||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -418,18 +370,17 @@ export const ChatImpl = memo(
|
||||
setMessages(messages.slice(0, -1));
|
||||
}
|
||||
|
||||
const modifiedFiles = workbenchStore.getModifiedFiles();
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
|
||||
if (modifiedFiles !== undefined) {
|
||||
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
|
||||
if (fileModifications !== undefined) {
|
||||
append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@@ -445,7 +396,7 @@ export const ChatImpl = memo(
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
@@ -486,6 +437,8 @@ export const ChatImpl = memo(
|
||||
[],
|
||||
);
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
@@ -512,9 +465,6 @@ export const ChatImpl = memo(
|
||||
showChat={showChat}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading || fakeLoading}
|
||||
onStreamingChange={(streaming) => {
|
||||
streamingState.set(streaming);
|
||||
}}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
@@ -523,6 +473,8 @@ export const ChatImpl = memo(
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
providerList={activeProviders}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
onTextareaChange(e);
|
||||
debouncedCachePrompt(e);
|
||||
@@ -559,10 +511,6 @@ export const ChatImpl = memo(
|
||||
setImageDataList={setImageDataList}
|
||||
actionAlert={actionAlert}
|
||||
clearAlert={() => workbenchStore.clearAlert()}
|
||||
supabaseAlert={supabaseAlert}
|
||||
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
|
||||
deployAlert={deployAlert}
|
||||
clearDeployAlert={() => workbenchStore.clearDeployAlert()}
|
||||
data={chatData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
|
||||
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
{/* Icon */}
|
||||
|
||||
@@ -35,21 +35,18 @@ export const CodeBlock = memo(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let effectiveLanguage = language;
|
||||
|
||||
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
||||
logger.warn(`Unsupported language '${language}', falling back to plaintext`);
|
||||
effectiveLanguage = 'plaintext';
|
||||
logger.warn(`Unsupported language '${language}'`);
|
||||
}
|
||||
|
||||
logger.trace(`Language = ${effectiveLanguage}`);
|
||||
logger.trace(`Language = ${language}`);
|
||||
|
||||
const processCode = async () => {
|
||||
setHTML(await codeToHtml(code, { lang: effectiveLanguage, theme }));
|
||||
setHTML(await codeToHtml(code, { lang: language, theme }));
|
||||
};
|
||||
|
||||
processCode();
|
||||
}, [code, language, theme]);
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative group text-left', className)}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Create a mobile app idea generator' },
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ text: 'Build a simple blog using Astro' },
|
||||
{ text: 'Create a cookie consent form using Material UI' },
|
||||
|
||||
@@ -12,21 +12,18 @@ const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemov
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row overflow-x-auto mx-2 -mt-1 p-2 bg-bolt-elements-background-depth-3 border border-b-none border-bolt-elements-borderColor rounded-lg rounded-b-none">
|
||||
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name + file.size} className="mr-2 relative">
|
||||
{imageDataList[index] && (
|
||||
<div className="relative">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20 rounded-lg" />
|
||||
<div className="relative pt-4 pr-4">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute -top-1 -right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 w-full h-5 flex items-center px-2 rounded-b-lg text-bolt-elements-textTertiary font-thin text-xs bg-bolt-elements-background-depth-2">
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,7 @@ const IGNORE_PATTERNS = [
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
|
||||
// Include this so npm install runs much faster '**/*lock.json',
|
||||
'**/*lock.json',
|
||||
'**/*lock.yaml',
|
||||
];
|
||||
|
||||
@@ -71,7 +70,7 @@ export default function GitCloneButton({ importChat, className }: GitCloneButton
|
||||
// Skip binary files
|
||||
if (
|
||||
content instanceof Uint8Array &&
|
||||
!filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg|vue|svelte)$/i)
|
||||
!filePath.match(/\.(txt|md|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
|
||||
) {
|
||||
skippedFiles.push(filePath);
|
||||
continue;
|
||||
@@ -157,13 +156,13 @@ ${escapeBoltTags(file.content)}
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
title="Clone a Git Repo"
|
||||
variant="default"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
||||
'text-bolt-elements-textPrimary dark:text-white',
|
||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
||||
'border-[#E5E5E5] dark:border-[#333333]',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
className,
|
||||
|
||||
@@ -120,13 +120,13 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
input?.click();
|
||||
}}
|
||||
title="Import Folder"
|
||||
variant="default"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
||||
'text-bolt-elements-textPrimary dark:text-white',
|
||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
||||
'border-[#E5E5E5] dark:border-[#333333]',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
className,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocation } from '@remix-run/react';
|
||||
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { forkChat } from '~/lib/persistence/db';
|
||||
import { toast } from 'react-toastify';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { profileStore } from '~/lib/stores/profile';
|
||||
import { forwardRef } from 'react';
|
||||
@@ -62,7 +63,7 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 py-5 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
||||
isStreaming && isLast,
|
||||
@@ -88,21 +89,42 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
content={content}
|
||||
annotations={message.annotations}
|
||||
messageId={messageId}
|
||||
onRewind={handleRewind}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
'i-ph:arrow-u-up-left',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => handleFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className={classNames(
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isStreaming && (
|
||||
<div className="text-center w-full text-bolt-elements-item-contentAccent i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
@@ -26,28 +27,17 @@ export const ModelSelector = ({
|
||||
}: ModelSelectorProps) => {
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
|
||||
const modelSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [providerSearchQuery, setProviderSearchQuery] = useState('');
|
||||
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
|
||||
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
|
||||
const providerSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const providerDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
|
||||
if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
@@ -55,6 +45,7 @@ export const ModelSelector = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter models based on search query
|
||||
const filteredModels = [...modelList]
|
||||
.filter((e) => e.provider === provider?.name && e.name)
|
||||
.filter(
|
||||
@@ -63,31 +54,20 @@ export const ModelSelector = ({
|
||||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredProviders = providerList.filter((p) =>
|
||||
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Reset focused index when search query changes or dropdown opens/closes
|
||||
useEffect(() => {
|
||||
setFocusedModelIndex(-1);
|
||||
setFocusedIndex(-1);
|
||||
}, [modelSearchQuery, isModelDropdownOpen]);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
setFocusedProviderIndex(-1);
|
||||
}, [providerSearchQuery, isProviderDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModelDropdownOpen && modelSearchInputRef.current) {
|
||||
modelSearchInputRef.current.focus();
|
||||
if (isModelDropdownOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isModelDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProviderDropdownOpen && providerSearchInputRef.current) {
|
||||
providerSearchInputRef.current.focus();
|
||||
}
|
||||
}, [isProviderDropdownOpen]);
|
||||
|
||||
const handleModelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isModelDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
@@ -95,30 +75,50 @@ export const ModelSelector = ({
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1));
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev + 1;
|
||||
|
||||
if (next >= filteredModels.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1));
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev - 1;
|
||||
|
||||
if (next < 0) {
|
||||
return filteredModels.length - 1;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) {
|
||||
const selectedModel = filteredModels[focusedModelIndex];
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
|
||||
const selectedModel = filteredModels[focusedIndex];
|
||||
setModel?.(selectedModel.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
|
||||
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
|
||||
@@ -126,76 +126,25 @@ export const ModelSelector = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isProviderDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedProviderIndex((prev) => (prev + 1 >= filteredProviders.length ? 0 : prev + 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedProviderIndex((prev) => (prev - 1 < 0 ? filteredProviders.length - 1 : prev - 1));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedProviderIndex >= 0 && focusedProviderIndex < filteredProviders.length) {
|
||||
const selectedProvider = filteredProviders[focusedProviderIndex];
|
||||
|
||||
if (setProvider) {
|
||||
setProvider(selectedProvider);
|
||||
|
||||
const firstModel = modelList.find((m) => m.provider === selectedProvider.name);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
break;
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
|
||||
setIsProviderDropdownOpen(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the selected option
|
||||
useEffect(() => {
|
||||
if (focusedModelIndex >= 0 && modelOptionsRef.current[focusedModelIndex]) {
|
||||
modelOptionsRef.current[focusedModelIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
|
||||
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedModelIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedProviderIndex >= 0 && providerOptionsRef.current[focusedProviderIndex]) {
|
||||
providerOptionsRef.current[focusedProviderIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedProviderIndex]);
|
||||
}, [focusedIndex]);
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
useEffect(() => {
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (providerList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider && !providerList.some((p) => p.name === provider.name)) {
|
||||
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
||||
const firstEnabledProvider = providerList[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
if (firstModel) {
|
||||
@@ -216,136 +165,32 @@ export const ModelSelector = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-col sm:flex-row">
|
||||
{/* Provider Combobox */}
|
||||
<div className="relative flex w-full" onKeyDown={handleProviderKeyDown} ref={providerDropdownRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
||||
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
|
||||
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
|
||||
'transition-all cursor-pointer',
|
||||
isProviderDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={() => setIsProviderDropdownOpen(!isProviderDropdownOpen)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsProviderDropdownOpen(!isProviderDropdownOpen);
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
value={provider?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
|
||||
if (newProvider && setProvider) {
|
||||
setProvider(newProvider);
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={isProviderDropdownOpen}
|
||||
aria-controls="provider-listbox"
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="truncate">{provider?.name || 'Select provider'}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
|
||||
isProviderDropdownOpen ? 'rotate-180' : undefined,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProviderDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-20 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
|
||||
role="listbox"
|
||||
id="provider-listbox"
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={providerSearchInputRef}
|
||||
type="text"
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
placeholder="Search providers..."
|
||||
className={classNames(
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
|
||||
'transition-all',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="searchbox"
|
||||
aria-label="Search providers"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'max-h-60 overflow-y-auto',
|
||||
'sm:scrollbar-none',
|
||||
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
|
||||
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
|
||||
'[&::-webkit-scrollbar-track]:rounded-full',
|
||||
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
|
||||
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
|
||||
) : (
|
||||
filteredProviders.map((providerOption, index) => (
|
||||
<div
|
||||
ref={(el) => (providerOptionsRef.current[index] = el)}
|
||||
key={providerOption.name}
|
||||
role="option"
|
||||
aria-selected={provider?.name === providerOption.name}
|
||||
className={classNames(
|
||||
'px-3 py-2 text-sm cursor-pointer',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'outline-none',
|
||||
provider?.name === providerOption.name || focusedProviderIndex === index
|
||||
? 'bg-bolt-elements-background-depth-2'
|
||||
: undefined,
|
||||
focusedProviderIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (setProvider) {
|
||||
setProvider(providerOption);
|
||||
|
||||
const firstModel = modelList.find((m) => m.provider === providerOption.name);
|
||||
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedProviderIndex === index ? 0 : -1}
|
||||
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
|
||||
>
|
||||
{providerOption.name}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Model Combobox */}
|
||||
<div className="relative flex w-full min-w-[70%]" onKeyDown={handleModelKeyDown} ref={modelDropdownRef}>
|
||||
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
||||
@@ -387,13 +232,13 @@ export const ModelSelector = ({
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={modelSearchInputRef}
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
className={classNames(
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
|
||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
|
||||
@@ -432,8 +277,8 @@ export const ModelSelector = ({
|
||||
) : (
|
||||
filteredModels.map((modelOption, index) => (
|
||||
<div
|
||||
ref={(el) => (modelOptionsRef.current[index] = el)}
|
||||
key={index} // Consider using modelOption.name if unique
|
||||
ref={(el) => (optionsRef.current[index] = el)}
|
||||
key={index}
|
||||
role="option"
|
||||
aria-selected={model === modelOption.name}
|
||||
className={classNames(
|
||||
@@ -441,10 +286,10 @@ export const ModelSelector = ({
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'outline-none',
|
||||
model === modelOption.name || focusedModelIndex === index
|
||||
model === modelOption.name || focusedIndex === index
|
||||
? 'bg-bolt-elements-background-depth-2'
|
||||
: undefined,
|
||||
focusedModelIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -452,7 +297,7 @@ export const ModelSelector = ({
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedModelIndex === index ? 0 : -1}
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
>
|
||||
{modelOption.label}
|
||||
</div>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function NetlifyDeploymentLink() {
|
||||
const connection = useStore(netlifyConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.token && currentChatId) {
|
||||
fetchNetlifyStats(connection.token);
|
||||
}
|
||||
}, [connection.token, currentChatId]);
|
||||
|
||||
const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`));
|
||||
|
||||
if (!deployedSite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deployedSite.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // This is to prevent click from bubbling up
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:link w-4 h-4 hover:text-blue-400" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deployedSite.url}
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export default function ProgressCompilation({ data }: { data?: ProgressAnnotatio
|
||||
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
||||
'p-1',
|
||||
)}
|
||||
style={{ transform: 'translateY(1rem)' }}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
|
||||
@@ -21,11 +21,19 @@ const FrameworkLink: React.FC<FrameworkLinkProps> = ({ template }) => (
|
||||
);
|
||||
|
||||
const StarterTemplates: React.FC = () => {
|
||||
// Debug: Log available templates and their icons
|
||||
React.useEffect(() => {
|
||||
console.log(
|
||||
'Available templates:',
|
||||
STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-sm text-gray-500">or start a blank app with your favorite stack</span>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex flex-wrap justify-center items-center gap-4 max-w-sm">
|
||||
<div className="flex w-70 flex-wrap items-center justify-center gap-4">
|
||||
{STARTER_TEMPLATES.map((template) => (
|
||||
<FrameworkLink key={template.name} template={template} />
|
||||
))}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { SupabaseAlert } from '~/types/actions';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { supabaseConnection } from '~/lib/stores/supabase';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
alert: SupabaseAlert;
|
||||
clearAlert: () => void;
|
||||
postMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
export function SupabaseChatAlert({ alert, clearAlert, postMessage }: Props) {
|
||||
const { content } = alert;
|
||||
const connection = useStore(supabaseConnection);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
// Determine connection state
|
||||
const isConnected = !!(connection.token && connection.selectedProjectId);
|
||||
|
||||
// Set title and description based on connection state
|
||||
const title = isConnected ? 'Supabase Query' : 'Supabase Connection Required';
|
||||
const description = isConnected ? 'Execute database query' : 'Supabase connection required';
|
||||
const message = isConnected
|
||||
? 'Please review the proposed changes and apply them to your database.'
|
||||
: 'Please connect to Supabase to continue with this operation.';
|
||||
|
||||
const handleConnectClick = () => {
|
||||
// Dispatch an event to open the Supabase connection dialog
|
||||
document.dispatchEvent(new CustomEvent('open-supabase-connection'));
|
||||
};
|
||||
|
||||
// Determine if we should show the Connect button or Apply Changes button
|
||||
const showConnectButton = !isConnected;
|
||||
|
||||
const executeSupabaseAction = async (sql: string) => {
|
||||
if (!connection.token || !connection.selectedProjectId) {
|
||||
console.error('No Supabase token or project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/supabase/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: connection.selectedProjectId,
|
||||
query: sql,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as any;
|
||||
throw new Error(`Supabase query failed: ${errorData.error?.message || response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Supabase query executed successfully:', result);
|
||||
clearAlert();
|
||||
} catch (error) {
|
||||
console.error('Failed to execute Supabase action:', error);
|
||||
postMessage(
|
||||
`*Error executing Supabase query please fix and return the query again*\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\`\n`,
|
||||
);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanSqlContent = (content: string) => {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let cleaned = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
cleaned = cleaned.replace(/(--).*$/gm, '').replace(/(#).*$/gm, '');
|
||||
|
||||
const statements = cleaned
|
||||
.split(';')
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter((stmt) => stmt.length > 0)
|
||||
.join(';\n\n');
|
||||
|
||||
return statements;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img height="10" width="18" crossOrigin="anonymous" src="https://cdn.simpleicons.org/supabase" />
|
||||
<h3 className="text-sm font-medium text-[#3DCB8F]">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL Content */}
|
||||
<div className="px-4">
|
||||
{!isConnected ? (
|
||||
<div className="p-3 rounded-md bg-bolt-elements-background-depth-3">
|
||||
<span className="text-sm text-bolt-elements-textPrimary">
|
||||
You must first connect to Supabase and select a project.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center p-2 rounded-md bg-bolt-elements-background-depth-3 cursor-pointer"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className="i-ph:database text-bolt-elements-textPrimary mr-2"></div>
|
||||
<span className="text-sm text-bolt-elements-textPrimary flex-grow">
|
||||
{description || 'Create table and setup auth'}
|
||||
</span>
|
||||
<div
|
||||
className={`i-ph:caret-up text-bolt-elements-textPrimary transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && content && (
|
||||
<div className="mt-2 p-3 bg-bolt-elements-background-depth-4 rounded-md overflow-auto max-h-60 font-mono text-xs text-bolt-elements-textSecondary">
|
||||
<pre>{cleanSqlContent(content)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message and Actions */}
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">{message}</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showConnectButton ? (
|
||||
<button
|
||||
onClick={handleConnectClick}
|
||||
className={classNames(
|
||||
`px-3 py-2 rounded-md text-sm font-medium`,
|
||||
'bg-[#098F5F]',
|
||||
'hover:bg-[#0aa06c]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
|
||||
'text-white',
|
||||
'flex items-center gap-1.5',
|
||||
)}
|
||||
>
|
||||
Connect to Supabase
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => executeSupabaseAction(content)}
|
||||
disabled={isExecuting}
|
||||
className={classNames(
|
||||
`px-3 py-2 rounded-md text-sm font-medium`,
|
||||
'bg-[#098F5F]',
|
||||
'hover:bg-[#0aa06c]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
|
||||
'text-white',
|
||||
'flex items-center gap-1.5',
|
||||
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
|
||||
)}
|
||||
>
|
||||
{isExecuting ? 'Applying...' : 'Apply Changes'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={clearAlert}
|
||||
disabled={isExecuting}
|
||||
className={classNames(
|
||||
`px-3 py-2 rounded-md text-sm font-medium`,
|
||||
'bg-[#503B26]',
|
||||
'hover:bg-[#774f28]',
|
||||
'focus:outline-none',
|
||||
'text-[#F79007]',
|
||||
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
|
||||
)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSupabaseConnection } from '~/lib/hooks/useSupabaseConnection';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { fetchSupabaseStats } from '~/lib/stores/supabase';
|
||||
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
|
||||
|
||||
export function SupabaseConnection() {
|
||||
const {
|
||||
connection: supabaseConn,
|
||||
connecting,
|
||||
fetchingStats,
|
||||
isProjectsExpanded,
|
||||
setIsProjectsExpanded,
|
||||
isDropdownOpen: isDialogOpen,
|
||||
setIsDropdownOpen: setIsDialogOpen,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
selectProject,
|
||||
handleCreateProject,
|
||||
updateToken,
|
||||
isConnected,
|
||||
fetchProjectApiKeys,
|
||||
} = useSupabaseConnection();
|
||||
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenConnectionDialog = () => {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
document.addEventListener('open-supabase-connection', handleOpenConnectionDialog);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('open-supabase-connection', handleOpenConnectionDialog);
|
||||
};
|
||||
}, [setIsDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && currentChatId) {
|
||||
const savedProjectId = localStorage.getItem(`supabase-project-${currentChatId}`);
|
||||
|
||||
/*
|
||||
* If there's no saved project for this chat but there is a global selected project,
|
||||
* use the global one instead of clearing it
|
||||
*/
|
||||
if (!savedProjectId && supabaseConn.selectedProjectId) {
|
||||
// Save the current global project to this chat
|
||||
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
|
||||
} else if (savedProjectId && savedProjectId !== supabaseConn.selectedProjectId) {
|
||||
selectProject(savedProjectId);
|
||||
}
|
||||
}
|
||||
}, [isConnected, currentChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatId && supabaseConn.selectedProjectId) {
|
||||
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
|
||||
} else if (currentChatId && !supabaseConn.selectedProjectId) {
|
||||
localStorage.removeItem(`supabase-project-${currentChatId}`);
|
||||
}
|
||||
}, [currentChatId, supabaseConn.selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && supabaseConn.token) {
|
||||
fetchSupabaseStats(supabaseConn.token).catch(console.error);
|
||||
}
|
||||
}, [isConnected, supabaseConn.token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && supabaseConn.selectedProjectId && supabaseConn.token && !supabaseConn.credentials) {
|
||||
fetchProjectApiKeys(supabaseConn.selectedProjectId).catch(console.error);
|
||||
}
|
||||
}, [isConnected, supabaseConn.selectedProjectId, supabaseConn.token, supabaseConn.credentials]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={connecting}
|
||||
onClick={() => setIsDialogOpen(!isDialogOpen)}
|
||||
className="hover:bg-bolt-elements-item-backgroundActive !text-white flex items-center gap-2"
|
||||
>
|
||||
<img
|
||||
className="w-4 h-4"
|
||||
height="20"
|
||||
width="20"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/supabase"
|
||||
/>
|
||||
{isConnected && supabaseConn.project && (
|
||||
<span className="ml-1 text-xs max-w-[100px] truncate">{supabaseConn.project.name}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
{isDialogOpen && (
|
||||
<Dialog className="max-w-[520px] p-6">
|
||||
{!isConnected ? (
|
||||
<div className="space-y-4">
|
||||
<DialogTitle>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/supabase"
|
||||
/>
|
||||
Connect to Supabase
|
||||
</DialogTitle>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={supabaseConn.token}
|
||||
onChange={(e) => updateToken(e.target.value)}
|
||||
disabled={connecting}
|
||||
placeholder="Enter your Supabase 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-[#3ECF8E]',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href="https://app.supabase.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#3ECF8E] hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<DialogClose asChild>
|
||||
<DialogButton type="secondary">Cancel</DialogButton>
|
||||
</DialogClose>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !supabaseConn.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#3ECF8E] text-white',
|
||||
'hover:bg-[#3BBF84]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<DialogTitle>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/supabase"
|
||||
/>
|
||||
Supabase Connection
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{supabaseConn.user?.email}</h4>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Role: {supabaseConn.user?.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fetchingStats ? (
|
||||
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching projects...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
||||
className="bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:database w-4 h-4" />
|
||||
Your Projects ({supabaseConn.stats?.totalProjects || 0})
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 transition-transform',
|
||||
isProjectsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchSupabaseStats(supabaseConn.token)}
|
||||
className="px-2 py-1 rounded-md text-xs bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#333333] flex items-center gap-1"
|
||||
title="Refresh projects list"
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise w-3 h-3" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCreateProject()}
|
||||
className="px-2 py-1 rounded-md text-xs bg-[#3ECF8E] text-white hover:bg-[#3BBF84] flex items-center gap-1"
|
||||
>
|
||||
<div className="i-ph:plus w-3 h-3" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProjectsExpanded && (
|
||||
<>
|
||||
{!supabaseConn.selectedProjectId && (
|
||||
<div className="mb-2 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg text-sm text-bolt-elements-textSecondary">
|
||||
Select a project or create a new one for this chat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supabaseConn.stats?.projects?.length ? (
|
||||
<div className="grid gap-2 max-h-60 overflow-y-auto">
|
||||
{supabaseConn.stats.projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="block p-3 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#3ECF8E] dark:hover:border-[#3ECF8E] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-1">
|
||||
<div className="i-ph:database w-3 h-3 text-[#3ECF8E]" />
|
||||
{project.name}
|
||||
</h5>
|
||||
<div className="text-xs text-bolt-elements-textSecondary mt-1">
|
||||
{project.region}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => selectProject(project.id)}
|
||||
className={classNames(
|
||||
'px-3 py-1 rounded-md text-xs',
|
||||
supabaseConn.selectedProjectId === project.id
|
||||
? 'bg-[#3ECF8E] text-white'
|
||||
: 'bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#3ECF8E] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{supabaseConn.selectedProjectId === project.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:check w-3 h-3" />
|
||||
Selected
|
||||
</span>
|
||||
) : (
|
||||
'Select'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
No projects found
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<DialogClose asChild>
|
||||
<DialogButton type="secondary">Close</DialogButton>
|
||||
</DialogClose>
|
||||
<DialogButton type="danger" onClick={handleDisconnect}>
|
||||
<div className="i-ph:plugs w-4 h-4" />
|
||||
Disconnect
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogRoot>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-1.5',
|
||||
{
|
||||
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
||||
!active,
|
||||
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentAccent': active && !disabled,
|
||||
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function UserMessage({ content }: UserMessageProps) {
|
||||
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{textContent && <Markdown html>{textContent}</Markdown>}
|
||||
{images.map((item, index) => (
|
||||
@@ -43,6 +43,5 @@ export function UserMessage({ content }: UserMessageProps) {
|
||||
}
|
||||
|
||||
function stripMetadata(content: string) {
|
||||
const artifactRegex = /<boltArtifact\s+[^>]*>[\s\S]*?<\/boltArtifact>/gm;
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function VercelDeploymentLink() {
|
||||
const connection = useStore(vercelConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
const [deploymentUrl, setDeploymentUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjectData() {
|
||||
if (!connection.token || !currentChatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have a stored project ID for this chat
|
||||
const projectId = localStorage.getItem(`vercel-project-${currentChatId}`);
|
||||
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Fetch projects directly from the API
|
||||
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!projectsResponse.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
|
||||
}
|
||||
|
||||
const projectsData = (await projectsResponse.json()) as any;
|
||||
const projects = projectsData.projects || [];
|
||||
|
||||
// Extract the chat number from currentChatId
|
||||
const chatNumber = currentChatId.split('-')[0];
|
||||
|
||||
// Find project by matching the chat number in the name
|
||||
const project = projects.find((p: { name: string | string[] }) => p.name.includes(`bolt-diy-${chatNumber}`));
|
||||
|
||||
if (project) {
|
||||
// Fetch project details including deployments
|
||||
const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (projectDetailsResponse.ok) {
|
||||
const projectDetails = (await projectDetailsResponse.json()) as any;
|
||||
|
||||
// Try to get URL from production aliases first
|
||||
if (projectDetails.targets?.production?.alias && projectDetails.targets.production.alias.length > 0) {
|
||||
// Find the clean URL (without -projects.vercel.app)
|
||||
const cleanUrl = projectDetails.targets.production.alias.find(
|
||||
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
||||
);
|
||||
|
||||
if (cleanUrl) {
|
||||
setDeploymentUrl(`https://${cleanUrl}`);
|
||||
return;
|
||||
} else {
|
||||
// If no clean URL found, use the first alias
|
||||
setDeploymentUrl(`https://${projectDetails.targets.production.alias[0]}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no aliases or project details failed, try fetching deployments
|
||||
const deploymentsResponse = await fetch(
|
||||
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
},
|
||||
);
|
||||
|
||||
if (deploymentsResponse.ok) {
|
||||
const deploymentsData = (await deploymentsResponse.json()) as any;
|
||||
|
||||
if (deploymentsData.deployments && deploymentsData.deployments.length > 0) {
|
||||
setDeploymentUrl(`https://${deploymentsData.deployments[0].url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if not found in fetched projects
|
||||
const fallbackResponse = await fetch(`/api/vercel-deploy?projectId=${projectId}&token=${connection.token}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const data = await fallbackResponse.json();
|
||||
|
||||
if ((data as { deploy?: { url?: string } }).deploy?.url) {
|
||||
setDeploymentUrl((data as { deploy: { url: string } }).deploy.url);
|
||||
} else if ((data as { project?: { url?: string } }).project?.url) {
|
||||
setDeploymentUrl((data as { project: { url: string } }).project.url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching Vercel deployment:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProjectData();
|
||||
}, [connection.token, currentChatId]);
|
||||
|
||||
if (!deploymentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deploymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#000000] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={`i-ph:link w-4 h-4 hover:text-blue-400 ${isLoading ? 'animate-pulse' : ''}`} />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deploymentUrl}
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -64,13 +64,13 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
||||
const input = document.getElementById('chat-import');
|
||||
input?.click();
|
||||
}}
|
||||
variant="default"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
||||
'text-bolt-elements-textPrimary dark:text-white',
|
||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
||||
'border-[#E5E5E5] dark:border-[#333333]',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
)}
|
||||
@@ -81,10 +81,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
||||
<ImportFolderButton
|
||||
importChat={importChat}
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
||||
'text-bolt-elements-textPrimary dark:text-white',
|
||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out rounded-lg',
|
||||
)}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { DeployAlert } from '~/types/actions';
|
||||
|
||||
interface DeployAlertProps {
|
||||
alert: DeployAlert;
|
||||
clearAlert: () => void;
|
||||
postMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
export default function DeployChatAlert({ alert, clearAlert, postMessage }: DeployAlertProps) {
|
||||
const { type, title, description, content, url, stage, buildStatus, deployStatus } = alert;
|
||||
|
||||
// Determine if we should show the deployment progress
|
||||
const showProgress = stage && (buildStatus || deployStatus);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="flex-shrink-0"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'text-xl',
|
||||
type === 'success'
|
||||
? 'i-ph:check-circle-duotone text-bolt-elements-icon-success'
|
||||
: type === 'error'
|
||||
? 'i-ph:warning-duotone text-bolt-elements-button-danger-text'
|
||||
: 'i-ph:info-duotone text-bolt-elements-loader-progress',
|
||||
)}
|
||||
></div>
|
||||
</motion.div>
|
||||
{/* Content */}
|
||||
<div className="ml-3 flex-1">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className={`text-sm font-medium text-bolt-elements-textPrimary`}
|
||||
>
|
||||
{title}
|
||||
</motion.h3>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
|
||||
>
|
||||
<p>{description}</p>
|
||||
|
||||
{/* Deployment Progress Visualization */}
|
||||
{showProgress && (
|
||||
<div className="mt-4 mb-2">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
{/* Build Step */}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center',
|
||||
buildStatus === 'running'
|
||||
? 'bg-bolt-elements-loader-progress'
|
||||
: buildStatus === 'complete'
|
||||
? 'bg-bolt-elements-icon-success'
|
||||
: buildStatus === 'failed'
|
||||
? 'bg-bolt-elements-button-danger-background'
|
||||
: 'bg-bolt-elements-textTertiary',
|
||||
)}
|
||||
>
|
||||
{buildStatus === 'running' ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
|
||||
) : buildStatus === 'complete' ? (
|
||||
<div className="i-ph:check text-white text-xs"></div>
|
||||
) : buildStatus === 'failed' ? (
|
||||
<div className="i-ph:x text-white text-xs"></div>
|
||||
) : (
|
||||
<span className="text-white text-xs">1</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-2">Build</span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
<div
|
||||
className={classNames(
|
||||
'h-0.5 w-8',
|
||||
buildStatus === 'complete' ? 'bg-bolt-elements-icon-success' : 'bg-bolt-elements-textTertiary',
|
||||
)}
|
||||
></div>
|
||||
|
||||
{/* Deploy Step */}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center',
|
||||
deployStatus === 'running'
|
||||
? 'bg-bolt-elements-loader-progress'
|
||||
: deployStatus === 'complete'
|
||||
? 'bg-bolt-elements-icon-success'
|
||||
: deployStatus === 'failed'
|
||||
? 'bg-bolt-elements-button-danger-background'
|
||||
: 'bg-bolt-elements-textTertiary',
|
||||
)}
|
||||
>
|
||||
{deployStatus === 'running' ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
|
||||
) : deployStatus === 'complete' ? (
|
||||
<div className="i-ph:check text-white text-xs"></div>
|
||||
) : deployStatus === 'failed' ? (
|
||||
<div className="i-ph:x text-white text-xs"></div>
|
||||
) : (
|
||||
<span className="text-white text-xs">2</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-2">Deploy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{url && type === 'success' && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-item-contentAccent hover:underline flex items-center"
|
||||
>
|
||||
<span className="mr-1">View deployed site</span>
|
||||
<div className="i-ph:arrow-square-out"></div>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Actions */}
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className={classNames('flex gap-2')}>
|
||||
{type === 'error' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
postMessage(`*Fix this deployment error*\n\`\`\`\n${content || description}\n\`\`\`\n`)
|
||||
}
|
||||
className={classNames(
|
||||
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
||||
'bg-bolt-elements-button-primary-background',
|
||||
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
|
||||
'text-bolt-elements-button-primary-text',
|
||||
'flex items-center gap-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:chat-circle-duotone"></div>
|
||||
Ask Bolt
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={clearAlert}
|
||||
className={classNames(
|
||||
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
||||
'bg-bolt-elements-button-secondary-background',
|
||||
'hover:bg-bolt-elements-button-secondary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
|
||||
'text-bolt-elements-button-secondary-text',
|
||||
)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { path } from '~/utils/path';
|
||||
import { useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
|
||||
export function useNetlifyDeploy() {
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const handleNetlifyDeploy = async () => {
|
||||
if (!netlifyConn.user || !netlifyConn.token) {
|
||||
toast.error('Please connect to Netlify first in the settings tab!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
// Create a deployment artifact for visual feedback
|
||||
const deploymentId = `deploy-artifact`;
|
||||
workbenchStore.addArtifact({
|
||||
id: deploymentId,
|
||||
messageId: deploymentId,
|
||||
title: 'Netlify Deployment',
|
||||
type: 'standalone',
|
||||
});
|
||||
|
||||
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
|
||||
|
||||
// Notify that build is starting
|
||||
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' });
|
||||
|
||||
// Set up build action
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'netlify build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
},
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
// Notify that build failed
|
||||
deployArtifact.runner.handleDeployAction('building', 'failed', {
|
||||
error: 'Build failed. Check the terminal for details.',
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Notify that build succeeded and deployment is starting
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' });
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
|
||||
console.log('Original buildPath', buildPath);
|
||||
|
||||
// Check if the build path exists
|
||||
let finalBuildPath = buildPath;
|
||||
|
||||
// List of common output directories to check if the specified build path doesn't exist
|
||||
const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public'];
|
||||
|
||||
// Verify the build path exists, or try to find an alternative
|
||||
let buildPathExists = false;
|
||||
|
||||
for (const dir of commonOutputDirs) {
|
||||
try {
|
||||
await container.fs.readdir(dir);
|
||||
finalBuildPath = dir;
|
||||
buildPathExists = true;
|
||||
console.log(`Using build directory: ${finalBuildPath}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
// Directory doesn't exist, try the next one
|
||||
console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!buildPathExists) {
|
||||
throw new Error('Could not find build output directory. Please check your build configuration.');
|
||||
}
|
||||
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile()) {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Remove build path prefix from the path
|
||||
const deployPath = fullPath.replace(finalBuildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(finalBuildPath);
|
||||
|
||||
// Use chatId instead of artifact.id
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
||||
|
||||
const response = await fetch('/api/netlify-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteId: existingSiteId || undefined,
|
||||
files: fileContents,
|
||||
token: netlifyConn.token,
|
||||
chatId: currentChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.site) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
|
||||
// Notify that deployment failed
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: data.error || 'Invalid deployment response',
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
const maxAttempts = 20; // 2 minutes timeout
|
||||
let attempts = 0;
|
||||
let deploymentStatus;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const statusResponse = await fetch(
|
||||
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${netlifyConn.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
deploymentStatus = (await statusResponse.json()) as any;
|
||||
|
||||
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (deploymentStatus.state === 'error') {
|
||||
// Notify that deployment failed
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: 'Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'),
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
attempts++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
// Notify that deployment timed out
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: 'Deployment timed out',
|
||||
source: 'netlify',
|
||||
});
|
||||
throw new Error('Deployment timed out');
|
||||
}
|
||||
|
||||
// Store the site ID if it's a new site
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
|
||||
}
|
||||
|
||||
// Notify that deployment completed successfully
|
||||
deployArtifact.runner.handleDeployAction('complete', 'complete', {
|
||||
url: deploymentStatus.ssl_url || deploymentStatus.url,
|
||||
source: 'netlify',
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDeploying,
|
||||
handleNetlifyDeploy,
|
||||
isConnected: !!netlifyConn.user,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { path } from '~/utils/path';
|
||||
import { useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
|
||||
export function useVercelDeploy() {
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const handleVercelDeploy = async () => {
|
||||
if (!vercelConn.user || !vercelConn.token) {
|
||||
toast.error('Please connect to Vercel first in the settings tab!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
// Create a deployment artifact for visual feedback
|
||||
const deploymentId = `deploy-vercel-project`;
|
||||
workbenchStore.addArtifact({
|
||||
id: deploymentId,
|
||||
messageId: deploymentId,
|
||||
title: 'Vercel Deployment',
|
||||
type: 'standalone',
|
||||
});
|
||||
|
||||
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
|
||||
|
||||
// Notify that build is starting
|
||||
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'vercel' });
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'vercel build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
},
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
// Notify that build failed
|
||||
deployArtifact.runner.handleDeployAction('building', 'failed', {
|
||||
error: 'Build failed. Check the terminal for details.',
|
||||
source: 'vercel',
|
||||
});
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Notify that build succeeded and deployment is starting
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'vercel' });
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
|
||||
// Check if the build path exists
|
||||
let finalBuildPath = buildPath;
|
||||
|
||||
// List of common output directories to check if the specified build path doesn't exist
|
||||
const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public'];
|
||||
|
||||
// Verify the build path exists, or try to find an alternative
|
||||
let buildPathExists = false;
|
||||
|
||||
for (const dir of commonOutputDirs) {
|
||||
try {
|
||||
await container.fs.readdir(dir);
|
||||
finalBuildPath = dir;
|
||||
buildPathExists = true;
|
||||
console.log(`Using build directory: ${finalBuildPath}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`);
|
||||
|
||||
// Directory doesn't exist, try the next one
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!buildPathExists) {
|
||||
throw new Error('Could not find build output directory. Please check your build configuration.');
|
||||
}
|
||||
|
||||
// Get all files recursively
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile()) {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Remove build path prefix from the path
|
||||
const deployPath = fullPath.replace(finalBuildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(finalBuildPath);
|
||||
|
||||
// Use chatId instead of artifact.id
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
|
||||
|
||||
const response = await fetch('/api/vercel-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: existingProjectId || undefined,
|
||||
files: fileContents,
|
||||
token: vercelConn.token,
|
||||
chatId: currentChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.project) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
|
||||
// Notify that deployment failed
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: data.error || 'Invalid deployment response',
|
||||
source: 'vercel',
|
||||
});
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
|
||||
}
|
||||
|
||||
// Notify that deployment completed successfully
|
||||
deployArtifact.runner.handleDeployAction('complete', 'complete', {
|
||||
url: data.deploy.url,
|
||||
source: 'vercel',
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Vercel deploy error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Vercel deployment failed');
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDeploying,
|
||||
handleVercelDeploy,
|
||||
isConnected: !!vercelConn.user,
|
||||
};
|
||||
}
|
||||
@@ -21,18 +21,13 @@ import type { Theme } from '~/types/theme';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { isFileLocked, getCurrentChatId } from '~/utils/fileLocks';
|
||||
import { BinaryContent } from './BinaryContent';
|
||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||
import { indentKeyBinding } from './indent';
|
||||
import { getLanguage } from './languages';
|
||||
import { createEnvMaskingExtension } from './EnvMasking';
|
||||
|
||||
const logger = createScopedLogger('CodeMirrorEditor');
|
||||
|
||||
// Create a module-level reference to the current document for use in tooltip functions
|
||||
let currentDocRef: EditorDocument | undefined;
|
||||
|
||||
export interface EditorDocument {
|
||||
value: string;
|
||||
isBinary: boolean;
|
||||
@@ -51,10 +46,8 @@ type TextEditorDocument = EditorDocument & {
|
||||
};
|
||||
|
||||
export interface ScrollPosition {
|
||||
top?: number;
|
||||
left?: number;
|
||||
line?: number;
|
||||
column?: number;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface EditorUpdate {
|
||||
@@ -141,9 +134,6 @@ export const CodeMirrorEditor = memo(
|
||||
|
||||
const [languageCompartment] = useState(new Compartment());
|
||||
|
||||
// Add a compartment for the env masking extension
|
||||
const [envMaskingCompartment] = useState(new Compartment());
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView>();
|
||||
const themeRef = useRef<Theme>();
|
||||
@@ -162,44 +152,9 @@ export const CodeMirrorEditor = memo(
|
||||
onChangeRef.current = onChange;
|
||||
onSaveRef.current = onSave;
|
||||
docRef.current = doc;
|
||||
|
||||
// Update the module-level reference for use in tooltip functions
|
||||
currentDocRef = doc;
|
||||
themeRef.current = theme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewRef.current || !doc || doc.isBinary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof doc.scroll?.line === 'number') {
|
||||
const line = doc.scroll.line;
|
||||
const column = doc.scroll.column ?? 0;
|
||||
|
||||
try {
|
||||
// Check if the line number is valid for the current document
|
||||
const totalLines = viewRef.current.state.doc.lines;
|
||||
|
||||
// Only proceed if the line number is within the document's range
|
||||
if (line < totalLines) {
|
||||
const linePos = viewRef.current.state.doc.line(line + 1).from + column;
|
||||
viewRef.current.dispatch({
|
||||
selection: { anchor: linePos },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
viewRef.current.focus();
|
||||
} else {
|
||||
logger.warn(`Invalid line number ${line + 1} in ${totalLines}-line document`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error scrolling to line:', error);
|
||||
}
|
||||
} else if (typeof doc.scroll?.top === 'number' || typeof doc.scroll?.left === 'number') {
|
||||
viewRef.current.scrollDOM.scrollTo(doc.scroll.left ?? 0, doc.scroll.top ?? 0);
|
||||
}
|
||||
}, [doc?.scroll?.line, doc?.scroll?.column, doc?.scroll?.top, doc?.scroll?.left]);
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdate = debounce((update: EditorUpdate) => {
|
||||
onChangeRef.current?.(update);
|
||||
@@ -259,7 +214,6 @@ export const CodeMirrorEditor = memo(
|
||||
if (!doc) {
|
||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
envMaskingCompartment.of([]),
|
||||
]);
|
||||
|
||||
view.setState(state);
|
||||
@@ -282,7 +236,6 @@ export const CodeMirrorEditor = memo(
|
||||
if (!state) {
|
||||
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]),
|
||||
]);
|
||||
|
||||
editorStates.set(doc.filePath, state);
|
||||
@@ -298,16 +251,6 @@ export const CodeMirrorEditor = memo(
|
||||
autoFocusOnDocumentChange,
|
||||
doc as TextEditorDocument,
|
||||
);
|
||||
|
||||
// Check if the file is locked and update the editor state accordingly
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(doc.filePath, currentChatId);
|
||||
|
||||
if (locked) {
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(false)],
|
||||
});
|
||||
}
|
||||
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||
|
||||
return (
|
||||
@@ -449,13 +392,8 @@ function setEditorDocument(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the file is locked
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(doc.filePath, currentChatId);
|
||||
|
||||
// Set editable state based on both the editable prop and the file's lock state
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(editable && !doc.isBinary && !locked)],
|
||||
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
||||
});
|
||||
|
||||
getLanguage(doc.filePath).then((languageSupport) => {
|
||||
@@ -473,36 +411,11 @@ function setEditorDocument(
|
||||
const newLeft = doc.scroll?.left ?? 0;
|
||||
const newTop = doc.scroll?.top ?? 0;
|
||||
|
||||
if (typeof doc.scroll?.line === 'number') {
|
||||
const line = doc.scroll.line;
|
||||
const column = doc.scroll.column ?? 0;
|
||||
|
||||
try {
|
||||
// Check if the line number is valid for the current document
|
||||
const totalLines = view.state.doc.lines;
|
||||
|
||||
// Only proceed if the line number is within the document's range
|
||||
if (line < totalLines) {
|
||||
const linePos = view.state.doc.line(line + 1).from + column;
|
||||
view.dispatch({
|
||||
selection: { anchor: linePos },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
} else {
|
||||
logger.warn(`Invalid line number ${line + 1} in ${totalLines}-line document`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error scrolling to line:', error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
|
||||
|
||||
if (autoFocus && editable) {
|
||||
if (needsScrolling) {
|
||||
// we have to wait until the scroll position was changed before we can set the focus
|
||||
view.scrollDOM.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
@@ -511,6 +424,7 @@ function setEditorDocument(
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
// if the scroll position is still the same we can focus immediately
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
@@ -525,20 +439,6 @@ function getReadOnlyTooltip(state: EditorState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get the current document from the module-level reference
|
||||
const currentDoc = currentDocRef;
|
||||
let tooltipMessage = 'Cannot edit file while AI response is being generated';
|
||||
|
||||
// If we have a current document, check if it's locked
|
||||
if (currentDoc?.filePath) {
|
||||
const currentChatId = getCurrentChatId();
|
||||
const { locked } = isFileLocked(currentDoc.filePath, currentChatId);
|
||||
|
||||
if (locked) {
|
||||
tooltipMessage = 'This file is locked and cannot be edited';
|
||||
}
|
||||
}
|
||||
|
||||
return state.selection.ranges
|
||||
.filter((range) => {
|
||||
return range.empty;
|
||||
@@ -552,7 +452,7 @@ function getReadOnlyTooltip(state: EditorState) {
|
||||
create: () => {
|
||||
const divElement = document.createElement('div');
|
||||
divElement.className = 'cm-readonly-tooltip';
|
||||
divElement.textContent = tooltipMessage;
|
||||
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
||||
|
||||
return { dom: divElement };
|
||||
},
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { EditorView, Decoration, type DecorationSet, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
// Create a proper WidgetType class for the masked text
|
||||
class MaskedTextWidget extends WidgetType {
|
||||
constructor(private readonly _value: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: MaskedTextWidget) {
|
||||
return other._value === this._value;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '*'.repeat(this._value.length);
|
||||
span.className = 'cm-masked-text';
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvMaskingExtension(getFilePath: () => string | undefined) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: { docChanged: boolean; view: EditorView; viewportChanged: boolean }) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view: EditorView) {
|
||||
const filePath = getFilePath();
|
||||
const isEnvFile = filePath?.endsWith('.env') || filePath?.includes('.env.') || filePath?.includes('/.env');
|
||||
|
||||
if (!isEnvFile) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const decorations: any[] = [];
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i);
|
||||
const text = line.text;
|
||||
|
||||
// Match lines with KEY=VALUE format
|
||||
const match = text.match(/^([^=]+)=(.+)$/);
|
||||
|
||||
if (match && !text.trim().startsWith('#')) {
|
||||
const [, key, value] = match;
|
||||
const valueStart = line.from + key.length + 1;
|
||||
|
||||
// Create a decoration that replaces the value with asterisks
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new MaskedTextWidget(value),
|
||||
}).range(valueStart, line.to),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,7 @@ const IGNORE_PATTERNS = [
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
|
||||
// Include this so npm install runs much faster '**/*lock.json',
|
||||
'**/*lock.json',
|
||||
'**/*lock.yaml',
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { chatStore } from '~/lib/stores/chat';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { HeaderActionButtons } from './HeaderActionButtons.client';
|
||||
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
||||
import { SlateWordmark } from './SlateWordmark';
|
||||
|
||||
export function Header() {
|
||||
const chat = useStore(chatStore);
|
||||
@@ -18,8 +17,10 @@ export function Header() {
|
||||
>
|
||||
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
|
||||
<div className="i-ph:sidebar-simple-duotone text-xl" />
|
||||
<a href="/" className="text-2xl font-semibold flex items-center text-bolt-elements-textPrimary">
|
||||
<SlateWordmark className="h-8 w-auto" />
|
||||
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
|
||||
{/* <span className="i-bolt:logo-text?mask w-[46px] inline-block" /> */}
|
||||
<img src="/logo-light-styled.png" alt="logo" className="w-[90px] inline-block dark:hidden" />
|
||||
<img src="/logo-dark-styled.png" alt="logo" className="w-[90px] inline-block hidden dark:block" />
|
||||
</a>
|
||||
</div>
|
||||
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||
|
||||
@@ -1,151 +1,21 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
|
||||
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const { showChat } = useStore(chatStore);
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const [activePreviewIndex] = useState(0);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const isStreaming = useStore(streamingState);
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const onVercelDeploy = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
try {
|
||||
await handleVercelDeploy();
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onNetlifyDeploy = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('netlify');
|
||||
|
||||
try {
|
||||
await handleNetlifyDeploy();
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || !activePreview || isStreaming}
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
|
||||
>
|
||||
{isDeploying ? `Launching to ${deployingTo}...` : 'Launch'}
|
||||
<div
|
||||
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[13.5rem] bg-bolt-elements-background-depth-2 rounded-md shadow-lg bg-bolt-elements-backgroundDefault border border-bolt-elements-borderColor">
|
||||
<Button
|
||||
active
|
||||
onClick={() => {
|
||||
onNetlifyDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !netlifyConn.user}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/netlify"
|
||||
/>
|
||||
<span className="mx-auto">
|
||||
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
|
||||
</span>
|
||||
{netlifyConn.user && <NetlifyDeploymentLink />}
|
||||
</Button>
|
||||
<Button
|
||||
active
|
||||
onClick={() => {
|
||||
onVercelDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !vercelConn.user}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5 bg-black p-1 rounded"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/vercel/white"
|
||||
alt="vercel"
|
||||
/>
|
||||
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
|
||||
{vercelConn.user && <VercelDeploymentLink />}
|
||||
</Button>
|
||||
<Button
|
||||
active={false}
|
||||
disabled
|
||||
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
|
||||
>
|
||||
<span className="sr-only">Coming Soon</span>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/cloudflare"
|
||||
alt="cloudflare"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||
<Button
|
||||
active={showChat}
|
||||
@@ -181,23 +51,18 @@ interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-1.5',
|
||||
{
|
||||
className={classNames('flex items-center p-1.5', {
|
||||
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
||||
!active,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
||||
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export function SlateWordmark({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 132 28"
|
||||
aria-label="Slate"
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="slate-mark-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#FF1F8F" />
|
||||
<stop offset="100%" stopColor="#00D4FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="2" width="24" height="24" rx="6" fill="url(#slate-mark-grad)" />
|
||||
<path d="M7.5 18.2c0 1.6 1.4 2.6 3.4 2.6 1.9 0 3.3-.9 3.3-2.4 0-1.4-1-2-2.7-2.4l-1.3-.3c-2-.4-3.5-1.5-3.5-3.6 0-2.4 2-4.1 4.9-4.1 2.8 0 4.7 1.5 4.8 3.9h-2.2c-.1-1.3-1.1-2-2.6-2-1.5 0-2.5.7-2.5 1.9 0 1.1.9 1.7 2.5 2l1.3.3c2.3.5 3.7 1.6 3.7 3.8 0 2.6-2 4.3-5.2 4.3-3 0-5.1-1.5-5.2-4z" fill="#FFFFFF"/>
|
||||
<text x="32" y="22" font-family="Inter, system-ui, -apple-system, Segoe UI, sans-serif" font-weight="700" font-size="22" fill="currentColor" letter-spacing="-0.5">Slate</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import { useParams } from '@remix-run/react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
import { forwardRef, type ForwardedRef, useCallback } from 'react';
|
||||
import { Checkbox } from '~/components/ui/Checkbox';
|
||||
import { forwardRef, type ForwardedRef } from 'react';
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ChatHistoryItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
exportChat: (id?: string) => void;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({
|
||||
item,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
exportChat,
|
||||
selectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
}: HistoryItemProps) {
|
||||
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
||||
const { id: urlId } = useParams();
|
||||
const isActiveChat = urlId === item.urlId;
|
||||
|
||||
@@ -35,56 +24,13 @@ export function HistoryItem({
|
||||
syncWithGlobalStore: isActiveChat,
|
||||
});
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (selectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Item clicked in selection mode:', item.id);
|
||||
onToggleSelection?.(item.id);
|
||||
}
|
||||
},
|
||||
[selectionMode, item.id, onToggleSelection],
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(() => {
|
||||
console.log('Checkbox changed for item:', item.id);
|
||||
onToggleSelection?.(item.id);
|
||||
}, [item.id, onToggleSelection]);
|
||||
|
||||
const handleDeleteClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log('Delete button clicked for item:', item.id);
|
||||
|
||||
if (onDelete) {
|
||||
onDelete(event as unknown as React.UIEvent);
|
||||
}
|
||||
},
|
||||
[onDelete, item.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'group rounded-lg text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50/80 dark:hover:bg-gray-800/30 overflow-hidden flex justify-between items-center px-3 py-2 transition-colors',
|
||||
{ 'text-gray-900 dark:text-white bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
|
||||
{ 'cursor-pointer': selectionMode },
|
||||
)}
|
||||
onClick={selectionMode ? handleItemClick : undefined}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className="flex items-center mr-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
id={`select-${item.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
@@ -103,17 +49,14 @@ export function HistoryItem({
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<a
|
||||
href={`/chat/${item.urlId}`}
|
||||
className="flex w-full relative truncate block"
|
||||
onClick={selectionMode ? handleItemClick : undefined}
|
||||
>
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
<WithTooltip tooltip={currentDescription}>
|
||||
<span className="truncate pr-24">{currentDescription}</span>
|
||||
</WithTooltip>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute right-0 top-0 bottom-0 flex items-center bg-transparent px-2 transition-colors',
|
||||
'absolute right-0 top-0 bottom-0 flex items-center bg-white dark:bg-gray-950 group-hover:bg-gray-50/80 dark:group-hover:bg-gray-800/30 px-2',
|
||||
{ 'bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -129,10 +72,7 @@ export function HistoryItem({
|
||||
<ChatActionButton
|
||||
toolTipContent="Duplicate"
|
||||
icon="i-ph:copy h-4 w-4"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onDuplicate?.(item.id);
|
||||
}}
|
||||
onClick={() => onDuplicate?.(item.id)}
|
||||
/>
|
||||
)}
|
||||
<ChatActionButton
|
||||
@@ -143,12 +83,17 @@ export function HistoryItem({
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
<Dialog.Trigger asChild>
|
||||
<ChatActionButton
|
||||
toolTipContent="Delete"
|
||||
icon="i-ph:trash h-4 w-4"
|
||||
className="hover:text-red-500 dark:hover:text-red-400"
|
||||
onClick={handleDeleteClick}
|
||||
className="hover:text-red-500"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { ControlPanel } from '~/components/@settings/core/ControlPanel';
|
||||
import { SettingsButton } from '~/components/ui/SettingsButton';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { binDates } from './date-binning';
|
||||
import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
|
||||
@@ -36,10 +36,7 @@ const menuVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
type DialogContent =
|
||||
| { type: 'delete'; item: ChatHistoryItem }
|
||||
| { type: 'bulkDelete'; items: ChatHistoryItem[] }
|
||||
| null;
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
function CurrentDateTime() {
|
||||
const [dateTime, setDateTime] = useState(new Date());
|
||||
@@ -54,7 +51,7 @@ function CurrentDateTime() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-sm text-gray-600 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800/50">
|
||||
<div className="h-4 w-4 i-ph:clock opacity-80" />
|
||||
<div className="h-4 w-4 i-lucide:clock opacity-80" />
|
||||
<div className="flex gap-2">
|
||||
<span>{dateTime.toLocaleDateString()}</span>
|
||||
<span>{dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
@@ -71,8 +68,6 @@ export const Menu = () => {
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const profile = useStore(profileStore);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||
|
||||
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
|
||||
items: list,
|
||||
@@ -88,195 +83,35 @@ export const Menu = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
if (!db) {
|
||||
throw new Error('Database not available');
|
||||
}
|
||||
|
||||
// Delete chat snapshot from localStorage
|
||||
try {
|
||||
const snapshotKey = `snapshot:${id}`;
|
||||
localStorage.removeItem(snapshotKey);
|
||||
console.log('Removed snapshot for chat:', id);
|
||||
} catch (snapshotError) {
|
||||
console.error(`Error deleting snapshot for chat ${id}:`, snapshotError);
|
||||
}
|
||||
|
||||
// Delete the chat from the database
|
||||
await deleteById(db, id);
|
||||
console.log('Successfully deleted chat:', id);
|
||||
},
|
||||
[db],
|
||||
);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
(event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Log the delete operation to help debugging
|
||||
console.log('Attempting to delete chat:', { id: item.id, description: item.description });
|
||||
|
||||
deleteChat(item.id)
|
||||
if (db) {
|
||||
deleteById(db, item.id)
|
||||
.then(() => {
|
||||
toast.success('Chat deleted successfully', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
// Always refresh the list
|
||||
loadEntries();
|
||||
|
||||
if (chatId.get() === item.id) {
|
||||
// hard page navigation to clear the stores
|
||||
console.log('Navigating away from deleted chat');
|
||||
window.location.pathname = '/';
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete chat:', error);
|
||||
toast.error('Failed to delete conversation', {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
// Still try to reload entries in case data has changed
|
||||
loadEntries();
|
||||
});
|
||||
},
|
||||
[loadEntries, deleteChat],
|
||||
);
|
||||
|
||||
const deleteSelectedItems = useCallback(
|
||||
async (itemsToDeleteIds: string[]) => {
|
||||
if (!db || itemsToDeleteIds.length === 0) {
|
||||
console.log('Bulk delete skipped: No DB or no items to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Starting bulk delete for ${itemsToDeleteIds.length} chats`, itemsToDeleteIds);
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
const currentChatId = chatId.get();
|
||||
let shouldNavigate = false;
|
||||
|
||||
// Process deletions sequentially using the shared deleteChat logic
|
||||
for (const id of itemsToDeleteIds) {
|
||||
try {
|
||||
await deleteChat(id);
|
||||
deletedCount++;
|
||||
|
||||
if (id === currentChatId) {
|
||||
shouldNavigate = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting chat ${id}:`, error);
|
||||
errors.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate toast message
|
||||
if (errors.length === 0) {
|
||||
toast.success(`${deletedCount} chat${deletedCount === 1 ? '' : 's'} deleted successfully`);
|
||||
} else {
|
||||
toast.warning(`Deleted ${deletedCount} of ${itemsToDeleteIds.length} chats. ${errors.length} failed.`, {
|
||||
autoClose: 5000,
|
||||
toast.error('Failed to delete conversation');
|
||||
logger.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Reload the list after all deletions
|
||||
await loadEntries();
|
||||
|
||||
// Clear selection state
|
||||
setSelectedItems([]);
|
||||
setSelectionMode(false);
|
||||
|
||||
// Navigate if needed
|
||||
if (shouldNavigate) {
|
||||
console.log('Navigating away from deleted chat');
|
||||
window.location.pathname = '/';
|
||||
}
|
||||
},
|
||||
[deleteChat, loadEntries, db],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogContent(null);
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(!selectionMode);
|
||||
|
||||
if (selectionMode) {
|
||||
// If turning selection mode OFF, clear selection
|
||||
setSelectedItems([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSelectedItems = prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id];
|
||||
console.log('Selected items updated:', newSelectedItems);
|
||||
|
||||
return newSelectedItems; // Return the new array
|
||||
});
|
||||
}, []); // No dependencies needed
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.length === 0) {
|
||||
toast.info('Select at least one chat to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedChats = list.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
if (selectedChats.length === 0) {
|
||||
toast.error('Could not find selected chats');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogContent({ type: 'bulkDelete', items: selectedChats });
|
||||
}, [selectedItems, list]); // Keep list dependency
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
const allFilteredIds = filteredList.map((item) => item.id);
|
||||
setSelectedItems((prev) => {
|
||||
const allFilteredAreSelected = allFilteredIds.length > 0 && allFilteredIds.every((id) => prev.includes(id));
|
||||
|
||||
if (allFilteredAreSelected) {
|
||||
// Deselect only the filtered items
|
||||
const newSelectedItems = prev.filter((id) => !allFilteredIds.includes(id));
|
||||
console.log('Deselecting all filtered items. New selection:', newSelectedItems);
|
||||
|
||||
return newSelectedItems;
|
||||
} else {
|
||||
// Select all filtered items, adding them to any existing selections
|
||||
const newSelectedItems = [...new Set([...prev, ...allFilteredIds])];
|
||||
console.log('Selecting all filtered items. New selection:', newSelectedItems);
|
||||
|
||||
return newSelectedItems;
|
||||
}
|
||||
});
|
||||
}, [filteredList]); // Depends only on filteredList
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadEntries();
|
||||
}
|
||||
}, [open, loadEntries]);
|
||||
|
||||
// Exit selection mode when sidebar is closed
|
||||
useEffect(() => {
|
||||
if (!open && selectionMode) {
|
||||
/*
|
||||
* Don't clear selection state anymore when sidebar closes
|
||||
* This allows the selection to persist when reopening the sidebar
|
||||
*/
|
||||
console.log('Sidebar closed, preserving selection state');
|
||||
}
|
||||
}, [open, selectionMode]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const enterThreshold = 40;
|
||||
@@ -303,6 +138,11 @@ export const Menu = () => {
|
||||
};
|
||||
}, [isSettingsOpen]);
|
||||
|
||||
const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
event.preventDefault();
|
||||
setDialogContent({ type: 'delete', item });
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
await duplicateCurrentChat(id);
|
||||
loadEntries(); // Reload the list after duplication
|
||||
@@ -317,11 +157,6 @@ export const Menu = () => {
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
const setDialogContentWithLogging = useCallback((content: DialogContent) => {
|
||||
console.log('Setting dialog content:', content);
|
||||
setDialogContent(content);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
@@ -361,30 +196,16 @@ export const Menu = () => {
|
||||
<CurrentDateTime />
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="/"
|
||||
className="flex-1 flex gap-2 items-center bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-500/20 rounded-lg px-4 py-2 transition-colors"
|
||||
className="flex gap-2 items-center bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-500/20 rounded-lg px-4 py-2 transition-colors"
|
||||
>
|
||||
<span className="inline-block i-ph:plus-circle h-4 w-4" />
|
||||
<span className="inline-block i-lucide:message-square h-4 w-4" />
|
||||
<span className="text-sm font-medium">Start new chat</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleSelectionMode}
|
||||
className={classNames(
|
||||
'flex gap-1 items-center rounded-lg px-3 py-2 transition-colors',
|
||||
selectionMode
|
||||
? 'bg-purple-600 dark:bg-purple-500 text-white border border-purple-700 dark:border-purple-600'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700',
|
||||
)}
|
||||
aria-label={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
|
||||
>
|
||||
<span className={selectionMode ? 'i-ph:x h-4 w-4' : 'i-ph:check-square h-4 w-4'} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<span className="i-lucide:search h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
className="w-full bg-gray-50 dark:bg-gray-900 relative pl-9 pr-3 py-2 rounded-lg focus:outline-none focus:ring-1 focus:ring-purple-500/50 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-500 border border-gray-200 dark:border-gray-800"
|
||||
@@ -395,24 +216,7 @@ export const Menu = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm px-4 py-2">
|
||||
<div className="font-medium text-gray-600 dark:text-gray-400">Your Chats</div>
|
||||
{selectionMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
{selectedItems.length === filteredList.length ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={selectedItems.length === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400 text-sm font-medium px-4 py-2">Your Chats</div>
|
||||
<div className="flex-1 overflow-auto px-3 pb-3">
|
||||
{filteredList.length === 0 && (
|
||||
<div className="px-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||
@@ -431,16 +235,8 @@ export const Menu = () => {
|
||||
key={item.id}
|
||||
item={item}
|
||||
exportChat={exportChat}
|
||||
onDelete={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log('Delete triggered for item:', item);
|
||||
setDialogContentWithLogging({ type: 'delete', item });
|
||||
}}
|
||||
onDelete={(event) => handleDeleteClick(event, item)}
|
||||
onDuplicate={() => handleDuplicate(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedItems.includes(item.id)}
|
||||
onToggleSelection={toggleItemSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -468,7 +264,6 @@ export const Menu = () => {
|
||||
<DialogButton
|
||||
type="danger"
|
||||
onClick={(event) => {
|
||||
console.log('Dialog delete button clicked for item:', dialogContent.item);
|
||||
deleteItem(event, dialogContent.item);
|
||||
closeDialog();
|
||||
}}
|
||||
@@ -478,49 +273,6 @@ export const Menu = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dialogContent?.type === 'bulkDelete' && (
|
||||
<>
|
||||
<div className="p-6 bg-white dark:bg-gray-950">
|
||||
<DialogTitle className="text-gray-900 dark:text-white">Delete Selected Chats?</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
You are about to delete {dialogContent.items.length}{' '}
|
||||
{dialogContent.items.length === 1 ? 'chat' : 'chats'}:
|
||||
</p>
|
||||
<div className="mt-2 max-h-32 overflow-auto border border-gray-100 dark:border-gray-800 rounded-md bg-gray-50 dark:bg-gray-900 p-2">
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{dialogContent.items.map((item) => (
|
||||
<li key={item.id} className="text-sm">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{item.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="mt-3">Are you sure you want to delete these chats?</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800">
|
||||
<DialogButton type="secondary" onClick={closeDialog}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
/*
|
||||
* Pass the current selectedItems to the delete function.
|
||||
* This captures the state at the moment the user confirms.
|
||||
*/
|
||||
const itemsToDeleteNow = [...selectedItems];
|
||||
console.log('Bulk delete confirmed for', itemsToDeleteNow.length, 'items', itemsToDeleteNow);
|
||||
deleteSelectedItems(itemsToDeleteNow);
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DialogButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -15,39 +15,18 @@ const badgeVariants = cva(
|
||||
'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
|
||||
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
|
||||
outline: 'text-bolt-elements-textPrimary',
|
||||
primary: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
|
||||
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
|
||||
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
|
||||
danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
subtle:
|
||||
'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-white/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||
},
|
||||
size: {
|
||||
default: 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
||||
sm: 'rounded-full px-1.5 py-0.5 text-xs',
|
||||
md: 'rounded-md px-2 py-1 text-xs font-medium',
|
||||
lg: 'rounded-md px-2.5 py-1.5 text-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
|
||||
icon?: string;
|
||||
}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={classNames(badgeVariants({ variant, size }), className)} {...props}>
|
||||
{icon && <span className={icon} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
separator?: string;
|
||||
maxItems?: number;
|
||||
renderItem?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({
|
||||
items,
|
||||
className,
|
||||
separator = 'i-ph:caret-right',
|
||||
maxItems = 0,
|
||||
renderItem,
|
||||
}: BreadcrumbsProps) {
|
||||
const displayItems =
|
||||
maxItems > 0 && items.length > maxItems
|
||||
? [
|
||||
...items.slice(0, 1),
|
||||
{ label: '...', onClick: undefined, href: undefined },
|
||||
...items.slice(-Math.max(1, maxItems - 2)),
|
||||
]
|
||||
: items;
|
||||
|
||||
const defaultRenderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
||||
const content = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
|
||||
<span
|
||||
className={classNames(
|
||||
isLast
|
||||
? 'font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary-dark',
|
||||
item.onClick || item.href ? 'cursor-pointer' : '',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (item.href && !isLast) {
|
||||
return (
|
||||
<motion.a href={item.href} className="hover:underline" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
{content}
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.onClick && !isLast) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="hover:underline"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{content}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className={classNames('flex items-center', className)} aria-label="Breadcrumbs">
|
||||
<ol className="flex items-center gap-1.5">
|
||||
{displayItems.map((item, index) => {
|
||||
const isLast = index === displayItems.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center">
|
||||
{renderItem ? renderItem(item, index, isLast) : defaultRenderItem(item, index, isLast)}
|
||||
{!isLast && (
|
||||
<span
|
||||
className={classNames(
|
||||
separator,
|
||||
'w-3 h-3 mx-1 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
outline:
|
||||
'border border-bolt-elements-borderColor bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary text-bolt-elements-textPrimary dark:border-bolt-elements-borderColorActive',
|
||||
'border border-input bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary',
|
||||
secondary:
|
||||
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
||||
ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border transition-colors',
|
||||
'bg-transparent dark:bg-transparent',
|
||||
'border-gray-400 dark:border-gray-600',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-purple-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-950',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-purple-500 dark:data-[state=checked]:bg-purple-500',
|
||||
'data-[state=checked]:border-purple-500 dark:data-[state=checked]:border-purple-500',
|
||||
'data-[state=checked]:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-3 w-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface CloseButtonProps {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* CloseButton component
|
||||
*
|
||||
* A button with an X icon used for closing dialogs, modals, etc.
|
||||
* The button has a transparent background and only shows a background on hover.
|
||||
*/
|
||||
export function CloseButton({ onClick, className, size = 'md' }: CloseButtonProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'p-1',
|
||||
md: 'p-2',
|
||||
lg: 'p-3',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark',
|
||||
'rounded-lg hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<div className={classNames('i-ph:x', iconSizeClasses[size])} />
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FileIcon } from './FileIcon';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
filename?: string;
|
||||
showLineNumbers?: boolean;
|
||||
highlightLines?: number[];
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language,
|
||||
filename,
|
||||
showLineNumbers = true,
|
||||
highlightLines = [],
|
||||
maxHeight = '400px',
|
||||
className,
|
||||
onCopy,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
const lines = code.split('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
{filename && (
|
||||
<>
|
||||
<FileIcon filename={filename} size="sm" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
{filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{language && !filename && (
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark uppercase">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip content={copied ? 'Copied!' : 'Copy code'}>
|
||||
<motion.button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-md text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{copied ? <span className="i-ph:check w-4 h-4 text-green-500" /> : <span className="i-ph:copy w-4 h-4" />}
|
||||
</motion.button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className={classNames('overflow-auto', 'font-mono text-sm', 'custom-scrollbar')} style={{ maxHeight }}>
|
||||
<table className="min-w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={classNames(
|
||||
highlightLines.includes(index + 1) ? 'bg-purple-500/10 dark:bg-purple-500/20' : '',
|
||||
'hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4',
|
||||
)}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<td className="py-1 pl-4 pr-2 text-right select-none text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark border-r border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<span className="inline-block min-w-[1.5rem] text-xs">{index + 1}</span>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-1 pl-4 pr-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark whitespace-pre">
|
||||
{line || ' '}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import React, { memo, type ReactNode, useState, useEffect } from 'react';
|
||||
import React, { memo, type ReactNode } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Button } from './Button';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Label } from './Label';
|
||||
|
||||
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
||||
|
||||
@@ -21,14 +17,12 @@ interface DialogButtonProps {
|
||||
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
|
||||
type === 'primary'
|
||||
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
|
||||
: type === 'secondary'
|
||||
? 'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
: 'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
|
||||
)}
|
||||
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors', {
|
||||
'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600': type === 'primary',
|
||||
'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100':
|
||||
type === 'secondary',
|
||||
'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger',
|
||||
})}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -40,7 +34,7 @@ export const DialogButton = memo(({ type, children, onClick, disabled }: DialogB
|
||||
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
||||
return (
|
||||
<RadixDialog.Title
|
||||
className={classNames('text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-2', className)}
|
||||
className={classNames('text-lg font-medium text-bolt-elements-textPrimary', 'flex items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -51,7 +45,7 @@ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.
|
||||
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
||||
return (
|
||||
<RadixDialog.Description
|
||||
className={classNames('text-sm text-bolt-elements-textSecondary mt-1', className)}
|
||||
className={classNames('text-sm text-bolt-elements-textSecondary', 'mt-1', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -105,7 +99,11 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
|
||||
className={classNames(
|
||||
'fixed inset-0 z-[9999]',
|
||||
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
|
||||
'backdrop-blur-[2px]',
|
||||
)}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
@@ -116,7 +114,11 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px] focus:outline-none',
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'z-[9999] w-[520px]',
|
||||
className,
|
||||
)}
|
||||
initial="closed"
|
||||
@@ -130,7 +132,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton
|
||||
icon="i-ph:x"
|
||||
className="absolute top-3 right-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
|
||||
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
/>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
@@ -140,310 +142,3 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the ConfirmationDialog component
|
||||
*/
|
||||
export interface ConfirmationDialogProps {
|
||||
/**
|
||||
* Whether the dialog is open
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the dialog is closed
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Callback when the confirm button is clicked
|
||||
*/
|
||||
onConfirm: () => void;
|
||||
|
||||
/**
|
||||
* The title of the dialog
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The description of the dialog
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The text for the confirm button
|
||||
*/
|
||||
confirmLabel?: string;
|
||||
|
||||
/**
|
||||
* The text for the cancel button
|
||||
*/
|
||||
cancelLabel?: string;
|
||||
|
||||
/**
|
||||
* The variant of the confirm button
|
||||
*/
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
|
||||
/**
|
||||
* Whether the confirm button is in a loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable confirmation dialog component that uses the Dialog component
|
||||
*/
|
||||
export function ConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
}: ConfirmationDialogProps) {
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog showCloseButton={false}>
|
||||
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mb-4">{description}</DialogDescription>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
variant === 'destructive'
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent hover:bg-bolt-elements-button-primary-backgroundHover'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
||||
{confirmLabel}
|
||||
</>
|
||||
) : (
|
||||
confirmLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for selection item in SelectionDialog
|
||||
*/
|
||||
type SelectionItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the SelectionDialog component
|
||||
*/
|
||||
export interface SelectionDialogProps {
|
||||
/**
|
||||
* The title of the dialog
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The items to select from
|
||||
*/
|
||||
items: SelectionItem[];
|
||||
|
||||
/**
|
||||
* Whether the dialog is open
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the dialog is closed
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Callback when the confirm button is clicked with selected item IDs
|
||||
*/
|
||||
onConfirm: (selectedIds: string[]) => void;
|
||||
|
||||
/**
|
||||
* The text for the confirm button
|
||||
*/
|
||||
confirmLabel?: string;
|
||||
|
||||
/**
|
||||
* The maximum height of the selection list
|
||||
*/
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable selection dialog component that uses the Dialog component
|
||||
*/
|
||||
export function SelectionDialog({
|
||||
title,
|
||||
items,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel = 'Confirm',
|
||||
maxHeight = '60vh',
|
||||
}: SelectionDialogProps) {
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// Reset selected items when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedItems([]);
|
||||
setSelectAll(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedItems((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedItems.length === items.length) {
|
||||
setSelectedItems([]);
|
||||
setSelectAll(false);
|
||||
} else {
|
||||
setSelectedItems(items.map((item) => item.id));
|
||||
setSelectAll(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedItems);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Calculate the height for the virtualized list
|
||||
const listHeight = Math.min(
|
||||
items.length * 60,
|
||||
parseInt(maxHeight.replace('vh', '')) * window.innerHeight * 0.01 - 40,
|
||||
);
|
||||
|
||||
// Render each item in the virtualized list
|
||||
const ItemRenderer = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = items[index];
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={classNames(
|
||||
'flex items-start space-x-3 p-2 rounded-md transition-colors',
|
||||
selectedItems.includes(item.id)
|
||||
? 'bg-bolt-elements-item-backgroundAccent'
|
||||
: 'bg-bolt-elements-bg-depth-2 hover:bg-bolt-elements-item-backgroundActive',
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={`item-${item.id}`}
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor={`item-${item.id}`}
|
||||
className={classNames(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
selectedItems.includes(item.id)
|
||||
? 'text-bolt-elements-item-contentAccent'
|
||||
: 'text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Label>
|
||||
{item.description && <p className="text-xs text-bolt-elements-textSecondary">{item.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog showCloseButton={false}>
|
||||
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-2 mb-4">
|
||||
Select the items you want to include and click{' '}
|
||||
<span className="text-bolt-elements-item-contentAccent font-medium">{confirmLabel}</span>.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-bolt-elements-textSecondary">
|
||||
{selectedItems.length} of {items.length} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-bg-depth-2 dark:bg-transparent"
|
||||
>
|
||||
{selectAll ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pr-2 border rounded-md border-bolt-elements-borderColor bg-bolt-elements-bg-depth-2"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<FixedSizeList
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
itemCount={items.length}
|
||||
itemSize={60}
|
||||
className="scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-bolt-elements-bg-depth-3"
|
||||
>
|
||||
{ItemRenderer}
|
||||
</FixedSizeList>
|
||||
) : (
|
||||
<div className="text-center py-4 text-sm text-bolt-elements-textTertiary">No items to display</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedItems.length === 0}
|
||||
className="bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Button } from './Button';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// Variant-specific styles
|
||||
const VARIANT_STYLES = {
|
||||
default: {
|
||||
container: 'py-8 p-6',
|
||||
icon: {
|
||||
container: 'w-12 h-12 mb-3',
|
||||
size: 'w-6 h-6',
|
||||
},
|
||||
title: 'text-base',
|
||||
description: 'text-sm mt-1',
|
||||
actions: 'mt-4',
|
||||
buttonSize: 'default' as const,
|
||||
},
|
||||
compact: {
|
||||
container: 'py-4 p-4',
|
||||
icon: {
|
||||
container: 'w-10 h-10 mb-2',
|
||||
size: 'w-5 h-5',
|
||||
},
|
||||
title: 'text-sm',
|
||||
description: 'text-xs mt-0.5',
|
||||
actions: 'mt-3',
|
||||
buttonSize: 'sm' as const,
|
||||
},
|
||||
};
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Icon class name */
|
||||
icon?: string;
|
||||
|
||||
/** Title text */
|
||||
title: string;
|
||||
|
||||
/** Optional description text */
|
||||
description?: string;
|
||||
|
||||
/** Primary action button label */
|
||||
actionLabel?: string;
|
||||
|
||||
/** Primary action button callback */
|
||||
onAction?: () => void;
|
||||
|
||||
/** Secondary action button label */
|
||||
secondaryActionLabel?: string;
|
||||
|
||||
/** Secondary action button callback */
|
||||
onSecondaryAction?: () => void;
|
||||
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
|
||||
/** Component size variant */
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState component
|
||||
*
|
||||
* A component for displaying empty states with optional actions.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon = 'i-ph:folder-simple-dashed',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
secondaryActionLabel,
|
||||
onSecondaryAction,
|
||||
className,
|
||||
variant = 'default',
|
||||
}: EmptyStateProps) {
|
||||
// Get styles based on variant
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
|
||||
// Animation variants for buttons
|
||||
const buttonAnimation = {
|
||||
whileHover: { scale: 1.02 },
|
||||
whileTap: { scale: 0.98 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col items-center justify-center',
|
||||
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg',
|
||||
styles.container,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 flex items-center justify-center',
|
||||
styles.icon.container,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
icon,
|
||||
styles.icon.size,
|
||||
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className={classNames('font-medium', styles.title)}>{title}</p>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p
|
||||
className={classNames(
|
||||
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark text-center max-w-xs',
|
||||
styles.description,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{(actionLabel || secondaryActionLabel) && (
|
||||
<div className={classNames('flex items-center gap-2', styles.actions)}>
|
||||
{actionLabel && onAction && (
|
||||
<motion.div {...buttonAnimation}>
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant="default"
|
||||
size={styles.buttonSize}
|
||||
className="bg-purple-500 hover:bg-purple-600 text-white"
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{secondaryActionLabel && onSecondaryAction && (
|
||||
<motion.div {...buttonAnimation}>
|
||||
<Button onClick={onSecondaryAction} variant="outline" size={styles.buttonSize}>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface FileIconProps {
|
||||
filename: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ filename, size = 'md', className }: FileIconProps) {
|
||||
const getFileExtension = (filename: string): string => {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
const getIconForExtension = (extension: string): string => {
|
||||
// Code files
|
||||
if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) {
|
||||
return 'i-ph:file-js';
|
||||
}
|
||||
|
||||
if (['html', 'htm', 'xhtml'].includes(extension)) {
|
||||
return 'i-ph:file-html';
|
||||
}
|
||||
|
||||
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
|
||||
return 'i-ph:file-css';
|
||||
}
|
||||
|
||||
if (['json', 'jsonc'].includes(extension)) {
|
||||
return 'i-ph:brackets-curly';
|
||||
}
|
||||
|
||||
if (['md', 'markdown'].includes(extension)) {
|
||||
return 'i-ph:file-text';
|
||||
}
|
||||
|
||||
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
|
||||
return 'i-ph:file-py';
|
||||
}
|
||||
|
||||
if (['java', 'class', 'jar'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
if (['php'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
if (['rb', 'ruby'].includes(extension)) {
|
||||
return 'i-ph:file-rs';
|
||||
}
|
||||
|
||||
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
|
||||
return 'i-ph:file-cpp';
|
||||
}
|
||||
|
||||
if (['go'].includes(extension)) {
|
||||
return 'i-ph:file-rs';
|
||||
}
|
||||
|
||||
if (['rs', 'rust'].includes(extension)) {
|
||||
return 'i-ph:file-rs';
|
||||
}
|
||||
|
||||
if (['swift'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
if (['kt', 'kotlin'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
if (['dart'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
// Config files
|
||||
if (['yml', 'yaml'].includes(extension)) {
|
||||
return 'i-ph:file-cloud';
|
||||
}
|
||||
|
||||
if (['xml', 'svg'].includes(extension)) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
if (['toml'].includes(extension)) {
|
||||
return 'i-ph:file-text';
|
||||
}
|
||||
|
||||
if (['ini', 'conf', 'config'].includes(extension)) {
|
||||
return 'i-ph:file-text';
|
||||
}
|
||||
|
||||
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
|
||||
return 'i-ph:file-lock';
|
||||
}
|
||||
|
||||
// Document files
|
||||
if (['pdf'].includes(extension)) {
|
||||
return 'i-ph:file-pdf';
|
||||
}
|
||||
|
||||
if (['doc', 'docx'].includes(extension)) {
|
||||
return 'i-ph:file-doc';
|
||||
}
|
||||
|
||||
if (['xls', 'xlsx'].includes(extension)) {
|
||||
return 'i-ph:file-xls';
|
||||
}
|
||||
|
||||
if (['ppt', 'pptx'].includes(extension)) {
|
||||
return 'i-ph:file-ppt';
|
||||
}
|
||||
|
||||
if (['txt'].includes(extension)) {
|
||||
return 'i-ph:file-text';
|
||||
}
|
||||
|
||||
// Image files
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
|
||||
return 'i-ph:file-image';
|
||||
}
|
||||
|
||||
// Audio/Video files
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
|
||||
return 'i-ph:file-audio';
|
||||
}
|
||||
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
|
||||
return 'i-ph:file-video';
|
||||
}
|
||||
|
||||
// Archive files
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
||||
return 'i-ph:file-zip';
|
||||
}
|
||||
|
||||
// Special files
|
||||
if (filename === 'package.json') {
|
||||
return 'i-ph:package';
|
||||
}
|
||||
|
||||
if (filename === 'tsconfig.json') {
|
||||
return 'i-ph:file-ts';
|
||||
}
|
||||
|
||||
if (filename === 'README.md') {
|
||||
return 'i-ph:book-open';
|
||||
}
|
||||
|
||||
if (filename === 'LICENSE') {
|
||||
return 'i-ph:scales';
|
||||
}
|
||||
|
||||
if (filename === '.gitignore') {
|
||||
return 'i-ph:git-branch';
|
||||
}
|
||||
|
||||
if (filename.startsWith('Dockerfile')) {
|
||||
return 'i-ph:file-code';
|
||||
}
|
||||
|
||||
// Default
|
||||
return 'i-ph:file';
|
||||
};
|
||||
|
||||
const getIconColorForExtension = (extension: string): string => {
|
||||
// Code files
|
||||
if (['js', 'jsx'].includes(extension)) {
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
|
||||
if (['ts', 'tsx'].includes(extension)) {
|
||||
return 'text-blue-500';
|
||||
}
|
||||
|
||||
if (['html', 'htm', 'xhtml'].includes(extension)) {
|
||||
return 'text-orange-500';
|
||||
}
|
||||
|
||||
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
|
||||
return 'text-blue-400';
|
||||
}
|
||||
|
||||
if (['json', 'jsonc'].includes(extension)) {
|
||||
return 'text-yellow-400';
|
||||
}
|
||||
|
||||
if (['md', 'markdown'].includes(extension)) {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
|
||||
if (['java', 'class', 'jar'].includes(extension)) {
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
if (['php'].includes(extension)) {
|
||||
return 'text-purple-500';
|
||||
}
|
||||
|
||||
if (['rb', 'ruby'].includes(extension)) {
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
|
||||
return 'text-blue-600';
|
||||
}
|
||||
|
||||
if (['go'].includes(extension)) {
|
||||
return 'text-cyan-500';
|
||||
}
|
||||
|
||||
if (['rs', 'rust'].includes(extension)) {
|
||||
return 'text-orange-600';
|
||||
}
|
||||
|
||||
if (['swift'].includes(extension)) {
|
||||
return 'text-orange-500';
|
||||
}
|
||||
|
||||
if (['kt', 'kotlin'].includes(extension)) {
|
||||
return 'text-purple-400';
|
||||
}
|
||||
|
||||
if (['dart'].includes(extension)) {
|
||||
return 'text-cyan-400';
|
||||
}
|
||||
|
||||
// Config files
|
||||
if (['yml', 'yaml'].includes(extension)) {
|
||||
return 'text-purple-300';
|
||||
}
|
||||
|
||||
if (['xml'].includes(extension)) {
|
||||
return 'text-orange-300';
|
||||
}
|
||||
|
||||
if (['svg'].includes(extension)) {
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
if (['toml'].includes(extension)) {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
if (['ini', 'conf', 'config'].includes(extension)) {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
|
||||
// Document files
|
||||
if (['pdf'].includes(extension)) {
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
if (['doc', 'docx'].includes(extension)) {
|
||||
return 'text-blue-600';
|
||||
}
|
||||
|
||||
if (['xls', 'xlsx'].includes(extension)) {
|
||||
return 'text-green-600';
|
||||
}
|
||||
|
||||
if (['ppt', 'pptx'].includes(extension)) {
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
if (['txt'].includes(extension)) {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
// Image files
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
|
||||
return 'text-pink-500';
|
||||
}
|
||||
|
||||
// Audio/Video files
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
|
||||
return 'text-blue-500';
|
||||
}
|
||||
|
||||
// Archive files
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
|
||||
// Special files
|
||||
if (filename === 'package.json') {
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
if (filename === 'tsconfig.json') {
|
||||
return 'text-blue-500';
|
||||
}
|
||||
|
||||
if (filename === 'README.md') {
|
||||
return 'text-blue-400';
|
||||
}
|
||||
|
||||
if (filename === 'LICENSE') {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
if (filename === '.gitignore') {
|
||||
return 'text-orange-500';
|
||||
}
|
||||
|
||||
if (filename.startsWith('Dockerfile')) {
|
||||
return 'text-blue-500';
|
||||
}
|
||||
|
||||
// Default
|
||||
return 'text-gray-400';
|
||||
};
|
||||
|
||||
const getSizeClass = (size: 'sm' | 'md' | 'lg'): string => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'w-4 h-4';
|
||||
case 'md':
|
||||
return 'w-5 h-5';
|
||||
case 'lg':
|
||||
return 'w-6 h-6';
|
||||
default:
|
||||
return 'w-5 h-5';
|
||||
}
|
||||
};
|
||||
|
||||
const extension = getFileExtension(filename);
|
||||
const icon = getIconForExtension(extension);
|
||||
const color = getIconColorForExtension(extension);
|
||||
const sizeClass = getSizeClass(size);
|
||||
|
||||
return <span className={classNames(icon, color, sizeClass, className)} />;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface FilterChipProps {
|
||||
/** The label text to display */
|
||||
label: string;
|
||||
|
||||
/** Optional value to display after the label */
|
||||
value?: string | number;
|
||||
|
||||
/** Function to call when the remove button is clicked */
|
||||
onRemove?: () => void;
|
||||
|
||||
/** Whether the chip is active/selected */
|
||||
active?: boolean;
|
||||
|
||||
/** Optional icon to display before the label */
|
||||
icon?: string;
|
||||
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterChip component
|
||||
*
|
||||
* A chip component for displaying filters with optional remove button.
|
||||
*/
|
||||
export function FilterChip({ label, value, onRemove, active = false, icon, className }: FilterChipProps) {
|
||||
// Animation variants
|
||||
const variants = {
|
||||
initial: { opacity: 0, scale: 0.9 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.9 },
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={variants}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
|
||||
active
|
||||
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border border-purple-500/30'
|
||||
: 'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
onRemove && 'pr-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{icon && <span className={classNames(icon, 'text-inherit')} />}
|
||||
|
||||
{/* Label and value */}
|
||||
<span>
|
||||
{label}
|
||||
{value !== undefined && ': '}
|
||||
{value !== undefined && (
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-purple-700 dark:text-purple-300 font-semibold'
|
||||
: 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Remove button */}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={classNames(
|
||||
'ml-1 p-0.5 rounded-full hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors',
|
||||
active
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||
)}
|
||||
aria-label={`Remove ${label} filter`}
|
||||
>
|
||||
<span className="i-ph:x w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Predefined gradient colors
|
||||
const GRADIENT_COLORS = [
|
||||
'from-purple-500/10 to-blue-500/5',
|
||||
'from-blue-500/10 to-cyan-500/5',
|
||||
'from-cyan-500/10 to-green-500/5',
|
||||
'from-green-500/10 to-yellow-500/5',
|
||||
'from-yellow-500/10 to-orange-500/5',
|
||||
'from-orange-500/10 to-red-500/5',
|
||||
'from-red-500/10 to-pink-500/5',
|
||||
'from-pink-500/10 to-purple-500/5',
|
||||
];
|
||||
|
||||
interface GradientCardProps {
|
||||
/** Custom gradient class (overrides seed-based gradient) */
|
||||
gradient?: string;
|
||||
|
||||
/** Seed string to determine gradient color */
|
||||
seed?: string;
|
||||
|
||||
/** Whether to apply hover animation effect */
|
||||
hoverEffect?: boolean;
|
||||
|
||||
/** Whether to apply border effect */
|
||||
borderEffect?: boolean;
|
||||
|
||||
/** Card content */
|
||||
children: React.ReactNode;
|
||||
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
|
||||
/** Additional props */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* GradientCard component
|
||||
*
|
||||
* A card with a gradient background that can be determined by a seed string.
|
||||
*/
|
||||
export function GradientCard({
|
||||
gradient,
|
||||
seed,
|
||||
hoverEffect = true,
|
||||
borderEffect = true,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: GradientCardProps) {
|
||||
// Get gradient color based on seed or use provided gradient
|
||||
const gradientClass = gradient || getGradientColorFromSeed(seed);
|
||||
|
||||
// Animation variants for hover effect
|
||||
const hoverAnimation = hoverEffect
|
||||
? {
|
||||
whileHover: {
|
||||
scale: 1.02,
|
||||
y: -2,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||
},
|
||||
whileTap: { scale: 0.98 },
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'p-5 rounded-xl bg-gradient-to-br',
|
||||
gradientClass,
|
||||
borderEffect
|
||||
? 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40'
|
||||
: '',
|
||||
'transition-all duration-300 shadow-sm',
|
||||
hoverEffect ? 'hover:shadow-md' : '',
|
||||
className,
|
||||
)}
|
||||
{...hoverAnimation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a gradient color based on the seed string for visual variety
|
||||
*/
|
||||
function getGradientColorFromSeed(seedString?: string): string {
|
||||
if (!seedString) {
|
||||
return GRADIENT_COLORS[0];
|
||||
}
|
||||
|
||||
const index = seedString.length % GRADIENT_COLORS.length;
|
||||
|
||||
return GRADIENT_COLORS[index];
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export const IconButton = memo(
|
||||
<button
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed focus:outline-none',
|
||||
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
|
||||
{
|
||||
[classNames('opacity-30', disabledClassName)]: disabled,
|
||||
},
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
|
||||
interface RepositoryStatsProps {
|
||||
stats: {
|
||||
totalFiles?: number;
|
||||
totalSize?: number;
|
||||
languages?: Record<string, number>;
|
||||
hasPackageJson?: boolean;
|
||||
hasDependencies?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function RepositoryStats({ stats, className, compact = false }: RepositoryStatsProps) {
|
||||
const { totalFiles, totalSize, languages, hasPackageJson, hasDependencies } = stats;
|
||||
|
||||
return (
|
||||
<div className={classNames('space-y-3', className)}>
|
||||
{!compact && (
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Repository Statistics:
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={classNames('grid gap-3', compact ? 'grid-cols-2' : 'grid-cols-2 md:grid-cols-3')}>
|
||||
{totalFiles !== undefined && (
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
||||
<span className={compact ? 'text-xs' : 'text-sm'}>Total Files: {totalFiles.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalSize !== undefined && (
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
||||
<span className={compact ? 'text-xs' : 'text-sm'}>Total Size: {formatSize(totalSize)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{languages && Object.keys(languages).length > 0 && (
|
||||
<div className={compact ? 'pt-1' : 'pt-2'}>
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2">
|
||||
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
||||
<span className={compact ? 'text-xs' : 'text-sm'}>Languages:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, compact ? 3 : 5)
|
||||
.map(([lang, size]) => (
|
||||
<Badge key={lang} variant="subtle" size={compact ? 'sm' : 'md'}>
|
||||
{lang} ({formatSize(size)})
|
||||
</Badge>
|
||||
))}
|
||||
{Object.keys(languages).length > (compact ? 3 : 5) && (
|
||||
<Badge variant="subtle" size={compact ? 'sm' : 'md'}>
|
||||
+{Object.keys(languages).length - (compact ? 3 : 5)} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasPackageJson || hasDependencies) && (
|
||||
<div className={compact ? 'pt-1' : 'pt-2'}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasPackageJson && (
|
||||
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:package w-3.5 h-3.5">
|
||||
package.json
|
||||
</Badge>
|
||||
)}
|
||||
{hasDependencies && (
|
||||
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:tree-structure w-3.5 h-3.5">
|
||||
Dependencies
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Input } from './Input';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/** Function to call when the clear button is clicked */
|
||||
onClear?: () => void;
|
||||
|
||||
/** Whether to show the clear button when there is input */
|
||||
showClearButton?: boolean;
|
||||
|
||||
/** Additional class name for the search icon */
|
||||
iconClassName?: string;
|
||||
|
||||
/** Additional class name for the container */
|
||||
containerClassName?: string;
|
||||
|
||||
/** Whether the search is loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchInput component
|
||||
*
|
||||
* A search input field with a search icon and optional clear button.
|
||||
*/
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
(
|
||||
{ className, onClear, showClearButton = true, iconClassName, containerClassName, loading = false, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const hasValue = Boolean(props.value);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative flex items-center w-full', containerClassName)}>
|
||||
{/* Search icon or loading spinner */}
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary',
|
||||
iconClassName,
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||
) : (
|
||||
<span className="i-ph:magnifying-glass w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input field */}
|
||||
<Input
|
||||
ref={ref}
|
||||
className={classNames('pl-10', hasValue && showClearButton ? 'pr-10' : '', className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Clear button */}
|
||||
<AnimatePresence>
|
||||
{hasValue && showClearButton && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary p-1 rounded-full hover:bg-bolt-elements-background-depth-2"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<span className="i-ph:x w-3.5 h-3.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface SearchResultItemProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconBackground?: string;
|
||||
iconColor?: string;
|
||||
tags?: string[];
|
||||
metadata?: Array<{
|
||||
icon?: string;
|
||||
label: string;
|
||||
value?: string | number;
|
||||
}>;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchResultItem({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
icon,
|
||||
iconBackground = 'bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80',
|
||||
iconColor = 'text-purple-500',
|
||||
tags,
|
||||
metadata,
|
||||
actionLabel,
|
||||
onAction,
|
||||
onClick,
|
||||
className,
|
||||
}: SearchResultItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-3/50',
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className,
|
||||
)}
|
||||
whileHover={{
|
||||
scale: 1.01,
|
||||
y: -1,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||
}}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-10 h-10 rounded-xl backdrop-blur-sm flex items-center justify-center shadow-sm',
|
||||
iconBackground,
|
||||
)}
|
||||
>
|
||||
<span className={classNames(icon, 'w-5 h-5', iconColor)} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionLabel && onAction && (
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction();
|
||||
}}
|
||||
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{actionLabel}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="subtle" size="sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata && metadata.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
|
||||
<span>
|
||||
{item.label}
|
||||
{item.value !== undefined && ': '}
|
||||
{item.value !== undefined && (
|
||||
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
|
||||
icon="i-ph:gear"
|
||||
size="xl"
|
||||
title="Settings"
|
||||
data-testid="settings-button"
|
||||
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,15 @@ import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { genericMemo } from '~/utils/react';
|
||||
|
||||
export type SliderOptions<T> = {
|
||||
left: { value: T; text: string };
|
||||
middle?: { value: T; text: string };
|
||||
right: { value: T; text: string };
|
||||
};
|
||||
interface SliderOption<T> {
|
||||
value: T;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SliderOptions<T> {
|
||||
left: SliderOption<T>;
|
||||
right: SliderOption<T>;
|
||||
}
|
||||
|
||||
interface SliderProps<T> {
|
||||
selected: T;
|
||||
@@ -17,26 +21,14 @@ interface SliderProps<T> {
|
||||
}
|
||||
|
||||
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
||||
const hasMiddle = !!options.middle;
|
||||
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
|
||||
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
|
||||
const isLeftSelected = selected === options.left.value;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
||||
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
||||
{options.left.text}
|
||||
</SliderButton>
|
||||
|
||||
{options.middle && (
|
||||
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
|
||||
{options.middle.text}
|
||||
</SliderButton>
|
||||
)}
|
||||
|
||||
<SliderButton
|
||||
selected={!isLeftSelected && !isMiddleSelected}
|
||||
setSelected={() => setSelected?.(options.right.value)}
|
||||
>
|
||||
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||
{options.right.text}
|
||||
</SliderButton>
|
||||
</div>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Status types supported by the component
|
||||
type StatusType = 'online' | 'offline' | 'away' | 'busy' | 'success' | 'warning' | 'error' | 'info' | 'loading';
|
||||
|
||||
// Size types for the indicator
|
||||
type SizeType = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Status color mapping
|
||||
const STATUS_COLORS: Record<StatusType, string> = {
|
||||
online: 'bg-green-500',
|
||||
success: 'bg-green-500',
|
||||
offline: 'bg-red-500',
|
||||
error: 'bg-red-500',
|
||||
away: 'bg-yellow-500',
|
||||
warning: 'bg-yellow-500',
|
||||
busy: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
loading: 'bg-purple-500',
|
||||
};
|
||||
|
||||
// Size class mapping
|
||||
const SIZE_CLASSES: Record<SizeType, string> = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4',
|
||||
};
|
||||
|
||||
// Text size mapping based on indicator size
|
||||
const TEXT_SIZE_CLASSES: Record<SizeType, string> = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
/** The status to display */
|
||||
status: StatusType;
|
||||
|
||||
/** Size of the indicator */
|
||||
size?: SizeType;
|
||||
|
||||
/** Whether to show a pulsing animation */
|
||||
pulse?: boolean;
|
||||
|
||||
/** Optional label text */
|
||||
label?: string;
|
||||
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusIndicator component
|
||||
*
|
||||
* A component for displaying status indicators with optional labels and pulse animations.
|
||||
*/
|
||||
export function StatusIndicator({ status, size = 'md', pulse = false, label, className }: StatusIndicatorProps) {
|
||||
// Get the color class for the status
|
||||
const colorClass = STATUS_COLORS[status] || 'bg-gray-500';
|
||||
|
||||
// Get the size class for the indicator
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
|
||||
// Get the text size class for the label
|
||||
const textSizeClass = TEXT_SIZE_CLASSES[size];
|
||||
|
||||
return (
|
||||
<div className={classNames('flex items-center gap-2', className)}>
|
||||
{/* Status indicator dot */}
|
||||
<span className={classNames('rounded-full relative', colorClass, sizeClass)}>
|
||||
{/* Pulse animation */}
|
||||
{pulse && <span className={classNames('absolute inset-0 rounded-full animate-ping opacity-75', colorClass)} />}
|
||||
</span>
|
||||
|
||||
{/* Optional label */}
|
||||
{label && (
|
||||
<span
|
||||
className={classNames(
|
||||
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||
textSizeClass,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-3-dark p-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background p-1 text-bolt-elements-textSecondary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background dark:ring-offset-bolt-elements-background-dark transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background-depth-0 dark:data-[state=active]:bg-bolt-elements-background-depth-2-dark data-[state=active]:text-bolt-elements-textPrimary dark:data-[state=active]:text-bolt-elements-textPrimary-dark data-[state=active]:shadow-sm',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background data-[state=active]:text-bolt-elements-textPrimary data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface Tab {
|
||||
/** Unique identifier for the tab */
|
||||
id: string;
|
||||
|
||||
/** Content to display in the tab */
|
||||
label: React.ReactNode;
|
||||
|
||||
/** Optional icon to display before the label */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface TabsWithSliderProps {
|
||||
/** Array of tab objects */
|
||||
tabs: Tab[];
|
||||
|
||||
/** ID of the currently active tab */
|
||||
activeTab: string;
|
||||
|
||||
/** Function called when a tab is clicked */
|
||||
onChange: (tabId: string) => void;
|
||||
|
||||
/** Additional class name for the container */
|
||||
className?: string;
|
||||
|
||||
/** Additional class name for inactive tabs */
|
||||
tabClassName?: string;
|
||||
|
||||
/** Additional class name for the active tab */
|
||||
activeTabClassName?: string;
|
||||
|
||||
/** Additional class name for the slider */
|
||||
sliderClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabsWithSlider component
|
||||
*
|
||||
* A tabs component with an animated slider that moves to the active tab.
|
||||
*/
|
||||
export function TabsWithSlider({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
className,
|
||||
tabClassName,
|
||||
activeTabClassName,
|
||||
sliderClassName,
|
||||
}: TabsWithSliderProps) {
|
||||
// State for slider dimensions
|
||||
const [sliderDimensions, setSliderDimensions] = useState({ width: 0, left: 0 });
|
||||
|
||||
// Refs for tab elements
|
||||
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Update slider position when active tab changes
|
||||
useEffect(() => {
|
||||
const activeIndex = tabs.findIndex((tab) => tab.id === activeTab);
|
||||
|
||||
if (activeIndex !== -1 && tabsRef.current[activeIndex]) {
|
||||
const activeTabElement = tabsRef.current[activeIndex];
|
||||
|
||||
if (activeTabElement) {
|
||||
setSliderDimensions({
|
||||
width: activeTabElement.offsetWidth,
|
||||
left: activeTabElement.offsetLeft,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeTab, tabs]);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative flex gap-2', className)}>
|
||||
{/* Tab buttons */}
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => (tabsRef.current[index] = el)}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={classNames(
|
||||
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center relative overflow-hidden',
|
||||
tab.id === activeTab
|
||||
? classNames('text-white shadow-sm shadow-purple-500/20', activeTabClassName)
|
||||
: classNames(
|
||||
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
tabClassName,
|
||||
),
|
||||
)}
|
||||
>
|
||||
<span className={classNames('flex items-center gap-2', tab.id === activeTab ? 'font-medium' : '')}>
|
||||
{tab.icon && <span className={tab.icon} />}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Animated slider */}
|
||||
<motion.div
|
||||
className={classNames('absolute bottom-0 left-0 h-10 rounded-lg bg-purple-500 -z-10', sliderClassName)}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: sliderDimensions.width,
|
||||
x: sliderDimensions.left,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Original WithTooltip component
|
||||
interface WithTooltipProps {
|
||||
interface TooltipProps {
|
||||
tooltip: React.ReactNode;
|
||||
children: ReactElement;
|
||||
sideOffset?: number;
|
||||
@@ -27,15 +25,14 @@ const WithTooltip = forwardRef(
|
||||
position = 'top',
|
||||
maxWidth = 250,
|
||||
delay = 0,
|
||||
}: WithTooltipProps,
|
||||
}: TooltipProps,
|
||||
_ref: ForwardedRef<HTMLElement>,
|
||||
) => {
|
||||
return (
|
||||
<TooltipPrimitive.Provider>
|
||||
<TooltipPrimitive.Root delayDuration={delay}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
<Tooltip.Root delayDuration={delay}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={position}
|
||||
className={`
|
||||
z-[2000]
|
||||
@@ -64,7 +61,7 @@ const WithTooltip = forwardRef(
|
||||
}}
|
||||
>
|
||||
<div className="break-words">{tooltip}</div>
|
||||
<TooltipPrimitive.Arrow
|
||||
<Tooltip.Arrow
|
||||
className={`
|
||||
fill-bolt-elements-background-depth-3
|
||||
${arrowClassName}
|
||||
@@ -72,51 +69,11 @@ const WithTooltip = forwardRef(
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
</TooltipPrimitive.Provider>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// New Tooltip component with simpler API
|
||||
interface TooltipProps {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
align?: 'start' | 'center' | 'end';
|
||||
delayDuration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
align = 'center',
|
||||
delayDuration = 300,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider>
|
||||
<TooltipPrimitive.Root delayDuration={delayDuration}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
className={classNames(
|
||||
'z-50 overflow-hidden rounded-md bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 px-3 py-1.5 text-xs text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
sideOffset={5}
|
||||
>
|
||||
{content}
|
||||
<TooltipPrimitive.Arrow className="fill-bolt-elements-background-depth-3 dark:fill-bolt-elements-background-depth-4" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default WithTooltip;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Export all UI components for easier imports
|
||||
|
||||
// Core components
|
||||
export * from './Badge';
|
||||
export * from './Button';
|
||||
export * from './Card';
|
||||
export * from './Checkbox';
|
||||
export * from './Collapsible';
|
||||
export * from './Dialog';
|
||||
export * from './IconButton';
|
||||
export * from './Input';
|
||||
export * from './Label';
|
||||
export * from './ScrollArea';
|
||||
export * from './Switch';
|
||||
export * from './Tabs';
|
||||
export * from './ThemeSwitch';
|
||||
|
||||
// Loading components
|
||||
export * from './LoadingDots';
|
||||
export * from './LoadingOverlay';
|
||||
|
||||
// New components
|
||||
export * from './Breadcrumbs';
|
||||
export * from './CloseButton';
|
||||
export * from './CodeBlock';
|
||||
export * from './EmptyState';
|
||||
export * from './FileIcon';
|
||||
export * from './FilterChip';
|
||||
export * from './GradientCard';
|
||||
export * from './RepositoryStats';
|
||||
export * from './SearchInput';
|
||||
export * from './SearchResultItem';
|
||||
export * from './StatusIndicator';
|
||||
export * from './TabsWithSlider';
|
||||
|
||||
// Tooltip components
|
||||
export { default as WithTooltip } from './Tooltip';
|
||||
export { Tooltip } from './Tooltip';
|
||||
@@ -1,18 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
|
||||
// Configure standard toast settings
|
||||
export const configuredToast = {
|
||||
success: (message: string, options = {}) => toastify.success(message, { autoClose: 3000, ...options }),
|
||||
error: (message: string, options = {}) => toastify.error(message, { autoClose: 3000, ...options }),
|
||||
info: (message: string, options = {}) => toastify.info(message, { autoClose: 3000, ...options }),
|
||||
warning: (message: string, options = {}) => toastify.warning(message, { autoClose: 3000, ...options }),
|
||||
loading: (message: string, options = {}) => toastify.loading(message, { autoClose: 3000, ...options }),
|
||||
};
|
||||
|
||||
// Export the original toast for cases where specific configuration is needed
|
||||
export { toastify as toast };
|
||||
|
||||
interface ToastOptions {
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
@@ -48,19 +36,5 @@ export function useToast() {
|
||||
[toast],
|
||||
);
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
|
||||
toast(message, { ...options, type: 'info' });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
|
||||
toast(message, { ...options, type: 'warning' });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
return { toast, success, error, info, warning };
|
||||
return { toast, success, error };
|
||||
}
|
||||
|
||||
@@ -1,755 +0,0 @@
|
||||
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { getHighlighter } from 'shiki';
|
||||
import '~/styles/diff-view.css';
|
||||
import { diffFiles, extractRelativePath } from '~/utils/diff';
|
||||
import { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
|
||||
interface CodeComparisonProps {
|
||||
beforeCode: string;
|
||||
afterCode: string;
|
||||
language: string;
|
||||
filename: string;
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
}
|
||||
|
||||
interface DiffBlock {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
correspondingLine?: number;
|
||||
charChanges?: Array<{
|
||||
value: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
onClick: () => void;
|
||||
isFullscreen: boolean;
|
||||
}
|
||||
|
||||
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
>
|
||||
<div className={isFullscreen ? 'i-ph:corners-in' : 'i-ph:corners-out'} />
|
||||
</button>
|
||||
));
|
||||
|
||||
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
|
||||
if (!isFullscreen) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
|
||||
<div className="w-full h-full max-w-[90vw] max-h-[90vh] bg-bolt-elements-background-depth-2 rounded-lg border border-bolt-elements-borderColor shadow-xl overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
||||
const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
|
||||
|
||||
const isBinaryFile = (content: string) => {
|
||||
return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
|
||||
};
|
||||
|
||||
const processChanges = (beforeCode: string, afterCode: string) => {
|
||||
try {
|
||||
if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
isBinary: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize line endings and content
|
||||
const normalizeContent = (content: string): string[] => {
|
||||
return content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
};
|
||||
|
||||
const beforeLines = normalizeContent(beforeCode);
|
||||
const afterLines = normalizeContent(afterCode);
|
||||
|
||||
// Early return if files are identical
|
||||
if (beforeLines.join('\n') === afterLines.join('\n')) {
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lineChanges = {
|
||||
before: new Set<number>(),
|
||||
after: new Set<number>(),
|
||||
};
|
||||
|
||||
const unifiedBlocks: DiffBlock[] = [];
|
||||
|
||||
// Compare lines directly for more accurate diff
|
||||
let i = 0,
|
||||
j = 0;
|
||||
|
||||
while (i < beforeLines.length || j < afterLines.length) {
|
||||
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
|
||||
// Unchanged line
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'unchanged',
|
||||
correspondingLine: i,
|
||||
});
|
||||
i++;
|
||||
j++;
|
||||
} else {
|
||||
// Look ahead for potential matches
|
||||
let matchFound = false;
|
||||
const lookAhead = 3; // Number of lines to look ahead
|
||||
|
||||
// Try to find matching lines ahead
|
||||
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
|
||||
if (beforeLines[i + k] === afterLines[j]) {
|
||||
// Found match in after lines - mark lines as removed
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.before.add(i + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i + l,
|
||||
content: beforeLines[i + l],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i + l], type: 'removed' }],
|
||||
});
|
||||
}
|
||||
i += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
} else if (beforeLines[i] === afterLines[j + k]) {
|
||||
// Found match in before lines - mark lines as added
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.after.add(j + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j + l,
|
||||
content: afterLines[j + l],
|
||||
type: 'added',
|
||||
correspondingLine: i,
|
||||
charChanges: [{ value: afterLines[j + l], type: 'added' }],
|
||||
});
|
||||
}
|
||||
j += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
// No match found - try to find character-level changes
|
||||
if (i < beforeLines.length && j < afterLines.length) {
|
||||
const beforeLine = beforeLines[i];
|
||||
const afterLine = afterLines[j];
|
||||
|
||||
// Find common prefix and suffix
|
||||
let prefixLength = 0;
|
||||
|
||||
while (
|
||||
prefixLength < beforeLine.length &&
|
||||
prefixLength < afterLine.length &&
|
||||
beforeLine[prefixLength] === afterLine[prefixLength]
|
||||
) {
|
||||
prefixLength++;
|
||||
}
|
||||
|
||||
let suffixLength = 0;
|
||||
|
||||
while (
|
||||
suffixLength < beforeLine.length - prefixLength &&
|
||||
suffixLength < afterLine.length - prefixLength &&
|
||||
beforeLine[beforeLine.length - 1 - suffixLength] === afterLine[afterLine.length - 1 - suffixLength]
|
||||
) {
|
||||
suffixLength++;
|
||||
}
|
||||
|
||||
const prefix = beforeLine.slice(0, prefixLength);
|
||||
const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
|
||||
const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
|
||||
const suffix = beforeLine.slice(beforeLine.length - suffixLength);
|
||||
|
||||
if (beforeMiddle || afterMiddle) {
|
||||
// There are character-level changes
|
||||
if (beforeMiddle) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLine,
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: beforeMiddle, type: 'removed' },
|
||||
{ value: suffix, type: 'unchanged' },
|
||||
],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (afterMiddle) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLine,
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: afterMiddle, type: 'added' },
|
||||
{ value: suffix, type: 'unchanged' },
|
||||
],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
} else {
|
||||
// No character-level changes found, treat as regular line changes
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle remaining lines
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort blocks by line number
|
||||
const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
|
||||
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
||||
lineChanges,
|
||||
unifiedBlocks: processedBlocks,
|
||||
isBinary: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing changes:', error);
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
error: true,
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const lineNumberStyles =
|
||||
'w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1';
|
||||
const lineContentStyles =
|
||||
'px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary';
|
||||
const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
|
||||
|
||||
// Updated color styles for better consistency
|
||||
const diffLineStyles = {
|
||||
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
|
||||
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
|
||||
unchanged: '',
|
||||
};
|
||||
|
||||
const changeColorStyles = {
|
||||
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
|
||||
removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
|
||||
unchanged: 'text-bolt-elements-textPrimary',
|
||||
};
|
||||
|
||||
const renderContentWarning = (type: 'binary' | 'error') => (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-bolt-elements-textTertiary">
|
||||
<div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
{type === 'binary' ? 'Binary file detected' : 'Error processing file'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{type === 'binary' ? 'Diff view is not available for binary files' : 'Could not generate diff preview'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NoChangesView = memo(
|
||||
({
|
||||
beforeCode,
|
||||
language,
|
||||
highlighter,
|
||||
theme,
|
||||
}: {
|
||||
beforeCode: string;
|
||||
language: string;
|
||||
highlighter: any;
|
||||
theme: string;
|
||||
}) => (
|
||||
<div className="h-full flex flex-col items-center justify-center p-4">
|
||||
<div className="text-center text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
|
||||
<p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
|
||||
<p className="text-sm mt-1">Both versions match exactly</p>
|
||||
</div>
|
||||
<div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
|
||||
<div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
|
||||
Current Content
|
||||
</div>
|
||||
<div className="overflow-auto max-h-96">
|
||||
{beforeCode.split('\n').map((line, index) => (
|
||||
<div key={index} className="flex group min-w-fit">
|
||||
<div className={lineNumberStyles}>{index + 1}</div>
|
||||
<div className={lineContentStyles}>
|
||||
<span className="mr-2"> </span>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlighter
|
||||
? highlighter
|
||||
.codeToHtml(line, {
|
||||
lang: language,
|
||||
theme: theme === 'dark' ? 'github-dark' : 'github-light',
|
||||
})
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: line,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
// Otimização do processamento de diferenças com memoização
|
||||
const useProcessChanges = (beforeCode: string, afterCode: string) => {
|
||||
return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
|
||||
};
|
||||
|
||||
// Componente otimizado para renderização de linhas de código
|
||||
const CodeLine = memo(
|
||||
({
|
||||
lineNumber,
|
||||
content,
|
||||
type,
|
||||
highlighter,
|
||||
language,
|
||||
block,
|
||||
theme,
|
||||
}: {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
highlighter: any;
|
||||
language: string;
|
||||
block: DiffBlock;
|
||||
theme: string;
|
||||
}) => {
|
||||
const bgColor = diffLineStyles[type];
|
||||
|
||||
const renderContent = () => {
|
||||
if (type === 'unchanged' || !block.charChanges) {
|
||||
const highlightedCode = highlighter
|
||||
? highlighter
|
||||
.codeToHtml(content, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: content;
|
||||
return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{block.charChanges.map((change, index) => {
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
|
||||
const highlightedCode = highlighter
|
||||
? highlighter
|
||||
.codeToHtml(change.value, {
|
||||
lang: language,
|
||||
theme: theme === 'dark' ? 'github-dark' : 'github-light',
|
||||
})
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: change.value;
|
||||
|
||||
return <span key={index} className={changeClass} dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex group min-w-fit">
|
||||
<div className={lineNumberStyles}>{lineNumber + 1}</div>
|
||||
<div className={`${lineContentStyles} ${bgColor}`}>
|
||||
<span className="mr-2 text-bolt-elements-textTertiary">
|
||||
{type === 'added' && <span className="text-green-700 dark:text-green-500">+</span>}
|
||||
{type === 'removed' && <span className="text-red-700 dark:text-red-500">-</span>}
|
||||
{type === 'unchanged' && ' '}
|
||||
</span>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Componente para exibir informações sobre o arquivo
|
||||
const FileInfo = memo(
|
||||
({
|
||||
filename,
|
||||
hasChanges,
|
||||
onToggleFullscreen,
|
||||
isFullscreen,
|
||||
beforeCode,
|
||||
afterCode,
|
||||
}: {
|
||||
filename: string;
|
||||
hasChanges: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
isFullscreen: boolean;
|
||||
beforeCode: string;
|
||||
afterCode: string;
|
||||
}) => {
|
||||
// Calculate additions and deletions from the current document
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!hasChanges) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(beforeCode, afterCode, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
}, [hasChanges, beforeCode, afterCode]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
|
||||
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{filename}</span>
|
||||
<span className="ml-auto shrink-0 flex items-center gap-2">
|
||||
{hasChanges ? (
|
||||
<>
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && <span className="text-green-700 dark:text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-700 dark:text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-yellow-600 dark:text-yellow-400">Modified</span>
|
||||
<span className="text-bolt-elements-textTertiary text-xs">{new Date().toLocaleTimeString()}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-green-700 dark:text-green-400">No Changes</span>
|
||||
)}
|
||||
<FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }: CodeComparisonProps) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [highlighter, setHighlighter] = useState<any>(null);
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
|
||||
|
||||
useEffect(() => {
|
||||
getHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: [
|
||||
'typescript',
|
||||
'javascript',
|
||||
'json',
|
||||
'html',
|
||||
'css',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'python',
|
||||
'php',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'csharp',
|
||||
'go',
|
||||
'ruby',
|
||||
'rust',
|
||||
'plaintext',
|
||||
],
|
||||
}).then(setHighlighter);
|
||||
}, []);
|
||||
|
||||
if (isBinary || error) {
|
||||
return renderContentWarning(isBinary ? 'binary' : 'error');
|
||||
}
|
||||
|
||||
return (
|
||||
<FullscreenOverlay isFullscreen={isFullscreen}>
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<FileInfo
|
||||
filename={filename}
|
||||
hasChanges={hasChanges}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
isFullscreen={isFullscreen}
|
||||
beforeCode={beforeCode}
|
||||
afterCode={afterCode}
|
||||
/>
|
||||
<div className={diffPanelStyles}>
|
||||
{hasChanges ? (
|
||||
<div className="overflow-x-auto min-w-full">
|
||||
{unifiedBlocks.map((block, index) => (
|
||||
<CodeLine
|
||||
key={`${block.lineNumber}-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenOverlay>
|
||||
);
|
||||
});
|
||||
|
||||
interface DiffViewProps {
|
||||
fileHistory: Record<string, FileHistory>;
|
||||
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
|
||||
actionRunner: ActionRunner;
|
||||
}
|
||||
|
||||
export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) => {
|
||||
const files = useStore(workbenchStore.files) as FileMap;
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile && currentDocument) {
|
||||
const file = files[selectedFile];
|
||||
|
||||
if (!file || !('content' in file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingHistory = fileHistory[selectedFile];
|
||||
const currentContent = currentDocument.value;
|
||||
|
||||
// Normalizar o conteúdo para comparação
|
||||
const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
|
||||
const normalizedOriginalContent = (existingHistory?.originalContent || file.content)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.trim();
|
||||
|
||||
// Se não há histórico existente, criar um novo apenas se houver diferenças
|
||||
if (!existingHistory) {
|
||||
if (normalizedCurrentContent !== normalizedOriginalContent) {
|
||||
const newChanges = diffLines(file.content, currentContent);
|
||||
setFileHistory((prev) => ({
|
||||
...prev,
|
||||
[selectedFile]: {
|
||||
originalContent: file.content,
|
||||
lastModified: Date.now(),
|
||||
changes: newChanges,
|
||||
versions: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: currentContent,
|
||||
},
|
||||
],
|
||||
changeSource: 'auto-save',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Se já existe histórico, verificar se há mudanças reais desde a última versão
|
||||
const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
|
||||
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
|
||||
|
||||
if (normalizedCurrentContent === normalizedLastContent) {
|
||||
return; // Não criar novo histórico se o conteúdo é o mesmo
|
||||
}
|
||||
|
||||
// Verificar se há mudanças significativas usando diffFiles
|
||||
const relativePath = extractRelativePath(selectedFile);
|
||||
const unifiedDiff = diffFiles(relativePath, existingHistory.originalContent, currentContent);
|
||||
|
||||
if (unifiedDiff) {
|
||||
const newChanges = diffLines(existingHistory.originalContent, currentContent);
|
||||
|
||||
// Verificar se as mudanças são significativas
|
||||
const hasSignificantChanges = newChanges.some(
|
||||
(change) => (change.added || change.removed) && change.value.trim().length > 0,
|
||||
);
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
const newHistory: FileHistory = {
|
||||
originalContent: existingHistory.originalContent,
|
||||
lastModified: Date.now(),
|
||||
changes: [...existingHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
|
||||
versions: [
|
||||
...existingHistory.versions,
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: currentContent,
|
||||
},
|
||||
].slice(-10), // Manter apenas as 10 últimas versões
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
|
||||
setFileHistory((prev) => ({ ...prev, [selectedFile]: newHistory }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
|
||||
|
||||
if (!selectedFile || !currentDocument) {
|
||||
return (
|
||||
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
|
||||
Select a file to view differences
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const file = files[selectedFile];
|
||||
const originalContent = file && 'content' in file ? file.content : '';
|
||||
const currentContent = currentDocument.value;
|
||||
|
||||
const history = fileHistory[selectedFile];
|
||||
const effectiveOriginalContent = history?.originalContent || originalContent;
|
||||
const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
|
||||
|
||||
try {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<InlineDiffComparison
|
||||
beforeCode={effectiveOriginalContent}
|
||||
afterCode={currentContent}
|
||||
language={language}
|
||||
filename={selectedFile}
|
||||
lightTheme="github-light"
|
||||
darkTheme="github-dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('DiffView render error:', error);
|
||||
return (
|
||||
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
|
||||
<div className="text-center">
|
||||
<div className="i-ph:warning-circle text-4xl mb-2" />
|
||||
<p>Failed to render diff view</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
@@ -22,9 +20,6 @@ import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { Search } from './Search'; // <-- Ensure Search is imported
|
||||
import { classNames } from '~/utils/classNames'; // <-- Import classNames if not already present
|
||||
import { LockManager } from './LockManager'; // <-- Import LockManager
|
||||
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
@@ -32,7 +27,6 @@ interface EditorPanelProps {
|
||||
editorDocument?: EditorDocument;
|
||||
selectedFile?: string | undefined;
|
||||
isStreaming?: boolean;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
onEditorChange?: OnEditorChange;
|
||||
onEditorScroll?: OnEditorScroll;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
@@ -51,7 +45,6 @@ export const EditorPanel = memo(
|
||||
editorDocument,
|
||||
selectedFile,
|
||||
isStreaming,
|
||||
fileHistory,
|
||||
onFileSelect,
|
||||
onEditorChange,
|
||||
onEditorScroll,
|
||||
@@ -72,76 +65,30 @@ export const EditorPanel = memo(
|
||||
}, [editorDocument]);
|
||||
|
||||
const activeFileUnsaved = useMemo(() => {
|
||||
if (!editorDocument || !unsavedFiles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure unsavedFiles is a Set before calling has()
|
||||
return unsavedFiles instanceof Set && unsavedFiles.has(editorDocument.filePath);
|
||||
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
||||
}, [editorDocument, unsavedFiles]);
|
||||
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={20} minSize={15} collapsible className="border-r border-bolt-elements-borderColor">
|
||||
<div className="h-full">
|
||||
<Tabs.Root defaultValue="files" className="flex flex-col h-full">
|
||||
<PanelHeader className="w-full text-sm font-medium text-bolt-elements-textSecondary px-1">
|
||||
<div className="h-full flex-shrink-0 flex items-center justify-between w-full">
|
||||
<Tabs.List className="h-full flex-shrink-0 flex items-center">
|
||||
<Tabs.Trigger
|
||||
value="files"
|
||||
className={classNames(
|
||||
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
<Panel defaultSize={20} minSize={10} collapsible>
|
||||
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
||||
<PanelHeader>
|
||||
<div className="i-ph:tree-structure-duotone shrink-0" />
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="search"
|
||||
className={classNames(
|
||||
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="locks"
|
||||
className={classNames(
|
||||
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
Locks
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<Tabs.Content value="files" className="flex-grow overflow-auto focus-visible:outline-none">
|
||||
<FileTree
|
||||
className="h-full"
|
||||
files={files}
|
||||
hideRoot
|
||||
unsavedFiles={unsavedFiles}
|
||||
fileHistory={fileHistory}
|
||||
rootFolder={WORK_DIR}
|
||||
selectedFile={selectedFile}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="search" className="flex-grow overflow-auto focus-visible:outline-none">
|
||||
<Search />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="locks" className="flex-grow overflow-auto focus-visible:outline-none">
|
||||
<LockManager />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle />
|
||||
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
||||
<PanelHeader className="overflow-x-auto">
|
||||
@@ -163,7 +110,7 @@ export const EditorPanel = memo(
|
||||
</div>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="h-full flex-1 overflow-hidden modern-scrollbar">
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={!isStreaming && editorDocument !== undefined}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { QRCode } from 'react-qrcode-logo';
|
||||
|
||||
interface ExpoQrModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ExpoQrModal: React.FC<ExpoQrModalProps> = ({ open, onClose }) => {
|
||||
const expoUrl = useStore(expoUrlAtom);
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Dialog
|
||||
className="text-center !flex-col !mx-auto !text-center !max-w-md"
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="border !border-bolt-elements-borderColor flex flex-col gap-5 justify-center items-center p-6 bg-bolt-elements-background-depth-2 rounded-md">
|
||||
<div className="i-bolt:expo-brand h-10 w-full invert dark:invert-none"></div>
|
||||
<DialogTitle className="text-bolt-elements-textTertiary text-lg font-semibold leading-6">
|
||||
Preview on your own mobile device
|
||||
</DialogTitle>
|
||||
<DialogDescription className="bg-bolt-elements-background-depth-3 max-w-sm rounded-md p-1 border border-bolt-elements-borderColor">
|
||||
Scan this QR code with the Expo Go app on your mobile device to open your project.
|
||||
</DialogDescription>
|
||||
<div className="my-6 flex flex-col items-center">
|
||||
{expoUrl ? (
|
||||
<QRCode
|
||||
logoImage="/favicon.svg"
|
||||
removeQrCodeBehindLogo={true}
|
||||
logoPadding={3}
|
||||
logoHeight={50}
|
||||
logoWidth={50}
|
||||
logoPaddingStyle="square"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
padding: 2,
|
||||
backgroundColor: '#8a5fff',
|
||||
}}
|
||||
value={expoUrl}
|
||||
size={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">No Expo URL detected.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,8 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { toast } from 'react-toastify';
|
||||
import { path } from '~/utils/path';
|
||||
|
||||
const logger = createScopedLogger('FileTree');
|
||||
|
||||
@@ -24,18 +19,9 @@ interface Props {
|
||||
allowFolderSelection?: boolean;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
unsavedFiles?: Set<string>;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface InlineInputProps {
|
||||
depth: number;
|
||||
placeholder: string;
|
||||
initialValue?: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const FileTree = memo(
|
||||
({
|
||||
files = {},
|
||||
@@ -48,7 +34,6 @@ export const FileTree = memo(
|
||||
hiddenFiles,
|
||||
className,
|
||||
unsavedFiles,
|
||||
fileHistory = {},
|
||||
}: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
@@ -143,7 +128,7 @@ export const FileTree = memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto modern-scrollbar')}>
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
@@ -152,8 +137,7 @@ export const FileTree = memo(
|
||||
key={fileOrFolder.id}
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
unsavedChanges={unsavedFiles instanceof Set && unsavedFiles.has(fileOrFolder.fullPath)}
|
||||
fileHistory={fileHistory}
|
||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
@@ -224,375 +208,28 @@ function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; childr
|
||||
);
|
||||
}
|
||||
|
||||
function InlineInput({ depth, placeholder, initialValue = '', onSubmit, onCancel }: InlineInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
|
||||
if (initialValue) {
|
||||
inputRef.current.value = initialValue;
|
||||
inputRef.current.select();
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const value = inputRef.current?.value.trim();
|
||||
|
||||
if (value) {
|
||||
onSubmit(value);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center w-full px-2 bg-bolt-elements-background-depth-4 border border-bolt-elements-item-contentAccent py-0.5 text-bolt-elements-textPrimary"
|
||||
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
|
||||
>
|
||||
<div className="scale-120 shrink-0 i-ph:file-plus text-bolt-elements-textTertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="ml-2 flex-1 bg-transparent border-none outline-none py-0.5 text-sm text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary min-w-0"
|
||||
placeholder={placeholder}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== inputRef.current) {
|
||||
onCancel();
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileContextMenu({
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
fullPath,
|
||||
children,
|
||||
}: FolderContextMenuProps & { fullPath: string }) {
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
|
||||
const fileName = useMemo(() => path.basename(fullPath), [fullPath]);
|
||||
|
||||
const isFolder = useMemo(() => {
|
||||
const files = workbenchStore.files.get();
|
||||
const fileEntry = files[fullPath];
|
||||
|
||||
return !fileEntry || fileEntry.type === 'folder';
|
||||
}, [fullPath]);
|
||||
|
||||
const targetPath = useMemo(() => {
|
||||
return isFolder ? fullPath : path.dirname(fullPath);
|
||||
}, [fullPath, isFolder]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
const files = items.filter((item) => item.kind === 'file');
|
||||
|
||||
for (const item of files) {
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
const filePath = path.join(fullPath, file.name);
|
||||
|
||||
// Convert file to binary data (Uint8Array)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const binaryContent = new Uint8Array(arrayBuffer);
|
||||
|
||||
const success = await workbenchStore.createFile(filePath, binaryContent);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File ${file.name} uploaded successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to upload file ${file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error uploading ${file.name}`);
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
},
|
||||
[fullPath],
|
||||
);
|
||||
|
||||
const handleCreateFile = async (fileName: string) => {
|
||||
const newFilePath = path.join(targetPath, fileName);
|
||||
const success = await workbenchStore.createFile(newFilePath, '');
|
||||
|
||||
if (success) {
|
||||
toast.success('File created successfully');
|
||||
} else {
|
||||
toast.error('Failed to create file');
|
||||
}
|
||||
|
||||
setIsCreatingFile(false);
|
||||
};
|
||||
|
||||
const handleCreateFolder = async (folderName: string) => {
|
||||
const newFolderPath = path.join(targetPath, folderName);
|
||||
const success = await workbenchStore.createFolder(newFolderPath);
|
||||
|
||||
if (success) {
|
||||
toast.success('Folder created successfully');
|
||||
} else {
|
||||
toast.error('Failed to create folder');
|
||||
}
|
||||
|
||||
setIsCreatingFolder(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let success;
|
||||
|
||||
if (isFolder) {
|
||||
success = await workbenchStore.deleteFolder(fullPath);
|
||||
} else {
|
||||
success = await workbenchStore.deleteFile(fullPath);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(`${isFolder ? 'Folder' : 'File'} deleted successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to delete ${isFolder ? 'folder' : 'file'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error deleting ${isFolder ? 'folder' : 'file'}`);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for locking a file with full lock
|
||||
const handleLockFile = () => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.lockFile(fullPath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File locked successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to lock file`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error locking file`);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for unlocking a file
|
||||
const handleUnlockFile = () => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.unlockFile(fullPath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File unlocked successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to unlock file`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error unlocking file`);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for locking a folder with full lock
|
||||
const handleLockFolder = () => {
|
||||
try {
|
||||
if (!isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.lockFolder(fullPath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`Folder locked successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to lock folder`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error locking folder`);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for unlocking a folder
|
||||
const handleUnlockFolder = () => {
|
||||
try {
|
||||
if (!isFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = workbenchStore.unlockFolder(fullPath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`Folder unlocked successfully`);
|
||||
} else {
|
||||
toast.error(`Failed to unlock folder`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error unlocking folder`);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={classNames('relative', {
|
||||
'bg-bolt-elements-background-depth-2 border border-dashed border-bolt-elements-item-contentAccent rounded-md':
|
||||
isDragging,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
style={{ zIndex: 998 }}
|
||||
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
|
||||
>
|
||||
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
|
||||
<ContextMenuItem onSelect={() => setIsCreatingFile(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:file-plus" />
|
||||
New File
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => setIsCreatingFolder(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:folder-plus" />
|
||||
New Folder
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
<ContextMenu.Group className="p-1">
|
||||
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
{/* Add lock/unlock options for files and folders */}
|
||||
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
|
||||
{!isFolder ? (
|
||||
<>
|
||||
<ContextMenuItem onSelect={handleLockFile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-simple" />
|
||||
Lock File
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleUnlockFile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-key-open" />
|
||||
Unlock File
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ContextMenuItem onSelect={handleLockFolder}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-simple" />
|
||||
Lock Folder
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleUnlockFolder}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:lock-key-open" />
|
||||
Unlock Folder
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.Group>
|
||||
{/* Add delete option in a new group */}
|
||||
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
|
||||
<ContextMenuItem onSelect={handleDelete}>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<div className="i-ph:trash" />
|
||||
Delete {isFolder ? 'Folder' : 'File'}
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
{isCreatingFile && (
|
||||
<InlineInput
|
||||
depth={depth}
|
||||
placeholder="Enter file name..."
|
||||
onSubmit={handleCreateFile}
|
||||
onCancel={() => setIsCreatingFile(false)}
|
||||
/>
|
||||
)}
|
||||
{isCreatingFolder && (
|
||||
<InlineInput
|
||||
depth={depth}
|
||||
placeholder="Enter folder name..."
|
||||
onSubmit={handleCreateFolder}
|
||||
onCancel={() => setIsCreatingFolder(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
|
||||
// Check if the folder is locked
|
||||
const { isLocked } = workbenchStore.isFolderLocked(folder.fullPath);
|
||||
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={folder.fullPath}>
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
@@ -606,15 +243,7 @@ function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativ
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex-1 truncate pr-2">{folder.name}</div>
|
||||
{isLocked && (
|
||||
<span
|
||||
className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')}
|
||||
title={'Folder is locked'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{folder.name}
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
@@ -624,67 +253,21 @@ interface FileProps {
|
||||
file: FileNode;
|
||||
selected: boolean;
|
||||
unsavedChanges?: boolean;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({
|
||||
file,
|
||||
file: { depth, name },
|
||||
onClick,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
selected,
|
||||
unsavedChanges = false,
|
||||
fileHistory = {},
|
||||
}: FileProps) {
|
||||
const { depth, name, fullPath } = file;
|
||||
|
||||
// Check if the file is locked
|
||||
const { locked } = workbenchStore.isFileLocked(fullPath);
|
||||
|
||||
const fileModifications = fileHistory[fullPath];
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!fileModifications?.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
}, [fileModifications]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={fullPath}>
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':
|
||||
@@ -703,22 +286,8 @@ function File({
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)}
|
||||
{locked && (
|
||||
<span
|
||||
className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')}
|
||||
title={'File is locked'}
|
||||
/>
|
||||
)}
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</div>
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Checkbox } from '~/components/ui/Checkbox';
|
||||
import { toast } from '~/components/ui/use-toast';
|
||||
|
||||
interface LockedItem {
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
}
|
||||
|
||||
export function LockManager() {
|
||||
const [lockedItems, setLockedItems] = useState<LockedItem[]>([]);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<'all' | 'files' | 'folders'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Load locked items
|
||||
useEffect(() => {
|
||||
const loadLockedItems = () => {
|
||||
// We don't need to filter by chat ID here as we want to show all locked files
|
||||
const items: LockedItem[] = [];
|
||||
|
||||
// Get all files and folders from the workbench store
|
||||
const allFiles = workbenchStore.files.get();
|
||||
|
||||
// Check each file/folder for locks
|
||||
Object.entries(allFiles).forEach(([path, item]) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'file' && item.isLocked) {
|
||||
items.push({
|
||||
path,
|
||||
type: 'file',
|
||||
});
|
||||
} else if (item.type === 'folder' && item.isLocked) {
|
||||
items.push({
|
||||
path,
|
||||
type: 'folder',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLockedItems(items);
|
||||
};
|
||||
|
||||
loadLockedItems();
|
||||
|
||||
// Set up an interval to refresh the list periodically
|
||||
const intervalId = setInterval(loadLockedItems, 5000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Filter and sort the locked items
|
||||
const filteredAndSortedItems = lockedItems
|
||||
.filter((item) => {
|
||||
// Apply type filter
|
||||
if (filter === 'files' && item.type !== 'file') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter === 'folders' && item.type !== 'folder') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm && !item.path.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
// Handle selecting/deselecting a single item
|
||||
const handleSelectItem = (path: string) => {
|
||||
const newSelectedItems = new Set(selectedItems);
|
||||
|
||||
if (newSelectedItems.has(path)) {
|
||||
newSelectedItems.delete(path);
|
||||
} else {
|
||||
newSelectedItems.add(path);
|
||||
}
|
||||
|
||||
setSelectedItems(newSelectedItems);
|
||||
};
|
||||
|
||||
// Handle selecting/deselecting all visible items
|
||||
const handleSelectAll = (checked: boolean | 'indeterminate') => {
|
||||
if (checked === true) {
|
||||
// Select all filtered items
|
||||
const allVisiblePaths = new Set(filteredAndSortedItems.map((item) => item.path));
|
||||
setSelectedItems(allVisiblePaths);
|
||||
} else {
|
||||
// Deselect all (clear selection)
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Handle unlocking selected items
|
||||
const handleUnlockSelected = () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('No items selected to unlock.');
|
||||
return;
|
||||
}
|
||||
|
||||
let unlockedCount = 0;
|
||||
selectedItems.forEach((path) => {
|
||||
const item = lockedItems.find((i) => i.path === path);
|
||||
|
||||
if (item) {
|
||||
if (item.type === 'file') {
|
||||
workbenchStore.unlockFile(path);
|
||||
} else {
|
||||
workbenchStore.unlockFolder(path);
|
||||
}
|
||||
|
||||
unlockedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (unlockedCount > 0) {
|
||||
toast.success(`Unlocked ${unlockedCount} selected item(s).`);
|
||||
setSelectedItems(new Set()); // Clear selection after unlocking
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the state of the "Select All" checkbox
|
||||
const isAllSelected = filteredAndSortedItems.length > 0 && selectedItems.size === filteredAndSortedItems.length;
|
||||
const isSomeSelected = selectedItems.size > 0 && selectedItems.size < filteredAndSortedItems.length;
|
||||
const selectAllCheckedState: boolean | 'indeterminate' = isAllSelected
|
||||
? true
|
||||
: isSomeSelected
|
||||
? 'indeterminate'
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-bolt-elements-borderColor">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary i-ph:magnifying-glass text-xs pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-full text-xs pl-6 pr-2 py-0.5 h-6 bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary rounded border border-bolt-elements-borderColor focus:outline-none"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{/* Filter Select */}
|
||||
<select
|
||||
className="text-xs px-1 py-0.5 h-6 bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary rounded border border-bolt-elements-borderColor focus:outline-none"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="files">Files</option>
|
||||
<option value="folders">Folders</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Header Row with Select All */}
|
||||
<div className="flex items-center justify-between px-2 py-1 text-xs text-bolt-elements-textSecondary">
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={selectAllCheckedState}
|
||||
onCheckedChange={handleSelectAll}
|
||||
className="w-3 h-3 rounded border-bolt-elements-borderColor mr-2"
|
||||
aria-label="Select all items"
|
||||
disabled={filteredAndSortedItems.length === 0} // Disable if no items to select
|
||||
/>
|
||||
<span>All</span>
|
||||
</div>
|
||||
{selectedItems.size > 0 && (
|
||||
<button
|
||||
className="ml-auto px-2 py-0.5 rounded bg-bolt-elements-button-secondary-background hover:bg-bolt-elements-button-secondary-backgroundHover text-bolt-elements-button-secondary-text text-xs flex items-center gap-1"
|
||||
onClick={handleUnlockSelected}
|
||||
title="Unlock all selected items"
|
||||
>
|
||||
Unlock all
|
||||
</button>
|
||||
)}
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* List of locked items */}
|
||||
<div className="flex-1 overflow-auto modern-scrollbar px-1 py-1">
|
||||
{filteredAndSortedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-bolt-elements-textTertiary text-xs gap-2">
|
||||
<span className="i-ph:lock-open-duotone text-lg opacity-50" />
|
||||
<span>No locked items found</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{filteredAndSortedItems.map((item) => (
|
||||
<li
|
||||
key={item.path}
|
||||
className={classNames(
|
||||
'text-bolt-elements-textTertiary flex items-center gap-2 px-2 py-1 rounded hover:bg-bolt-elements-background-depth-2 transition-colors group',
|
||||
selectedItems.has(item.path) ? 'bg-bolt-elements-background-depth-2' : '',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.path)}
|
||||
onCheckedChange={() => handleSelectItem(item.path)}
|
||||
className="w-3 h-3 rounded border-bolt-elements-borderColor"
|
||||
aria-labelledby={`item-label-${item.path}`} // For accessibility
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'shrink-0 text-bolt-elements-textTertiary text-xs',
|
||||
item.type === 'file' ? 'i-ph:file-text-duotone' : 'i-ph:folder-duotone',
|
||||
)}
|
||||
/>
|
||||
<span id={`item-label-${item.path}`} className="truncate flex-1 text-xs" title={item.path}>
|
||||
{item.path.replace('/home/project/', '')}
|
||||
</span>
|
||||
{/* ... rest of the item details and buttons ... */}
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-flex items-center px-1 rounded-sm text-xs',
|
||||
'bg-red-500/10 text-red-500',
|
||||
)}
|
||||
></span>
|
||||
<button
|
||||
className="flex items-center px-1 py-0.5 text-xs rounded bg-transparent hover:bg-bolt-elements-background-depth-3"
|
||||
onClick={() => {
|
||||
if (item.type === 'file') {
|
||||
workbenchStore.unlockFile(item.path);
|
||||
} else {
|
||||
workbenchStore.unlockFolder(item.path);
|
||||
}
|
||||
|
||||
toast.success(`${item.path.replace('/home/project/', '')} unlocked`);
|
||||
}}
|
||||
title="Unlock"
|
||||
>
|
||||
<span className="i-ph:lock-open text-xs" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-2 py-1 border-t border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 text-xs text-bolt-elements-textTertiary flex justify-between items-center">
|
||||
<div>
|
||||
{filteredAndSortedItems.length} item(s) • {selectedItems.size} selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import type { PreviewInfo } from '~/lib/stores/previews';
|
||||
|
||||
interface PortDropdownProps {
|
||||
@@ -47,18 +48,9 @@ export const PortDropdown = memo(
|
||||
|
||||
return (
|
||||
<div className="relative z-port-dropdown" ref={dropdownRef}>
|
||||
{/* Display the active port if available, otherwise show the plug icon */}
|
||||
<button
|
||||
className="flex items-center group-focus-within:text-bolt-elements-preview-addressBar-text bg-white group-focus-within:bg-bolt-elements-preview-addressBar-background dark:bg-bolt-elements-preview-addressBar-backgroundHover rounded-full px-2 py-1 gap-1.5"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<span className="i-ph:plug text-base"></span>
|
||||
{previews.length > 0 && activePreviewIndex >= 0 && activePreviewIndex < previews.length ? (
|
||||
<span className="text-xs font-medium">{previews[activePreviewIndex].port}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute left-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
|
||||
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
|
||||
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
|
||||
Ports
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import type { TextSearchOptions, TextSearchOnProgressCallback, WebContainer } from '@webcontainer/api';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
interface DisplayMatch {
|
||||
path: string;
|
||||
lineNumber: number;
|
||||
previewText: string;
|
||||
matchCharStart: number;
|
||||
matchCharEnd: number;
|
||||
}
|
||||
|
||||
async function performTextSearch(
|
||||
instance: WebContainer,
|
||||
query: string,
|
||||
options: Omit<TextSearchOptions, 'folders'>,
|
||||
onProgress: (results: DisplayMatch[]) => void,
|
||||
): Promise<void> {
|
||||
if (!instance || typeof instance.internal?.textSearch !== 'function') {
|
||||
console.error('WebContainer instance not available or internal searchText method is missing/not a function.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const searchOptions: TextSearchOptions = {
|
||||
...options,
|
||||
folders: [WORK_DIR],
|
||||
};
|
||||
|
||||
const progressCallback: TextSearchOnProgressCallback = (filePath: any, apiMatches: any[]) => {
|
||||
const displayMatches: DisplayMatch[] = [];
|
||||
|
||||
apiMatches.forEach((apiMatch: { preview: { text: string; matches: string | any[] }; ranges: any[] }) => {
|
||||
const previewLines = apiMatch.preview.text.split('\n');
|
||||
|
||||
apiMatch.ranges.forEach((range: { startLineNumber: number; startColumn: any; endColumn: any }) => {
|
||||
let previewLineText = '(Preview line not found)';
|
||||
let lineIndexInPreview = -1;
|
||||
|
||||
if (apiMatch.preview.matches.length > 0) {
|
||||
const previewStartLine = apiMatch.preview.matches[0].startLineNumber;
|
||||
lineIndexInPreview = range.startLineNumber - previewStartLine;
|
||||
}
|
||||
|
||||
if (lineIndexInPreview >= 0 && lineIndexInPreview < previewLines.length) {
|
||||
previewLineText = previewLines[lineIndexInPreview];
|
||||
} else {
|
||||
previewLineText = previewLines[0] ?? '(Preview unavailable)';
|
||||
}
|
||||
|
||||
displayMatches.push({
|
||||
path: filePath,
|
||||
lineNumber: range.startLineNumber,
|
||||
previewText: previewLineText,
|
||||
matchCharStart: range.startColumn,
|
||||
matchCharEnd: range.endColumn,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (displayMatches.length > 0) {
|
||||
onProgress(displayMatches);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await instance.internal.textSearch(query, searchOptions, progressCallback);
|
||||
} catch (error) {
|
||||
console.error('Error during internal text search:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function groupResultsByFile(results: DisplayMatch[]): Record<string, DisplayMatch[]> {
|
||||
return results.reduce(
|
||||
(acc, result) => {
|
||||
if (!acc[result.path]) {
|
||||
acc[result.path] = [];
|
||||
}
|
||||
|
||||
acc[result.path].push(result);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, DisplayMatch[]>,
|
||||
);
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DisplayMatch[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>({});
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
const groupedResults = useMemo(() => groupResultsByFile(searchResults), [searchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchResults.length > 0) {
|
||||
const allExpanded: Record<string, boolean> = {};
|
||||
Object.keys(groupedResults).forEach((file) => {
|
||||
allExpanded[file] = true;
|
||||
});
|
||||
setExpandedFiles(allExpanded);
|
||||
}
|
||||
}, [groupedResults, searchResults]);
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
setExpandedFiles({});
|
||||
setHasSearched(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
setExpandedFiles({});
|
||||
setHasSearched(true);
|
||||
|
||||
const minLoaderTime = 300; // ms
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const instance = await webcontainer;
|
||||
const options: Omit<TextSearchOptions, 'folders'> = {
|
||||
homeDir: WORK_DIR, // Adjust this path as needed
|
||||
includes: ['**/*.*'],
|
||||
excludes: ['**/node_modules/**', '**/package-lock.json', '**/.git/**', '**/dist/**', '**/*.lock'],
|
||||
gitignore: true,
|
||||
requireGit: false,
|
||||
globalIgnoreFiles: true,
|
||||
ignoreSymlinks: false,
|
||||
resultLimit: 500,
|
||||
isRegex: false,
|
||||
caseSensitive: false,
|
||||
isWordMatch: false,
|
||||
};
|
||||
|
||||
const progressHandler = (batchResults: DisplayMatch[]) => {
|
||||
setSearchResults((prevResults) => [...prevResults, ...batchResults]);
|
||||
};
|
||||
|
||||
await performTextSearch(instance, query, options, progressHandler);
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate search:', error);
|
||||
} finally {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
if (elapsed < minLoaderTime) {
|
||||
setTimeout(() => setIsSearching(false), minLoaderTime - elapsed);
|
||||
} else {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedSearch = useCallback(debounce(handleSearch, 300), [handleSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSearch(searchQuery);
|
||||
}, [searchQuery, debouncedSearch]);
|
||||
|
||||
const handleResultClick = (filePath: string, line?: number) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
|
||||
/*
|
||||
* Adjust line number to be 0-based if it's defined
|
||||
* The search results use 1-based line numbers, but CodeMirrorEditor expects 0-based
|
||||
*/
|
||||
const adjustedLine = typeof line === 'number' ? Math.max(0, line - 1) : undefined;
|
||||
|
||||
workbenchStore.setCurrentDocumentScrollPosition({ line: adjustedLine, column: 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bolt-elements-background-depth-2">
|
||||
{/* Search Bar */}
|
||||
<div className="flex items-center py-3 px-3">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
className="w-full px-2 py-1 rounded-md bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{isSearching && (
|
||||
<div className="flex items-center justify-center h-32 text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:circle-notch animate-spin mr-2" /> Searching...
|
||||
</div>
|
||||
)}
|
||||
{!isSearching && hasSearched && searchResults.length === 0 && searchQuery.trim() !== '' && (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">No results found.</div>
|
||||
)}
|
||||
{!isSearching &&
|
||||
Object.keys(groupedResults).map((file) => (
|
||||
<div key={file} className="mb-2">
|
||||
<button
|
||||
className="flex gap-2 items-center w-full text-left py-1 px-2 text-bolt-elements-textSecondary bg-transparent hover:bg-bolt-elements-background-depth-3 group"
|
||||
onClick={() => setExpandedFiles((prev) => ({ ...prev, [file]: !prev[file] }))}
|
||||
>
|
||||
<span
|
||||
className=" i-ph:caret-down-thin w-3 h-3 text-bolt-elements-textSecondary transition-transform"
|
||||
style={{ transform: expandedFiles[file] ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
<span className="font-normal text-sm">{file.split('/').pop()}</span>
|
||||
<span className="h-5.5 w-5.5 flex items-center justify-center text-xs ml-auto bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent rounded-full">
|
||||
{groupedResults[file].length}
|
||||
</span>
|
||||
</button>
|
||||
{expandedFiles[file] && (
|
||||
<div className="">
|
||||
{groupedResults[file].map((match, idx) => {
|
||||
const contextChars = 7;
|
||||
const isStart = match.matchCharStart <= contextChars;
|
||||
const previewStart = isStart ? 0 : match.matchCharStart - contextChars;
|
||||
const previewText = match.previewText.slice(previewStart);
|
||||
const matchStart = isStart ? match.matchCharStart : contextChars;
|
||||
const matchEnd = isStart
|
||||
? match.matchCharEnd
|
||||
: contextChars + (match.matchCharEnd - match.matchCharStart);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="hover:bg-bolt-elements-background-depth-3 cursor-pointer transition-colors pl-6 py-1"
|
||||
onClick={() => handleResultClick(match.path, match.lineNumber)}
|
||||
>
|
||||
<pre className="font-mono text-xs text-bolt-elements-textTertiary truncate">
|
||||
{!isStart && <span>...</span>}
|
||||
{previewText.slice(0, matchStart)}
|
||||
<span className="bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent rounded px-1">
|
||||
{previewText.slice(matchStart, matchEnd)}
|
||||
</span>
|
||||
{previewText.slice(matchEnd)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { DiffView } from './DiffView';
|
||||
import {
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
@@ -24,17 +18,10 @@ import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { usePreviewStore } from '~/lib/stores/previews';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
actionRunner: ActionRunner;
|
||||
metadata?: {
|
||||
gitUrl?: string;
|
||||
};
|
||||
updateChatMestaData?: (metadata: any) => void;
|
||||
}
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
@@ -44,10 +31,6 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
||||
value: 'code',
|
||||
text: 'Code',
|
||||
},
|
||||
middle: {
|
||||
value: 'diff',
|
||||
text: 'Diff',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: 'Preview',
|
||||
@@ -71,221 +54,11 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
const FileModifiedDropdown = memo(
|
||||
({
|
||||
fileHistory,
|
||||
onSelectFile,
|
||||
}: {
|
||||
fileHistory: Record<string, FileHistory>;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
}) => {
|
||||
const modifiedFiles = Object.entries(fileHistory);
|
||||
const hasChanges = modifiedFiles.length > 0;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [modifiedFiles, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open }: { open: boolean }) => (
|
||||
<>
|
||||
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-item-contentDefault">
|
||||
<span>File Changes</span>
|
||||
{hasChanges && (
|
||||
<span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
|
||||
{modifiedFiles.length}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor">
|
||||
<div className="p-2">
|
||||
<div className="relative mx-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:magnifying-glass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredFiles.length > 0 ? (
|
||||
filteredFiles.map(([filePath, history]) => {
|
||||
const extension = filePath.split('.').pop() || '';
|
||||
const language = getLanguageFromExtension(extension);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => onSelectFile(filePath)}
|
||||
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
|
||||
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && (
|
||||
<div className="i-ph:file-js" />
|
||||
)}
|
||||
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
|
||||
{language === 'html' && <div className="i-ph:code" />}
|
||||
{language === 'json' && <div className="i-ph:brackets-curly" />}
|
||||
{language === 'python' && <div className="i-ph:file-text" />}
|
||||
{language === 'markdown' && <div className="i-ph:article" />}
|
||||
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
|
||||
{language === 'sql' && <div className="i-ph:database" />}
|
||||
{language === 'dockerfile' && <div className="i-ph:cube" />}
|
||||
{language === 'shell' && <div className="i-ph:terminal" />}
|
||||
{![
|
||||
'typescript',
|
||||
'javascript',
|
||||
'css',
|
||||
'html',
|
||||
'json',
|
||||
'python',
|
||||
'markdown',
|
||||
'yaml',
|
||||
'yml',
|
||||
'sql',
|
||||
'dockerfile',
|
||||
'shell',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'scss',
|
||||
'less',
|
||||
].includes(language) && <div className="i-ph:file-text" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{filePath.split('/').pop()}
|
||||
</span>
|
||||
<span className="truncate text-xs text-bolt-elements-textTertiary">
|
||||
{filePath}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// Calculate diff stats
|
||||
const { additions, deletions } = (() => {
|
||||
if (!history.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
history.versions[history.versions.length - 1]?.content.replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
) || '';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
})();
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
showStats && (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:file-dashed" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{searchQuery ? 'No matching files' : 'No modified files'}
|
||||
</p>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="border-t border-bolt-elements-borderColor p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n'));
|
||||
toast('File list copied to clipboard', {
|
||||
icon: <div className="i-ph:check-circle text-accent-500" />,
|
||||
});
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary"
|
||||
>
|
||||
Copy File List
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Workbench = memo(
|
||||
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => {
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
||||
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
||||
|
||||
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
||||
|
||||
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
@@ -324,14 +97,7 @@ export const Workbench = memo(
|
||||
}, []);
|
||||
|
||||
const onFileSave = useCallback(() => {
|
||||
workbenchStore
|
||||
.saveCurrentDocument()
|
||||
.then(() => {
|
||||
// Explicitly refresh all previews after a file save
|
||||
const previewStore = usePreviewStore();
|
||||
previewStore.refreshAllPreviews();
|
||||
})
|
||||
.catch(() => {
|
||||
workbenchStore.saveCurrentDocument().catch(() => {
|
||||
toast.error('Failed to update file content');
|
||||
});
|
||||
}, []);
|
||||
@@ -355,11 +121,6 @@ export const Workbench = memo(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = useCallback((filePath: string) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
workbenchStore.currentView.set('diff');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
@@ -381,11 +142,24 @@ export const Workbench = memo(
|
||||
>
|
||||
<div className="absolute inset-0 px-2 lg:px-6">
|
||||
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
workbenchStore.downloadZip();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:code" />
|
||||
Download Code
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
|
||||
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
@@ -395,66 +169,11 @@ export const Workbench = memo(
|
||||
<div className="i-ph:terminal" />
|
||||
Toggle Terminal
|
||||
</PanelHeaderButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
|
||||
<div className="i-ph:box-arrow-up" />
|
||||
Sync & Export
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'min-w-[240px] z-[250]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-gray-200/50 dark:border-gray-800/50',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'py-1',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
|
||||
)}
|
||||
onClick={() => {
|
||||
workbenchStore.downloadZip();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:download-simple"></div>
|
||||
<span>Download Code</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
|
||||
)}
|
||||
onClick={handleSyncFiles}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
|
||||
)}
|
||||
onClick={() => setIsPushDialogOpen(true)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
|
||||
<div className="i-ph:git-branch" />
|
||||
Push to GitHub
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === 'diff' && (
|
||||
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
@@ -466,14 +185,16 @@ export const Workbench = memo(
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
|
||||
<View
|
||||
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
fileHistory={fileHistory}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
@@ -482,12 +203,9 @@ export const Workbench = memo(
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
|
||||
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
>
|
||||
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
|
||||
</View>
|
||||
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
||||
<Preview />
|
||||
</View>
|
||||
</div>
|
||||
@@ -499,31 +217,19 @@ export const Workbench = memo(
|
||||
onClose={() => setIsPushDialogOpen(false)}
|
||||
onPush={async (repoName, username, token, isPrivate) => {
|
||||
try {
|
||||
console.log('Dialog onPush called with isPrivate =', isPrivate);
|
||||
|
||||
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
||||
const repoUrl = await workbenchStore.pushToGitHub(repoName, commitMessage, username, token, isPrivate);
|
||||
|
||||
if (updateChatMestaData && !metadata?.gitUrl) {
|
||||
updateChatMestaData({
|
||||
...(metadata || {}),
|
||||
gitUrl: repoUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
|
||||
return repoUrl;
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
toast.error('Failed to push to GitHub');
|
||||
throw error;
|
||||
throw error; // Rethrow to let PushToGitHubDialog handle the error state
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// View component for rendering content with motion transitions
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
|
||||
@@ -150,7 +150,7 @@ export const TerminalTabs = memo(() => {
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden modern-scrollbar-invert', {
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
@@ -166,7 +166,7 @@ export const TerminalTabs = memo(() => {
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('modern-scrollbar h-full overflow-hidden', {
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
|
||||
@@ -8,14 +8,10 @@ export interface File {
|
||||
type: 'file';
|
||||
content: string;
|
||||
isBinary: boolean;
|
||||
isLocked?: boolean;
|
||||
lockedByFolder?: string;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
type: 'folder';
|
||||
isLocked?: boolean;
|
||||
lockedByFolder?: string;
|
||||
}
|
||||
|
||||
type Dirent = File | Folder;
|
||||
|
||||
@@ -204,10 +204,7 @@ export async function selectContext(props: {
|
||||
}
|
||||
|
||||
if (!filePaths.includes(fullPath)) {
|
||||
logger.error(`File ${path} is not in the list of files above.`);
|
||||
return;
|
||||
|
||||
// throw new Error(`File ${path} is not in the list of files above.`);
|
||||
throw new Error(`File ${path} is not in the list of files above.`);
|
||||
}
|
||||
|
||||
if (currrentFiles.includes(path)) {
|
||||
@@ -221,13 +218,6 @@ export async function selectContext(props: {
|
||||
onFinish(resp);
|
||||
}
|
||||
|
||||
const totalFiles = Object.keys(filteredFiles).length;
|
||||
logger.info(`Total files: ${totalFiles}`);
|
||||
|
||||
if (totalFiles == 0) {
|
||||
throw new Error(`Bolt failed to select files`);
|
||||
}
|
||||
|
||||
return filteredFiles;
|
||||
|
||||
// generateText({
|
||||
|
||||
@@ -8,19 +8,11 @@ import { allowedHTMLElements } from '~/utils/markdown';
|
||||
import { LLMManager } from '~/lib/modules/llm/manager';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { createFilesContext, extractPropertiesFromMessage } from './utils';
|
||||
import { getFilePaths } from './select-context';
|
||||
|
||||
export type Messages = Message[];
|
||||
|
||||
export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
|
||||
supabaseConnection?: {
|
||||
isConnected: boolean;
|
||||
hasSelectedProject: boolean;
|
||||
credentials?: {
|
||||
anonKey?: string;
|
||||
supabaseUrl?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
||||
|
||||
const logger = createScopedLogger('stream-text');
|
||||
|
||||
@@ -63,15 +55,6 @@ export async function streamText(props: {
|
||||
content = content.replace(/<div class=\\"__boltThought__\\">.*?<\/div>/s, '');
|
||||
content = content.replace(/<think>.*?<\/think>/s, '');
|
||||
|
||||
// Remove package-lock.json content specifically keeping token usage MUCH lower
|
||||
content = content.replace(
|
||||
/<boltAction type="file" filePath="package-lock\.json">[\s\S]*?<\/boltAction>/g,
|
||||
'[package-lock.json content removed]',
|
||||
);
|
||||
|
||||
// Trim whitespace potentially left after removals
|
||||
content = content.trim();
|
||||
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
@@ -114,17 +97,17 @@ export async function streamText(props: {
|
||||
cwd: WORK_DIR,
|
||||
allowedHtmlElements: allowedHTMLElements,
|
||||
modificationTagName: MODIFICATIONS_TAG_NAME,
|
||||
supabase: {
|
||||
isConnected: options?.supabaseConnection?.isConnected || false,
|
||||
hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false,
|
||||
credentials: options?.supabaseConnection?.credentials || undefined,
|
||||
},
|
||||
}) ?? getSystemPrompt();
|
||||
|
||||
if (contextFiles && contextOptimization) {
|
||||
if (files && contextFiles && contextOptimization) {
|
||||
const codeContext = createFilesContext(contextFiles, true);
|
||||
const filePaths = getFilePaths(files);
|
||||
|
||||
systemPrompt = `${systemPrompt}
|
||||
Below are all the files present in the project:
|
||||
---
|
||||
${filePaths.join('\n')}
|
||||
---
|
||||
|
||||
Below is the artifact containing the context loaded into context buffer for you to have knowledge of and might need changes to fullfill current user request.
|
||||
CONTEXT BUFFER:
|
||||
@@ -154,30 +137,6 @@ ${props.summary}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveLockedFilePaths = new Set<string>();
|
||||
|
||||
if (files) {
|
||||
for (const [filePath, fileDetails] of Object.entries(files)) {
|
||||
if (fileDetails?.isLocked) {
|
||||
effectiveLockedFilePaths.add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveLockedFilePaths.size > 0) {
|
||||
const lockedFilesListString = Array.from(effectiveLockedFilePaths)
|
||||
.map((filePath) => `- ${filePath}`)
|
||||
.join('\n');
|
||||
systemPrompt = `${systemPrompt}
|
||||
|
||||
IMPORTANT: The following files are locked and MUST NOT be modified in any way. Do not suggest or make any changes to these files. You can proceed with the request but DO NOT make any changes to these files specifically:
|
||||
${lockedFilesListString}
|
||||
---
|
||||
`;
|
||||
} else {
|
||||
console.log('No locked files found from any source for prompt.');
|
||||
}
|
||||
|
||||
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
|
||||
|
||||
// console.log(systemPrompt,processedMessages);
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { getSystemPrompt } from './prompts/prompts';
|
||||
import optimized from './prompts/optimized';
|
||||
import { getFineTunedPrompt } from './prompts/new-prompt';
|
||||
|
||||
export interface PromptOptions {
|
||||
cwd: string;
|
||||
allowedHtmlElements: string[];
|
||||
modificationTagName: string;
|
||||
supabase?: {
|
||||
isConnected: boolean;
|
||||
hasSelectedProject: boolean;
|
||||
credentials?: {
|
||||
anonKey?: string;
|
||||
supabaseUrl?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class PromptLibrary {
|
||||
@@ -28,12 +19,7 @@ export class PromptLibrary {
|
||||
default: {
|
||||
label: 'Default Prompt',
|
||||
description: 'This is the battle tested default system Prompt',
|
||||
get: (options) => getSystemPrompt(options.cwd, options.supabase),
|
||||
},
|
||||
enhanced: {
|
||||
label: 'Fine Tuned Prompt',
|
||||
description: 'An fine tuned prompt for better results',
|
||||
get: (options) => getFineTunedPrompt(options.cwd, options.supabase),
|
||||
get: (options) => getSystemPrompt(options.cwd),
|
||||
},
|
||||
optimized: {
|
||||
label: 'Optimized Prompt (experimental)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user