Compare commits
1 Commits
update-doc
...
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
|
||||||
81
.github/workflows/docker.yaml
vendored
81
.github/workflows/docker.yaml
vendored
@@ -1,14 +1,14 @@
|
|||||||
|
---
|
||||||
name: Docker Publish
|
name: Docker Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main, stable]
|
|
||||||
tags: ['v*', '*.*.*']
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
concurrency:
|
branches:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
- main
|
||||||
cancel-in-progress: true
|
tags:
|
||||||
|
- v*
|
||||||
|
- '*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@@ -16,47 +16,66 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
DOCKER_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
|
BUILD_TARGET: bolt-ai-production # bolt-ai-development
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build-publish:
|
docker-build-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# timeout-minutes: 30
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Extract metadata for Docker image
|
- name: Build and push
|
||||||
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
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
target: ${{ env.BUILD_TARGET }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
target: bolt-ai-production
|
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
- name: Check manifest
|
- 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
|
||||||
|
|||||||
4
.github/workflows/update-stable.yml
vendored
4
.github/workflows/update-stable.yml
vendored
@@ -119,9 +119,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="v${{ steps.bump_version.outputs.new_version }}"
|
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" \
|
gh release create "$VERSION" \
|
||||||
--title "Release $VERSION" \
|
--title "Release $VERSION" \
|
||||||
--notes-file release_notes.md \
|
--notes "${{ steps.changelog.outputs.content }}" \
|
||||||
--target stable
|
--target stable
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,4 +43,5 @@ app/commit.json
|
|||||||
changelogUI.md
|
changelogUI.md
|
||||||
docs/instructions/Roadmap.md
|
docs/instructions/Roadmap.md
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
.cursorrules
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
2
.tool-versions
Normal file
2
.tool-versions
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
nodejs 20.15.1
|
||||||
|
pnpm 9.4.0
|
||||||
@@ -6,10 +6,9 @@ WORKDIR /app
|
|||||||
# Install dependencies (this step is cached as long as the dependencies don't change)
|
# Install dependencies (this step is cached as long as the dependencies don't change)
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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 corepack enable pnpm && pnpm install
|
||||||
RUN npm install -g pnpm && pnpm install
|
|
||||||
|
|
||||||
# Copy the rest of your app's source code
|
# Copy the rest of your app's source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -1,13 +1,15 @@
|
|||||||
# bolt.diy
|
# bolt.diy (Previously oTToDev)
|
||||||
|
|
||||||
[](https://bolt.diy)
|
[](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.
|
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!
|
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/).
|
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)
|
- ✅ Add Starter Template Options (@thecodacus)
|
||||||
- ✅ Perplexity Integration (@meetpateltech)
|
- ✅ Perplexity Integration (@meetpateltech)
|
||||||
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
- ✅ 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** - 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** - 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
|
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||||
- ✅ Deploy directly to Netlify (@xKevIsDev)
|
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms
|
||||||
- ⬜ Supabase Integration
|
|
||||||
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
||||||
- ⬜ VSCode Integration with git-like confirmations
|
- ⬜ VSCode Integration with git-like confirmations
|
||||||
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
- ⬜ 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.
|
- **Attach images to prompts** for better contextual understanding.
|
||||||
- **Integrated terminal** to view output of LLM-run commands.
|
- **Integrated terminal** to view output of LLM-run commands.
|
||||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
- **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.
|
- **Integration-ready Docker support** for a hassle-free setup.
|
||||||
- **Deploy** directly to **Netlify**
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -243,7 +242,8 @@ This method is recommended for developers who want to:
|
|||||||
1. **Clone the Repository**:
|
1. **Clone the Repository**:
|
||||||
|
|
||||||
```bash
|
```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**:
|
2. **Navigate to Project Directory**:
|
||||||
@@ -252,30 +252,20 @@ This method is recommended for developers who want to:
|
|||||||
cd bolt.diy
|
cd bolt.diy
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install Dependencies**:
|
3. **Switch to the Main Branch**:
|
||||||
|
|
||||||
```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:
|
|
||||||
```bash
|
```bash
|
||||||
git checkout main
|
git checkout main
|
||||||
|
```
|
||||||
|
4. **Install Dependencies**:
|
||||||
|
|
||||||
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start the Development Server**:
|
||||||
|
```bash
|
||||||
pnpm run dev
|
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
|
#### Staying Updated
|
||||||
|
|
||||||
@@ -290,7 +280,7 @@ To get the latest changes from the repository:
|
|||||||
2. **Pull Latest Updates**:
|
2. **Pull Latest Updates**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Update Dependencies**:
|
3. **Update Dependencies**:
|
||||||
@@ -360,9 +350,3 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
|
|||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).
|
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.
|
|
||||||
|
|||||||
@@ -1,21 +1,244 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { logStore } from '~/lib/stores/logs';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import React, { Suspense } from 'react';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
// Use React.lazy for dynamic imports
|
interface GitHubUserResponse {
|
||||||
const GithubConnection = React.lazy(() => import('./GithubConnection'));
|
login: string;
|
||||||
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
|
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
|
interface GitHubRepoInfo {
|
||||||
const LoadingFallback = () => (
|
name: string;
|
||||||
<div className="p-4 bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
full_name: string;
|
||||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
html_url: string;
|
||||||
<div className="i-ph:spinner-gap w-5 h-5 animate-spin" />
|
description: string;
|
||||||
<span>Loading connection...</span>
|
stargazers_count: number;
|
||||||
</div>
|
forks_count: number;
|
||||||
</div>
|
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() {
|
export default function ConnectionsTab() {
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -33,12 +256,359 @@ export default function ConnectionsTab() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
{/* GitHub Connection */}
|
||||||
<GithubConnection />
|
<motion.div
|
||||||
</Suspense>
|
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<NetlifyConnection />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</Suspense>
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<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 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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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 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-purple-500',
|
||||||
|
'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-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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,578 +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';
|
|
||||||
|
|
||||||
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[];
|
|
||||||
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 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 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
|
||||||
Cookies.set('githubToken', token);
|
|
||||||
Cookies.set('githubUsername', data.login);
|
|
||||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
|
||||||
|
|
||||||
setConnection(newConnection);
|
|
||||||
|
|
||||||
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 fetchGitHubStats = async (token: string) => {
|
|
||||||
try {
|
|
||||||
setIsFetchingStats(true);
|
|
||||||
|
|
||||||
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[];
|
|
||||||
|
|
||||||
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[];
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedConnection = localStorage.getItem('github_connection');
|
|
||||||
|
|
||||||
if (savedConnection) {
|
|
||||||
const parsed = JSON.parse(savedConnection);
|
|
||||||
|
|
||||||
if (!parsed.tokenType) {
|
|
||||||
parsed.tokenType = 'classic';
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnection(parsed);
|
|
||||||
|
|
||||||
if (parsed.user && parsed.token) {
|
|
||||||
fetchGitHubStats(parsed.token);
|
|
||||||
}
|
|
||||||
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
|
|
||||||
fetchGithubUser(import.meta.env.VITE_GITHUB_ACCESS_TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = connection.token;
|
|
||||||
const data = connection.user;
|
|
||||||
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]);
|
|
||||||
|
|
||||||
if (isLoading || isConnecting || isFetchingStats) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
};
|
|
||||||
|
|
||||||
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.2 }}
|
|
||||||
>
|
|
||||||
<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 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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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 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-purple-500',
|
|
||||||
'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-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>
|
|
||||||
|
|
||||||
<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 && connection.stats && (
|
|
||||||
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
|
||||||
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.user.name || connection.user.login}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
|
||||||
isStatsExpanded ? 'rotate-180' : '',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{connection.user.bio && (
|
|
||||||
<p className="text-sm text-start 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:book-bookmark w-4 h-4" />
|
|
||||||
{connection.user.public_repos} public repos
|
|
||||||
</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>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isStatsExpanded && (
|
|
||||||
<div className="pt-4">
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
</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,263 +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 {
|
|
||||||
netlifyConnection,
|
|
||||||
isConnecting,
|
|
||||||
isFetchingStats,
|
|
||||||
updateNetlifyConnection,
|
|
||||||
fetchNetlifyStats,
|
|
||||||
} from '~/lib/stores/netlify';
|
|
||||||
import type { NetlifyUser } from '~/types/netlify';
|
|
||||||
|
|
||||||
export default function NetlifyConnection() {
|
|
||||||
const connection = useStore(netlifyConnection);
|
|
||||||
const connecting = useStore(isConnecting);
|
|
||||||
const fetchingStats = useStore(isFetchingStats);
|
|
||||||
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSites = async () => {
|
|
||||||
if (connection.user && connection.token) {
|
|
||||||
await fetchNetlifyStats(connection.token);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchSites();
|
|
||||||
}, [connection.user, connection.token]);
|
|
||||||
|
|
||||||
const handleConnect = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
isConnecting.set(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://api.netlify.com/api/v1/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 NetlifyUser;
|
|
||||||
updateNetlifyConnection({
|
|
||||||
user: userData,
|
|
||||||
token: connection.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetchNetlifyStats(connection.token);
|
|
||||||
toast.success('Successfully connected to Netlify');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth error:', error);
|
|
||||||
logStore.logError('Failed to authenticate with Netlify', { error });
|
|
||||||
toast.error('Failed to connect to Netlify');
|
|
||||||
updateNetlifyConnection({ user: null, token: '' });
|
|
||||||
} finally {
|
|
||||||
isConnecting.set(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
updateNetlifyConnection({ user: null, token: '' });
|
|
||||||
toast.success('Disconnected from Netlify');
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
height="24"
|
|
||||||
width="24"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
src="https://cdn.simpleicons.org/netlify"
|
|
||||||
/>
|
|
||||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify 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) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
|
||||||
disabled={connecting}
|
|
||||||
placeholder="Enter your Netlify 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-[#00AD9F]',
|
|
||||||
'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-[#00AD9F] 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-[#00AD9F] text-white',
|
|
||||||
'hover:bg-[#00968A]',
|
|
||||||
'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 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 Netlify
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
|
||||||
<img
|
|
||||||
src={connection.user.avatar_url}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
alt={connection.user.full_name}
|
|
||||||
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</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 Netlify sites...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
|
||||||
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 Sites ({connection.stats?.totalSites || 0})
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
|
||||||
isSitesExpanded ? 'rotate-180' : '',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{isSitesExpanded && connection.stats?.sites?.length ? (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{connection.stats.sites.map((site) => (
|
|
||||||
<a
|
|
||||||
key={site.id}
|
|
||||||
href={site.admin_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] 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-[#00AD9F]" />
|
|
||||||
{site.name}
|
|
||||||
</h5>
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
|
||||||
<a
|
|
||||||
href={site.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-[#00AD9F]"
|
|
||||||
>
|
|
||||||
{site.url}
|
|
||||||
</a>
|
|
||||||
{site.published_deploy && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:clock w-3 h-3" />
|
|
||||||
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{site.build_settings?.provider && (
|
|
||||||
<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:git-branch w-3 h-3" />
|
|
||||||
{site.build_settings.provider}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : isSitesExpanded ? (
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
|
||||||
<div className="i-ph:info w-4 h-4" />
|
|
||||||
No sites found in your Netlify account
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -292,24 +292,11 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
|
|
||||||
const connection = getLocalStorage('github_connection');
|
const connection = getLocalStorage('github_connection');
|
||||||
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
||||||
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
const repoObjData = (await repoObjResponse.json()) as any;
|
|
||||||
|
|
||||||
if (!repoObjData.default_branch) {
|
|
||||||
throw new Error('Failed to fetch repository branch');
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultBranch = repoObjData.default_branch;
|
|
||||||
|
|
||||||
// Fetch repository tree
|
// Fetch repository tree
|
||||||
const treeResponse = await fetch(
|
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
||||||
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
headers,
|
||||||
{
|
});
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!treeResponse.ok) {
|
if (!treeResponse.ok) {
|
||||||
throw new Error('Failed to fetch repository structure');
|
throw new Error('Failed to fetch repository structure');
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import ChatAlert from './ChatAlert';
|
|||||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||||
import ProgressCompilation from './ProgressCompilation';
|
import ProgressCompilation from './ProgressCompilation';
|
||||||
import type { ProgressAnnotation } from '~/types/context';
|
import type { ProgressAnnotation } from '~/types/context';
|
||||||
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
|
||||||
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
||||||
|
|
||||||
const TEXTAREA_MIN_HEIGHT = 76;
|
const TEXTAREA_MIN_HEIGHT = 76;
|
||||||
@@ -46,7 +45,6 @@ interface BaseChatProps {
|
|||||||
showChat?: boolean;
|
showChat?: boolean;
|
||||||
chatStarted?: boolean;
|
chatStarted?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onStreamingChange?: (streaming: boolean) => void;
|
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
description?: string;
|
description?: string;
|
||||||
enhancingPrompt?: boolean;
|
enhancingPrompt?: boolean;
|
||||||
@@ -70,7 +68,6 @@ interface BaseChatProps {
|
|||||||
actionAlert?: ActionAlert;
|
actionAlert?: ActionAlert;
|
||||||
clearAlert?: () => void;
|
clearAlert?: () => void;
|
||||||
data?: JSONValue[] | undefined;
|
data?: JSONValue[] | undefined;
|
||||||
actionRunner?: ActionRunner;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||||
@@ -82,7 +79,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
showChat = true,
|
showChat = true,
|
||||||
chatStarted = false,
|
chatStarted = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
onStreamingChange,
|
|
||||||
model,
|
model,
|
||||||
setModel,
|
setModel,
|
||||||
provider,
|
provider,
|
||||||
@@ -106,7 +102,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
actionAlert,
|
actionAlert,
|
||||||
clearAlert,
|
clearAlert,
|
||||||
data,
|
data,
|
||||||
actionRunner,
|
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -131,10 +126,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
console.log(transcript);
|
console.log(transcript);
|
||||||
}, [transcript]);
|
}, [transcript]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onStreamingChange?.(isStreaming);
|
|
||||||
}, [isStreaming, onStreamingChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
@@ -313,7 +304,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
data-chat-visible={showChat}
|
data-chat-visible={showChat}
|
||||||
>
|
>
|
||||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||||
{!chatStarted && (
|
{!chatStarted && (
|
||||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||||
@@ -327,39 +318,40 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames('pt-6 px-2 sm:px-6', {
|
className={classNames('pt-6 px-2 sm:px-6', {
|
||||||
'h-full flex flex-col': chatStarted,
|
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
|
||||||
})}
|
})}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => {
|
{() => {
|
||||||
return chatStarted ? (
|
return chatStarted ? (
|
||||||
<Messages
|
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
|
||||||
ref={messageRef}
|
<Messages
|
||||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
ref={messageRef}
|
||||||
messages={messages}
|
className="flex flex-col "
|
||||||
isStreaming={isStreaming}
|
messages={messages}
|
||||||
/>
|
isStreaming={isStreaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}}
|
}}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<div
|
<div
|
||||||
className={classNames('flex flex-col gap-4 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,
|
'sticky bottom-2': chatStarted,
|
||||||
|
'position-absolute': chatStarted,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="bg-bolt-elements-background-depth-2">
|
{actionAlert && (
|
||||||
{actionAlert && (
|
<ChatAlert
|
||||||
<ChatAlert
|
alert={actionAlert}
|
||||||
alert={actionAlert}
|
clearAlert={() => clearAlert?.()}
|
||||||
clearAlert={() => clearAlert?.()}
|
postMessage={(message) => {
|
||||||
postMessage={(message) => {
|
sendMessage?.({} as any, message);
|
||||||
sendMessage?.({} as any, message);
|
clearAlert?.();
|
||||||
clearAlert?.();
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -413,17 +405,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
apiKeys={apiKeys}
|
apiKeys={apiKeys}
|
||||||
modelLoading={isModelLoading}
|
modelLoading={isModelLoading}
|
||||||
/>
|
/>
|
||||||
{(providerList || []).length > 0 &&
|
{(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
|
||||||
provider &&
|
<APIKeyManager
|
||||||
(!LOCAL_PROVIDERS.includes(provider.name) || 'OpenAILike') && (
|
provider={provider}
|
||||||
<APIKeyManager
|
apiKey={apiKeys[provider.name] || ''}
|
||||||
provider={provider}
|
setApiKey={(key) => {
|
||||||
apiKey={apiKeys[provider.name] || ''}
|
onApiKeysChange(provider.name, key);
|
||||||
setApiKey={(key) => {
|
}}
|
||||||
onApiKeysChange(provider.name, key);
|
/>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -595,15 +585,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center gap-5">
|
{!chatStarted && (
|
||||||
{!chatStarted && (
|
<div className="flex flex-col justify-center mt-6 gap-5">
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
{ImportButtons(importChat)}
|
<div className="flex items-center gap-2">
|
||||||
<GitCloneButton importChat={importChat} />
|
{ImportButtons(importChat)}
|
||||||
|
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!chatStarted &&
|
{ExamplePrompts((event, messageInput) => {
|
||||||
ExamplePrompts((event, messageInput) => {
|
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
handleStop?.();
|
handleStop?.();
|
||||||
return;
|
return;
|
||||||
@@ -611,18 +602,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
|
|
||||||
handleSendMessage?.(event, messageInput);
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import { useSearchParams } from '@remix-run/react';
|
|||||||
import { createSampler } from '~/utils/sampler';
|
import { createSampler } from '~/utils/sampler';
|
||||||
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
||||||
import { logStore } from '~/lib/stores/logs';
|
import { logStore } from '~/lib/stores/logs';
|
||||||
import { streamingState } from '~/lib/stores/streaming';
|
|
||||||
import { filesToArtifacts } from '~/utils/fileUtils';
|
|
||||||
|
|
||||||
const toastAnimation = cssTransition({
|
const toastAnimation = cssTransition({
|
||||||
enter: 'animated fadeInRight',
|
enter: 'animated fadeInRight',
|
||||||
@@ -321,41 +319,23 @@ export const ChatImpl = memo(
|
|||||||
const { assistantMessage, userMessage } = temResp;
|
const { assistantMessage, userMessage } = temResp;
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
id: `1-${new Date().getTime()}`,
|
id: `${new Date().getTime()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: messageContent,
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
|
||||||
},
|
|
||||||
...imageDataList.map((imageData) => ({
|
|
||||||
type: 'image',
|
|
||||||
image: imageData,
|
|
||||||
})),
|
|
||||||
] as any,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `2-${new Date().getTime()}`,
|
id: `${new Date().getTime()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantMessage,
|
content: assistantMessage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `3-${new Date().getTime()}`,
|
id: `${new Date().getTime()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
|
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
|
||||||
annotations: ['hidden'],
|
annotations: ['hidden'],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
reload();
|
reload();
|
||||||
setInput('');
|
|
||||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
|
||||||
|
|
||||||
setUploadedFiles([]);
|
|
||||||
setImageDataList([]);
|
|
||||||
|
|
||||||
resetEnhancer();
|
|
||||||
|
|
||||||
textareaRef.current?.blur();
|
|
||||||
setFakeLoading(false);
|
setFakeLoading(false);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -382,15 +362,6 @@ export const ChatImpl = memo(
|
|||||||
]);
|
]);
|
||||||
reload();
|
reload();
|
||||||
setFakeLoading(false);
|
setFakeLoading(false);
|
||||||
setInput('');
|
|
||||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
|
||||||
|
|
||||||
setUploadedFiles([]);
|
|
||||||
setImageDataList([]);
|
|
||||||
|
|
||||||
resetEnhancer();
|
|
||||||
|
|
||||||
textareaRef.current?.blur();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -399,18 +370,17 @@ export const ChatImpl = memo(
|
|||||||
setMessages(messages.slice(0, -1));
|
setMessages(messages.slice(0, -1));
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifiedFiles = workbenchStore.getModifiedFiles();
|
const fileModifications = workbenchStore.getFileModifcations();
|
||||||
|
|
||||||
chatStore.setKey('aborted', false);
|
chatStore.setKey('aborted', false);
|
||||||
|
|
||||||
if (modifiedFiles !== undefined) {
|
if (fileModifications !== undefined) {
|
||||||
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
|
|
||||||
append({
|
append({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
|
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
||||||
},
|
},
|
||||||
...imageDataList.map((imageData) => ({
|
...imageDataList.map((imageData) => ({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
@@ -495,9 +465,6 @@ export const ChatImpl = memo(
|
|||||||
showChat={showChat}
|
showChat={showChat}
|
||||||
chatStarted={chatStarted}
|
chatStarted={chatStarted}
|
||||||
isStreaming={isLoading || fakeLoading}
|
isStreaming={isLoading || fakeLoading}
|
||||||
onStreamingChange={(streaming) => {
|
|
||||||
streamingState.set(streaming);
|
|
||||||
}}
|
|
||||||
enhancingPrompt={enhancingPrompt}
|
enhancingPrompt={enhancingPrompt}
|
||||||
promptEnhanced={promptEnhanced}
|
promptEnhanced={promptEnhanced}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.3 }}
|
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">
|
<div className="flex items-start">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function GitCloneButton({ importChat, className }: GitCloneButton
|
|||||||
// Skip binary files
|
// Skip binary files
|
||||||
if (
|
if (
|
||||||
content instanceof Uint8Array &&
|
content instanceof Uint8Array &&
|
||||||
!filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
|
!filePath.match(/\.(txt|md|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
|
||||||
) {
|
) {
|
||||||
skippedFiles.push(filePath);
|
skippedFiles.push(filePath);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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(); // Add this to prevent click from bubbling up
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="i-ph:rocket-launch w-5 h-5" />
|
|
||||||
</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',
|
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
||||||
'p-1',
|
'p-1',
|
||||||
)}
|
)}
|
||||||
|
style={{ transform: 'translateY(1rem)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|||||||
@@ -43,6 +43,5 @@ export function UserMessage({ content }: UserMessageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stripMetadata(content: string) {
|
function stripMetadata(content: string) {
|
||||||
const artifactRegex = /<boltArtifact\s+[^>]*>[\s\S]*?<\/boltArtifact>/gm;
|
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,281 +1,21 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import useViewport from '~/lib/hooks';
|
import useViewport from '~/lib/hooks';
|
||||||
import { chatStore } from '~/lib/stores/chat';
|
import { chatStore } from '~/lib/stores/chat';
|
||||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { webcontainer } from '~/lib/webcontainer';
|
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import { path } from '~/utils/path';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
|
||||||
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
|
|
||||||
import { streamingState } from '~/lib/stores/streaming';
|
|
||||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
|
||||||
|
|
||||||
interface HeaderActionButtonsProps {}
|
interface HeaderActionButtonsProps {}
|
||||||
|
|
||||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||||
const { showChat } = useStore(chatStore);
|
const { showChat } = useStore(chatStore);
|
||||||
const connection = useStore(netlifyConnection);
|
|
||||||
const [activePreviewIndex] = useState(0);
|
|
||||||
const previews = useStore(workbenchStore.previews);
|
|
||||||
const activePreview = previews[activePreviewIndex];
|
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
|
||||||
const isSmallViewport = useViewport(1024);
|
const isSmallViewport = useViewport(1024);
|
||||||
|
|
||||||
const canHideChat = showWorkbench || !showChat;
|
const canHideChat = showWorkbench || !showChat;
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isStreaming = useStore(streamingState);
|
|
||||||
|
|
||||||
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 currentChatId = useStore(chatId);
|
|
||||||
|
|
||||||
const handleDeploy = async () => {
|
|
||||||
if (!connection.user || !connection.token) {
|
|
||||||
toast.error('Please connect to Netlify first in the settings tab!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentChatId) {
|
|
||||||
toast.error('No active chat found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsDeploying(true);
|
|
||||||
|
|
||||||
const artifact = workbenchStore.firstArtifact;
|
|
||||||
|
|
||||||
if (!artifact) {
|
|
||||||
throw new Error('No active project found');
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new Error('Build failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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', '');
|
|
||||||
|
|
||||||
// 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 /dist prefix from the path
|
|
||||||
const deployPath = fullPath.replace(buildPath, '');
|
|
||||||
files[deployPath] = content;
|
|
||||||
} else if (entry.isDirectory()) {
|
|
||||||
const subFiles = await getAllFiles(fullPath);
|
|
||||||
Object.assign(files, subFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileContents = await getAllFiles(buildPath);
|
|
||||||
|
|
||||||
// Use chatId instead of artifact.id
|
|
||||||
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
|
||||||
|
|
||||||
// Deploy using the API route with file contents
|
|
||||||
const response = await fetch('/api/deploy', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
siteId: existingSiteId || undefined,
|
|
||||||
files: fileContents,
|
|
||||||
token: connection.token,
|
|
||||||
chatId: currentChatId, // Use chatId instead of artifact.id
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await response.json()) as any;
|
|
||||||
|
|
||||||
if (!response.ok || !data.deploy || !data.site) {
|
|
||||||
console.error('Invalid deploy response:', data);
|
|
||||||
throw new Error(data.error || 'Invalid deployment response');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for deployment status
|
|
||||||
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 ${connection.token}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
deploymentStatus = (await statusResponse.json()) as any;
|
|
||||||
|
|
||||||
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deploymentStatus.state === 'error') {
|
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
<div>
|
|
||||||
Deployed successfully!{' '}
|
|
||||||
<a
|
|
||||||
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
View site
|
|
||||||
</a>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Deploy error:', error);
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
|
||||||
} finally {
|
|
||||||
setIsDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<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 ? 'Deploying...' : 'Deploy'}
|
|
||||||
<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={() => {
|
|
||||||
handleDeploy();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={isDeploying || !activePreview || !connection.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">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
|
|
||||||
{connection.user && <NetlifyDeploymentLink />}
|
|
||||||
</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 bg-black p-1 rounded"
|
|
||||||
height="24"
|
|
||||||
width="24"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
src="https://cdn.simpleicons.org/vercel/white"
|
|
||||||
alt="vercel"
|
|
||||||
/>
|
|
||||||
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
|
|
||||||
</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="vercel"
|
|
||||||
/>
|
|
||||||
<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">
|
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
active={showChat}
|
active={showChat}
|
||||||
@@ -311,23 +51,18 @@ interface ButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: any;
|
children?: any;
|
||||||
onClick?: VoidFunction;
|
onClick?: VoidFunction;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames('flex items-center p-1.5', {
|
||||||
'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 hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
||||||
!active,
|
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
disabled,
|
||||||
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
})}
|
||||||
disabled,
|
|
||||||
},
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import { classNames } from '~/utils/classNames';
|
|||||||
import { cubicEasingFn } from '~/utils/easings';
|
import { cubicEasingFn } from '~/utils/easings';
|
||||||
import { genericMemo } from '~/utils/react';
|
import { genericMemo } from '~/utils/react';
|
||||||
|
|
||||||
export type SliderOptions<T> = {
|
interface SliderOption<T> {
|
||||||
left: { value: T; text: string };
|
value: T;
|
||||||
middle?: { value: T; text: string };
|
text: string;
|
||||||
right: { value: T; text: string };
|
}
|
||||||
};
|
|
||||||
|
export interface SliderOptions<T> {
|
||||||
|
left: SliderOption<T>;
|
||||||
|
right: SliderOption<T>;
|
||||||
|
}
|
||||||
|
|
||||||
interface SliderProps<T> {
|
interface SliderProps<T> {
|
||||||
selected: T;
|
selected: T;
|
||||||
@@ -17,26 +21,14 @@ interface SliderProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
||||||
const hasMiddle = !!options.middle;
|
const isLeftSelected = selected === options.left.value;
|
||||||
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
|
|
||||||
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
<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)}>
|
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
||||||
{options.left.text}
|
{options.left.text}
|
||||||
</SliderButton>
|
</SliderButton>
|
||||||
|
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||||
{options.middle && (
|
|
||||||
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
|
|
||||||
{options.middle.text}
|
|
||||||
</SliderButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SliderButton
|
|
||||||
selected={!isLeftSelected && !isMiddleSelected}
|
|
||||||
setSelected={() => setSelected?.(options.right.value)}
|
|
||||||
>
|
|
||||||
{options.right.text}
|
{options.right.text}
|
||||||
</SliderButton>
|
</SliderButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
import type { FileMap } from '~/lib/stores/files';
|
||||||
import type { FileHistory } from '~/types/actions';
|
|
||||||
import { themeStore } from '~/lib/stores/theme';
|
import { themeStore } from '~/lib/stores/theme';
|
||||||
import { WORK_DIR } from '~/utils/constants';
|
import { WORK_DIR } from '~/utils/constants';
|
||||||
import { renderLogger } from '~/utils/logger';
|
import { renderLogger } from '~/utils/logger';
|
||||||
@@ -28,7 +27,6 @@ interface EditorPanelProps {
|
|||||||
editorDocument?: EditorDocument;
|
editorDocument?: EditorDocument;
|
||||||
selectedFile?: string | undefined;
|
selectedFile?: string | undefined;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
fileHistory?: Record<string, FileHistory>;
|
|
||||||
onEditorChange?: OnEditorChange;
|
onEditorChange?: OnEditorChange;
|
||||||
onEditorScroll?: OnEditorScroll;
|
onEditorScroll?: OnEditorScroll;
|
||||||
onFileSelect?: (value?: string) => void;
|
onFileSelect?: (value?: string) => void;
|
||||||
@@ -47,7 +45,6 @@ export const EditorPanel = memo(
|
|||||||
editorDocument,
|
editorDocument,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
fileHistory,
|
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onEditorChange,
|
onEditorChange,
|
||||||
onEditorScroll,
|
onEditorScroll,
|
||||||
@@ -86,7 +83,6 @@ export const EditorPanel = memo(
|
|||||||
files={files}
|
files={files}
|
||||||
hideRoot
|
hideRoot
|
||||||
unsavedFiles={unsavedFiles}
|
unsavedFiles={unsavedFiles}
|
||||||
fileHistory={fileHistory}
|
|
||||||
rootFolder={WORK_DIR}
|
rootFolder={WORK_DIR}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { FileMap } from '~/lib/stores/files';
|
|||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||||
import type { FileHistory } from '~/types/actions';
|
|
||||||
import { diffLines, type Change } from 'diff';
|
|
||||||
|
|
||||||
const logger = createScopedLogger('FileTree');
|
const logger = createScopedLogger('FileTree');
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ interface Props {
|
|||||||
allowFolderSelection?: boolean;
|
allowFolderSelection?: boolean;
|
||||||
hiddenFiles?: Array<string | RegExp>;
|
hiddenFiles?: Array<string | RegExp>;
|
||||||
unsavedFiles?: Set<string>;
|
unsavedFiles?: Set<string>;
|
||||||
fileHistory?: Record<string, FileHistory>;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ export const FileTree = memo(
|
|||||||
hiddenFiles,
|
hiddenFiles,
|
||||||
className,
|
className,
|
||||||
unsavedFiles,
|
unsavedFiles,
|
||||||
fileHistory = {},
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
renderLogger.trace('FileTree');
|
renderLogger.trace('FileTree');
|
||||||
|
|
||||||
@@ -142,7 +138,6 @@ export const FileTree = memo(
|
|||||||
selected={selectedFile === fileOrFolder.fullPath}
|
selected={selectedFile === fileOrFolder.fullPath}
|
||||||
file={fileOrFolder}
|
file={fileOrFolder}
|
||||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||||
fileHistory={fileHistory}
|
|
||||||
onCopyPath={() => {
|
onCopyPath={() => {
|
||||||
onCopyPath(fileOrFolder);
|
onCopyPath(fileOrFolder);
|
||||||
}}
|
}}
|
||||||
@@ -258,64 +253,19 @@ interface FileProps {
|
|||||||
file: FileNode;
|
file: FileNode;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
unsavedChanges?: boolean;
|
unsavedChanges?: boolean;
|
||||||
fileHistory?: Record<string, FileHistory>;
|
|
||||||
onCopyPath: () => void;
|
onCopyPath: () => void;
|
||||||
onCopyRelativePath: () => void;
|
onCopyRelativePath: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function File({
|
function File({
|
||||||
file: { depth, name, fullPath },
|
file: { depth, name },
|
||||||
onClick,
|
onClick,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onCopyRelativePath,
|
onCopyRelativePath,
|
||||||
selected,
|
selected,
|
||||||
unsavedChanges = false,
|
unsavedChanges = false,
|
||||||
fileHistory = {},
|
|
||||||
}: FileProps) {
|
}: FileProps) {
|
||||||
const fileModifications = fileHistory[fullPath];
|
|
||||||
|
|
||||||
// const hasModifications = fileModifications !== undefined;
|
|
||||||
|
|
||||||
// Calculate added and removed lines from the most recent changes
|
|
||||||
const { additions, deletions } = useMemo(() => {
|
|
||||||
if (!fileModifications?.originalContent) {
|
|
||||||
return { additions: 0, deletions: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usar a mesma lógica do DiffView para processar as mudanças
|
|
||||||
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 (
|
return (
|
||||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||||
<NodeButton
|
<NodeButton
|
||||||
@@ -336,15 +286,7 @@ function File({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex-1 truncate pr-2">{name}</div>
|
<div className="flex-1 truncate pr-2">{name}</div>
|
||||||
<div className="flex items-center gap-1">
|
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</NodeButton>
|
</NodeButton>
|
||||||
</FileContextMenu>
|
</FileContextMenu>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||||
import { computed } from 'nanostores';
|
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 { 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 {
|
import {
|
||||||
type OnChangeCallback as OnEditorChange,
|
type OnChangeCallback as OnEditorChange,
|
||||||
type OnScrollCallback as OnEditorScroll,
|
type OnScrollCallback as OnEditorScroll,
|
||||||
@@ -28,11 +22,6 @@ import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/comp
|
|||||||
interface WorkspaceProps {
|
interface WorkspaceProps {
|
||||||
chatStarted?: boolean;
|
chatStarted?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
actionRunner: ActionRunner;
|
|
||||||
metadata?: {
|
|
||||||
gitUrl?: string;
|
|
||||||
};
|
|
||||||
updateChatMestaData?: (metadata: any) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewTransition = { ease: cubicEasingFn };
|
const viewTransition = { ease: cubicEasingFn };
|
||||||
@@ -42,10 +31,6 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
|||||||
value: 'code',
|
value: 'code',
|
||||||
text: 'Code',
|
text: 'Code',
|
||||||
},
|
},
|
||||||
middle: {
|
|
||||||
value: 'diff',
|
|
||||||
text: 'Diff',
|
|
||||||
},
|
|
||||||
right: {
|
right: {
|
||||||
value: 'preview',
|
value: 'preview',
|
||||||
text: 'Preview',
|
text: 'Preview',
|
||||||
@@ -69,413 +54,182 @@ const workbenchVariants = {
|
|||||||
},
|
},
|
||||||
} satisfies Variants;
|
} satisfies Variants;
|
||||||
|
|
||||||
const FileModifiedDropdown = memo(
|
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||||
({
|
renderLogger.trace('Workbench');
|
||||||
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(() => {
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase()));
|
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
||||||
}, [modifiedFiles, searchQuery]);
|
|
||||||
|
|
||||||
return (
|
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||||
<div className="flex items-center gap-2">
|
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||||
<Popover className="relative">
|
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||||
{({ open }: { open: boolean }) => (
|
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||||
<>
|
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||||
<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-textPrimary border border-bolt-elements-borderColor">
|
const files = useStore(workbenchStore.files);
|
||||||
<span className="font-medium">File Changes</span>
|
const selectedView = useStore(workbenchStore.currentView);
|
||||||
{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">
|
const isSmallViewport = useViewport(1024);
|
||||||
{filteredFiles.length > 0 ? (
|
|
||||||
filteredFiles.map(([filePath, history]) => {
|
|
||||||
const extension = filePath.split('.').pop() || '';
|
|
||||||
const language = getLanguageFromExtension(extension);
|
|
||||||
|
|
||||||
return (
|
const setSelectedView = (view: WorkbenchViewType) => {
|
||||||
<button
|
workbenchStore.currentView.set(view);
|
||||||
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');
|
useEffect(() => {
|
||||||
const normalizedCurrent =
|
if (hasPreview) {
|
||||||
history.versions[history.versions.length - 1]?.content.replace(
|
setSelectedView('preview');
|
||||||
/\r\n/g,
|
}
|
||||||
'\n',
|
}, [hasPreview]);
|
||||||
) || '';
|
|
||||||
|
|
||||||
if (normalizedOriginal === normalizedCurrent) {
|
useEffect(() => {
|
||||||
return { additions: 0, deletions: 0 };
|
workbenchStore.setDocuments(files);
|
||||||
}
|
}, [files]);
|
||||||
|
|
||||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
const onEditorChange = useCallback<OnEditorChange>((update) => {
|
||||||
newlineIsToken: false,
|
workbenchStore.setCurrentDocumentContent(update.content);
|
||||||
ignoreWhitespace: true,
|
}, []);
|
||||||
ignoreCase: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return changes.reduce(
|
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
|
||||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
workbenchStore.setCurrentDocumentScrollPosition(position);
|
||||||
if (change.added) {
|
}, []);
|
||||||
acc.additions += change.value.split('\n').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (change.removed) {
|
const onFileSelect = useCallback((filePath: string | undefined) => {
|
||||||
acc.deletions += change.value.split('\n').length;
|
workbenchStore.setSelectedFile(filePath);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
return acc;
|
const onFileSave = useCallback(() => {
|
||||||
},
|
workbenchStore.saveCurrentDocument().catch(() => {
|
||||||
{ additions: 0, deletions: 0 },
|
toast.error('Failed to update file content');
|
||||||
);
|
});
|
||||||
})();
|
}, []);
|
||||||
|
|
||||||
const showStats = additions > 0 || deletions > 0;
|
const onFileReset = useCallback(() => {
|
||||||
|
workbenchStore.resetCurrentDocument();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
const handleSyncFiles = useCallback(async () => {
|
||||||
showStats && (
|
setIsSyncing(true);
|
||||||
<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 && (
|
try {
|
||||||
<div className="border-t border-bolt-elements-borderColor p-2">
|
const directoryHandle = await window.showDirectoryPicker();
|
||||||
<button
|
await workbenchStore.syncFiles(directoryHandle);
|
||||||
onClick={() => {
|
toast.success('Files synced successfully');
|
||||||
navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n'));
|
} catch (error) {
|
||||||
toast('File list copied to clipboard', {
|
console.error('Error syncing files:', error);
|
||||||
icon: <div className="i-ph:check-circle text-accent-500" />,
|
toast.error('Failed to sync files');
|
||||||
});
|
} finally {
|
||||||
}}
|
setIsSyncing(false);
|
||||||
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>
|
return (
|
||||||
</div>
|
chatStarted && (
|
||||||
)}
|
<motion.div
|
||||||
</Popover.Panel>
|
initial="closed"
|
||||||
</Transition>
|
animate={showWorkbench ? 'open' : 'closed'}
|
||||||
</>
|
variants={workbenchVariants}
|
||||||
|
className="z-workbench"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
|
||||||
|
{
|
||||||
|
'w-full': isSmallViewport,
|
||||||
|
'left-0': showWorkbench && isSmallViewport,
|
||||||
|
'left-[var(--workbench-left)]': showWorkbench,
|
||||||
|
'left-[100%]': !showWorkbench,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Workbench = memo(
|
|
||||||
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: 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);
|
|
||||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
|
||||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
|
||||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
|
||||||
const files = useStore(workbenchStore.files);
|
|
||||||
const selectedView = useStore(workbenchStore.currentView);
|
|
||||||
|
|
||||||
const isSmallViewport = useViewport(1024);
|
|
||||||
|
|
||||||
const setSelectedView = (view: WorkbenchViewType) => {
|
|
||||||
workbenchStore.currentView.set(view);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasPreview) {
|
|
||||||
setSelectedView('preview');
|
|
||||||
}
|
|
||||||
}, [hasPreview]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
workbenchStore.setDocuments(files);
|
|
||||||
}, [files]);
|
|
||||||
|
|
||||||
const onEditorChange = useCallback<OnEditorChange>((update) => {
|
|
||||||
workbenchStore.setCurrentDocumentContent(update.content);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
|
|
||||||
workbenchStore.setCurrentDocumentScrollPosition(position);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onFileSelect = useCallback((filePath: string | undefined) => {
|
|
||||||
workbenchStore.setSelectedFile(filePath);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onFileSave = useCallback(() => {
|
|
||||||
workbenchStore.saveCurrentDocument().catch(() => {
|
|
||||||
toast.error('Failed to update file content');
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onFileReset = useCallback(() => {
|
|
||||||
workbenchStore.resetCurrentDocument();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSyncFiles = useCallback(async () => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const directoryHandle = await window.showDirectoryPicker();
|
|
||||||
await workbenchStore.syncFiles(directoryHandle);
|
|
||||||
toast.success('Files synced successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing files:', error);
|
|
||||||
toast.error('Failed to sync files');
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectFile = useCallback((filePath: string) => {
|
|
||||||
workbenchStore.setSelectedFile(filePath);
|
|
||||||
workbenchStore.currentView.set('diff');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
chatStarted && (
|
|
||||||
<motion.div
|
|
||||||
initial="closed"
|
|
||||||
animate={showWorkbench ? 'open' : 'closed'}
|
|
||||||
variants={workbenchVariants}
|
|
||||||
className="z-workbench"
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="absolute inset-0 px-2 lg:px-6">
|
||||||
className={classNames(
|
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||||
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
|
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||||
{
|
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||||
'w-full': isSmallViewport,
|
<div className="ml-auto" />
|
||||||
'left-0': showWorkbench && isSmallViewport,
|
{selectedView === 'code' && (
|
||||||
'left-[var(--workbench-left)]': showWorkbench,
|
<div className="flex overflow-y-auto">
|
||||||
'left-[100%]': !showWorkbench,
|
<PanelHeaderButton
|
||||||
},
|
className="mr-1 text-sm"
|
||||||
)}
|
onClick={() => {
|
||||||
>
|
workbenchStore.downloadZip();
|
||||||
<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">
|
<div className="i-ph:code" />
|
||||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
Download Code
|
||||||
<div className="ml-auto" />
|
</PanelHeaderButton>
|
||||||
{selectedView === 'code' && (
|
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
|
||||||
<div className="flex overflow-y-auto">
|
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||||
<PanelHeaderButton
|
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
||||||
className="mr-1 text-sm"
|
</PanelHeaderButton>
|
||||||
onClick={() => {
|
<PanelHeaderButton
|
||||||
workbenchStore.downloadZip();
|
className="mr-1 text-sm"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
||||||
<div className="i-ph:code" />
|
}}
|
||||||
Download Code
|
>
|
||||||
</PanelHeaderButton>
|
<div className="i-ph:terminal" />
|
||||||
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
|
Toggle Terminal
|
||||||
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
</PanelHeaderButton>
|
||||||
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
|
||||||
</PanelHeaderButton>
|
<div className="i-ph:git-branch" />
|
||||||
<PanelHeaderButton
|
Push to GitHub
|
||||||
className="mr-1 text-sm"
|
</PanelHeaderButton>
|
||||||
onClick={() => {
|
</div>
|
||||||
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
)}
|
||||||
}}
|
<IconButton
|
||||||
>
|
icon="i-ph:x-circle"
|
||||||
<div className="i-ph:terminal" />
|
className="-mr-1"
|
||||||
Toggle Terminal
|
size="xl"
|
||||||
</PanelHeaderButton>
|
onClick={() => {
|
||||||
<PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}>
|
workbenchStore.showWorkbench.set(false);
|
||||||
<div className="i-ph:git-branch" />
|
}}
|
||||||
Push to GitHub
|
/>
|
||||||
</PanelHeaderButton>
|
</div>
|
||||||
</div>
|
<div className="relative flex-1 overflow-hidden">
|
||||||
)}
|
<View
|
||||||
{selectedView === 'diff' && (
|
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||||
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
|
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||||
)}
|
>
|
||||||
<IconButton
|
<EditorPanel
|
||||||
icon="i-ph:x-circle"
|
editorDocument={currentDocument}
|
||||||
className="-mr-1"
|
isStreaming={isStreaming}
|
||||||
size="xl"
|
selectedFile={selectedFile}
|
||||||
onClick={() => {
|
files={files}
|
||||||
workbenchStore.showWorkbench.set(false);
|
unsavedFiles={unsavedFiles}
|
||||||
}}
|
onFileSelect={onFileSelect}
|
||||||
|
onEditorScroll={onEditorScroll}
|
||||||
|
onEditorChange={onEditorChange}
|
||||||
|
onFileSave={onFileSave}
|
||||||
|
onFileReset={onFileReset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</View>
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<View
|
||||||
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
|
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||||
<EditorPanel
|
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||||
editorDocument={currentDocument}
|
>
|
||||||
isStreaming={isStreaming}
|
<Preview />
|
||||||
selectedFile={selectedFile}
|
</View>
|
||||||
files={files}
|
|
||||||
unsavedFiles={unsavedFiles}
|
|
||||||
fileHistory={fileHistory}
|
|
||||||
onFileSelect={onFileSelect}
|
|
||||||
onEditorScroll={onEditorScroll}
|
|
||||||
onEditorChange={onEditorChange}
|
|
||||||
onFileSave={onFileSave}
|
|
||||||
onFileReset={onFileReset}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
initial={{ x: '100%' }}
|
|
||||||
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
|
|
||||||
>
|
|
||||||
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
|
|
||||||
</View>
|
|
||||||
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
|
||||||
<Preview />
|
|
||||||
</View>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PushToGitHubDialog
|
</div>
|
||||||
isOpen={isPushDialogOpen}
|
<PushToGitHubDialog
|
||||||
onClose={() => setIsPushDialogOpen(false)}
|
isOpen={isPushDialogOpen}
|
||||||
onPush={async (repoName, username, token) => {
|
onClose={() => setIsPushDialogOpen(false)}
|
||||||
try {
|
onPush={async (repoName, username, token, isPrivate) => {
|
||||||
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
try {
|
||||||
await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
|
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
|
||||||
|
return repoUrl;
|
||||||
const repoUrl = `https://github.com/${username}/${repoName}`;
|
} catch (error) {
|
||||||
|
console.error('Error pushing to GitHub:', error);
|
||||||
if (updateChatMestaData && !metadata?.gitUrl) {
|
toast.error('Failed to push to GitHub');
|
||||||
updateChatMestaData({
|
throw error; // Rethrow to let PushToGitHubDialog handle the error state
|
||||||
...(metadata || {}),
|
}
|
||||||
gitUrl: repoUrl,
|
}}
|
||||||
});
|
/>
|
||||||
}
|
</motion.div>
|
||||||
|
)
|
||||||
return repoUrl;
|
);
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error pushing to GitHub:', error);
|
|
||||||
toast.error('Failed to push to GitHub');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// View component for rendering content with motion transitions
|
// View component for rendering content with motion transitions
|
||||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||||
|
|||||||
@@ -204,10 +204,7 @@ export async function selectContext(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!filePaths.includes(fullPath)) {
|
if (!filePaths.includes(fullPath)) {
|
||||||
logger.error(`File ${path} is not in the list of files above.`);
|
throw new 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.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currrentFiles.includes(path)) {
|
if (currrentFiles.includes(path)) {
|
||||||
@@ -221,13 +218,6 @@ export async function selectContext(props: {
|
|||||||
onFinish(resp);
|
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;
|
return filteredFiles;
|
||||||
|
|
||||||
// generateText({
|
// generateText({
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ const messageParser = new StreamingMessageParser({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const extractTextContent = (message: Message) =>
|
|
||||||
Array.isArray(message.content)
|
|
||||||
? (message.content.find((item) => item.type === 'text')?.text as string) || ''
|
|
||||||
: message.content;
|
|
||||||
|
|
||||||
export function useMessageParser() {
|
export function useMessageParser() {
|
||||||
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
|
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
|
||||||
@@ -59,8 +55,9 @@ export function useMessageParser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, message] of messages.entries()) {
|
for (const [index, message] of messages.entries()) {
|
||||||
if (message.role === 'assistant' || message.role === 'user') {
|
if (message.role === 'assistant') {
|
||||||
const newParsedContent = messageParser.parse(message.id, extractTextContent(message));
|
const newParsedContent = messageParser.parse(message.id, message.content);
|
||||||
|
|
||||||
setParsedMessages((prevParsed) => ({
|
setParsedMessages((prevParsed) => ({
|
||||||
...prevParsed,
|
...prevParsed,
|
||||||
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
|
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function useShortcuts(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debug logging in development only
|
// Debug logging in development only
|
||||||
if (import.meta.env.DEV) {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('Key pressed:', {
|
console.log('Key pressed:', {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
code: event.code,
|
code: event.code,
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ export default class AnthropicProvider extends BaseProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
staticModels: ModelInfo[] = [
|
staticModels: ModelInfo[] = [
|
||||||
{
|
|
||||||
name: 'claude-3-7-sonnet-20250219',
|
|
||||||
label: 'Claude 3.7 Sonnet',
|
|
||||||
provider: 'Anthropic',
|
|
||||||
maxTokenAllowed: 8000,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'claude-3-5-sonnet-latest',
|
name: 'claude-3-5-sonnet-latest',
|
||||||
label: 'Claude 3.5 Sonnet (new)',
|
label: 'Claude 3.5 Sonnet (new)',
|
||||||
@@ -41,44 +35,6 @@ export default class AnthropicProvider extends BaseProvider {
|
|||||||
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||||
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||||
];
|
];
|
||||||
|
|
||||||
async getDynamicModels(
|
|
||||||
apiKeys?: Record<string, string>,
|
|
||||||
settings?: IProviderSetting,
|
|
||||||
serverEnv?: Record<string, string>,
|
|
||||||
): Promise<ModelInfo[]> {
|
|
||||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
|
||||||
apiKeys,
|
|
||||||
providerSettings: settings,
|
|
||||||
serverEnv: serverEnv as any,
|
|
||||||
defaultBaseUrlKey: '',
|
|
||||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`https://api.anthropic.com/v1/models`, {
|
|
||||||
headers: {
|
|
||||||
'x-api-key': `${apiKey}`,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = (await response.json()) as any;
|
|
||||||
const staticModelIds = this.staticModels.map((m) => m.name);
|
|
||||||
|
|
||||||
const data = res.data.filter((model: any) => model.type === 'model' && !staticModelIds.includes(model.id));
|
|
||||||
|
|
||||||
return data.map((m: any) => ({
|
|
||||||
name: m.id,
|
|
||||||
label: `${m.display_name}`,
|
|
||||||
provider: this.name,
|
|
||||||
maxTokenAllowed: 32000,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
getModelInstance: (options: {
|
getModelInstance: (options: {
|
||||||
model: string;
|
model: string;
|
||||||
serverEnv: Env;
|
serverEnv: Env;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default class LMStudioProvider extends BaseProvider {
|
|||||||
throw new Error('No baseUrl found for LMStudio provider');
|
throw new Error('No baseUrl found for LMStudio provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
|
const isDocker = process.env.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface OllamaApiResponse {
|
|||||||
models: OllamaModel[];
|
models: OllamaModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_NUM_CTX = process?.env?.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
|
||||||
|
|
||||||
export default class OllamaProvider extends BaseProvider {
|
export default class OllamaProvider extends BaseProvider {
|
||||||
name = 'Ollama';
|
name = 'Ollama';
|
||||||
getApiKeyLink = 'https://ollama.com/download';
|
getApiKeyLink = 'https://ollama.com/download';
|
||||||
@@ -39,26 +41,6 @@ export default class OllamaProvider extends BaseProvider {
|
|||||||
|
|
||||||
staticModels: ModelInfo[] = [];
|
staticModels: ModelInfo[] = [];
|
||||||
|
|
||||||
private _convertEnvToRecord(env?: Env): Record<string, string> {
|
|
||||||
if (!env) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Env to a plain object with string values
|
|
||||||
return Object.entries(env).reduce(
|
|
||||||
(acc, [key, value]) => {
|
|
||||||
acc[key] = String(value);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultNumCtx(serverEnv?: Env): number {
|
|
||||||
const envRecord = this._convertEnvToRecord(serverEnv);
|
|
||||||
return envRecord.DEFAULT_NUM_CTX ? parseInt(envRecord.DEFAULT_NUM_CTX, 10) : 32768;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDynamicModels(
|
async getDynamicModels(
|
||||||
apiKeys?: Record<string, string>,
|
apiKeys?: Record<string, string>,
|
||||||
settings?: IProviderSetting,
|
settings?: IProviderSetting,
|
||||||
@@ -99,7 +81,6 @@ export default class OllamaProvider extends BaseProvider {
|
|||||||
maxTokenAllowed: 8000,
|
maxTokenAllowed: 8000,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
getModelInstance: (options: {
|
getModelInstance: (options: {
|
||||||
model: string;
|
model: string;
|
||||||
serverEnv?: Env;
|
serverEnv?: Env;
|
||||||
@@ -107,12 +88,10 @@ export default class OllamaProvider extends BaseProvider {
|
|||||||
providerSettings?: Record<string, IProviderSetting>;
|
providerSettings?: Record<string, IProviderSetting>;
|
||||||
}) => LanguageModelV1 = (options) => {
|
}) => LanguageModelV1 = (options) => {
|
||||||
const { apiKeys, providerSettings, serverEnv, model } = options;
|
const { apiKeys, providerSettings, serverEnv, model } = options;
|
||||||
const envRecord = this._convertEnvToRecord(serverEnv);
|
|
||||||
|
|
||||||
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
||||||
apiKeys,
|
apiKeys,
|
||||||
providerSettings: providerSettings?.[this.name],
|
providerSettings: providerSettings?.[this.name],
|
||||||
serverEnv: envRecord,
|
serverEnv: serverEnv as any,
|
||||||
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
|
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
|
||||||
defaultApiTokenKey: '',
|
defaultApiTokenKey: '',
|
||||||
});
|
});
|
||||||
@@ -122,14 +101,14 @@ export default class OllamaProvider extends BaseProvider {
|
|||||||
throw new Error('No baseUrl found for OLLAMA provider');
|
throw new Error('No baseUrl found for OLLAMA provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || envRecord.RUNNING_IN_DOCKER === 'true';
|
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
|
||||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||||
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
||||||
|
|
||||||
logger.debug('Ollama Base Url used: ', baseUrl);
|
logger.debug('Ollama Base Url used: ', baseUrl);
|
||||||
|
|
||||||
const ollamaInstance = ollama(model, {
|
const ollamaInstance = ollama(model, {
|
||||||
numCtx: this.getDefaultNumCtx(serverEnv),
|
numCtx: DEFAULT_NUM_CTX,
|
||||||
}) as LanguageModelV1 & { config: any };
|
}) as LanguageModelV1 & { config: any };
|
||||||
|
|
||||||
ollamaInstance.config.baseURL = `${baseUrl}/api`;
|
ollamaInstance.config.baseURL = `${baseUrl}/api`;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { ChatHistoryItem } from './useChatHistory';
|
|||||||
export interface IChatMetadata {
|
export interface IChatMetadata {
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
netlifySiteId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createScopedLogger('ChatHistory');
|
const logger = createScopedLogger('ChatHistory');
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { FileMap } from '~/lib/stores/files';
|
|
||||||
|
|
||||||
export interface Snapshot {
|
|
||||||
chatIndex: string;
|
|
||||||
files: FileMap;
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { atom } from 'nanostores';
|
import { atom } from 'nanostores';
|
||||||
import { generateId, type JSONValue, type Message } from 'ai';
|
import type { Message } from 'ai';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
||||||
@@ -15,11 +15,6 @@ import {
|
|||||||
createChatFromMessages,
|
createChatFromMessages,
|
||||||
type IChatMetadata,
|
type IChatMetadata,
|
||||||
} from './db';
|
} from './db';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
|
||||||
import type { Snapshot } from './types';
|
|
||||||
import { webcontainer } from '~/lib/webcontainer';
|
|
||||||
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
|
|
||||||
import type { ContextAnnotation } from '~/types/context';
|
|
||||||
|
|
||||||
export interface ChatHistoryItem {
|
export interface ChatHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,7 +37,6 @@ export function useChatHistory() {
|
|||||||
const { id: mixedId } = useLoaderData<{ id?: string }>();
|
const { id: mixedId } = useLoaderData<{ id?: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [archivedMessages, setArchivedMessages] = useState<Message[]>([]);
|
|
||||||
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
||||||
const [ready, setReady] = useState<boolean>(false);
|
const [ready, setReady] = useState<boolean>(false);
|
||||||
const [urlId, setUrlId] = useState<string | undefined>();
|
const [urlId, setUrlId] = useState<string | undefined>();
|
||||||
@@ -62,128 +56,14 @@ export function useChatHistory() {
|
|||||||
|
|
||||||
if (mixedId) {
|
if (mixedId) {
|
||||||
getMessages(db, mixedId)
|
getMessages(db, mixedId)
|
||||||
.then(async (storedMessages) => {
|
.then((storedMessages) => {
|
||||||
if (storedMessages && storedMessages.messages.length > 0) {
|
if (storedMessages && storedMessages.messages.length > 0) {
|
||||||
const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
|
|
||||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
|
||||||
const summary = snapshot.summary;
|
|
||||||
|
|
||||||
const rewindId = searchParams.get('rewindTo');
|
const rewindId = searchParams.get('rewindTo');
|
||||||
let startingIdx = -1;
|
const filteredMessages = rewindId
|
||||||
const endingIdx = rewindId
|
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
|
||||||
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
|
: storedMessages.messages;
|
||||||
: storedMessages.messages.length;
|
|
||||||
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
|
|
||||||
|
|
||||||
if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
|
|
||||||
startingIdx = snapshotIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
|
|
||||||
startingIdx = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
|
|
||||||
let archivedMessages: Message[] = [];
|
|
||||||
|
|
||||||
if (startingIdx >= 0) {
|
|
||||||
archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setArchivedMessages(archivedMessages);
|
|
||||||
|
|
||||||
if (startingIdx > 0) {
|
|
||||||
const files = Object.entries(snapshot?.files || {})
|
|
||||||
.map(([key, value]) => {
|
|
||||||
if (value?.type !== 'file') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: value.content,
|
|
||||||
path: key,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((x) => !!x);
|
|
||||||
const projectCommands = await detectProjectCommands(files);
|
|
||||||
const commands = createCommandsMessage(projectCommands);
|
|
||||||
|
|
||||||
filteredMessages = [
|
|
||||||
{
|
|
||||||
id: generateId(),
|
|
||||||
role: 'user',
|
|
||||||
content: `Restore project from snapshot
|
|
||||||
`,
|
|
||||||
annotations: ['no-store', 'hidden'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: storedMessages.messages[snapshotIndex].id,
|
|
||||||
role: 'assistant',
|
|
||||||
content: ` 📦 Chat Restored from snapshot, You can revert this message to load the full chat history
|
|
||||||
<boltArtifact id="imported-files" title="Project Files Snapshot" type="bundled">
|
|
||||||
${Object.entries(snapshot?.files || {})
|
|
||||||
.filter((x) => !x[0].endsWith('lock.json'))
|
|
||||||
.map(([key, value]) => {
|
|
||||||
if (value?.type === 'file') {
|
|
||||||
return `
|
|
||||||
<boltAction type="file" filePath="${key}">
|
|
||||||
${value.content}
|
|
||||||
</boltAction>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join('\n')}
|
|
||||||
</boltArtifact>
|
|
||||||
`,
|
|
||||||
annotations: [
|
|
||||||
'no-store',
|
|
||||||
...(summary
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
chatId: storedMessages.messages[snapshotIndex].id,
|
|
||||||
type: 'chatSummary',
|
|
||||||
summary,
|
|
||||||
} satisfies ContextAnnotation,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...(commands !== null
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: `${storedMessages.messages[snapshotIndex].id}-2`,
|
|
||||||
role: 'user' as const,
|
|
||||||
content: `setup project`,
|
|
||||||
annotations: ['no-store', 'hidden'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commands,
|
|
||||||
id: `${storedMessages.messages[snapshotIndex].id}-3`,
|
|
||||||
annotations: [
|
|
||||||
'no-store',
|
|
||||||
...(commands.annotations || []),
|
|
||||||
...(summary
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
chatId: `${storedMessages.messages[snapshotIndex].id}-3`,
|
|
||||||
type: 'chatSummary',
|
|
||||||
summary,
|
|
||||||
} satisfies ContextAnnotation,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...filteredMessages,
|
|
||||||
];
|
|
||||||
restoreSnapshot(mixedId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialMessages(filteredMessages);
|
setInitialMessages(filteredMessages);
|
||||||
|
|
||||||
setUrlId(storedMessages.urlId);
|
setUrlId(storedMessages.urlId);
|
||||||
description.set(storedMessages.description);
|
description.set(storedMessages.description);
|
||||||
chatId.set(storedMessages.id);
|
chatId.set(storedMessages.id);
|
||||||
@@ -195,64 +75,10 @@ ${value.content}
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
logStore.logError('Failed to load chat messages', error);
|
logStore.logError('Failed to load chat messages', error);
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [mixedId]);
|
|
||||||
|
|
||||||
const takeSnapshot = useCallback(
|
|
||||||
async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
|
|
||||||
const id = _chatId || chatId;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot: Snapshot = {
|
|
||||||
chatIndex: chatIdx,
|
|
||||||
files,
|
|
||||||
summary: chatSummary,
|
|
||||||
};
|
|
||||||
localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
|
|
||||||
},
|
|
||||||
[chatId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const restoreSnapshot = useCallback(async (id: string) => {
|
|
||||||
const snapshotStr = localStorage.getItem(`snapshot:${id}`);
|
|
||||||
const container = await webcontainer;
|
|
||||||
|
|
||||||
// if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
|
|
||||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
|
||||||
|
|
||||||
if (!snapshot?.files) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
|
||||||
if (key.startsWith(container.workdir)) {
|
|
||||||
key = key.replace(container.workdir, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value?.type === 'folder') {
|
|
||||||
await container.fs.mkdir(key, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
|
||||||
if (value?.type === 'file') {
|
|
||||||
if (key.startsWith(container.workdir)) {
|
|
||||||
key = key.replace(container.workdir, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
await container.fs.writeFile(key, value.content, { encoding: value.isBinary ? undefined : 'utf8' });
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// workbenchStore.files.setKey(snapshot?.files)
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -279,34 +105,14 @@ ${value.content}
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { firstArtifact } = workbenchStore;
|
const { firstArtifact } = workbenchStore;
|
||||||
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
|
|
||||||
|
|
||||||
let _urlId = urlId;
|
|
||||||
|
|
||||||
if (!urlId && firstArtifact?.id) {
|
if (!urlId && firstArtifact?.id) {
|
||||||
const urlId = await getUrlId(db, firstArtifact.id);
|
const urlId = await getUrlId(db, firstArtifact.id);
|
||||||
_urlId = urlId;
|
|
||||||
navigateChat(urlId);
|
navigateChat(urlId);
|
||||||
setUrlId(urlId);
|
setUrlId(urlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let chatSummary: string | undefined = undefined;
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
|
|
||||||
if (lastMessage.role === 'assistant') {
|
|
||||||
const annotations = lastMessage.annotations as JSONValue[];
|
|
||||||
const filteredAnnotations = (annotations?.filter(
|
|
||||||
(annotation: JSONValue) =>
|
|
||||||
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
|
||||||
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
|
||||||
|
|
||||||
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
|
|
||||||
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
|
|
||||||
|
|
||||||
if (!description.get() && firstArtifact?.title) {
|
if (!description.get() && firstArtifact?.title) {
|
||||||
description.set(firstArtifact?.title);
|
description.set(firstArtifact?.title);
|
||||||
}
|
}
|
||||||
@@ -321,15 +127,7 @@ ${value.content}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await setMessages(
|
await setMessages(db, chatId.get() as string, messages, urlId, description.get(), undefined, chatMetadata.get());
|
||||||
db,
|
|
||||||
chatId.get() as string,
|
|
||||||
[...archivedMessages, ...messages],
|
|
||||||
urlId,
|
|
||||||
description.get(),
|
|
||||||
undefined,
|
|
||||||
chatMetadata.get(),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
duplicateCurrentChat: async (listItemId: string) => {
|
duplicateCurrentChat: async (listItemId: string) => {
|
||||||
if (!db || (!mixedId && !listItemId)) {
|
if (!db || (!mixedId && !listItemId)) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { WebContainer } from '@webcontainer/api';
|
import type { WebContainer } from '@webcontainer/api';
|
||||||
import { path as nodePath } from '~/utils/path';
|
import { path } from '~/utils/path';
|
||||||
import { atom, map, type MapStore } from 'nanostores';
|
import { atom, map, type MapStore } from 'nanostores';
|
||||||
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
|
import type { ActionAlert, BoltAction } from '~/types/actions';
|
||||||
import { createScopedLogger } from '~/utils/logger';
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
import { unreachable } from '~/utils/unreachable';
|
import { unreachable } from '~/utils/unreachable';
|
||||||
import type { ActionCallbackData } from './message-parser';
|
import type { ActionCallbackData } from './message-parser';
|
||||||
@@ -70,7 +70,6 @@ export class ActionRunner {
|
|||||||
runnerId = atom<string>(`${Date.now()}`);
|
runnerId = atom<string>(`${Date.now()}`);
|
||||||
actions: ActionsMap = map({});
|
actions: ActionsMap = map({});
|
||||||
onAlert?: (alert: ActionAlert) => void;
|
onAlert?: (alert: ActionAlert) => void;
|
||||||
buildOutput?: { path: string; exitCode: number; output: string };
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
webcontainerPromise: Promise<WebContainer>,
|
webcontainerPromise: Promise<WebContainer>,
|
||||||
@@ -157,13 +156,6 @@ export class ActionRunner {
|
|||||||
await this.#runFileAction(action);
|
await this.#runFileAction(action);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'build': {
|
|
||||||
const buildOutput = await this.#runBuildAction(action);
|
|
||||||
|
|
||||||
// Store build output for deployment
|
|
||||||
this.buildOutput = buildOutput;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'start': {
|
case 'start': {
|
||||||
// making the start app non blocking
|
// making the start app non blocking
|
||||||
|
|
||||||
@@ -284,9 +276,9 @@ export class ActionRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const webcontainer = await this.#webcontainer;
|
const webcontainer = await this.#webcontainer;
|
||||||
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
|
const relativePath = path.relative(webcontainer.workdir, action.filePath);
|
||||||
|
|
||||||
let folder = nodePath.dirname(relativePath);
|
let folder = path.dirname(relativePath);
|
||||||
|
|
||||||
// remove trailing slashes
|
// remove trailing slashes
|
||||||
folder = folder.replace(/\/+$/g, '');
|
folder = folder.replace(/\/+$/g, '');
|
||||||
@@ -307,74 +299,9 @@ export class ActionRunner {
|
|||||||
logger.error('Failed to write file\n\n', error);
|
logger.error('Failed to write file\n\n', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateAction(id: string, newState: ActionStateUpdate) {
|
#updateAction(id: string, newState: ActionStateUpdate) {
|
||||||
const actions = this.actions.get();
|
const actions = this.actions.get();
|
||||||
|
|
||||||
this.actions.setKey(id, { ...actions[id], ...newState });
|
this.actions.setKey(id, { ...actions[id], ...newState });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFileHistory(filePath: string): Promise<FileHistory | null> {
|
|
||||||
try {
|
|
||||||
const webcontainer = await this.#webcontainer;
|
|
||||||
const historyPath = this.#getHistoryPath(filePath);
|
|
||||||
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
|
|
||||||
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get file history:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveFileHistory(filePath: string, history: FileHistory) {
|
|
||||||
// const webcontainer = await this.#webcontainer;
|
|
||||||
const historyPath = this.#getHistoryPath(filePath);
|
|
||||||
|
|
||||||
await this.#runFileAction({
|
|
||||||
type: 'file',
|
|
||||||
filePath: historyPath,
|
|
||||||
content: JSON.stringify(history),
|
|
||||||
changeSource: 'auto-save',
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
#getHistoryPath(filePath: string) {
|
|
||||||
return nodePath.join('.history', filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #runBuildAction(action: ActionState) {
|
|
||||||
if (action.type !== 'build') {
|
|
||||||
unreachable('Expected build action');
|
|
||||||
}
|
|
||||||
|
|
||||||
const webcontainer = await this.#webcontainer;
|
|
||||||
|
|
||||||
// Create a new terminal specifically for the build
|
|
||||||
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
buildProcess.output.pipeTo(
|
|
||||||
new WritableStream({
|
|
||||||
write(data) {
|
|
||||||
output += data;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const exitCode = await buildProcess.exit;
|
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
throw new ActionCommandError('Build Failed', output || 'No Output Available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the build output directory path
|
|
||||||
const buildDir = nodePath.join(webcontainer.workdir, 'dist');
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: buildDir,
|
|
||||||
exitCode,
|
|
||||||
output,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,29 +75,6 @@ export class FilesStore {
|
|||||||
getFileModifications() {
|
getFileModifications() {
|
||||||
return computeFileModifications(this.files.get(), this.#modifiedFiles);
|
return computeFileModifications(this.files.get(), this.#modifiedFiles);
|
||||||
}
|
}
|
||||||
getModifiedFiles() {
|
|
||||||
let modifiedFiles: { [path: string]: File } | undefined = undefined;
|
|
||||||
|
|
||||||
for (const [filePath, originalContent] of this.#modifiedFiles) {
|
|
||||||
const file = this.files.get()[filePath];
|
|
||||||
|
|
||||||
if (file?.type !== 'file') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.content === originalContent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!modifiedFiles) {
|
|
||||||
modifiedFiles = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiedFiles[filePath] = file;
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifiedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetFileModifications() {
|
resetFileModifications() {
|
||||||
this.#modifiedFiles.clear();
|
this.#modifiedFiles.clear();
|
||||||
@@ -115,7 +92,7 @@ export class FilesStore {
|
|||||||
|
|
||||||
const oldContent = this.getFile(filePath)?.content;
|
const oldContent = this.getFile(filePath)?.content;
|
||||||
|
|
||||||
if (!oldContent && oldContent !== '') {
|
if (!oldContent) {
|
||||||
unreachable('Expected content to be defined');
|
unreachable('Expected content to be defined');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { atom } from 'nanostores';
|
|
||||||
import type { NetlifyConnection } from '~/types/netlify';
|
|
||||||
import { logStore } from './logs';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
// Initialize with stored connection or defaults
|
|
||||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
|
||||||
const initialConnection: NetlifyConnection = storedConnection
|
|
||||||
? JSON.parse(storedConnection)
|
|
||||||
: {
|
|
||||||
user: null,
|
|
||||||
token: '',
|
|
||||||
stats: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
|
|
||||||
export const isConnecting = atom<boolean>(false);
|
|
||||||
export const isFetchingStats = atom<boolean>(false);
|
|
||||||
|
|
||||||
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
|
||||||
const currentState = netlifyConnection.get();
|
|
||||||
const newState = { ...currentState, ...updates };
|
|
||||||
netlifyConnection.set(newState);
|
|
||||||
|
|
||||||
// Persist to localStorage
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchNetlifyStats(token: string) {
|
|
||||||
try {
|
|
||||||
isFetchingStats.set(true);
|
|
||||||
|
|
||||||
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sitesResponse.ok) {
|
|
||||||
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sites = (await sitesResponse.json()) as any;
|
|
||||||
|
|
||||||
const currentState = netlifyConnection.get();
|
|
||||||
updateNetlifyConnection({
|
|
||||||
...currentState,
|
|
||||||
stats: {
|
|
||||||
sites,
|
|
||||||
totalSites: sites.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Netlify API Error:', error);
|
|
||||||
logStore.logError('Failed to fetch Netlify stats', { error });
|
|
||||||
toast.error('Failed to fetch Netlify statistics');
|
|
||||||
} finally {
|
|
||||||
isFetchingStats.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -235,7 +235,7 @@ const getInitialTabConfiguration = (): TabWindowConfig => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log('Initial tab configuration:', getInitialTabConfiguration());
|
console.log('Initial tab configuration:', getInitialTabConfiguration());
|
||||||
|
|
||||||
export const tabConfigurationStore = map<TabWindowConfig>(getInitialTabConfiguration());
|
export const tabConfigurationStore = map<TabWindowConfig>(getInitialTabConfiguration());
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { atom } from 'nanostores';
|
|
||||||
|
|
||||||
export const streamingState = atom<boolean>(false);
|
|
||||||
@@ -19,6 +19,7 @@ import Cookies from 'js-cookie';
|
|||||||
import { createSampler } from '~/utils/sampler';
|
import { createSampler } from '~/utils/sampler';
|
||||||
import type { ActionAlert } from '~/types/actions';
|
import type { ActionAlert } from '~/types/actions';
|
||||||
|
|
||||||
|
// Destructure saveAs from the CommonJS module
|
||||||
const { saveAs } = fileSaver;
|
const { saveAs } = fileSaver;
|
||||||
|
|
||||||
export interface ArtifactState {
|
export interface ArtifactState {
|
||||||
@@ -33,7 +34,7 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
|||||||
|
|
||||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||||
|
|
||||||
export type WorkbenchViewType = 'code' | 'diff' | 'preview';
|
export type WorkbenchViewType = 'code' | 'preview';
|
||||||
|
|
||||||
export class WorkbenchStore {
|
export class WorkbenchStore {
|
||||||
#previewsStore = new PreviewsStore(webcontainer);
|
#previewsStore = new PreviewsStore(webcontainer);
|
||||||
@@ -238,9 +239,6 @@ export class WorkbenchStore {
|
|||||||
getFileModifcations() {
|
getFileModifcations() {
|
||||||
return this.#filesStore.getFileModifications();
|
return this.#filesStore.getFileModifications();
|
||||||
}
|
}
|
||||||
getModifiedFiles() {
|
|
||||||
return this.#filesStore.getModifiedFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAllFileModifications() {
|
resetAllFileModifications() {
|
||||||
this.#filesStore.resetFileModifications();
|
this.#filesStore.resetFileModifications();
|
||||||
@@ -439,7 +437,13 @@ export class WorkbenchStore {
|
|||||||
return syncedFiles;
|
return syncedFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
|
async pushToGitHub(
|
||||||
|
repoName: string,
|
||||||
|
commitMessage?: string,
|
||||||
|
githubUsername?: string,
|
||||||
|
ghToken?: string,
|
||||||
|
isPrivate: boolean = false,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Use cookies if username and token are not provided
|
// Use cookies if username and token are not provided
|
||||||
const githubToken = ghToken || Cookies.get('githubToken');
|
const githubToken = ghToken || Cookies.get('githubToken');
|
||||||
@@ -463,7 +467,7 @@ export class WorkbenchStore {
|
|||||||
// Repository doesn't exist, so create a new one
|
// Repository doesn't exist, so create a new one
|
||||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
||||||
name: repoName,
|
name: repoName,
|
||||||
private: false,
|
private: isPrivate,
|
||||||
auto_init: true,
|
auto_init: true,
|
||||||
});
|
});
|
||||||
repo = newRepo;
|
repo = newRepo;
|
||||||
@@ -541,7 +545,7 @@ export class WorkbenchStore {
|
|||||||
sha: newCommit.sha,
|
sha: newCommit.sha,
|
||||||
});
|
});
|
||||||
|
|
||||||
alert(`Repository created and code pushed: ${repo.html_url}`);
|
return repo.html_url; // Return the URL instead of showing alert
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error pushing to GitHub:', error);
|
console.error('Error pushing to GitHub:', error);
|
||||||
throw error; // Rethrow the error for further handling
|
throw error; // Rethrow the error for further handling
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { createHead } from 'remix-island';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { ClientOnly } from 'remix-utils/client-only';
|
|
||||||
|
|
||||||
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
|
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
|
||||||
import globalStyles from './styles/index.scss?url';
|
import globalStyles from './styles/index.scss?url';
|
||||||
@@ -73,11 +72,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<ClientOnly>{() => <DndProvider backend={HTML5Backend}>{children}</DndProvider>}</ClientOnly>
|
{children}
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</>
|
</DndProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
import type { LoaderFunction } from '@remix-run/cloudflare';
|
import type { LoaderFunction } from '@remix-run/cloudflare';
|
||||||
import { LLMManager } from '~/lib/modules/llm/manager';
|
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
||||||
import { getApiKeysFromCookie } from '~/lib/api/cookies';
|
|
||||||
|
|
||||||
export const loader: LoaderFunction = async ({ context, request }) => {
|
export const loader: LoaderFunction = async ({ context, request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const provider = url.searchParams.get('provider');
|
const provider = url.searchParams.get('provider');
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider || !providerBaseUrlEnvKeys[provider].apiTokenKey) {
|
||||||
return Response.json({ isSet: false });
|
return Response.json({ isSet: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any);
|
const envVarName = providerBaseUrlEnvKeys[provider].apiTokenKey;
|
||||||
const providerInstance = llmManager.getProvider(provider);
|
const isSet = !!(process.env[envVarName] || (context?.cloudflare?.env as Record<string, any>)?.[envVarName]);
|
||||||
|
|
||||||
if (!providerInstance || !providerInstance.config.apiTokenKey) {
|
|
||||||
return Response.json({ isSet: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVarName = providerInstance.config.apiTokenKey;
|
|
||||||
|
|
||||||
// Get API keys from cookie
|
|
||||||
const cookieHeader = request.headers.get('Cookie');
|
|
||||||
const apiKeys = getApiKeysFromCookie(cookieHeader);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Check API key in order of precedence:
|
|
||||||
* 1. Client-side API keys (from cookies)
|
|
||||||
* 2. Server environment variables (from Cloudflare env)
|
|
||||||
* 3. Process environment variables (from .env.local)
|
|
||||||
* 4. LLMManager environment variables
|
|
||||||
*/
|
|
||||||
const isSet = !!(
|
|
||||||
apiKeys?.[provider] ||
|
|
||||||
(context?.cloudflare?.env as Record<string, any>)?.[envVarName] ||
|
|
||||||
process.env[envVarName] ||
|
|
||||||
llmManager.env[envVarName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response.json({ isSet });
|
return Response.json({ isSet });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
|
||||||
import type { NetlifySiteInfo } from '~/types/netlify';
|
|
||||||
|
|
||||||
interface DeployRequestBody {
|
|
||||||
siteId?: string;
|
|
||||||
files: Record<string, string>;
|
|
||||||
chatId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha1(message: string) {
|
|
||||||
const msgBuffer = new TextEncoder().encode(message);
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
return hashHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
|
||||||
try {
|
|
||||||
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return json({ error: 'Not connected to Netlify' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetSiteId = siteId;
|
|
||||||
let siteInfo: NetlifySiteInfo | undefined;
|
|
||||||
|
|
||||||
// If no siteId provided, create a new site
|
|
||||||
if (!targetSiteId) {
|
|
||||||
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
|
||||||
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: siteName,
|
|
||||||
custom_domain: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createSiteResponse.ok) {
|
|
||||||
return json({ error: 'Failed to create site' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSite = (await createSiteResponse.json()) as any;
|
|
||||||
targetSiteId = newSite.id;
|
|
||||||
siteInfo = {
|
|
||||||
id: newSite.id,
|
|
||||||
name: newSite.name,
|
|
||||||
url: newSite.url,
|
|
||||||
chatId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Get existing site info
|
|
||||||
if (targetSiteId) {
|
|
||||||
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (siteResponse.ok) {
|
|
||||||
const existingSite = (await siteResponse.json()) as any;
|
|
||||||
siteInfo = {
|
|
||||||
id: existingSite.id,
|
|
||||||
name: existingSite.name,
|
|
||||||
url: existingSite.url,
|
|
||||||
chatId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
targetSiteId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no siteId provided or site doesn't exist, create a new site
|
|
||||||
if (!targetSiteId) {
|
|
||||||
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
|
||||||
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: siteName,
|
|
||||||
custom_domain: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createSiteResponse.ok) {
|
|
||||||
return json({ error: 'Failed to create site' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSite = (await createSiteResponse.json()) as any;
|
|
||||||
targetSiteId = newSite.id;
|
|
||||||
siteInfo = {
|
|
||||||
id: newSite.id,
|
|
||||||
name: newSite.name,
|
|
||||||
url: newSite.url,
|
|
||||||
chatId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create file digests
|
|
||||||
const fileDigests: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(files)) {
|
|
||||||
// Ensure file path starts with a forward slash
|
|
||||||
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
|
||||||
const hash = await sha1(content);
|
|
||||||
fileDigests[normalizedPath] = hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new deploy with digests
|
|
||||||
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
files: fileDigests,
|
|
||||||
async: true,
|
|
||||||
skip_processing: false,
|
|
||||||
draft: false, // Change this to false for production deployments
|
|
||||||
function_schedules: [],
|
|
||||||
required: Object.keys(fileDigests), // Add this line
|
|
||||||
framework: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!deployResponse.ok) {
|
|
||||||
return json({ error: 'Failed to create deployment' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const deploy = (await deployResponse.json()) as any;
|
|
||||||
let retryCount = 0;
|
|
||||||
const maxRetries = 60;
|
|
||||||
|
|
||||||
// Poll until deploy is ready for file uploads
|
|
||||||
while (retryCount < maxRetries) {
|
|
||||||
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = (await statusResponse.json()) as any;
|
|
||||||
|
|
||||||
if (status.state === 'prepared' || status.state === 'uploaded') {
|
|
||||||
// Upload all files regardless of required array
|
|
||||||
for (const [filePath, content] of Object.entries(files)) {
|
|
||||||
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
|
||||||
|
|
||||||
let uploadSuccess = false;
|
|
||||||
let uploadRetries = 0;
|
|
||||||
|
|
||||||
while (!uploadSuccess && uploadRetries < 3) {
|
|
||||||
try {
|
|
||||||
const uploadResponse = await fetch(
|
|
||||||
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
},
|
|
||||||
body: content,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
uploadSuccess = uploadResponse.ok;
|
|
||||||
|
|
||||||
if (!uploadSuccess) {
|
|
||||||
console.error('Upload failed:', await uploadResponse.text());
|
|
||||||
uploadRetries++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
uploadRetries++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uploadSuccess) {
|
|
||||||
return json({ error: `Failed to upload file ${filePath}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.state === 'ready') {
|
|
||||||
// Only return after files are uploaded
|
|
||||||
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
deploy: {
|
|
||||||
id: status.id,
|
|
||||||
state: status.state,
|
|
||||||
url: status.ssl_url || status.url,
|
|
||||||
},
|
|
||||||
site: siteInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.state === 'error') {
|
|
||||||
return json({ error: status.error_message || 'Deploy preparation failed' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
retryCount++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryCount >= maxRetries) {
|
|
||||||
return json({ error: 'Deploy preparation timed out' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure we're returning the deploy ID and site info
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
deploy: {
|
|
||||||
id: deploy.id,
|
|
||||||
state: deploy.state,
|
|
||||||
},
|
|
||||||
site: siteInfo,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Deploy error:', error);
|
|
||||||
return json({ error: 'Deployment failed' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,11 @@ import { streamText } from '~/lib/.server/llm/stream-text';
|
|||||||
import { stripIndents } from '~/utils/stripIndent';
|
import { stripIndents } from '~/utils/stripIndent';
|
||||||
import type { ProviderInfo } from '~/types/model';
|
import type { ProviderInfo } from '~/types/model';
|
||||||
import { getApiKeysFromCookie, getProviderSettingsFromCookie } from '~/lib/api/cookies';
|
import { getApiKeysFromCookie, getProviderSettingsFromCookie } from '~/lib/api/cookies';
|
||||||
import { createScopedLogger } from '~/utils/logger';
|
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
return enhancerAction(args);
|
return enhancerAction(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createScopedLogger('api.enhancher');
|
|
||||||
|
|
||||||
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||||
const { message, model, provider } = await request.json<{
|
const { message, model, provider } = await request.json<{
|
||||||
message: string;
|
message: string;
|
||||||
@@ -80,43 +77,15 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
|||||||
env: context.cloudflare?.env as any,
|
env: context.cloudflare?.env as any,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
providerSettings,
|
providerSettings,
|
||||||
options: {
|
|
||||||
system:
|
|
||||||
'You are a senior software principal architect, you should help the user analyse the user query and enrich it with the necessary context and constraints to make it more specific, actionable, and effective. You should also ensure that the prompt is self-contained and uses professional language. Your response should ONLY contain the enhanced prompt text. Do not include any explanations, metadata, or wrapper tags.',
|
|
||||||
|
|
||||||
/*
|
|
||||||
* onError: (event) => {
|
|
||||||
* throw new Response(null, {
|
|
||||||
* status: 500,
|
|
||||||
* statusText: 'Internal Server Error',
|
|
||||||
* });
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle streaming errors in a non-blocking way
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
for await (const part of result.fullStream) {
|
|
||||||
if (part.type === 'error') {
|
|
||||||
const error: any = part.error;
|
|
||||||
logger.error('Streaming error:', error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error processing stream:', error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Return the text stream directly since it's already text data
|
|
||||||
return new Response(result.textStream, {
|
return new Response(result.textStream, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
Connection: 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
'Text-Encoding': 'chunked',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,47 +1,6 @@
|
|||||||
import { json } from '@remix-run/cloudflare';
|
import { json } from '@remix-run/cloudflare';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
|
|
||||||
// Allowed headers to forward to the target server
|
|
||||||
const ALLOW_HEADERS = [
|
|
||||||
'accept-encoding',
|
|
||||||
'accept-language',
|
|
||||||
'accept',
|
|
||||||
'access-control-allow-origin',
|
|
||||||
'authorization',
|
|
||||||
'cache-control',
|
|
||||||
'connection',
|
|
||||||
'content-length',
|
|
||||||
'content-type',
|
|
||||||
'dnt',
|
|
||||||
'pragma',
|
|
||||||
'range',
|
|
||||||
'referer',
|
|
||||||
'user-agent',
|
|
||||||
'x-authorization',
|
|
||||||
'x-http-method-override',
|
|
||||||
'x-requested-with',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Headers to expose from the target server's response
|
|
||||||
const EXPOSE_HEADERS = [
|
|
||||||
'accept-ranges',
|
|
||||||
'age',
|
|
||||||
'cache-control',
|
|
||||||
'content-length',
|
|
||||||
'content-language',
|
|
||||||
'content-type',
|
|
||||||
'date',
|
|
||||||
'etag',
|
|
||||||
'expires',
|
|
||||||
'last-modified',
|
|
||||||
'pragma',
|
|
||||||
'server',
|
|
||||||
'transfer-encoding',
|
|
||||||
'vary',
|
|
||||||
'x-github-request-id',
|
|
||||||
'x-redirected-url',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Handle all HTTP methods
|
// Handle all HTTP methods
|
||||||
export async function action({ request, params }: ActionFunctionArgs) {
|
export async function action({ request, params }: ActionFunctionArgs) {
|
||||||
return handleProxyRequest(request, params['*']);
|
return handleProxyRequest(request, params['*']);
|
||||||
@@ -57,121 +16,50 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
|
|||||||
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
|
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight request
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Reconstruct the target URL
|
||||||
|
const targetURL = `https://${path}${url.search}`;
|
||||||
|
|
||||||
|
// Forward the request to the target URL
|
||||||
|
const response = await fetch(targetURL, {
|
||||||
|
method: request.method,
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(request.headers),
|
||||||
|
|
||||||
|
// Override host header with the target host
|
||||||
|
host: new URL(targetURL).host,
|
||||||
|
},
|
||||||
|
body: ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create response with CORS headers
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
if (request.method === 'OPTIONS') {
|
if (request.method === 'OPTIONS') {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
headers: corsHeaders,
|
||||||
headers: {
|
status: 204,
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': ALLOW_HEADERS.join(', '),
|
|
||||||
'Access-Control-Expose-Headers': EXPOSE_HEADERS.join(', '),
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain and remaining path
|
// Forward the response with CORS headers
|
||||||
const parts = path.match(/([^\/]+)\/?(.*)/);
|
const responseHeaders = new Headers(response.headers);
|
||||||
|
Object.entries(corsHeaders).forEach(([key, value]) => {
|
||||||
|
responseHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
if (!parts) {
|
|
||||||
return json({ error: 'Invalid path format' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = parts[1];
|
|
||||||
const remainingPath = parts[2] || '';
|
|
||||||
|
|
||||||
// Reconstruct the target URL with query parameters
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const targetURL = `https://${domain}/${remainingPath}${url.search}`;
|
|
||||||
|
|
||||||
console.log('Target URL:', targetURL);
|
|
||||||
|
|
||||||
// Filter and prepare headers
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
// Only forward allowed headers
|
|
||||||
for (const header of ALLOW_HEADERS) {
|
|
||||||
if (request.headers.has(header)) {
|
|
||||||
headers.set(header, request.headers.get(header)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the host header
|
|
||||||
headers.set('Host', domain);
|
|
||||||
|
|
||||||
// Set Git user agent if not already present
|
|
||||||
if (!headers.has('user-agent') || !headers.get('user-agent')?.startsWith('git/')) {
|
|
||||||
headers.set('User-Agent', 'git/@isomorphic-git/cors-proxy');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Request headers:', Object.fromEntries(headers.entries()));
|
|
||||||
|
|
||||||
// Prepare fetch options
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
method: request.method,
|
|
||||||
headers,
|
|
||||||
redirect: 'follow',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add body for non-GET/HEAD requests
|
|
||||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
|
||||||
fetchOptions.body = request.body;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Note: duplex property is removed to ensure TypeScript compatibility
|
|
||||||
* across different environments and versions
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward the request to the target URL
|
|
||||||
const response = await fetch(targetURL, fetchOptions);
|
|
||||||
|
|
||||||
console.log('Response status:', response.status);
|
|
||||||
|
|
||||||
// Create response headers
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
|
|
||||||
// Add CORS headers
|
|
||||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Headers', ALLOW_HEADERS.join(', '));
|
|
||||||
responseHeaders.set('Access-Control-Expose-Headers', EXPOSE_HEADERS.join(', '));
|
|
||||||
|
|
||||||
// Copy exposed headers from the target response
|
|
||||||
for (const header of EXPOSE_HEADERS) {
|
|
||||||
// Skip content-length as we'll use the original response's content-length
|
|
||||||
if (header === 'content-length') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.headers.has(header)) {
|
|
||||||
responseHeaders.set(header, response.headers.get(header)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the response was redirected, add the x-redirected-url header
|
|
||||||
if (response.redirected) {
|
|
||||||
responseHeaders.set('x-redirected-url', response.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Response headers:', Object.fromEntries(responseHeaders.entries()));
|
|
||||||
|
|
||||||
// Return the response with the target's body stream piped directly
|
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Proxy error:', error);
|
console.error('Git proxy error:', error);
|
||||||
return json(
|
return json({ error: 'Proxy error' }, { status: 500 });
|
||||||
{
|
|
||||||
error: 'Proxy error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
url: path ? `https://${path}` : 'Invalid URL',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
|
import type { LoaderFunctionArgs } from '@remix-run/node';
|
||||||
|
|
||||||
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
|
||||||
return json({
|
// Return a simple 200 OK response with some basic health information
|
||||||
status: 'healthy',
|
return new Response(
|
||||||
timestamp: new Date().toISOString(),
|
JSON.stringify({
|
||||||
});
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
|
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
|
||||||
import { json } from '@remix-run/cloudflare';
|
import { json } from '@remix-run/cloudflare';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
// These are injected by Vite at build time
|
// These are injected by Vite at build time
|
||||||
declare const __APP_VERSION: string;
|
declare const __APP_VERSION: string;
|
||||||
@@ -10,24 +11,34 @@ declare const __PKG_DEPENDENCIES: Record<string, string>;
|
|||||||
declare const __PKG_DEV_DEPENDENCIES: Record<string, string>;
|
declare const __PKG_DEV_DEPENDENCIES: Record<string, string>;
|
||||||
declare const __PKG_PEER_DEPENDENCIES: Record<string, string>;
|
declare const __PKG_PEER_DEPENDENCIES: Record<string, string>;
|
||||||
declare const __PKG_OPTIONAL_DEPENDENCIES: Record<string, string>;
|
declare const __PKG_OPTIONAL_DEPENDENCIES: Record<string, string>;
|
||||||
declare const __COMMIT_HASH: string;
|
|
||||||
declare const __GIT_BRANCH: string;
|
|
||||||
declare const __GIT_COMMIT_TIME: string;
|
|
||||||
declare const __GIT_AUTHOR: string;
|
|
||||||
declare const __GIT_EMAIL: string;
|
|
||||||
declare const __GIT_REMOTE_URL: string;
|
|
||||||
declare const __GIT_REPO_NAME: string;
|
|
||||||
|
|
||||||
const getGitInfo = () => {
|
const getGitInfo = () => {
|
||||||
return {
|
try {
|
||||||
commitHash: __COMMIT_HASH || 'unknown',
|
return {
|
||||||
branch: __GIT_BRANCH || 'unknown',
|
commitHash: execSync('git rev-parse --short HEAD').toString().trim(),
|
||||||
commitTime: __GIT_COMMIT_TIME || 'unknown',
|
branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
|
||||||
author: __GIT_AUTHOR || 'unknown',
|
commitTime: execSync('git log -1 --format=%cd').toString().trim(),
|
||||||
email: __GIT_EMAIL || 'unknown',
|
author: execSync('git log -1 --format=%an').toString().trim(),
|
||||||
remoteUrl: __GIT_REMOTE_URL || 'unknown',
|
email: execSync('git log -1 --format=%ae').toString().trim(),
|
||||||
repoName: __GIT_REPO_NAME || 'unknown',
|
remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
|
||||||
};
|
repoName: execSync('git config --get remote.origin.url')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace(/^.*github.com[:/]/, '')
|
||||||
|
.replace(/\.git$/, ''),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get git info:', error);
|
||||||
|
return {
|
||||||
|
commitHash: 'unknown',
|
||||||
|
branch: 'unknown',
|
||||||
|
commitTime: 'unknown',
|
||||||
|
author: 'unknown',
|
||||||
|
email: 'unknown',
|
||||||
|
remoteUrl: 'unknown',
|
||||||
|
repoName: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDependencies = (
|
const formatDependencies = (
|
||||||
@@ -49,11 +60,11 @@ const getAppResponse = () => {
|
|||||||
version: __APP_VERSION || '0.1.0',
|
version: __APP_VERSION || '0.1.0',
|
||||||
description: __PKG_DESCRIPTION || 'A DIY LLM interface',
|
description: __PKG_DESCRIPTION || 'A DIY LLM interface',
|
||||||
license: __PKG_LICENSE || 'MIT',
|
license: __PKG_LICENSE || 'MIT',
|
||||||
environment: 'cloudflare',
|
environment: process.env.NODE_ENV || 'development',
|
||||||
gitInfo,
|
gitInfo,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
nodeVersion: 'cloudflare',
|
nodeVersion: process.version || 'unknown',
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
production: formatDependencies(__PKG_DEPENDENCIES, 'production'),
|
production: formatDependencies(__PKG_DEPENDENCIES, 'production'),
|
||||||
|
|||||||
@@ -1,48 +1,138 @@
|
|||||||
import { json, type LoaderFunction } from '@remix-run/cloudflare';
|
import type { LoaderFunction } from '@remix-run/cloudflare';
|
||||||
|
import { json } from '@remix-run/cloudflare';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
interface GitInfo {
|
interface GitHubRepoInfo {
|
||||||
local: {
|
name: string;
|
||||||
commitHash: string;
|
full_name: string;
|
||||||
branch: string;
|
default_branch: string;
|
||||||
commitTime: string;
|
stargazers_count: number;
|
||||||
author: string;
|
forks_count: number;
|
||||||
email: string;
|
open_issues_count: number;
|
||||||
remoteUrl: string;
|
parent?: {
|
||||||
repoName: string;
|
full_name: string;
|
||||||
|
default_branch: string;
|
||||||
|
stargazers_count: number;
|
||||||
|
forks_count: number;
|
||||||
};
|
};
|
||||||
github?: {
|
|
||||||
currentRepo?: {
|
|
||||||
fullName: string;
|
|
||||||
defaultBranch: string;
|
|
||||||
stars: number;
|
|
||||||
forks: number;
|
|
||||||
openIssues?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
isForked?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// These values will be replaced at build time
|
const getLocalGitInfo = () => {
|
||||||
declare const __COMMIT_HASH: string;
|
try {
|
||||||
declare const __GIT_BRANCH: string;
|
return {
|
||||||
declare const __GIT_COMMIT_TIME: string;
|
commitHash: execSync('git rev-parse HEAD').toString().trim(),
|
||||||
declare const __GIT_AUTHOR: string;
|
branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
|
||||||
declare const __GIT_EMAIL: string;
|
commitTime: execSync('git log -1 --format=%cd').toString().trim(),
|
||||||
declare const __GIT_REMOTE_URL: string;
|
author: execSync('git log -1 --format=%an').toString().trim(),
|
||||||
declare const __GIT_REPO_NAME: string;
|
email: execSync('git log -1 --format=%ae').toString().trim(),
|
||||||
|
remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
|
||||||
|
repoName: execSync('git config --get remote.origin.url')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace(/^.*github.com[:/]/, '')
|
||||||
|
.replace(/\.git$/, ''),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get local git info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const loader: LoaderFunction = async () => {
|
const getGitHubInfo = async (repoFullName: string) => {
|
||||||
const gitInfo: GitInfo = {
|
try {
|
||||||
local: {
|
// Add GitHub token if available
|
||||||
commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
|
const headers: Record<string, string> = {
|
||||||
branch: typeof __GIT_BRANCH !== 'undefined' ? __GIT_BRANCH : 'main',
|
Accept: 'application/vnd.github.v3+json',
|
||||||
commitTime: typeof __GIT_COMMIT_TIME !== 'undefined' ? __GIT_COMMIT_TIME : new Date().toISOString(),
|
};
|
||||||
author: typeof __GIT_AUTHOR !== 'undefined' ? __GIT_AUTHOR : 'development',
|
|
||||||
email: typeof __GIT_EMAIL !== 'undefined' ? __GIT_EMAIL : 'development@local',
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
|
|
||||||
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
|
if (githubToken) {
|
||||||
|
headers.Authorization = `token ${githubToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching GitHub info for:', repoFullName); // Debug log
|
||||||
|
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${repoFullName}`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('GitHub API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
repoFullName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we get a 404, try the main repo as fallback
|
||||||
|
if (response.status === 404 && repoFullName !== 'stackblitz-labs/bolt.diy') {
|
||||||
|
return getGitHubInfo('stackblitz-labs/bolt.diy');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`GitHub API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('GitHub API response:', data); // Debug log
|
||||||
|
|
||||||
|
return data as GitHubRepoInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get GitHub info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async ({ request: _request }) => {
|
||||||
|
const localInfo = getLocalGitInfo();
|
||||||
|
console.log('Local git info:', localInfo); // Debug log
|
||||||
|
|
||||||
|
// If we have local info, try to get GitHub info for both our fork and upstream
|
||||||
|
let githubInfo = null;
|
||||||
|
|
||||||
|
if (localInfo?.repoName) {
|
||||||
|
githubInfo = await getGitHubInfo(localInfo.repoName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no local info or GitHub info, try the main repo
|
||||||
|
if (!githubInfo) {
|
||||||
|
githubInfo = await getGitHubInfo('stackblitz-labs/bolt.diy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
local: localInfo || {
|
||||||
|
commitHash: 'unknown',
|
||||||
|
branch: 'unknown',
|
||||||
|
commitTime: 'unknown',
|
||||||
|
author: 'unknown',
|
||||||
|
email: 'unknown',
|
||||||
|
remoteUrl: 'unknown',
|
||||||
|
repoName: 'unknown',
|
||||||
},
|
},
|
||||||
|
github: githubInfo
|
||||||
|
? {
|
||||||
|
currentRepo: {
|
||||||
|
fullName: githubInfo.full_name,
|
||||||
|
defaultBranch: githubInfo.default_branch,
|
||||||
|
stars: githubInfo.stargazers_count,
|
||||||
|
forks: githubInfo.forks_count,
|
||||||
|
openIssues: githubInfo.open_issues_count,
|
||||||
|
},
|
||||||
|
upstream: githubInfo.parent
|
||||||
|
? {
|
||||||
|
fullName: githubInfo.parent.full_name,
|
||||||
|
defaultBranch: githubInfo.parent.default_branch,
|
||||||
|
stars: githubInfo.parent.stargazers_count,
|
||||||
|
forks: githubInfo.parent.forks_count,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
isForked: Boolean(githubInfo?.parent),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(gitInfo);
|
console.log('Final response:', response);
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
return json(response);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,573 @@
|
|||||||
import { json, type ActionFunction } from '@remix-run/cloudflare';
|
import { json } from '@remix-run/node';
|
||||||
|
import type { ActionFunction } from '@remix-run/node';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
interface UpdateRequestBody {
|
||||||
|
branch: string;
|
||||||
|
autoUpdate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateProgress {
|
||||||
|
stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
|
||||||
|
message: string;
|
||||||
|
progress?: number;
|
||||||
|
error?: string;
|
||||||
|
details?: {
|
||||||
|
changedFiles?: string[];
|
||||||
|
additions?: number;
|
||||||
|
deletions?: number;
|
||||||
|
commitMessages?: string[];
|
||||||
|
totalSize?: string;
|
||||||
|
currentCommit?: string;
|
||||||
|
remoteCommit?: string;
|
||||||
|
updateReady?: boolean;
|
||||||
|
changelog?: string;
|
||||||
|
compareUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ request }) => {
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(
|
try {
|
||||||
{
|
const body = await request.json();
|
||||||
error: 'Updates must be performed manually in a server environment',
|
|
||||||
instructions: [
|
if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') {
|
||||||
'1. Navigate to the project directory',
|
return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 });
|
||||||
'2. Run: git fetch upstream',
|
}
|
||||||
'3. Run: git pull upstream main',
|
|
||||||
'4. Run: pnpm install',
|
const { branch, autoUpdate = false } = body as UpdateRequestBody;
|
||||||
'5. Run: pnpm run build',
|
|
||||||
],
|
// Create a ReadableStream to send progress updates
|
||||||
},
|
const stream = new ReadableStream({
|
||||||
{ status: 400 },
|
async start(controller) {
|
||||||
);
|
const encoder = new TextEncoder();
|
||||||
|
const sendProgress = (update: UpdateProgress) => {
|
||||||
|
controller.enqueue(encoder.encode(JSON.stringify(update) + '\n'));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initial check stage
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Checking repository status...',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if remote exists
|
||||||
|
let defaultBranch = branch || 'main'; // Make branch mutable
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync('git remote get-url upstream');
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Repository remote verified',
|
||||||
|
progress: 10,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'No upstream repository found. Please set up the upstream repository first by running:\ngit remote add upstream https://github.com/stackblitz-labs/bolt.diy.git',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default branch if not specified
|
||||||
|
if (!branch) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Detecting default branch...',
|
||||||
|
progress: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('git remote show upstream | grep "HEAD branch" | cut -d" " -f5');
|
||||||
|
defaultBranch = stdout.trim() || 'main';
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: `Using branch: ${defaultBranch}`,
|
||||||
|
progress: 30,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
defaultBranch = 'main'; // Fallback to main if we can't detect
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Using default branch: main',
|
||||||
|
progress: 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stage
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Fetching latest changes...',
|
||||||
|
progress: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all remotes
|
||||||
|
await execAsync('git fetch --all');
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Remote changes fetched',
|
||||||
|
progress: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if remote branch exists
|
||||||
|
try {
|
||||||
|
await execAsync(`git rev-parse --verify upstream/${defaultBranch}`);
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Remote branch verified',
|
||||||
|
progress: 60,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Remote branch 'upstream/${defaultBranch}' not found. Please ensure the upstream repository is properly configured.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current commit hash and remote commit hash
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Comparing versions...',
|
||||||
|
progress: 70,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout: currentCommit } = await execAsync('git rev-parse HEAD');
|
||||||
|
const { stdout: remoteCommit } = await execAsync(`git rev-parse upstream/${defaultBranch}`);
|
||||||
|
|
||||||
|
// If we're on the same commit, no update is available
|
||||||
|
if (currentCommit.trim() === remoteCommit.trim()) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: 'No updates available. You are on the latest version.',
|
||||||
|
progress: 100,
|
||||||
|
details: {
|
||||||
|
currentCommit: currentCommit.trim().substring(0, 7),
|
||||||
|
remoteCommit: remoteCommit.trim().substring(0, 7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Analyzing changes...',
|
||||||
|
progress: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize variables
|
||||||
|
let changedFiles: string[] = [];
|
||||||
|
let commitMessages: string[] = [];
|
||||||
|
let stats: RegExpMatchArray | null = null;
|
||||||
|
let totalSizeInBytes = 0;
|
||||||
|
|
||||||
|
// Format size for display
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get list of changed files and their sizes
|
||||||
|
try {
|
||||||
|
const { stdout: diffOutput } = await execAsync(
|
||||||
|
`git diff --name-status ${currentCommit.trim()}..${remoteCommit.trim()}`,
|
||||||
|
);
|
||||||
|
const files = diffOutput.split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: `No file changes detected between your version and upstream/${defaultBranch}. You might be on a different branch.`,
|
||||||
|
progress: 100,
|
||||||
|
details: {
|
||||||
|
currentCommit: currentCommit.trim().substring(0, 7),
|
||||||
|
remoteCommit: remoteCommit.trim().substring(0, 7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: `Found ${files.length} changed files, calculating sizes...`,
|
||||||
|
progress: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get size information for each changed file
|
||||||
|
for (const line of files) {
|
||||||
|
const [status, file] = line.split('\t');
|
||||||
|
|
||||||
|
if (status !== 'D') {
|
||||||
|
// Skip deleted files
|
||||||
|
try {
|
||||||
|
const { stdout: sizeOutput } = await execAsync(`git cat-file -s ${remoteCommit.trim()}:${file}`);
|
||||||
|
const size = parseInt(sizeOutput) || 0;
|
||||||
|
totalSizeInBytes += size;
|
||||||
|
} catch {
|
||||||
|
console.debug(`Could not get size for file: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changedFiles = files.map((line) => {
|
||||||
|
const [status, file] = line.split('\t');
|
||||||
|
return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('Failed to get changed files:', err);
|
||||||
|
throw new Error(`Failed to compare changes with upstream/${defaultBranch}. Are you on the correct branch?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commit messages between current and remote
|
||||||
|
try {
|
||||||
|
const { stdout: logOutput } = await execAsync(
|
||||||
|
`git log --pretty=format:"%h|%s|%aI" ${currentCommit.trim()}..${remoteCommit.trim()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse and group commits by type
|
||||||
|
const commits = logOutput
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [hash, subject, timestamp] = line.split('|');
|
||||||
|
let type = 'other';
|
||||||
|
let message = subject;
|
||||||
|
|
||||||
|
if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
|
||||||
|
type = 'feature';
|
||||||
|
message = subject.replace(/^feat(?:ure)?:/, '').trim();
|
||||||
|
} else if (subject.startsWith('fix:')) {
|
||||||
|
type = 'fix';
|
||||||
|
message = subject.replace(/^fix:/, '').trim();
|
||||||
|
} else if (subject.startsWith('docs:')) {
|
||||||
|
type = 'docs';
|
||||||
|
message = subject.replace(/^docs:/, '').trim();
|
||||||
|
} else if (subject.startsWith('style:')) {
|
||||||
|
type = 'style';
|
||||||
|
message = subject.replace(/^style:/, '').trim();
|
||||||
|
} else if (subject.startsWith('refactor:')) {
|
||||||
|
type = 'refactor';
|
||||||
|
message = subject.replace(/^refactor:/, '').trim();
|
||||||
|
} else if (subject.startsWith('perf:')) {
|
||||||
|
type = 'perf';
|
||||||
|
message = subject.replace(/^perf:/, '').trim();
|
||||||
|
} else if (subject.startsWith('test:')) {
|
||||||
|
type = 'test';
|
||||||
|
message = subject.replace(/^test:/, '').trim();
|
||||||
|
} else if (subject.startsWith('build:')) {
|
||||||
|
type = 'build';
|
||||||
|
message = subject.replace(/^build:/, '').trim();
|
||||||
|
} else if (subject.startsWith('ci:')) {
|
||||||
|
type = 'ci';
|
||||||
|
message = subject.replace(/^ci:/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
timestamp: new Date(timestamp),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group commits by type
|
||||||
|
const groupedCommits = commits.reduce(
|
||||||
|
(acc, commit) => {
|
||||||
|
if (!acc[commit.type]) {
|
||||||
|
acc[commit.type] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[commit.type].push(commit);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof commits>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format commit messages with emojis and timestamps
|
||||||
|
const formattedMessages = Object.entries(groupedCommits).map(([type, commits]) => {
|
||||||
|
const emoji = {
|
||||||
|
feature: '✨',
|
||||||
|
fix: '🐛',
|
||||||
|
docs: '📚',
|
||||||
|
style: '💎',
|
||||||
|
refactor: '♻️',
|
||||||
|
perf: '⚡',
|
||||||
|
test: '🧪',
|
||||||
|
build: '🛠️',
|
||||||
|
ci: '⚙️',
|
||||||
|
other: '🔍',
|
||||||
|
}[type];
|
||||||
|
|
||||||
|
const title = {
|
||||||
|
feature: 'Features',
|
||||||
|
fix: 'Bug Fixes',
|
||||||
|
docs: 'Documentation',
|
||||||
|
style: 'Styles',
|
||||||
|
refactor: 'Code Refactoring',
|
||||||
|
perf: 'Performance',
|
||||||
|
test: 'Tests',
|
||||||
|
build: 'Build',
|
||||||
|
ci: 'CI',
|
||||||
|
other: 'Other Changes',
|
||||||
|
}[type];
|
||||||
|
|
||||||
|
return `### ${emoji} ${title}\n\n${commits
|
||||||
|
.map((c) => `* ${c.message} (${c.hash.substring(0, 7)}) - ${c.timestamp.toLocaleString()}`)
|
||||||
|
.join('\n')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
commitMessages = formattedMessages;
|
||||||
|
} catch {
|
||||||
|
// Handle silently - empty commitMessages array will be used
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get diff stats using the specific commits
|
||||||
|
try {
|
||||||
|
const { stdout: diffStats } = await execAsync(
|
||||||
|
`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`,
|
||||||
|
);
|
||||||
|
stats = diffStats.match(
|
||||||
|
/(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Handle silently - null stats will be used
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we somehow still have no changes detected
|
||||||
|
if (!stats && changedFiles.length === 0) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: `No changes detected between your version and upstream/${defaultBranch}. This might be unexpected - please check your git status.`,
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch changelog
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: 'Fetching changelog...',
|
||||||
|
progress: 95,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changelog = await fetchChangelog(currentCommit.trim(), remoteCommit.trim());
|
||||||
|
|
||||||
|
// We have changes, send the details
|
||||||
|
sendProgress({
|
||||||
|
stage: 'fetch',
|
||||||
|
message: `Changes detected on upstream/${defaultBranch}`,
|
||||||
|
progress: 100,
|
||||||
|
details: {
|
||||||
|
changedFiles,
|
||||||
|
additions: stats?.[2] ? parseInt(stats[2]) : 0,
|
||||||
|
deletions: stats?.[3] ? parseInt(stats[3]) : 0,
|
||||||
|
commitMessages,
|
||||||
|
totalSize: formatSize(totalSizeInBytes),
|
||||||
|
currentCommit: currentCommit.trim().substring(0, 7),
|
||||||
|
remoteCommit: remoteCommit.trim().substring(0, 7),
|
||||||
|
updateReady: true,
|
||||||
|
changelog,
|
||||||
|
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only proceed with update if autoUpdate is true
|
||||||
|
if (!autoUpdate) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: 'Update is ready to be applied. Click "Update Now" to proceed.',
|
||||||
|
progress: 100,
|
||||||
|
details: {
|
||||||
|
changedFiles,
|
||||||
|
additions: stats?.[2] ? parseInt(stats[2]) : 0,
|
||||||
|
deletions: stats?.[3] ? parseInt(stats[3]) : 0,
|
||||||
|
commitMessages,
|
||||||
|
totalSize: formatSize(totalSizeInBytes),
|
||||||
|
currentCommit: currentCommit.trim().substring(0, 7),
|
||||||
|
remoteCommit: remoteCommit.trim().substring(0, 7),
|
||||||
|
updateReady: true,
|
||||||
|
changelog,
|
||||||
|
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull stage
|
||||||
|
sendProgress({
|
||||||
|
stage: 'pull',
|
||||||
|
message: `Pulling changes from upstream/${defaultBranch}...`,
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execAsync(`git pull upstream ${defaultBranch}`);
|
||||||
|
|
||||||
|
sendProgress({
|
||||||
|
stage: 'pull',
|
||||||
|
message: 'Changes pulled successfully',
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Install stage
|
||||||
|
sendProgress({
|
||||||
|
stage: 'install',
|
||||||
|
message: 'Installing dependencies...',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execAsync('pnpm install');
|
||||||
|
|
||||||
|
sendProgress({
|
||||||
|
stage: 'install',
|
||||||
|
message: 'Dependencies installed successfully',
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build stage
|
||||||
|
sendProgress({
|
||||||
|
stage: 'build',
|
||||||
|
message: 'Building application...',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execAsync('pnpm build');
|
||||||
|
|
||||||
|
sendProgress({
|
||||||
|
stage: 'build',
|
||||||
|
message: 'Build completed successfully',
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: 'Update completed successfully! Click Restart to apply changes.',
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sendProgress({
|
||||||
|
stage: 'complete',
|
||||||
|
message: 'Update failed',
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error occurred',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update preparation failed:', err);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error occurred while preparing update',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add this function to fetch the changelog
|
||||||
|
async function fetchChangelog(currentCommit: string, remoteCommit: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// First try to get the changelog.md content
|
||||||
|
const { stdout: changelogContent } = await execAsync('git show upstream/main:changelog.md');
|
||||||
|
|
||||||
|
// If we have a changelog, return it
|
||||||
|
if (changelogContent) {
|
||||||
|
return changelogContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no changelog.md, generate one in a similar format
|
||||||
|
let changelog = '# Changes in this Update\n\n';
|
||||||
|
|
||||||
|
// Get commit messages grouped by type
|
||||||
|
const { stdout: commitLog } = await execAsync(
|
||||||
|
`git log --pretty=format:"%h|%s|%b" ${currentCommit.trim()}..${remoteCommit.trim()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const commits = commitLog.split('\n').filter(Boolean);
|
||||||
|
const categorizedCommits: Record<string, string[]> = {
|
||||||
|
'✨ Features': [],
|
||||||
|
'🐛 Bug Fixes': [],
|
||||||
|
'📚 Documentation': [],
|
||||||
|
'💎 Styles': [],
|
||||||
|
'♻️ Code Refactoring': [],
|
||||||
|
'⚡ Performance': [],
|
||||||
|
'🧪 Tests': [],
|
||||||
|
'🛠️ Build': [],
|
||||||
|
'⚙️ CI': [],
|
||||||
|
'🔍 Other Changes': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categorize commits
|
||||||
|
for (const commit of commits) {
|
||||||
|
const [hash, subject] = commit.split('|');
|
||||||
|
let category = '🔍 Other Changes';
|
||||||
|
|
||||||
|
if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
|
||||||
|
category = '✨ Features';
|
||||||
|
} else if (subject.startsWith('fix:')) {
|
||||||
|
category = '🐛 Bug Fixes';
|
||||||
|
} else if (subject.startsWith('docs:')) {
|
||||||
|
category = '📚 Documentation';
|
||||||
|
} else if (subject.startsWith('style:')) {
|
||||||
|
category = '💎 Styles';
|
||||||
|
} else if (subject.startsWith('refactor:')) {
|
||||||
|
category = '♻️ Code Refactoring';
|
||||||
|
} else if (subject.startsWith('perf:')) {
|
||||||
|
category = '⚡ Performance';
|
||||||
|
} else if (subject.startsWith('test:')) {
|
||||||
|
category = '🧪 Tests';
|
||||||
|
} else if (subject.startsWith('build:')) {
|
||||||
|
category = '🛠️ Build';
|
||||||
|
} else if (subject.startsWith('ci:')) {
|
||||||
|
category = '⚙️ CI';
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = subject.includes(':') ? subject.split(':')[1].trim() : subject.trim();
|
||||||
|
categorizedCommits[category].push(`* ${message} (${hash.substring(0, 7)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build changelog content
|
||||||
|
for (const [category, commits] of Object.entries(categorizedCommits)) {
|
||||||
|
if (commits.length > 0) {
|
||||||
|
changelog += `\n## ${category}\n\n${commits.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stats
|
||||||
|
const { stdout: stats } = await execAsync(`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`);
|
||||||
|
|
||||||
|
if (stats) {
|
||||||
|
changelog += '\n## 📊 Stats\n\n';
|
||||||
|
changelog += `${stats.trim()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changelog;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching changelog:', error);
|
||||||
|
return 'Unable to fetch changelog';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
.diff-panel-content {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel-content::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel-content::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel-content::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(155, 155, 155, 0.5);
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel-content::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: rgba(155, 155, 155, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for the left panel when not hovered */
|
|
||||||
.diff-panel:not(:hover) .diff-panel-content::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel:not(:hover) .diff-panel-content {
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilos para as linhas de diff */
|
|
||||||
.diff-block-added {
|
|
||||||
@apply bg-green-500/20 border-l-4 border-green-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-block-removed {
|
|
||||||
@apply bg-red-500/20 border-l-4 border-red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Melhorar contraste para mudanças */
|
|
||||||
.diff-panel-content .group:hover .diff-block-added {
|
|
||||||
@apply bg-green-500/30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-panel-content .group:hover .diff-block-removed {
|
|
||||||
@apply bg-red-500/30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilos unificados para ambas as visualizações */
|
|
||||||
.diff-line {
|
|
||||||
@apply flex group min-w-fit transition-colors duration-150;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line-number {
|
|
||||||
@apply w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line-content {
|
|
||||||
@apply px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cores específicas para adições/remoções */
|
|
||||||
.diff-added {
|
|
||||||
@apply bg-green-500/20 border-l-4 border-green-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-removed {
|
|
||||||
@apply bg-red-500/20 border-l-4 border-red-500;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Change } from 'diff';
|
|
||||||
|
|
||||||
export type ActionType = 'file' | 'shell';
|
export type ActionType = 'file' | 'shell';
|
||||||
|
|
||||||
export interface BaseAction {
|
export interface BaseAction {
|
||||||
@@ -19,11 +17,7 @@ export interface StartAction extends BaseAction {
|
|||||||
type: 'start';
|
type: 'start';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildAction extends BaseAction {
|
export type BoltAction = FileAction | ShellAction | StartAction;
|
||||||
type: 'build';
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
|
|
||||||
|
|
||||||
export type BoltActionData = BoltAction | BaseAction;
|
export type BoltActionData = BoltAction | BaseAction;
|
||||||
|
|
||||||
@@ -34,16 +28,3 @@ export interface ActionAlert {
|
|||||||
content: string;
|
content: string;
|
||||||
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileHistory {
|
|
||||||
originalContent: string;
|
|
||||||
lastModified: number;
|
|
||||||
changes: Change[];
|
|
||||||
versions: {
|
|
||||||
timestamp: number;
|
|
||||||
content: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Novo campo para rastrear a origem das mudanças
|
|
||||||
changeSource?: 'user' | 'auto-save' | 'external';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
export interface NetlifySite {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
admin_url: string;
|
|
||||||
build_settings: {
|
|
||||||
provider: string;
|
|
||||||
repo_url: string;
|
|
||||||
cmd: string;
|
|
||||||
};
|
|
||||||
published_deploy: {
|
|
||||||
published_at: string;
|
|
||||||
deploy_time: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetlifyUser {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
avatar_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetlifyStats {
|
|
||||||
sites: NetlifySite[];
|
|
||||||
totalSites: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetlifyConnection {
|
|
||||||
user: NetlifyUser | null;
|
|
||||||
token: string;
|
|
||||||
stats?: NetlifyStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetlifySiteInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
chatId: string;
|
|
||||||
}
|
|
||||||
@@ -103,19 +103,3 @@ export const detectProjectType = async (
|
|||||||
|
|
||||||
return { type: '', setupCommand: '', followupMessage: '' };
|
return { type: '', setupCommand: '', followupMessage: '' };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filesToArtifacts = (files: { [path: string]: { content: string } }, id: string): string => {
|
|
||||||
return `
|
|
||||||
<boltArtifact id="${id}" title="User Updated Files">
|
|
||||||
${Object.keys(files)
|
|
||||||
.map(
|
|
||||||
(filePath) => `
|
|
||||||
<boltAction type="file" filePath="${filePath}">
|
|
||||||
${files[filePath].content}
|
|
||||||
</boltAction>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join('\n')}
|
|
||||||
</boltArtifact>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export const getLanguageFromExtension = (ext: string): string => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
js: 'javascript',
|
|
||||||
jsx: 'jsx',
|
|
||||||
ts: 'typescript',
|
|
||||||
tsx: 'tsx',
|
|
||||||
json: 'json',
|
|
||||||
html: 'html',
|
|
||||||
css: 'css',
|
|
||||||
py: 'python',
|
|
||||||
java: 'java',
|
|
||||||
rb: 'ruby',
|
|
||||||
cpp: 'cpp',
|
|
||||||
c: 'c',
|
|
||||||
cs: 'csharp',
|
|
||||||
go: 'go',
|
|
||||||
rs: 'rust',
|
|
||||||
php: 'php',
|
|
||||||
swift: 'swift',
|
|
||||||
md: 'plaintext',
|
|
||||||
sh: 'bash',
|
|
||||||
};
|
|
||||||
return map[ext] || 'typescript';
|
|
||||||
};
|
|
||||||
71
changelog.md
71
changelog.md
@@ -1,72 +1,21 @@
|
|||||||
# 🚀 Release v0.0.7
|
Release v0.0.6
|
||||||
|
|
||||||
## What's Changed 🌟
|
## What's Changed 🌟
|
||||||
|
|
||||||
### 🔄 Changes since v0.0.6
|
### 🔄 Changes since v0.0.5
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|
||||||
* added support for reasoning content ([#1168](https://github.com/stackblitz-labs/bolt.diy/pull/1168)) by @thecodacus
|
- implement Claude 3, Claude3.5, Nova Pro, Nova Lite and Mistral model integration with AWS Bedrock ([#974](https://github.com/stackblitz-labs/bolt.diy/pull/974)) by @kunjabijukchhe
|
||||||
* add deepseek-r1-distill-llama-70b to groq provider ([#1187](https://github.com/stackblitz-labs/bolt.diy/pull/1187)) by @saif78642
|
- enhance chat import with multi-format support ([#936](https://github.com/stackblitz-labs/bolt.diy/pull/936)) by @sidbetatester
|
||||||
* add Gemini 2.0 Flash-thinking-exp-01-21 model with 65k token support ([#1202](https://github.com/stackblitz-labs/bolt.diy/pull/1202)) by @saif78642
|
- added Github provider ([#1109](https://github.com/stackblitz-labs/bolt.diy/pull/1109)) by @newnol
|
||||||
* added more dynamic models, sorted and remove duplicate models ([#1206](https://github.com/stackblitz-labs/bolt.diy/pull/1206)) by @thecodacus
|
- added the "Open Preview in a New Tab" ([#1101](https://github.com/stackblitz-labs/bolt.diy/pull/1101)) by @Stijnus
|
||||||
* support for <think></think> tags to allow reasoning tokens formatted in UI ([#1205](https://github.com/stackblitz-labs/bolt.diy/pull/1205)) by @thecodacus
|
- configure dynamic providers via .env ([#1108](https://github.com/stackblitz-labs/bolt.diy/pull/1108)) by @mrsimpson
|
||||||
* enhanced Code Context and Project Summary Features ([#1191](https://github.com/stackblitz-labs/bolt.diy/pull/1191)) by @thecodacus
|
- added deepseek reasoner model in deepseek provider ([#1151](https://github.com/stackblitz-labs/bolt.diy/pull/1151)) by @thecodacus
|
||||||
* added dynamic model support for openAI provider ([#1241](https://github.com/stackblitz-labs/bolt.diy/pull/1241)) by @thecodacus
|
- enhance context handling by adding code context selection and implementing summary generation ([#1091](https://github.com/stackblitz-labs/bolt.diy/pull/1091)) by @thecodacus
|
||||||
* bolt dyi new settings UI V3 ([#1245](https://github.com/stackblitz-labs/bolt.diy/pull/1245)) by @Stijnus
|
|
||||||
* implement llm model search ([#1322](https://github.com/stackblitz-labs/bolt.diy/pull/1322)) by @kamilfurtak
|
|
||||||
* diff-view-v2-no-conflict ([#1335](https://github.com/stackblitz-labs/bolt.diy/pull/1335)) by @Toddyclipsgg
|
|
||||||
* netlify one click deployment ([#1376](https://github.com/stackblitz-labs/bolt.diy/pull/1376)) by @xKevIsDev
|
|
||||||
* diff view v3 ([#1367](https://github.com/stackblitz-labs/bolt.diy/pull/1367)) by @Toddyclipsgg
|
|
||||||
* added anthropic dynamic models ([#1374](https://github.com/stackblitz-labs/bolt.diy/pull/1374)) by @thecodacus
|
|
||||||
* make user made changes persistent after reload ([#1387](https://github.com/stackblitz-labs/bolt.diy/pull/1387)) by @thecodacus
|
|
||||||
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
* docker prod env variable fix ([#1170](https://github.com/stackblitz-labs/bolt.diy/pull/1170)) by @thecodacus
|
|
||||||
* improve push to github option ([#1111](https://github.com/stackblitz-labs/bolt.diy/pull/1111)) by @thecodacus
|
|
||||||
* git import issue when importing bolt on bolt ([#1020](https://github.com/stackblitz-labs/bolt.diy/pull/1020)) by @thecodacus
|
|
||||||
* issue with alternate message when importing from folder and git ([#1216](https://github.com/stackblitz-labs/bolt.diy/pull/1216)) by @thecodacus
|
|
||||||
* tune the system prompt to avoid diff writing ([#1218](https://github.com/stackblitz-labs/bolt.diy/pull/1218)) by @thecodacus
|
|
||||||
* removed chrome canary note (6a8449e) by @leex279
|
|
||||||
* starter template icons fix and auto resize of custon icons are reverted ([#1298](https://github.com/stackblitz-labs/bolt.diy/pull/1298)) by @thecodacus
|
|
||||||
* auto scroll fix, scroll allow user to scroll up during ai response ([#1299](https://github.com/stackblitz-labs/bolt.diy/pull/1299)) by @thecodacus
|
|
||||||
* bug fix New UI / Feature tab - Default values hard-coded (294adfd) by @leex279
|
|
||||||
* debounce profile update notifications to prevent toast spam (70b723d) by @xKevIsDev
|
|
||||||
* bolt dyi UI bugfix ([#1342](https://github.com/stackblitz-labs/bolt.diy/pull/1342)) by @Stijnus
|
|
||||||
* preserve complete provider settings in cookies (220e2da) by @xKevIsDev
|
|
||||||
* for remove settings icon _index.tsx ([#1356](https://github.com/stackblitz-labs/bolt.diy/pull/1356)) by @Stijnus
|
|
||||||
* fix enhance prompt to stop implementing full project instead of enhancing ([#1383](https://github.com/stackblitz-labs/bolt.diy/pull/1383)) by @thecodacus
|
|
||||||
|
|
||||||
|
|
||||||
### ⚙️ CI
|
|
||||||
|
|
||||||
* updated Dockerfile to install latest version of corepack to ensure to have the right version to pnpm (c88938c) by @BaptisteCDC
|
|
||||||
|
|
||||||
|
|
||||||
### 🔍 Other Changes
|
|
||||||
|
|
||||||
* new anthropogenic model for amazon bedrock (0fd039b) by @leex279
|
|
||||||
* This reverts commit 871aefbe83c31660b32b53b63772ebba33ed7954, reversing ([#1335](https://github.com/stackblitz-labs/bolt.diy/pull/1335)) by @Toddyclipsgg
|
|
||||||
* Update docker.yaml (stable/main deployment) (f0ea22e) by @leex279
|
|
||||||
* Update Dockerfile - Test Bugfix Dockerpipeline (8e790d0) by @leex279
|
|
||||||
* Update Dockerfile (5297081) by @leex279
|
|
||||||
* Update docker.yaml (7dda793) by @leex279
|
|
||||||
* Update docker.yaml (67c4051) by @leex279
|
|
||||||
* Fix broken astro project git clone ([#1352](https://github.com/stackblitz-labs/bolt.diy/pull/1352)) by @Phr33d0m
|
|
||||||
|
|
||||||
|
|
||||||
## ✨ First-time Contributors
|
|
||||||
|
|
||||||
A huge thank you to our amazing new contributors! Your first contribution marks the start of an exciting journey! 🌟
|
|
||||||
|
|
||||||
* 🌟 [@BaptisteCDC](https://github.com/BaptisteCDC)
|
|
||||||
* 🌟 [@Phr33d0m](https://github.com/Phr33d0m)
|
|
||||||
* 🌟 [@kamilfurtak](https://github.com/kamilfurtak)
|
|
||||||
* 🌟 [@saif78642](https://github.com/saif78642)
|
|
||||||
* 🌟 [@xKevIsDev](https://github.com/xKevIsDev)
|
|
||||||
|
|
||||||
## 📈 Stats
|
## 📈 Stats
|
||||||
|
|
||||||
**Full Changelog**: [`v0.0.6..v0.0.7`](https://github.com/stackblitz-labs/bolt.diy/compare/v0.0.6...v0.0.7)
|
**Full Changelog**: [`v0.0.5..v0.0.6`](https://github.com/stackblitz-labs/bolt.diy/compare/v0.0.5...v0.0.6)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.7",
|
"version": "0.0.6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"deploy": "npm run build && wrangler pages deploy",
|
"deploy": "npm run build && wrangler pages deploy",
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build",
|
||||||
@@ -78,7 +78,6 @@
|
|||||||
"@remix-run/cloudflare-pages": "^2.15.2",
|
"@remix-run/cloudflare-pages": "^2.15.2",
|
||||||
"@remix-run/node": "^2.15.2",
|
"@remix-run/node": "^2.15.2",
|
||||||
"@remix-run/react": "^2.15.2",
|
"@remix-run/react": "^2.15.2",
|
||||||
"@tanstack/react-virtual": "^3.13.0",
|
|
||||||
"@types/react-beautiful-dnd": "^13.1.8",
|
"@types/react-beautiful-dnd": "^13.1.8",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
||||||
"@unocss/reset": "^0.61.9",
|
"@unocss/reset": "^0.61.9",
|
||||||
@@ -123,7 +122,6 @@
|
|||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remix-island": "^0.2.0",
|
"remix-island": "^0.2.0",
|
||||||
"remix-utils": "^7.7.0",
|
"remix-utils": "^7.7.0",
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
|
||||||
"shiki": "^1.24.0",
|
"shiki": "^1.24.0",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
@@ -135,8 +133,6 @@
|
|||||||
"@iconify-json/ph": "^1.2.1",
|
"@iconify-json/ph": "^1.2.1",
|
||||||
"@iconify/types": "^2.0.0",
|
"@iconify/types": "^2.0.0",
|
||||||
"@remix-run/dev": "^2.15.2",
|
"@remix-run/dev": "^2.15.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.2.0",
|
|
||||||
"@types/diff": "^5.2.3",
|
"@types/diff": "^5.2.3",
|
||||||
"@types/dom-speech-recognition": "^0.0.4",
|
"@types/dom-speech-recognition": "^0.0.4",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
@@ -144,11 +140,9 @@
|
|||||||
"@types/path-browserify": "^1.0.3",
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"is-ci": "^3.0.1",
|
"is-ci": "^3.0.1",
|
||||||
"jsdom": "^26.0.0",
|
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pnpm": "^9.14.4",
|
"pnpm": "^9.14.4",
|
||||||
"prettier": "^3.4.1",
|
"prettier": "^3.4.1",
|
||||||
|
|||||||
1229
pnpm-lock.yaml
generated
1229
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,9 @@ const COLOR_PRIMITIVES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
safelist: [
|
||||||
|
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
|
||||||
|
],
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
||||||
|
|||||||
@@ -89,55 +89,14 @@ export default defineConfig((config) => {
|
|||||||
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
|
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
|
||||||
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
|
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
|
||||||
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
|
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
|
||||||
// Define global values
|
|
||||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
format: 'esm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commonjsOptions: {
|
|
||||||
transformMixedEsModules: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
|
||||||
esbuildOptions: {
|
|
||||||
define: {
|
|
||||||
global: 'globalThis',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
buffer: 'vite-plugin-node-polyfills/polyfills/buffer',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
nodePolyfills({
|
nodePolyfills({
|
||||||
include: ['buffer', 'process', 'util', 'stream'],
|
include: ['path', 'buffer', 'process'],
|
||||||
globals: {
|
|
||||||
Buffer: true,
|
|
||||||
process: true,
|
|
||||||
global: true,
|
|
||||||
},
|
|
||||||
protocolImports: true,
|
|
||||||
// Exclude Node.js modules that shouldn't be polyfilled in Cloudflare
|
|
||||||
exclude: ['child_process', 'fs', 'path'],
|
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: 'buffer-polyfill',
|
|
||||||
transform(code, id) {
|
|
||||||
if (id.includes('env.mjs')) {
|
|
||||||
return {
|
|
||||||
code: `import { Buffer } from 'buffer';\n${code}`,
|
|
||||||
map: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config.mode !== 'test' && remixCloudflareDevProxy(),
|
config.mode !== 'test' && remixCloudflareDevProxy(),
|
||||||
remixVitePlugin({
|
remixVitePlugin({
|
||||||
future: {
|
future: {
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ name = "bolt"
|
|||||||
compatibility_flags = ["nodejs_compat"]
|
compatibility_flags = ["nodejs_compat"]
|
||||||
compatibility_date = "2024-07-01"
|
compatibility_date = "2024-07-01"
|
||||||
pages_build_output_dir = "./build/client"
|
pages_build_output_dir = "./build/client"
|
||||||
send_metrics = false
|
send_metrics = false
|
||||||
|
|||||||
Reference in New Issue
Block a user