Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +26 -0
- .editorconfig +13 -0
- .env.example +52 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +63 -0
- .github/ISSUE_TEMPLATE/feature_request.md +23 -0
- .github/actions/setup-and-build/action.yaml +32 -0
- .github/workflows/ci.yaml +27 -0
- .github/workflows/github-build-push.yml +39 -0
- .github/workflows/semantic-pr.yaml +32 -0
- .gitignore +33 -0
- .husky/commit-msg +7 -0
- .prettierignore +2 -0
- .prettierrc +8 -0
- .tool-versions +2 -0
- CONTRIBUTING.md +201 -0
- Dockerfile +67 -0
- LICENSE +21 -0
- README.md +251 -10
- app/components/chat/Artifact.tsx +213 -0
- app/components/chat/AssistantMessage.tsx +14 -0
- app/components/chat/BaseChat.module.scss +19 -0
- app/components/chat/BaseChat.tsx +269 -0
- app/components/chat/Chat.client.tsx +240 -0
- app/components/chat/CodeBlock.module.scss +10 -0
- app/components/chat/CodeBlock.tsx +82 -0
- app/components/chat/Markdown.module.scss +171 -0
- app/components/chat/Markdown.tsx +74 -0
- app/components/chat/Messages.client.tsx +53 -0
- app/components/chat/SendButton.client.tsx +33 -0
- app/components/chat/UserMessage.tsx +21 -0
- app/components/editor/codemirror/BinaryContent.tsx +7 -0
- app/components/editor/codemirror/CodeMirrorEditor.tsx +461 -0
- app/components/editor/codemirror/cm-theme.ts +192 -0
- app/components/editor/codemirror/indent.ts +68 -0
- app/components/editor/codemirror/languages.ts +105 -0
- app/components/header/Header.tsx +41 -0
- app/components/header/HeaderActionButtons.client.tsx +68 -0
- app/components/sidebar/HistoryItem.tsx +64 -0
- app/components/sidebar/Menu.client.tsx +172 -0
- app/components/sidebar/date-binning.ts +59 -0
- app/components/ui/Dialog.tsx +133 -0
- app/components/ui/IconButton.tsx +77 -0
- app/components/ui/LoadingDots.tsx +27 -0
- app/components/ui/PanelHeader.tsx +20 -0
- app/components/ui/PanelHeaderButton.tsx +36 -0
- app/components/ui/Slider.tsx +65 -0
- app/components/ui/ThemeSwitch.tsx +29 -0
- app/components/workbench/EditorPanel.tsx +256 -0
- app/components/workbench/FileBreadcrumb.tsx +148 -0
- app/components/workbench/FileTree.tsx +409 -0
.dockerignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore Git and GitHub files
|
| 2 |
+
.git
|
| 3 |
+
.github/
|
| 4 |
+
|
| 5 |
+
# Ignore Husky configuration files
|
| 6 |
+
.husky/
|
| 7 |
+
|
| 8 |
+
# Ignore documentation and metadata files
|
| 9 |
+
CONTRIBUTING.md
|
| 10 |
+
LICENSE
|
| 11 |
+
README.md
|
| 12 |
+
|
| 13 |
+
# Ignore environment examples and sensitive info
|
| 14 |
+
.env
|
| 15 |
+
*.local
|
| 16 |
+
*.example
|
| 17 |
+
|
| 18 |
+
# Ignore node modules, logs and cache files
|
| 19 |
+
**/*.log
|
| 20 |
+
**/node_modules
|
| 21 |
+
**/dist
|
| 22 |
+
**/build
|
| 23 |
+
**/.cache
|
| 24 |
+
logs
|
| 25 |
+
dist-ssr
|
| 26 |
+
.DS_Store
|
.editorconfig
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
root = true
|
| 2 |
+
|
| 3 |
+
[*]
|
| 4 |
+
indent_style = space
|
| 5 |
+
end_of_line = lf
|
| 6 |
+
charset = utf-8
|
| 7 |
+
trim_trailing_whitespace = true
|
| 8 |
+
insert_final_newline = true
|
| 9 |
+
max_line_length = 120
|
| 10 |
+
indent_size = 2
|
| 11 |
+
|
| 12 |
+
[*.md]
|
| 13 |
+
trim_trailing_whitespace = false
|
.env.example
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rename this file to .env once you have filled in the below environment variables!
|
| 2 |
+
|
| 3 |
+
# Get your GROQ API Key here -
|
| 4 |
+
# https://console.groq.com/keys
|
| 5 |
+
# You only need this environment variable set if you want to use Groq models
|
| 6 |
+
GROQ_API_KEY=
|
| 7 |
+
|
| 8 |
+
# Get your Open AI API Key by following these instructions -
|
| 9 |
+
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
|
| 10 |
+
# You only need this environment variable set if you want to use GPT models
|
| 11 |
+
OPENAI_API_KEY=
|
| 12 |
+
|
| 13 |
+
# Get your Anthropic API Key in your account settings -
|
| 14 |
+
# https://console.anthropic.com/settings/keys
|
| 15 |
+
# You only need this environment variable set if you want to use Claude models
|
| 16 |
+
ANTHROPIC_API_KEY=
|
| 17 |
+
|
| 18 |
+
# Get your OpenRouter API Key in your account settings -
|
| 19 |
+
# https://openrouter.ai/settings/keys
|
| 20 |
+
# You only need this environment variable set if you want to use OpenRouter models
|
| 21 |
+
OPEN_ROUTER_API_KEY=
|
| 22 |
+
|
| 23 |
+
# Get your Google Generative AI API Key by following these instructions -
|
| 24 |
+
# https://console.cloud.google.com/apis/credentials
|
| 25 |
+
# You only need this environment variable set if you want to use Google Generative AI models
|
| 26 |
+
GOOGLE_GENERATIVE_AI_API_KEY=
|
| 27 |
+
|
| 28 |
+
# You only need this environment variable set if you want to use oLLAMA models
|
| 29 |
+
# EXAMPLE http://localhost:11434
|
| 30 |
+
OLLAMA_API_BASE_URL=
|
| 31 |
+
|
| 32 |
+
# You only need this environment variable set if you want to use OpenAI Like models
|
| 33 |
+
OPENAI_LIKE_API_BASE_URL=
|
| 34 |
+
|
| 35 |
+
# You only need this environment variable set if you want to use DeepSeek models through their API
|
| 36 |
+
DEEPSEEK_API_KEY=
|
| 37 |
+
|
| 38 |
+
# Get your OpenAI Like API Key
|
| 39 |
+
OPENAI_LIKE_API_KEY=
|
| 40 |
+
|
| 41 |
+
# Get your Mistral API Key by following these instructions -
|
| 42 |
+
# https://console.mistral.ai/api-keys/
|
| 43 |
+
# You only need this environment variable set if you want to use Mistral models
|
| 44 |
+
MISTRAL_API_KEY=
|
| 45 |
+
|
| 46 |
+
# Get your xAI API key
|
| 47 |
+
# https://x.ai/api
|
| 48 |
+
# You only need this environment variable set if you want to use xAI models
|
| 49 |
+
XAI_API_KEY=
|
| 50 |
+
|
| 51 |
+
# Include this environment variable if you want more logging for debugging locally
|
| 52 |
+
VITE_LOG_LEVEL=debug
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: "Bug report"
|
| 2 |
+
description: Create a report to help us improve
|
| 3 |
+
body:
|
| 4 |
+
- type: markdown
|
| 5 |
+
attributes:
|
| 6 |
+
value: |
|
| 7 |
+
Thank you for reporting an issue :pray:.
|
| 8 |
+
|
| 9 |
+
This issue tracker is for bugs and issues found with [Bolt.new](https://bolt.new).
|
| 10 |
+
If you experience issues related to WebContainer, please file an issue in our [WebContainer repo](https://github.com/stackblitz/webcontainer-core), or file an issue in our [StackBlitz core repo](https://github.com/stackblitz/core) for issues with StackBlitz.
|
| 11 |
+
|
| 12 |
+
The more information you fill in, the better we can help you.
|
| 13 |
+
- type: textarea
|
| 14 |
+
id: description
|
| 15 |
+
attributes:
|
| 16 |
+
label: Describe the bug
|
| 17 |
+
description: Provide a clear and concise description of what you're running into.
|
| 18 |
+
validations:
|
| 19 |
+
required: true
|
| 20 |
+
- type: input
|
| 21 |
+
id: link
|
| 22 |
+
attributes:
|
| 23 |
+
label: Link to the Bolt URL that caused the error
|
| 24 |
+
description: Please do not delete it after reporting!
|
| 25 |
+
validations:
|
| 26 |
+
required: true
|
| 27 |
+
- type: textarea
|
| 28 |
+
id: steps
|
| 29 |
+
attributes:
|
| 30 |
+
label: Steps to reproduce
|
| 31 |
+
description: Describe the steps we have to take to reproduce the behavior.
|
| 32 |
+
placeholder: |
|
| 33 |
+
1. Go to '...'
|
| 34 |
+
2. Click on '....'
|
| 35 |
+
3. Scroll down to '....'
|
| 36 |
+
4. See error
|
| 37 |
+
validations:
|
| 38 |
+
required: true
|
| 39 |
+
- type: textarea
|
| 40 |
+
id: expected
|
| 41 |
+
attributes:
|
| 42 |
+
label: Expected behavior
|
| 43 |
+
description: Provide a clear and concise description of what you expected to happen.
|
| 44 |
+
validations:
|
| 45 |
+
required: true
|
| 46 |
+
- type: textarea
|
| 47 |
+
id: screenshots
|
| 48 |
+
attributes:
|
| 49 |
+
label: Screen Recording / Screenshot
|
| 50 |
+
description: If applicable, **please include a screen recording** (preferably) or screenshot showcasing the issue. This will assist us in resolving your issue <u>quickly</u>.
|
| 51 |
+
- type: textarea
|
| 52 |
+
id: platform
|
| 53 |
+
attributes:
|
| 54 |
+
label: Platform
|
| 55 |
+
value: |
|
| 56 |
+
- OS: [e.g. macOS, Windows, Linux]
|
| 57 |
+
- Browser: [e.g. Chrome, Safari, Firefox]
|
| 58 |
+
- Version: [e.g. 91.1]
|
| 59 |
+
- type: textarea
|
| 60 |
+
id: additional
|
| 61 |
+
attributes:
|
| 62 |
+
label: Additional context
|
| 63 |
+
description: Add any other context about the problem here.
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
**Is your feature request related to a problem? Please describe:**
|
| 10 |
+
|
| 11 |
+
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
| 12 |
+
|
| 13 |
+
**Describe the solution you'd like:**
|
| 14 |
+
|
| 15 |
+
<!-- A clear and concise description of what you want to happen. -->
|
| 16 |
+
|
| 17 |
+
**Describe alternatives you've considered:**
|
| 18 |
+
|
| 19 |
+
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
| 20 |
+
|
| 21 |
+
**Additional context:**
|
| 22 |
+
|
| 23 |
+
<!-- Add any other context or screenshots about the feature request here. -->
|
.github/actions/setup-and-build/action.yaml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Setup and Build
|
| 2 |
+
description: Generic setup action
|
| 3 |
+
inputs:
|
| 4 |
+
pnpm-version:
|
| 5 |
+
required: false
|
| 6 |
+
type: string
|
| 7 |
+
default: '9.4.0'
|
| 8 |
+
node-version:
|
| 9 |
+
required: false
|
| 10 |
+
type: string
|
| 11 |
+
default: '20.15.1'
|
| 12 |
+
|
| 13 |
+
runs:
|
| 14 |
+
using: composite
|
| 15 |
+
|
| 16 |
+
steps:
|
| 17 |
+
- uses: pnpm/action-setup@v4
|
| 18 |
+
with:
|
| 19 |
+
version: ${{ inputs.pnpm-version }}
|
| 20 |
+
run_install: false
|
| 21 |
+
|
| 22 |
+
- name: Set Node.js version to ${{ inputs.node-version }}
|
| 23 |
+
uses: actions/setup-node@v4
|
| 24 |
+
with:
|
| 25 |
+
node-version: ${{ inputs.node-version }}
|
| 26 |
+
cache: pnpm
|
| 27 |
+
|
| 28 |
+
- name: Install dependencies and build project
|
| 29 |
+
shell: bash
|
| 30 |
+
run: |
|
| 31 |
+
pnpm install
|
| 32 |
+
pnpm run build
|
.github/workflows/ci.yaml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI/CD
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
pull_request:
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test:
|
| 11 |
+
name: Test
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- name: Checkout
|
| 15 |
+
uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Setup and Build
|
| 18 |
+
uses: ./.github/actions/setup-and-build
|
| 19 |
+
|
| 20 |
+
- name: Run type check
|
| 21 |
+
run: pnpm run typecheck
|
| 22 |
+
|
| 23 |
+
# - name: Run ESLint
|
| 24 |
+
# run: pnpm run lint
|
| 25 |
+
|
| 26 |
+
- name: Run tests
|
| 27 |
+
run: pnpm run test
|
.github/workflows/github-build-push.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build and Push Container
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
# paths:
|
| 8 |
+
# - 'Dockerfile'
|
| 9 |
+
workflow_dispatch:
|
| 10 |
+
jobs:
|
| 11 |
+
build-and-push:
|
| 12 |
+
runs-on: [ubuntu-latest]
|
| 13 |
+
steps:
|
| 14 |
+
- name: Checkout code
|
| 15 |
+
uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Set up QEMU
|
| 18 |
+
uses: docker/setup-qemu-action@v1
|
| 19 |
+
|
| 20 |
+
- name: Set up Docker Buildx
|
| 21 |
+
uses: docker/setup-buildx-action@v1
|
| 22 |
+
|
| 23 |
+
- name: Login to GitHub Container Registry
|
| 24 |
+
uses: docker/login-action@v1
|
| 25 |
+
with:
|
| 26 |
+
registry: ghcr.io
|
| 27 |
+
username: ${{ github.actor }}
|
| 28 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 29 |
+
|
| 30 |
+
- name: Build and Push Containers
|
| 31 |
+
uses: docker/build-push-action@v2
|
| 32 |
+
with:
|
| 33 |
+
context: .
|
| 34 |
+
file: Dockerfile
|
| 35 |
+
platforms: linux/amd64,linux/arm64
|
| 36 |
+
push: true
|
| 37 |
+
tags: |
|
| 38 |
+
ghcr.io/${{ github.repository }}:latest
|
| 39 |
+
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
.github/workflows/semantic-pr.yaml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Semantic Pull Request
|
| 2 |
+
on:
|
| 3 |
+
pull_request_target:
|
| 4 |
+
types: [opened, reopened, edited, synchronize]
|
| 5 |
+
permissions:
|
| 6 |
+
pull-requests: read
|
| 7 |
+
jobs:
|
| 8 |
+
main:
|
| 9 |
+
name: Validate PR Title
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
# https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
|
| 13 |
+
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
|
| 14 |
+
env:
|
| 15 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 16 |
+
with:
|
| 17 |
+
subjectPattern: ^(?![A-Z]).+$
|
| 18 |
+
subjectPatternError: |
|
| 19 |
+
The subject "{subject}" found in the pull request title "{title}"
|
| 20 |
+
didn't match the configured pattern. Please ensure that the subject
|
| 21 |
+
doesn't start with an uppercase character.
|
| 22 |
+
types: |
|
| 23 |
+
fix
|
| 24 |
+
feat
|
| 25 |
+
chore
|
| 26 |
+
build
|
| 27 |
+
ci
|
| 28 |
+
perf
|
| 29 |
+
docs
|
| 30 |
+
refactor
|
| 31 |
+
revert
|
| 32 |
+
test
|
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
logs
|
| 2 |
+
*.log
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
pnpm-debug.log*
|
| 7 |
+
lerna-debug.log*
|
| 8 |
+
|
| 9 |
+
node_modules
|
| 10 |
+
dist
|
| 11 |
+
dist-ssr
|
| 12 |
+
*.local
|
| 13 |
+
|
| 14 |
+
.vscode/*
|
| 15 |
+
.vscode/launch.json
|
| 16 |
+
!.vscode/extensions.json
|
| 17 |
+
.idea
|
| 18 |
+
.DS_Store
|
| 19 |
+
*.suo
|
| 20 |
+
*.ntvs*
|
| 21 |
+
*.njsproj
|
| 22 |
+
*.sln
|
| 23 |
+
*.sw?
|
| 24 |
+
|
| 25 |
+
/.cache
|
| 26 |
+
/build
|
| 27 |
+
.env.local
|
| 28 |
+
.env
|
| 29 |
+
*.vars
|
| 30 |
+
.wrangler
|
| 31 |
+
_worker.bundle
|
| 32 |
+
|
| 33 |
+
Modelfile
|
.husky/commit-msg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env sh
|
| 2 |
+
|
| 3 |
+
. "$(dirname "$0")/_/husky.sh"
|
| 4 |
+
|
| 5 |
+
npx commitlint --edit $1
|
| 6 |
+
|
| 7 |
+
exit 0
|
.prettierignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pnpm-lock.yaml
|
| 2 |
+
.astro
|
.prettierrc
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"printWidth": 120,
|
| 3 |
+
"singleQuote": true,
|
| 4 |
+
"useTabs": false,
|
| 5 |
+
"tabWidth": 2,
|
| 6 |
+
"semi": true,
|
| 7 |
+
"bracketSpacing": true
|
| 8 |
+
}
|
.tool-versions
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
nodejs 20.15.1
|
| 2 |
+
pnpm 9.4.0
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to Bolt.new Fork
|
| 2 |
+
|
| 3 |
+
First off, thank you for considering contributing to Bolt.new! This fork aims to expand the capabilities of the original project by integrating multiple LLM providers and enhancing functionality. Every contribution helps make Bolt.new a better tool for developers worldwide.
|
| 4 |
+
|
| 5 |
+
## 📋 Table of Contents
|
| 6 |
+
- [Code of Conduct](#code-of-conduct)
|
| 7 |
+
- [How Can I Contribute?](#how-can-i-contribute)
|
| 8 |
+
- [Pull Request Guidelines](#pull-request-guidelines)
|
| 9 |
+
- [Coding Standards](#coding-standards)
|
| 10 |
+
- [Development Setup](#development-setup)
|
| 11 |
+
- [Deploymnt with Docker](#docker-deployment-documentation)
|
| 12 |
+
- [Project Structure](#project-structure)
|
| 13 |
+
|
| 14 |
+
## Code of Conduct
|
| 15 |
+
|
| 16 |
+
This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
| 17 |
+
|
| 18 |
+
## How Can I Contribute?
|
| 19 |
+
|
| 20 |
+
### 🐞 Reporting Bugs and Feature Requests
|
| 21 |
+
- Check the issue tracker to avoid duplicates
|
| 22 |
+
- Use the issue templates when available
|
| 23 |
+
- Include as much relevant information as possible
|
| 24 |
+
- For bugs, add steps to reproduce the issue
|
| 25 |
+
|
| 26 |
+
### 🔧 Code Contributions
|
| 27 |
+
1. Fork the repository
|
| 28 |
+
2. Create a new branch for your feature/fix
|
| 29 |
+
3. Write your code
|
| 30 |
+
4. Submit a pull request
|
| 31 |
+
|
| 32 |
+
### ✨ Becoming a Core Contributor
|
| 33 |
+
We're looking for dedicated contributors to help maintain and grow this project. If you're interested in becoming a core contributor, please fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
|
| 34 |
+
|
| 35 |
+
## Pull Request Guidelines
|
| 36 |
+
|
| 37 |
+
### 📝 PR Checklist
|
| 38 |
+
- [ ] Branch from the main branch
|
| 39 |
+
- [ ] Update documentation if needed
|
| 40 |
+
- [ ] Manually verify all new functionality works as expected
|
| 41 |
+
- [ ] Keep PRs focused and atomic
|
| 42 |
+
|
| 43 |
+
### 👀 Review Process
|
| 44 |
+
1. Manually test the changes
|
| 45 |
+
2. At least one maintainer review required
|
| 46 |
+
3. Address all review comments
|
| 47 |
+
4. Maintain clean commit history
|
| 48 |
+
|
| 49 |
+
## Coding Standards
|
| 50 |
+
|
| 51 |
+
### 💻 General Guidelines
|
| 52 |
+
- Follow existing code style
|
| 53 |
+
- Comment complex logic
|
| 54 |
+
- Keep functions focused and small
|
| 55 |
+
- Use meaningful variable names
|
| 56 |
+
|
| 57 |
+
## Development Setup
|
| 58 |
+
|
| 59 |
+
### 🔄 Initial Setup
|
| 60 |
+
1. Clone the repository:
|
| 61 |
+
```bash
|
| 62 |
+
git clone https://github.com/coleam00/bolt.new-any-llm.git
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
2. Install dependencies:
|
| 66 |
+
```bash
|
| 67 |
+
pnpm install
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
3. Set up environment variables:
|
| 71 |
+
- Rename `.env.example` to `.env.local`
|
| 72 |
+
- Add your LLM API keys (only set the ones you plan to use):
|
| 73 |
+
```bash
|
| 74 |
+
GROQ_API_KEY=XXX
|
| 75 |
+
OPENAI_API_KEY=XXX
|
| 76 |
+
ANTHROPIC_API_KEY=XXX
|
| 77 |
+
...
|
| 78 |
+
```
|
| 79 |
+
- Optionally set debug level:
|
| 80 |
+
```bash
|
| 81 |
+
VITE_LOG_LEVEL=debug
|
| 82 |
+
```
|
| 83 |
+
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
|
| 84 |
+
|
| 85 |
+
### 🚀 Running the Development Server
|
| 86 |
+
```bash
|
| 87 |
+
pnpm run dev
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
**Note**: You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway.
|
| 91 |
+
|
| 92 |
+
## Testing
|
| 93 |
+
|
| 94 |
+
Run the test suite with:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
pnpm test
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Deployment
|
| 101 |
+
|
| 102 |
+
To deploy the application to Cloudflare Pages:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
pnpm run deploy
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.
|
| 109 |
+
|
| 110 |
+
# Docker Deployment Documentation
|
| 111 |
+
|
| 112 |
+
This guide outlines various methods for building and deploying the application using Docker.
|
| 113 |
+
|
| 114 |
+
## Build Methods
|
| 115 |
+
|
| 116 |
+
### 1. Using Helper Scripts
|
| 117 |
+
|
| 118 |
+
NPM scripts are provided for convenient building:
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
# Development build
|
| 122 |
+
npm run dockerbuild
|
| 123 |
+
|
| 124 |
+
# Production build
|
| 125 |
+
npm run dockerbuild:prod
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### 2. Direct Docker Build Commands
|
| 129 |
+
|
| 130 |
+
You can use Docker's target feature to specify the build environment:
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
# Development build
|
| 134 |
+
docker build . --target bolt-ai-development
|
| 135 |
+
|
| 136 |
+
# Production build
|
| 137 |
+
docker build . --target bolt-ai-production
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### 3. Docker Compose with Profiles
|
| 141 |
+
|
| 142 |
+
Use Docker Compose profiles to manage different environments:
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
# Development environment
|
| 146 |
+
docker-compose --profile development up
|
| 147 |
+
|
| 148 |
+
# Production environment
|
| 149 |
+
docker-compose --profile production up
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
## Running the Application
|
| 153 |
+
|
| 154 |
+
After building using any of the methods above, run the container with:
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
# Development
|
| 158 |
+
docker run -p 5173:5173 --env-file .env.local bolt-ai:development
|
| 159 |
+
|
| 160 |
+
# Production
|
| 161 |
+
docker run -p 5173:5173 --env-file .env.local bolt-ai:production
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Deployment with Coolify
|
| 165 |
+
|
| 166 |
+
[Coolify](https://github.com/coollabsio/coolify) provides a straightforward deployment process:
|
| 167 |
+
|
| 168 |
+
1. Import your Git repository as a new project
|
| 169 |
+
2. Select your target environment (development/production)
|
| 170 |
+
3. Choose "Docker Compose" as the Build Pack
|
| 171 |
+
4. Configure deployment domains
|
| 172 |
+
5. Set the custom start command:
|
| 173 |
+
```bash
|
| 174 |
+
docker compose --profile production up
|
| 175 |
+
```
|
| 176 |
+
6. Configure environment variables
|
| 177 |
+
- Add necessary AI API keys
|
| 178 |
+
- Adjust other environment variables as needed
|
| 179 |
+
7. Deploy the application
|
| 180 |
+
|
| 181 |
+
## VS Code Integration
|
| 182 |
+
|
| 183 |
+
The `docker-compose.yaml` configuration is compatible with VS Code dev containers:
|
| 184 |
+
|
| 185 |
+
1. Open the command palette in VS Code
|
| 186 |
+
2. Select the dev container configuration
|
| 187 |
+
3. Choose the "development" profile from the context menu
|
| 188 |
+
|
| 189 |
+
## Environment Files
|
| 190 |
+
|
| 191 |
+
Ensure you have the appropriate `.env.local` file configured before running the containers. This file should contain:
|
| 192 |
+
- API keys
|
| 193 |
+
- Environment-specific configurations
|
| 194 |
+
- Other required environment variables
|
| 195 |
+
|
| 196 |
+
## Notes
|
| 197 |
+
|
| 198 |
+
- Port 5173 is exposed and mapped for both development and production environments
|
| 199 |
+
- Environment variables are loaded from `.env.local`
|
| 200 |
+
- Different profiles (development/production) can be used for different deployment scenarios
|
| 201 |
+
- The configuration supports both local development and production deployment
|
Dockerfile
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ARG BASE=node:20.18.0
|
| 2 |
+
FROM ${BASE} AS base
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install dependencies (this step is cached as long as the dependencies don't change)
|
| 7 |
+
COPY package.json pnpm-lock.yaml ./
|
| 8 |
+
|
| 9 |
+
RUN corepack enable pnpm && pnpm install
|
| 10 |
+
|
| 11 |
+
# Copy the rest of your app's source code
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# Expose the port the app runs on
|
| 15 |
+
EXPOSE 5173
|
| 16 |
+
|
| 17 |
+
# Production image
|
| 18 |
+
FROM base AS bolt-ai-production
|
| 19 |
+
|
| 20 |
+
# Define environment variables with default values or let them be overridden
|
| 21 |
+
ARG GROQ_API_KEY
|
| 22 |
+
ARG OPENAI_API_KEY
|
| 23 |
+
ARG ANTHROPIC_API_KEY
|
| 24 |
+
ARG OPEN_ROUTER_API_KEY
|
| 25 |
+
ARG GOOGLE_GENERATIVE_AI_API_KEY
|
| 26 |
+
ARG OLLAMA_API_BASE_URL
|
| 27 |
+
ARG VITE_LOG_LEVEL=debug
|
| 28 |
+
|
| 29 |
+
ENV WRANGLER_SEND_METRICS=false \
|
| 30 |
+
GROQ_API_KEY=${GROQ_API_KEY} \
|
| 31 |
+
OPENAI_API_KEY=${OPENAI_API_KEY} \
|
| 32 |
+
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
|
| 33 |
+
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
| 34 |
+
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
|
| 35 |
+
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
|
| 36 |
+
VITE_LOG_LEVEL=${VITE_LOG_LEVEL}
|
| 37 |
+
|
| 38 |
+
# Pre-configure wrangler to disable metrics
|
| 39 |
+
RUN mkdir -p /root/.config/.wrangler && \
|
| 40 |
+
echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json
|
| 41 |
+
|
| 42 |
+
RUN npm run build
|
| 43 |
+
|
| 44 |
+
CMD [ "pnpm", "run", "dockerstart"]
|
| 45 |
+
|
| 46 |
+
# Development image
|
| 47 |
+
FROM base AS bolt-ai-development
|
| 48 |
+
|
| 49 |
+
# Define the same environment variables for development
|
| 50 |
+
ARG GROQ_API_KEY
|
| 51 |
+
ARG OPENAI_API_KEY
|
| 52 |
+
ARG ANTHROPIC_API_KEY
|
| 53 |
+
ARG OPEN_ROUTER_API_KEY
|
| 54 |
+
ARG GOOGLE_GENERATIVE_AI_API_KEY
|
| 55 |
+
ARG OLLAMA_API_BASE_URL
|
| 56 |
+
ARG VITE_LOG_LEVEL=debug
|
| 57 |
+
|
| 58 |
+
ENV GROQ_API_KEY=${GROQ_API_KEY} \
|
| 59 |
+
OPENAI_API_KEY=${OPENAI_API_KEY} \
|
| 60 |
+
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
|
| 61 |
+
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
| 62 |
+
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
|
| 63 |
+
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
|
| 64 |
+
VITE_LOG_LEVEL=${VITE_LOG_LEVEL}
|
| 65 |
+
|
| 66 |
+
RUN mkdir -p ${WORKDIR}/run
|
| 67 |
+
CMD pnpm run dev --host
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 StackBlitz, Inc.
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,251 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[](https://bolt.new)
|
| 2 |
+
|
| 3 |
+
# Bolt.new Fork by Cole Medin
|
| 4 |
+
|
| 5 |
+
This fork of Bolt.new allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, 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.
|
| 6 |
+
|
| 7 |
+
# Requested Additions to this Fork - Feel Free to Contribute!!
|
| 8 |
+
|
| 9 |
+
- ✅ OpenRouter Integration (@coleam00)
|
| 10 |
+
- ✅ Gemini Integration (@jonathands)
|
| 11 |
+
- ✅ Autogenerate Ollama models from what is downloaded (@yunatamos)
|
| 12 |
+
- ✅ Filter models by provider (@jasonm23)
|
| 13 |
+
- ✅ Download project as ZIP (@fabwaseem)
|
| 14 |
+
- ✅ Improvements to the main Bolt.new prompt in `app\lib\.server\llm\prompts.ts` (@kofi-bhr)
|
| 15 |
+
- ✅ DeepSeek API Integration (@zenith110)
|
| 16 |
+
- ✅ Mistral API Integration (@ArulGandhi)
|
| 17 |
+
- ✅ "Open AI Like" API Integration (@ZerxZ)
|
| 18 |
+
- ✅ Ability to sync files (one way sync) to local folder (@muzafferkadir)
|
| 19 |
+
- ✅ Containerize the application with Docker for easy installation (@aaronbolton)
|
| 20 |
+
- ✅ Publish projects directly to GitHub (@goncaloalves)
|
| 21 |
+
- ⬜ Prevent Bolt from rewriting files as often (Done but need to review PR still)
|
| 22 |
+
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
| 23 |
+
- ⬜ **HIGH PRIORITY** Load local projects into the app
|
| 24 |
+
- ⬜ **HIGH PRIORITY** - Attach images to prompts
|
| 25 |
+
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
| 26 |
+
- ⬜ LM Studio Integration
|
| 27 |
+
- ⬜ Together Integration
|
| 28 |
+
- ⬜ Azure Open AI API Integration
|
| 29 |
+
- ⬜ HuggingFace Integration
|
| 30 |
+
- ⬜ Perplexity Integration
|
| 31 |
+
- ⬜ Vertex AI Integration
|
| 32 |
+
- ⬜ Cohere Integration
|
| 33 |
+
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms
|
| 34 |
+
- ⬜ Ability to revert code to earlier version
|
| 35 |
+
- ⬜ Prompt caching
|
| 36 |
+
- ⬜ Better prompt enhancing
|
| 37 |
+
- ⬜ Ability to enter API keys in the UI
|
| 38 |
+
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
| 39 |
+
- ⬜ VSCode Integration with git-like confirmations
|
| 40 |
+
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
| 41 |
+
- ⬜ Voice prompting
|
| 42 |
+
|
| 43 |
+
# Bolt.new: AI-Powered Full-Stack Web Development in the Browser
|
| 44 |
+
|
| 45 |
+
Bolt.new is an AI-powered web development agent that allows you to prompt, run, edit, and deploy full-stack applications directly from your browser—no local setup required. If you're here to build your own AI-powered web dev agent using the Bolt open source codebase, [click here to get started!](./CONTRIBUTING.md)
|
| 46 |
+
|
| 47 |
+
## What Makes Bolt.new Different
|
| 48 |
+
|
| 49 |
+
Claude, v0, etc are incredible- but you can't install packages, run backends, or edit code. That’s where Bolt.new stands out:
|
| 50 |
+
|
| 51 |
+
- **Full-Stack in the Browser**: Bolt.new integrates cutting-edge AI models with an in-browser development environment powered by **StackBlitz’s WebContainers**. This allows you to:
|
| 52 |
+
- Install and run npm tools and libraries (like Vite, Next.js, and more)
|
| 53 |
+
- Run Node.js servers
|
| 54 |
+
- Interact with third-party APIs
|
| 55 |
+
- Deploy to production from chat
|
| 56 |
+
- Share your work via a URL
|
| 57 |
+
|
| 58 |
+
- **AI with Environment Control**: Unlike traditional dev environments where the AI can only assist in code generation, Bolt.new gives AI models **complete control** over the entire environment including the filesystem, node server, package manager, terminal, and browser console. This empowers AI agents to handle the whole app lifecycle—from creation to deployment.
|
| 59 |
+
|
| 60 |
+
Whether you’re an experienced developer, a PM, or a designer, Bolt.new allows you to easily build production-grade full-stack applications.
|
| 61 |
+
|
| 62 |
+
For developers interested in building their own AI-powered development tools with WebContainers, check out the open-source Bolt codebase in this repo!
|
| 63 |
+
|
| 64 |
+
## Setup
|
| 65 |
+
|
| 66 |
+
Many of you are new users to installing software from Github. If you have any installation troubles reach out and submit an "issue" using the links above, or feel free to enhance this documentation by forking, editing the instructions, and doing a pull request.
|
| 67 |
+
|
| 68 |
+
1. Install Git from https://git-scm.com/downloads
|
| 69 |
+
|
| 70 |
+
2. Install Node.js from https://nodejs.org/en/download/
|
| 71 |
+
|
| 72 |
+
Pay attention to the installer notes after completion.
|
| 73 |
+
|
| 74 |
+
On all operating systems, the path to Node.js should automatically be added to your system path. But you can check your path if you want to be sure. On Windows, you can search for "edit the system environment variables" in your system, select "Environment Variables..." once you are in the system properties, and then check for a path to Node in your "Path" system variable. On a Mac or Linux machine, it will tell you to check if /usr/local/bin is in your $PATH. To determine if usr/local/bin is included in $PATH open your Terminal and run:
|
| 75 |
+
|
| 76 |
+
```
|
| 77 |
+
echo $PATH .
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
If you see usr/local/bin in the output then you're good to go.
|
| 81 |
+
|
| 82 |
+
3. Clone the repository (if you haven't already) by opening a Terminal window (or CMD with admin permissions) and then typing in this:
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
git clone https://github.com/coleam00/bolt.new-any-llm.git
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
3. Rename .env.example to .env and add your LLM API keys. You will find this file on a Mac at "[your name]/bold.new-any-llm/.env.example". For Windows and Linux the path will be similar.
|
| 89 |
+
|
| 90 |
+

|
| 91 |
+
|
| 92 |
+
If you can't see the file indicated above, its likely you can't view hidden files. On Mac, open a Terminal window and enter this command below. On Windows, you will see the hidden files option in File Explorer Settings. A quick Google search will help you if you are stuck here.
|
| 93 |
+
|
| 94 |
+
```
|
| 95 |
+
defaults write com.apple.finder AppleShowAllFiles YES
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
**NOTE**: you only have to set the ones you want to use and Ollama doesn't need an API key because it runs locally on your computer:
|
| 99 |
+
|
| 100 |
+
Get your GROQ API Key here: https://console.groq.com/keys
|
| 101 |
+
|
| 102 |
+
Get your Open AI API Key by following these instructions: https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
|
| 103 |
+
|
| 104 |
+
Get your Anthropic API Key in your account settings: https://console.anthropic.com/settings/keys
|
| 105 |
+
|
| 106 |
+
```
|
| 107 |
+
GROQ_API_KEY=XXX
|
| 108 |
+
OPENAI_API_KEY=XXX
|
| 109 |
+
ANTHROPIC_API_KEY=XXX
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
Optionally, you can set the debug level:
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
VITE_LOG_LEVEL=debug
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**Important**: Never commit your `.env` file to version control. It's already included in .gitignore.
|
| 119 |
+
|
| 120 |
+
## Run with Docker
|
| 121 |
+
|
| 122 |
+
Prerequisites:
|
| 123 |
+
|
| 124 |
+
Git and Node.js as mentioned above, as well as Docker: https://www.docker.com/
|
| 125 |
+
|
| 126 |
+
### 1a. Using Helper Scripts
|
| 127 |
+
|
| 128 |
+
NPM scripts are provided for convenient building:
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
# Development build
|
| 132 |
+
npm run dockerbuild
|
| 133 |
+
|
| 134 |
+
# Production build
|
| 135 |
+
npm run dockerbuild:prod
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
### 1b. Direct Docker Build Commands (alternative to using NPM scripts)
|
| 139 |
+
|
| 140 |
+
You can use Docker's target feature to specify the build environment instead of using NPM scripts if you wish:
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
# Development build
|
| 144 |
+
docker build . --target bolt-ai-development
|
| 145 |
+
|
| 146 |
+
# Production build
|
| 147 |
+
docker build . --target bolt-ai-production
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### 2. Docker Compose with Profiles to Run the Container
|
| 151 |
+
|
| 152 |
+
Use Docker Compose profiles to manage different environments:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
# Development environment
|
| 156 |
+
docker-compose --profile development up
|
| 157 |
+
|
| 158 |
+
# Production environment
|
| 159 |
+
docker-compose --profile production up
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
When you run the Docker Compose command with the development profile, any changes you
|
| 163 |
+
make on your machine to the code will automatically be reflected in the site running
|
| 164 |
+
on the container (i.e. hot reloading still applies!).
|
| 165 |
+
|
| 166 |
+
## Run Without Docker
|
| 167 |
+
|
| 168 |
+
1. Install dependencies using Terminal (or CMD in Windows with admin permissions):
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
pnpm install
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
If you get an error saying "command not found: pnpm" or similar, then that means pnpm isn't installed. You can install it via this:
|
| 175 |
+
|
| 176 |
+
```
|
| 177 |
+
sudo npm install -g pnpm
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
2. Start the application with the command:
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
pnpm run dev
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## Super Important Note on Running Ollama Models
|
| 187 |
+
|
| 188 |
+
Ollama models by default only have 2048 tokens for their context window. Even for large models that can easily handle way more.
|
| 189 |
+
This is not a large enough window to handle the Bolt.new/oTToDev prompt! You have to create a version of any model you want
|
| 190 |
+
to use where you specify a larger context window. Luckily it's super easy to do that.
|
| 191 |
+
|
| 192 |
+
All you have to do is:
|
| 193 |
+
|
| 194 |
+
- Create a file called "Modelfile" (no file extension) anywhere on your computer
|
| 195 |
+
- Put in the two lines:
|
| 196 |
+
|
| 197 |
+
```
|
| 198 |
+
FROM [Ollama model ID such as qwen2.5-coder:7b]
|
| 199 |
+
PARAMETER num_ctx 32768
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
- Run the command:
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
ollama create -f Modelfile [your new model ID, can be whatever you want (example: qwen2.5-coder-extra-ctx:7b)]
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
Now you have a new Ollama model that isn't heavily limited in the context length like Ollama models are by default for some reason.
|
| 209 |
+
You'll see this new model in the list of Ollama models along with all the others you pulled!
|
| 210 |
+
|
| 211 |
+
## Adding New LLMs:
|
| 212 |
+
|
| 213 |
+
To make new LLMs available to use in this version of Bolt.new, head on over to `app/utils/constants.ts` and find the constant MODEL_LIST. Each element in this array is an object that has the model ID for the name (get this from the provider's API documentation), a label for the frontend model dropdown, and the provider.
|
| 214 |
+
|
| 215 |
+
By default, Anthropic, OpenAI, Groq, and Ollama are implemented as providers, but the YouTube video for this repo covers how to extend this to work with more providers if you wish!
|
| 216 |
+
|
| 217 |
+
When you add a new model to the MODEL_LIST array, it will immediately be available to use when you run the app locally or reload it. For Ollama models, make sure you have the model installed already before trying to use it here!
|
| 218 |
+
|
| 219 |
+
## Available Scripts
|
| 220 |
+
|
| 221 |
+
- `pnpm run dev`: Starts the development server.
|
| 222 |
+
- `pnpm run build`: Builds the project.
|
| 223 |
+
- `pnpm run start`: Runs the built application locally using Wrangler Pages. This script uses `bindings.sh` to set up necessary bindings so you don't have to duplicate environment variables.
|
| 224 |
+
- `pnpm run preview`: Builds the project and then starts it locally, useful for testing the production build. Note, HTTP streaming currently doesn't work as expected with `wrangler pages dev`.
|
| 225 |
+
- `pnpm test`: Runs the test suite using Vitest.
|
| 226 |
+
- `pnpm run typecheck`: Runs TypeScript type checking.
|
| 227 |
+
- `pnpm run typegen`: Generates TypeScript types using Wrangler.
|
| 228 |
+
- `pnpm run deploy`: Builds the project and deploys it to Cloudflare Pages.
|
| 229 |
+
|
| 230 |
+
## Development
|
| 231 |
+
|
| 232 |
+
To start the development server:
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
pnpm run dev
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
This will start the Remix Vite development server. You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway.
|
| 239 |
+
|
| 240 |
+
## Tips and Tricks
|
| 241 |
+
|
| 242 |
+
Here are some tips to get the most out of Bolt.new:
|
| 243 |
+
|
| 244 |
+
- **Be specific about your stack**: If you want to use specific frameworks or libraries (like Astro, Tailwind, ShadCN, or any other popular JavaScript framework), mention them in your initial prompt to ensure Bolt scaffolds the project accordingly.
|
| 245 |
+
|
| 246 |
+
- **Use the enhance prompt icon**: Before sending your prompt, try clicking the 'enhance' icon to have the AI model help you refine your prompt, then edit the results before submitting.
|
| 247 |
+
|
| 248 |
+
- **Scaffold the basics first, then add features**: Make sure the basic structure of your application is in place before diving into more advanced functionality. This helps Bolt understand the foundation of your project and ensure everything is wired up right before building out more advanced functionality.
|
| 249 |
+
|
| 250 |
+
- **Batch simple instructions**: Save time by combining simple instructions into one message. For example, you can ask Bolt to change the color scheme, add mobile responsiveness, and restart the dev server, all in one go saving you time and reducing API credit consumption significantly.
|
| 251 |
+
https://www.youtube.com/watch?v=YkF1gDcOp04
|
app/components/chat/Artifact.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { AnimatePresence, motion } from 'framer-motion';
|
| 3 |
+
import { computed } from 'nanostores';
|
| 4 |
+
import { memo, useEffect, useRef, useState } from 'react';
|
| 5 |
+
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
|
| 6 |
+
import type { ActionState } from '~/lib/runtime/action-runner';
|
| 7 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 8 |
+
import { classNames } from '~/utils/classNames';
|
| 9 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 10 |
+
|
| 11 |
+
const highlighterOptions = {
|
| 12 |
+
langs: ['shell'],
|
| 13 |
+
themes: ['light-plus', 'dark-plus'],
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
|
| 17 |
+
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
|
| 18 |
+
|
| 19 |
+
if (import.meta.hot) {
|
| 20 |
+
import.meta.hot.data.shellHighlighter = shellHighlighter;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
interface ArtifactProps {
|
| 24 |
+
messageId: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
| 28 |
+
const userToggledActions = useRef(false);
|
| 29 |
+
const [showActions, setShowActions] = useState(false);
|
| 30 |
+
|
| 31 |
+
const artifacts = useStore(workbenchStore.artifacts);
|
| 32 |
+
const artifact = artifacts[messageId];
|
| 33 |
+
|
| 34 |
+
const actions = useStore(
|
| 35 |
+
computed(artifact.runner.actions, (actions) => {
|
| 36 |
+
return Object.values(actions);
|
| 37 |
+
}),
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
const toggleActions = () => {
|
| 41 |
+
userToggledActions.current = true;
|
| 42 |
+
setShowActions(!showActions);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (actions.length && !showActions && !userToggledActions.current) {
|
| 47 |
+
setShowActions(true);
|
| 48 |
+
}
|
| 49 |
+
}, [actions]);
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
|
| 53 |
+
<div className="flex">
|
| 54 |
+
<button
|
| 55 |
+
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
|
| 56 |
+
onClick={() => {
|
| 57 |
+
const showWorkbench = workbenchStore.showWorkbench.get();
|
| 58 |
+
workbenchStore.showWorkbench.set(!showWorkbench);
|
| 59 |
+
}}
|
| 60 |
+
>
|
| 61 |
+
<div className="px-5 p-3.5 w-full text-left">
|
| 62 |
+
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
|
| 63 |
+
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
|
| 64 |
+
</div>
|
| 65 |
+
</button>
|
| 66 |
+
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
| 67 |
+
<AnimatePresence>
|
| 68 |
+
{actions.length && (
|
| 69 |
+
<motion.button
|
| 70 |
+
initial={{ width: 0 }}
|
| 71 |
+
animate={{ width: 'auto' }}
|
| 72 |
+
exit={{ width: 0 }}
|
| 73 |
+
transition={{ duration: 0.15, ease: cubicEasingFn }}
|
| 74 |
+
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
|
| 75 |
+
onClick={toggleActions}
|
| 76 |
+
>
|
| 77 |
+
<div className="p-4">
|
| 78 |
+
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
| 79 |
+
</div>
|
| 80 |
+
</motion.button>
|
| 81 |
+
)}
|
| 82 |
+
</AnimatePresence>
|
| 83 |
+
</div>
|
| 84 |
+
<AnimatePresence>
|
| 85 |
+
{showActions && actions.length > 0 && (
|
| 86 |
+
<motion.div
|
| 87 |
+
className="actions"
|
| 88 |
+
initial={{ height: 0 }}
|
| 89 |
+
animate={{ height: 'auto' }}
|
| 90 |
+
exit={{ height: '0px' }}
|
| 91 |
+
transition={{ duration: 0.15 }}
|
| 92 |
+
>
|
| 93 |
+
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
| 94 |
+
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
| 95 |
+
<ActionList actions={actions} />
|
| 96 |
+
</div>
|
| 97 |
+
</motion.div>
|
| 98 |
+
)}
|
| 99 |
+
</AnimatePresence>
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
interface ShellCodeBlockProps {
|
| 105 |
+
classsName?: string;
|
| 106 |
+
code: string;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
|
| 110 |
+
return (
|
| 111 |
+
<div
|
| 112 |
+
className={classNames('text-xs', classsName)}
|
| 113 |
+
dangerouslySetInnerHTML={{
|
| 114 |
+
__html: shellHighlighter.codeToHtml(code, {
|
| 115 |
+
lang: 'shell',
|
| 116 |
+
theme: 'dark-plus',
|
| 117 |
+
}),
|
| 118 |
+
}}
|
| 119 |
+
></div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
interface ActionListProps {
|
| 124 |
+
actions: ActionState[];
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const actionVariants = {
|
| 128 |
+
hidden: { opacity: 0, y: 20 },
|
| 129 |
+
visible: { opacity: 1, y: 0 },
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const ActionList = memo(({ actions }: ActionListProps) => {
|
| 133 |
+
return (
|
| 134 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
| 135 |
+
<ul className="list-none space-y-2.5">
|
| 136 |
+
{actions.map((action, index) => {
|
| 137 |
+
const { status, type, content } = action;
|
| 138 |
+
const isLast = index === actions.length - 1;
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<motion.li
|
| 142 |
+
key={index}
|
| 143 |
+
variants={actionVariants}
|
| 144 |
+
initial="hidden"
|
| 145 |
+
animate="visible"
|
| 146 |
+
transition={{
|
| 147 |
+
duration: 0.2,
|
| 148 |
+
ease: cubicEasingFn,
|
| 149 |
+
}}
|
| 150 |
+
>
|
| 151 |
+
<div className="flex items-center gap-1.5 text-sm">
|
| 152 |
+
<div className={classNames('text-lg', getIconColor(action.status))}>
|
| 153 |
+
{status === 'running' ? (
|
| 154 |
+
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
| 155 |
+
) : status === 'pending' ? (
|
| 156 |
+
<div className="i-ph:circle-duotone"></div>
|
| 157 |
+
) : status === 'complete' ? (
|
| 158 |
+
<div className="i-ph:check"></div>
|
| 159 |
+
) : status === 'failed' || status === 'aborted' ? (
|
| 160 |
+
<div className="i-ph:x"></div>
|
| 161 |
+
) : null}
|
| 162 |
+
</div>
|
| 163 |
+
{type === 'file' ? (
|
| 164 |
+
<div>
|
| 165 |
+
Create{' '}
|
| 166 |
+
<code className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md">
|
| 167 |
+
{action.filePath}
|
| 168 |
+
</code>
|
| 169 |
+
</div>
|
| 170 |
+
) : type === 'shell' ? (
|
| 171 |
+
<div className="flex items-center w-full min-h-[28px]">
|
| 172 |
+
<span className="flex-1">Run command</span>
|
| 173 |
+
</div>
|
| 174 |
+
) : null}
|
| 175 |
+
</div>
|
| 176 |
+
{type === 'shell' && (
|
| 177 |
+
<ShellCodeBlock
|
| 178 |
+
classsName={classNames('mt-1', {
|
| 179 |
+
'mb-3.5': !isLast,
|
| 180 |
+
})}
|
| 181 |
+
code={content}
|
| 182 |
+
/>
|
| 183 |
+
)}
|
| 184 |
+
</motion.li>
|
| 185 |
+
);
|
| 186 |
+
})}
|
| 187 |
+
</ul>
|
| 188 |
+
</motion.div>
|
| 189 |
+
);
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
function getIconColor(status: ActionState['status']) {
|
| 193 |
+
switch (status) {
|
| 194 |
+
case 'pending': {
|
| 195 |
+
return 'text-bolt-elements-textTertiary';
|
| 196 |
+
}
|
| 197 |
+
case 'running': {
|
| 198 |
+
return 'text-bolt-elements-loader-progress';
|
| 199 |
+
}
|
| 200 |
+
case 'complete': {
|
| 201 |
+
return 'text-bolt-elements-icon-success';
|
| 202 |
+
}
|
| 203 |
+
case 'aborted': {
|
| 204 |
+
return 'text-bolt-elements-textSecondary';
|
| 205 |
+
}
|
| 206 |
+
case 'failed': {
|
| 207 |
+
return 'text-bolt-elements-icon-error';
|
| 208 |
+
}
|
| 209 |
+
default: {
|
| 210 |
+
return undefined;
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
}
|
app/components/chat/AssistantMessage.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
import { Markdown } from './Markdown';
|
| 3 |
+
|
| 4 |
+
interface AssistantMessageProps {
|
| 5 |
+
content: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
|
| 9 |
+
return (
|
| 10 |
+
<div className="overflow-hidden w-full">
|
| 11 |
+
<Markdown html>{content}</Markdown>
|
| 12 |
+
</div>
|
| 13 |
+
);
|
| 14 |
+
});
|
app/components/chat/BaseChat.module.scss
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.BaseChat {
|
| 2 |
+
&[data-chat-visible='false'] {
|
| 3 |
+
--workbench-inner-width: 100%;
|
| 4 |
+
--workbench-left: 0;
|
| 5 |
+
|
| 6 |
+
.Chat {
|
| 7 |
+
--at-apply: bolt-ease-cubic-bezier;
|
| 8 |
+
transition-property: transform, opacity;
|
| 9 |
+
transition-duration: 0.3s;
|
| 10 |
+
will-change: transform, opacity;
|
| 11 |
+
transform: translateX(-50%);
|
| 12 |
+
opacity: 0;
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.Chat {
|
| 18 |
+
opacity: 1;
|
| 19 |
+
}
|
app/components/chat/BaseChat.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
// Preventing TS checks with files presented in the video for a better presentation.
|
| 3 |
+
import type { Message } from 'ai';
|
| 4 |
+
import React, { type RefCallback } from 'react';
|
| 5 |
+
import { ClientOnly } from 'remix-utils/client-only';
|
| 6 |
+
import { Menu } from '~/components/sidebar/Menu.client';
|
| 7 |
+
import { IconButton } from '~/components/ui/IconButton';
|
| 8 |
+
import { Workbench } from '~/components/workbench/Workbench.client';
|
| 9 |
+
import { classNames } from '~/utils/classNames';
|
| 10 |
+
import { MODEL_LIST, DEFAULT_PROVIDER } from '~/utils/constants';
|
| 11 |
+
import { Messages } from './Messages.client';
|
| 12 |
+
import { SendButton } from './SendButton.client';
|
| 13 |
+
import { useState } from 'react';
|
| 14 |
+
|
| 15 |
+
import styles from './BaseChat.module.scss';
|
| 16 |
+
|
| 17 |
+
const EXAMPLE_PROMPTS = [
|
| 18 |
+
{ text: 'Build a todo app in React using Tailwind' },
|
| 19 |
+
{ text: 'Build a simple blog using Astro' },
|
| 20 |
+
{ text: 'Create a cookie consent form using Material UI' },
|
| 21 |
+
{ text: 'Make a space invaders game' },
|
| 22 |
+
{ text: 'How do I center a div?' },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const providerList = [...new Set(MODEL_LIST.map((model) => model.provider))]
|
| 26 |
+
|
| 27 |
+
const ModelSelector = ({ model, setModel, modelList, providerList }) => {
|
| 28 |
+
const [provider, setProvider] = useState(DEFAULT_PROVIDER);
|
| 29 |
+
return (
|
| 30 |
+
<div className="mb-2">
|
| 31 |
+
<select
|
| 32 |
+
value={provider}
|
| 33 |
+
onChange={(e) => {
|
| 34 |
+
setProvider(e.target.value);
|
| 35 |
+
const firstModel = [...modelList].find(m => m.provider == e.target.value);
|
| 36 |
+
setModel(firstModel ? firstModel.name : '');
|
| 37 |
+
}}
|
| 38 |
+
className="w-full p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none"
|
| 39 |
+
>
|
| 40 |
+
{providerList.map((provider) => (
|
| 41 |
+
<option key={provider} value={provider}>
|
| 42 |
+
{provider}
|
| 43 |
+
</option>
|
| 44 |
+
))}
|
| 45 |
+
<option key="Ollama" value="Ollama">
|
| 46 |
+
Ollama
|
| 47 |
+
</option>
|
| 48 |
+
<option key="OpenAILike" value="OpenAILike">
|
| 49 |
+
OpenAILike
|
| 50 |
+
</option>
|
| 51 |
+
</select>
|
| 52 |
+
<select
|
| 53 |
+
value={model}
|
| 54 |
+
onChange={(e) => setModel(e.target.value)}
|
| 55 |
+
className="w-full p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none"
|
| 56 |
+
>
|
| 57 |
+
{[...modelList].filter(e => e.provider == provider && e.name).map((modelOption) => (
|
| 58 |
+
<option key={modelOption.name} value={modelOption.name}>
|
| 59 |
+
{modelOption.label}
|
| 60 |
+
</option>
|
| 61 |
+
))}
|
| 62 |
+
</select>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const TEXTAREA_MIN_HEIGHT = 76;
|
| 68 |
+
|
| 69 |
+
interface BaseChatProps {
|
| 70 |
+
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
| 71 |
+
messageRef?: RefCallback<HTMLDivElement> | undefined;
|
| 72 |
+
scrollRef?: RefCallback<HTMLDivElement> | undefined;
|
| 73 |
+
showChat?: boolean;
|
| 74 |
+
chatStarted?: boolean;
|
| 75 |
+
isStreaming?: boolean;
|
| 76 |
+
messages?: Message[];
|
| 77 |
+
enhancingPrompt?: boolean;
|
| 78 |
+
promptEnhanced?: boolean;
|
| 79 |
+
input?: string;
|
| 80 |
+
model: string;
|
| 81 |
+
setModel: (model: string) => void;
|
| 82 |
+
handleStop?: () => void;
|
| 83 |
+
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
| 84 |
+
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
| 85 |
+
enhancePrompt?: () => void;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
| 89 |
+
(
|
| 90 |
+
{
|
| 91 |
+
textareaRef,
|
| 92 |
+
messageRef,
|
| 93 |
+
scrollRef,
|
| 94 |
+
showChat = true,
|
| 95 |
+
chatStarted = false,
|
| 96 |
+
isStreaming = false,
|
| 97 |
+
enhancingPrompt = false,
|
| 98 |
+
promptEnhanced = false,
|
| 99 |
+
messages,
|
| 100 |
+
input = '',
|
| 101 |
+
model,
|
| 102 |
+
setModel,
|
| 103 |
+
sendMessage,
|
| 104 |
+
handleInputChange,
|
| 105 |
+
enhancePrompt,
|
| 106 |
+
handleStop,
|
| 107 |
+
},
|
| 108 |
+
ref,
|
| 109 |
+
) => {
|
| 110 |
+
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div
|
| 114 |
+
ref={ref}
|
| 115 |
+
className={classNames(
|
| 116 |
+
styles.BaseChat,
|
| 117 |
+
'relative flex h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
|
| 118 |
+
)}
|
| 119 |
+
data-chat-visible={showChat}
|
| 120 |
+
>
|
| 121 |
+
<ClientOnly>{() => <Menu />}</ClientOnly>
|
| 122 |
+
<div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
|
| 123 |
+
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
|
| 124 |
+
{!chatStarted && (
|
| 125 |
+
<div id="intro" className="mt-[26vh] max-w-chat mx-auto">
|
| 126 |
+
<h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2">
|
| 127 |
+
Where ideas begin
|
| 128 |
+
</h1>
|
| 129 |
+
<p className="mb-4 text-center text-bolt-elements-textSecondary">
|
| 130 |
+
Bring ideas to life in seconds or get help on existing projects.
|
| 131 |
+
</p>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
<div
|
| 135 |
+
className={classNames('pt-6 px-6', {
|
| 136 |
+
'h-full flex flex-col': chatStarted,
|
| 137 |
+
})}
|
| 138 |
+
>
|
| 139 |
+
<ClientOnly>
|
| 140 |
+
{() => {
|
| 141 |
+
return chatStarted ? (
|
| 142 |
+
<Messages
|
| 143 |
+
ref={messageRef}
|
| 144 |
+
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
|
| 145 |
+
messages={messages}
|
| 146 |
+
isStreaming={isStreaming}
|
| 147 |
+
/>
|
| 148 |
+
) : null;
|
| 149 |
+
}}
|
| 150 |
+
</ClientOnly>
|
| 151 |
+
<div
|
| 152 |
+
className={classNames('relative w-full max-w-chat mx-auto z-prompt', {
|
| 153 |
+
'sticky bottom-0': chatStarted,
|
| 154 |
+
})}
|
| 155 |
+
>
|
| 156 |
+
<ModelSelector
|
| 157 |
+
model={model}
|
| 158 |
+
setModel={setModel}
|
| 159 |
+
modelList={MODEL_LIST}
|
| 160 |
+
providerList={providerList}
|
| 161 |
+
/>
|
| 162 |
+
<div
|
| 163 |
+
className={classNames(
|
| 164 |
+
'shadow-sm border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden',
|
| 165 |
+
)}
|
| 166 |
+
>
|
| 167 |
+
<textarea
|
| 168 |
+
ref={textareaRef}
|
| 169 |
+
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent`}
|
| 170 |
+
onKeyDown={(event) => {
|
| 171 |
+
if (event.key === 'Enter') {
|
| 172 |
+
if (event.shiftKey) {
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
event.preventDefault();
|
| 177 |
+
|
| 178 |
+
sendMessage?.(event);
|
| 179 |
+
}
|
| 180 |
+
}}
|
| 181 |
+
value={input}
|
| 182 |
+
onChange={(event) => {
|
| 183 |
+
handleInputChange?.(event);
|
| 184 |
+
}}
|
| 185 |
+
style={{
|
| 186 |
+
minHeight: TEXTAREA_MIN_HEIGHT,
|
| 187 |
+
maxHeight: TEXTAREA_MAX_HEIGHT,
|
| 188 |
+
}}
|
| 189 |
+
placeholder="How can Bolt help you today?"
|
| 190 |
+
translate="no"
|
| 191 |
+
/>
|
| 192 |
+
<ClientOnly>
|
| 193 |
+
{() => (
|
| 194 |
+
<SendButton
|
| 195 |
+
show={input.length > 0 || isStreaming}
|
| 196 |
+
isStreaming={isStreaming}
|
| 197 |
+
onClick={(event) => {
|
| 198 |
+
if (isStreaming) {
|
| 199 |
+
handleStop?.();
|
| 200 |
+
return;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
sendMessage?.(event);
|
| 204 |
+
}}
|
| 205 |
+
/>
|
| 206 |
+
)}
|
| 207 |
+
</ClientOnly>
|
| 208 |
+
<div className="flex justify-between text-sm p-4 pt-2">
|
| 209 |
+
<div className="flex gap-1 items-center">
|
| 210 |
+
<IconButton
|
| 211 |
+
title="Enhance prompt"
|
| 212 |
+
disabled={input.length === 0 || enhancingPrompt}
|
| 213 |
+
className={classNames({
|
| 214 |
+
'opacity-100!': enhancingPrompt,
|
| 215 |
+
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
|
| 216 |
+
promptEnhanced,
|
| 217 |
+
})}
|
| 218 |
+
onClick={() => enhancePrompt?.()}
|
| 219 |
+
>
|
| 220 |
+
{enhancingPrompt ? (
|
| 221 |
+
<>
|
| 222 |
+
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl"></div>
|
| 223 |
+
<div className="ml-1.5">Enhancing prompt...</div>
|
| 224 |
+
</>
|
| 225 |
+
) : (
|
| 226 |
+
<>
|
| 227 |
+
<div className="i-bolt:stars text-xl"></div>
|
| 228 |
+
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
|
| 229 |
+
</>
|
| 230 |
+
)}
|
| 231 |
+
</IconButton>
|
| 232 |
+
</div>
|
| 233 |
+
{input.length > 3 ? (
|
| 234 |
+
<div className="text-xs text-bolt-elements-textTertiary">
|
| 235 |
+
Use <kbd className="kdb">Shift</kbd> + <kbd className="kdb">Return</kbd> for a new line
|
| 236 |
+
</div>
|
| 237 |
+
) : null}
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
<div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
{!chatStarted && (
|
| 244 |
+
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
|
| 245 |
+
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
|
| 246 |
+
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
|
| 247 |
+
return (
|
| 248 |
+
<button
|
| 249 |
+
key={index}
|
| 250 |
+
onClick={(event) => {
|
| 251 |
+
sendMessage?.(event, examplePrompt.text);
|
| 252 |
+
}}
|
| 253 |
+
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
|
| 254 |
+
>
|
| 255 |
+
{examplePrompt.text}
|
| 256 |
+
<div className="i-ph:arrow-bend-down-left" />
|
| 257 |
+
</button>
|
| 258 |
+
);
|
| 259 |
+
})}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
)}
|
| 263 |
+
</div>
|
| 264 |
+
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
);
|
| 268 |
+
},
|
| 269 |
+
);
|
app/components/chat/Chat.client.tsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
// Preventing TS checks with files presented in the video for a better presentation.
|
| 3 |
+
import { useStore } from '@nanostores/react';
|
| 4 |
+
import type { Message } from 'ai';
|
| 5 |
+
import { useChat } from 'ai/react';
|
| 6 |
+
import { useAnimate } from 'framer-motion';
|
| 7 |
+
import { memo, useEffect, useRef, useState } from 'react';
|
| 8 |
+
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
| 9 |
+
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
| 10 |
+
import { useChatHistory } from '~/lib/persistence';
|
| 11 |
+
import { chatStore } from '~/lib/stores/chat';
|
| 12 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 13 |
+
import { fileModificationsToHTML } from '~/utils/diff';
|
| 14 |
+
import { DEFAULT_MODEL } from '~/utils/constants';
|
| 15 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 16 |
+
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
| 17 |
+
import { BaseChat } from './BaseChat';
|
| 18 |
+
|
| 19 |
+
const toastAnimation = cssTransition({
|
| 20 |
+
enter: 'animated fadeInRight',
|
| 21 |
+
exit: 'animated fadeOutRight',
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
const logger = createScopedLogger('Chat');
|
| 25 |
+
|
| 26 |
+
export function Chat() {
|
| 27 |
+
renderLogger.trace('Chat');
|
| 28 |
+
|
| 29 |
+
const { ready, initialMessages, storeMessageHistory } = useChatHistory();
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<>
|
| 33 |
+
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
|
| 34 |
+
<ToastContainer
|
| 35 |
+
closeButton={({ closeToast }) => {
|
| 36 |
+
return (
|
| 37 |
+
<button className="Toastify__close-button" onClick={closeToast}>
|
| 38 |
+
<div className="i-ph:x text-lg" />
|
| 39 |
+
</button>
|
| 40 |
+
);
|
| 41 |
+
}}
|
| 42 |
+
icon={({ type }) => {
|
| 43 |
+
/**
|
| 44 |
+
* @todo Handle more types if we need them. This may require extra color palettes.
|
| 45 |
+
*/
|
| 46 |
+
switch (type) {
|
| 47 |
+
case 'success': {
|
| 48 |
+
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
|
| 49 |
+
}
|
| 50 |
+
case 'error': {
|
| 51 |
+
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return undefined;
|
| 56 |
+
}}
|
| 57 |
+
position="bottom-right"
|
| 58 |
+
pauseOnFocusLoss
|
| 59 |
+
transition={toastAnimation}
|
| 60 |
+
/>
|
| 61 |
+
</>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
interface ChatProps {
|
| 66 |
+
initialMessages: Message[];
|
| 67 |
+
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
|
| 71 |
+
useShortcuts();
|
| 72 |
+
|
| 73 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 74 |
+
|
| 75 |
+
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
| 76 |
+
const [model, setModel] = useState(DEFAULT_MODEL);
|
| 77 |
+
|
| 78 |
+
const { showChat } = useStore(chatStore);
|
| 79 |
+
|
| 80 |
+
const [animationScope, animate] = useAnimate();
|
| 81 |
+
|
| 82 |
+
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
|
| 83 |
+
api: '/api/chat',
|
| 84 |
+
onError: (error) => {
|
| 85 |
+
logger.error('Request failed\n\n', error);
|
| 86 |
+
toast.error('There was an error processing your request');
|
| 87 |
+
},
|
| 88 |
+
onFinish: () => {
|
| 89 |
+
logger.debug('Finished streaming');
|
| 90 |
+
},
|
| 91 |
+
initialMessages,
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
| 95 |
+
const { parsedMessages, parseMessages } = useMessageParser();
|
| 96 |
+
|
| 97 |
+
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
| 98 |
+
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
chatStore.setKey('started', initialMessages.length > 0);
|
| 101 |
+
}, []);
|
| 102 |
+
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
parseMessages(messages, isLoading);
|
| 105 |
+
|
| 106 |
+
if (messages.length > initialMessages.length) {
|
| 107 |
+
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
| 108 |
+
}
|
| 109 |
+
}, [messages, isLoading, parseMessages]);
|
| 110 |
+
|
| 111 |
+
const scrollTextArea = () => {
|
| 112 |
+
const textarea = textareaRef.current;
|
| 113 |
+
|
| 114 |
+
if (textarea) {
|
| 115 |
+
textarea.scrollTop = textarea.scrollHeight;
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const abort = () => {
|
| 120 |
+
stop();
|
| 121 |
+
chatStore.setKey('aborted', true);
|
| 122 |
+
workbenchStore.abortAllActions();
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
const textarea = textareaRef.current;
|
| 127 |
+
|
| 128 |
+
if (textarea) {
|
| 129 |
+
textarea.style.height = 'auto';
|
| 130 |
+
|
| 131 |
+
const scrollHeight = textarea.scrollHeight;
|
| 132 |
+
|
| 133 |
+
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
| 134 |
+
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
| 135 |
+
}
|
| 136 |
+
}, [input, textareaRef]);
|
| 137 |
+
|
| 138 |
+
const runAnimation = async () => {
|
| 139 |
+
if (chatStarted) {
|
| 140 |
+
return;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
await Promise.all([
|
| 144 |
+
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
| 145 |
+
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
| 146 |
+
]);
|
| 147 |
+
|
| 148 |
+
chatStore.setKey('started', true);
|
| 149 |
+
|
| 150 |
+
setChatStarted(true);
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
| 154 |
+
const _input = messageInput || input;
|
| 155 |
+
|
| 156 |
+
if (_input.length === 0 || isLoading) {
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
| 162 |
+
* many unsaved files. In that case we need to block user input and show an indicator
|
| 163 |
+
* of some kind so the user is aware that something is happening. But I consider the
|
| 164 |
+
* happy case to be no unsaved files and I would expect users to save their changes
|
| 165 |
+
* before they send another message.
|
| 166 |
+
*/
|
| 167 |
+
await workbenchStore.saveAllFiles();
|
| 168 |
+
|
| 169 |
+
const fileModifications = workbenchStore.getFileModifcations();
|
| 170 |
+
|
| 171 |
+
chatStore.setKey('aborted', false);
|
| 172 |
+
|
| 173 |
+
runAnimation();
|
| 174 |
+
|
| 175 |
+
if (fileModifications !== undefined) {
|
| 176 |
+
const diff = fileModificationsToHTML(fileModifications);
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* If we have file modifications we append a new user message manually since we have to prefix
|
| 180 |
+
* the user input with the file modifications and we don't want the new user input to appear
|
| 181 |
+
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
| 182 |
+
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
| 183 |
+
* aren't relevant here.
|
| 184 |
+
*/
|
| 185 |
+
append({ role: 'user', content: `[Model: ${model}]\n\n${diff}\n\n${_input}` });
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* After sending a new message we reset all modifications since the model
|
| 189 |
+
* should now be aware of all the changes.
|
| 190 |
+
*/
|
| 191 |
+
workbenchStore.resetAllFileModifications();
|
| 192 |
+
} else {
|
| 193 |
+
append({ role: 'user', content: `[Model: ${model}]\n\n${_input}` });
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
setInput('');
|
| 197 |
+
|
| 198 |
+
resetEnhancer();
|
| 199 |
+
|
| 200 |
+
textareaRef.current?.blur();
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
+
const [messageRef, scrollRef] = useSnapScroll();
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<BaseChat
|
| 207 |
+
ref={animationScope}
|
| 208 |
+
textareaRef={textareaRef}
|
| 209 |
+
input={input}
|
| 210 |
+
showChat={showChat}
|
| 211 |
+
chatStarted={chatStarted}
|
| 212 |
+
isStreaming={isLoading}
|
| 213 |
+
enhancingPrompt={enhancingPrompt}
|
| 214 |
+
promptEnhanced={promptEnhanced}
|
| 215 |
+
sendMessage={sendMessage}
|
| 216 |
+
model={model}
|
| 217 |
+
setModel={setModel}
|
| 218 |
+
messageRef={messageRef}
|
| 219 |
+
scrollRef={scrollRef}
|
| 220 |
+
handleInputChange={handleInputChange}
|
| 221 |
+
handleStop={abort}
|
| 222 |
+
messages={messages.map((message, i) => {
|
| 223 |
+
if (message.role === 'user') {
|
| 224 |
+
return message;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
...message,
|
| 229 |
+
content: parsedMessages[i] || '',
|
| 230 |
+
};
|
| 231 |
+
})}
|
| 232 |
+
enhancePrompt={() => {
|
| 233 |
+
enhancePrompt(input, (input) => {
|
| 234 |
+
setInput(input);
|
| 235 |
+
scrollTextArea();
|
| 236 |
+
});
|
| 237 |
+
}}
|
| 238 |
+
/>
|
| 239 |
+
);
|
| 240 |
+
});
|
app/components/chat/CodeBlock.module.scss
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.CopyButtonContainer {
|
| 2 |
+
button:before {
|
| 3 |
+
content: 'Copied';
|
| 4 |
+
font-size: 12px;
|
| 5 |
+
position: absolute;
|
| 6 |
+
left: -53px;
|
| 7 |
+
padding: 2px 6px;
|
| 8 |
+
height: 30px;
|
| 9 |
+
}
|
| 10 |
+
}
|
app/components/chat/CodeBlock.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useEffect, useState } from 'react';
|
| 2 |
+
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { createScopedLogger } from '~/utils/logger';
|
| 5 |
+
|
| 6 |
+
import styles from './CodeBlock.module.scss';
|
| 7 |
+
|
| 8 |
+
const logger = createScopedLogger('CodeBlock');
|
| 9 |
+
|
| 10 |
+
interface CodeBlockProps {
|
| 11 |
+
className?: string;
|
| 12 |
+
code: string;
|
| 13 |
+
language?: BundledLanguage | SpecialLanguage;
|
| 14 |
+
theme?: 'light-plus' | 'dark-plus';
|
| 15 |
+
disableCopy?: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const CodeBlock = memo(
|
| 19 |
+
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
|
| 20 |
+
const [html, setHTML] = useState<string | undefined>(undefined);
|
| 21 |
+
const [copied, setCopied] = useState(false);
|
| 22 |
+
|
| 23 |
+
const copyToClipboard = () => {
|
| 24 |
+
if (copied) {
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
navigator.clipboard.writeText(code);
|
| 29 |
+
|
| 30 |
+
setCopied(true);
|
| 31 |
+
|
| 32 |
+
setTimeout(() => {
|
| 33 |
+
setCopied(false);
|
| 34 |
+
}, 2000);
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
| 39 |
+
logger.warn(`Unsupported language '${language}'`);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
logger.trace(`Language = ${language}`);
|
| 43 |
+
|
| 44 |
+
const processCode = async () => {
|
| 45 |
+
setHTML(await codeToHtml(code, { lang: language, theme }));
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
processCode();
|
| 49 |
+
}, [code]);
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className={classNames('relative group text-left', className)}>
|
| 53 |
+
<div
|
| 54 |
+
className={classNames(
|
| 55 |
+
styles.CopyButtonContainer,
|
| 56 |
+
'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
|
| 57 |
+
{
|
| 58 |
+
'rounded-l-0 opacity-100': copied,
|
| 59 |
+
},
|
| 60 |
+
)}
|
| 61 |
+
>
|
| 62 |
+
{!disableCopy && (
|
| 63 |
+
<button
|
| 64 |
+
className={classNames(
|
| 65 |
+
'flex items-center bg-transparent p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300',
|
| 66 |
+
{
|
| 67 |
+
'before:opacity-0': !copied,
|
| 68 |
+
'before:opacity-100': copied,
|
| 69 |
+
},
|
| 70 |
+
)}
|
| 71 |
+
title="Copy Code"
|
| 72 |
+
onClick={() => copyToClipboard()}
|
| 73 |
+
>
|
| 74 |
+
<div className="i-ph:clipboard-text-duotone"></div>
|
| 75 |
+
</button>
|
| 76 |
+
)}
|
| 77 |
+
</div>
|
| 78 |
+
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
},
|
| 82 |
+
);
|
app/components/chat/Markdown.module.scss
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
| 2 |
+
$code-font-size: 13px;
|
| 3 |
+
|
| 4 |
+
@mixin not-inside-actions {
|
| 5 |
+
&:not(:has(:global(.actions)), :global(.actions *)) {
|
| 6 |
+
@content;
|
| 7 |
+
}
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.MarkdownContent {
|
| 11 |
+
line-height: 1.6;
|
| 12 |
+
color: var(--bolt-elements-textPrimary);
|
| 13 |
+
|
| 14 |
+
> *:not(:last-child) {
|
| 15 |
+
margin-block-end: 16px;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
:global(.artifact) {
|
| 19 |
+
margin: 1.5em 0;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
:is(h1, h2, h3, h4, h5, h6) {
|
| 23 |
+
@include not-inside-actions {
|
| 24 |
+
margin-block-start: 24px;
|
| 25 |
+
margin-block-end: 16px;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
line-height: 1.25;
|
| 28 |
+
color: var(--bolt-elements-textPrimary);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
h1 {
|
| 33 |
+
font-size: 2em;
|
| 34 |
+
border-bottom: 1px solid var(--bolt-elements-borderColor);
|
| 35 |
+
padding-bottom: 0.3em;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
h2 {
|
| 39 |
+
font-size: 1.5em;
|
| 40 |
+
border-bottom: 1px solid var(--bolt-elements-borderColor);
|
| 41 |
+
padding-bottom: 0.3em;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
h3 {
|
| 45 |
+
font-size: 1.25em;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
h4 {
|
| 49 |
+
font-size: 1em;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
h5 {
|
| 53 |
+
font-size: 0.875em;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
h6 {
|
| 57 |
+
font-size: 0.85em;
|
| 58 |
+
color: #6a737d;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
p {
|
| 62 |
+
white-space: pre-wrap;
|
| 63 |
+
|
| 64 |
+
&:not(:last-of-type) {
|
| 65 |
+
margin-block-start: 0;
|
| 66 |
+
margin-block-end: 16px;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
a {
|
| 71 |
+
color: var(--bolt-elements-messages-linkColor);
|
| 72 |
+
text-decoration: none;
|
| 73 |
+
cursor: pointer;
|
| 74 |
+
|
| 75 |
+
&:hover {
|
| 76 |
+
text-decoration: underline;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
:not(pre) > code {
|
| 81 |
+
font-family: $font-mono;
|
| 82 |
+
font-size: $code-font-size;
|
| 83 |
+
|
| 84 |
+
@include not-inside-actions {
|
| 85 |
+
border-radius: 6px;
|
| 86 |
+
padding: 0.2em 0.4em;
|
| 87 |
+
background-color: var(--bolt-elements-messages-inlineCode-background);
|
| 88 |
+
color: var(--bolt-elements-messages-inlineCode-text);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
pre {
|
| 93 |
+
padding: 20px 16px;
|
| 94 |
+
border-radius: 6px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
pre:has(> code) {
|
| 98 |
+
font-family: $font-mono;
|
| 99 |
+
font-size: $code-font-size;
|
| 100 |
+
background: transparent;
|
| 101 |
+
overflow-x: auto;
|
| 102 |
+
min-width: 0;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
blockquote {
|
| 106 |
+
margin: 0;
|
| 107 |
+
padding: 0 1em;
|
| 108 |
+
color: var(--bolt-elements-textTertiary);
|
| 109 |
+
border-left: 0.25em solid var(--bolt-elements-borderColor);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
:is(ul, ol) {
|
| 113 |
+
@include not-inside-actions {
|
| 114 |
+
padding-left: 2em;
|
| 115 |
+
margin-block-start: 0;
|
| 116 |
+
margin-block-end: 16px;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
ul {
|
| 121 |
+
@include not-inside-actions {
|
| 122 |
+
list-style-type: disc;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
ol {
|
| 127 |
+
@include not-inside-actions {
|
| 128 |
+
list-style-type: decimal;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
li {
|
| 133 |
+
@include not-inside-actions {
|
| 134 |
+
& + li {
|
| 135 |
+
margin-block-start: 8px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
> *:not(:last-child) {
|
| 139 |
+
margin-block-end: 16px;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
img {
|
| 145 |
+
max-width: 100%;
|
| 146 |
+
box-sizing: border-box;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
hr {
|
| 150 |
+
height: 0.25em;
|
| 151 |
+
padding: 0;
|
| 152 |
+
margin: 24px 0;
|
| 153 |
+
background-color: var(--bolt-elements-borderColor);
|
| 154 |
+
border: 0;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
table {
|
| 158 |
+
border-collapse: collapse;
|
| 159 |
+
width: 100%;
|
| 160 |
+
margin-block-end: 16px;
|
| 161 |
+
|
| 162 |
+
:is(th, td) {
|
| 163 |
+
padding: 6px 13px;
|
| 164 |
+
border: 1px solid #dfe2e5;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
tr:nth-child(2n) {
|
| 168 |
+
background-color: #f6f8fa;
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}
|
app/components/chat/Markdown.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useMemo } from 'react';
|
| 2 |
+
import ReactMarkdown, { type Components } from 'react-markdown';
|
| 3 |
+
import type { BundledLanguage } from 'shiki';
|
| 4 |
+
import { createScopedLogger } from '~/utils/logger';
|
| 5 |
+
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
|
| 6 |
+
import { Artifact } from './Artifact';
|
| 7 |
+
import { CodeBlock } from './CodeBlock';
|
| 8 |
+
|
| 9 |
+
import styles from './Markdown.module.scss';
|
| 10 |
+
|
| 11 |
+
const logger = createScopedLogger('MarkdownComponent');
|
| 12 |
+
|
| 13 |
+
interface MarkdownProps {
|
| 14 |
+
children: string;
|
| 15 |
+
html?: boolean;
|
| 16 |
+
limitedMarkdown?: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
|
| 20 |
+
logger.trace('Render');
|
| 21 |
+
|
| 22 |
+
const components = useMemo(() => {
|
| 23 |
+
return {
|
| 24 |
+
div: ({ className, children, node, ...props }) => {
|
| 25 |
+
if (className?.includes('__boltArtifact__')) {
|
| 26 |
+
const messageId = node?.properties.dataMessageId as string;
|
| 27 |
+
|
| 28 |
+
if (!messageId) {
|
| 29 |
+
logger.error(`Invalid message id ${messageId}`);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return <Artifact messageId={messageId} />;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className={className} {...props}>
|
| 37 |
+
{children}
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
},
|
| 41 |
+
pre: (props) => {
|
| 42 |
+
const { children, node, ...rest } = props;
|
| 43 |
+
|
| 44 |
+
const [firstChild] = node?.children ?? [];
|
| 45 |
+
|
| 46 |
+
if (
|
| 47 |
+
firstChild &&
|
| 48 |
+
firstChild.type === 'element' &&
|
| 49 |
+
firstChild.tagName === 'code' &&
|
| 50 |
+
firstChild.children[0].type === 'text'
|
| 51 |
+
) {
|
| 52 |
+
const { className, ...rest } = firstChild.properties;
|
| 53 |
+
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
|
| 54 |
+
|
| 55 |
+
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return <pre {...rest}>{children}</pre>;
|
| 59 |
+
},
|
| 60 |
+
} satisfies Components;
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<ReactMarkdown
|
| 65 |
+
allowedElements={allowedHTMLElements}
|
| 66 |
+
className={styles.MarkdownContent}
|
| 67 |
+
components={components}
|
| 68 |
+
remarkPlugins={remarkPlugins(limitedMarkdown)}
|
| 69 |
+
rehypePlugins={rehypePlugins(html)}
|
| 70 |
+
>
|
| 71 |
+
{children}
|
| 72 |
+
</ReactMarkdown>
|
| 73 |
+
);
|
| 74 |
+
});
|
app/components/chat/Messages.client.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Message } from 'ai';
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { AssistantMessage } from './AssistantMessage';
|
| 5 |
+
import { UserMessage } from './UserMessage';
|
| 6 |
+
|
| 7 |
+
interface MessagesProps {
|
| 8 |
+
id?: string;
|
| 9 |
+
className?: string;
|
| 10 |
+
isStreaming?: boolean;
|
| 11 |
+
messages?: Message[];
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
| 15 |
+
const { id, isStreaming = false, messages = [] } = props;
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div id={id} ref={ref} className={props.className}>
|
| 19 |
+
{messages.length > 0
|
| 20 |
+
? messages.map((message, index) => {
|
| 21 |
+
const { role, content } = message;
|
| 22 |
+
const isUserMessage = role === 'user';
|
| 23 |
+
const isFirst = index === 0;
|
| 24 |
+
const isLast = index === messages.length - 1;
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div
|
| 28 |
+
key={index}
|
| 29 |
+
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
| 30 |
+
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
| 31 |
+
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
| 32 |
+
isStreaming && isLast,
|
| 33 |
+
'mt-4': !isFirst,
|
| 34 |
+
})}
|
| 35 |
+
>
|
| 36 |
+
{isUserMessage && (
|
| 37 |
+
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
| 38 |
+
<div className="i-ph:user-fill text-xl"></div>
|
| 39 |
+
</div>
|
| 40 |
+
)}
|
| 41 |
+
<div className="grid grid-col-1 w-full">
|
| 42 |
+
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
})
|
| 47 |
+
: null}
|
| 48 |
+
{isStreaming && (
|
| 49 |
+
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
| 50 |
+
)}
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
});
|
app/components/chat/SendButton.client.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
|
| 2 |
+
|
| 3 |
+
interface SendButtonProps {
|
| 4 |
+
show: boolean;
|
| 5 |
+
isStreaming?: boolean;
|
| 6 |
+
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
| 10 |
+
|
| 11 |
+
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
| 12 |
+
return (
|
| 13 |
+
<AnimatePresence>
|
| 14 |
+
{show ? (
|
| 15 |
+
<motion.button
|
| 16 |
+
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme"
|
| 17 |
+
transition={{ ease: customEasingFn, duration: 0.17 }}
|
| 18 |
+
initial={{ opacity: 0, y: 10 }}
|
| 19 |
+
animate={{ opacity: 1, y: 0 }}
|
| 20 |
+
exit={{ opacity: 0, y: 10 }}
|
| 21 |
+
onClick={(event) => {
|
| 22 |
+
event.preventDefault();
|
| 23 |
+
onClick?.(event);
|
| 24 |
+
}}
|
| 25 |
+
>
|
| 26 |
+
<div className="text-lg">
|
| 27 |
+
{!isStreaming ? <div className="i-ph:arrow-right"></div> : <div className="i-ph:stop-circle-bold"></div>}
|
| 28 |
+
</div>
|
| 29 |
+
</motion.button>
|
| 30 |
+
) : null}
|
| 31 |
+
</AnimatePresence>
|
| 32 |
+
);
|
| 33 |
+
}
|
app/components/chat/UserMessage.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
// Preventing TS checks with files presented in the video for a better presentation.
|
| 3 |
+
import { modificationsRegex } from '~/utils/diff';
|
| 4 |
+
import { MODEL_REGEX } from '~/utils/constants';
|
| 5 |
+
import { Markdown } from './Markdown';
|
| 6 |
+
|
| 7 |
+
interface UserMessageProps {
|
| 8 |
+
content: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function UserMessage({ content }: UserMessageProps) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="overflow-hidden pt-[4px]">
|
| 14 |
+
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
|
| 15 |
+
</div>
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function sanitizeUserMessage(content: string) {
|
| 20 |
+
return content.replace(modificationsRegex, '').replace(MODEL_REGEX, '').trim();
|
| 21 |
+
}
|
app/components/editor/codemirror/BinaryContent.tsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function BinaryContent() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
|
| 4 |
+
File format cannot be displayed.
|
| 5 |
+
</div>
|
| 6 |
+
);
|
| 7 |
+
}
|
app/components/editor/codemirror/CodeMirrorEditor.tsx
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
|
| 2 |
+
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
| 3 |
+
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
|
| 4 |
+
import { searchKeymap } from '@codemirror/search';
|
| 5 |
+
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
| 6 |
+
import {
|
| 7 |
+
drawSelection,
|
| 8 |
+
dropCursor,
|
| 9 |
+
EditorView,
|
| 10 |
+
highlightActiveLine,
|
| 11 |
+
highlightActiveLineGutter,
|
| 12 |
+
keymap,
|
| 13 |
+
lineNumbers,
|
| 14 |
+
scrollPastEnd,
|
| 15 |
+
showTooltip,
|
| 16 |
+
tooltips,
|
| 17 |
+
type Tooltip,
|
| 18 |
+
} from '@codemirror/view';
|
| 19 |
+
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
| 20 |
+
import type { Theme } from '~/types/theme';
|
| 21 |
+
import { classNames } from '~/utils/classNames';
|
| 22 |
+
import { debounce } from '~/utils/debounce';
|
| 23 |
+
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
| 24 |
+
import { BinaryContent } from './BinaryContent';
|
| 25 |
+
import { getTheme, reconfigureTheme } from './cm-theme';
|
| 26 |
+
import { indentKeyBinding } from './indent';
|
| 27 |
+
import { getLanguage } from './languages';
|
| 28 |
+
|
| 29 |
+
const logger = createScopedLogger('CodeMirrorEditor');
|
| 30 |
+
|
| 31 |
+
export interface EditorDocument {
|
| 32 |
+
value: string;
|
| 33 |
+
isBinary: boolean;
|
| 34 |
+
filePath: string;
|
| 35 |
+
scroll?: ScrollPosition;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface EditorSettings {
|
| 39 |
+
fontSize?: string;
|
| 40 |
+
gutterFontSize?: string;
|
| 41 |
+
tabSize?: number;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type TextEditorDocument = EditorDocument & {
|
| 45 |
+
value: string;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export interface ScrollPosition {
|
| 49 |
+
top: number;
|
| 50 |
+
left: number;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface EditorUpdate {
|
| 54 |
+
selection: EditorSelection;
|
| 55 |
+
content: string;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export type OnChangeCallback = (update: EditorUpdate) => void;
|
| 59 |
+
export type OnScrollCallback = (position: ScrollPosition) => void;
|
| 60 |
+
export type OnSaveCallback = () => void;
|
| 61 |
+
|
| 62 |
+
interface Props {
|
| 63 |
+
theme: Theme;
|
| 64 |
+
id?: unknown;
|
| 65 |
+
doc?: EditorDocument;
|
| 66 |
+
editable?: boolean;
|
| 67 |
+
debounceChange?: number;
|
| 68 |
+
debounceScroll?: number;
|
| 69 |
+
autoFocusOnDocumentChange?: boolean;
|
| 70 |
+
onChange?: OnChangeCallback;
|
| 71 |
+
onScroll?: OnScrollCallback;
|
| 72 |
+
onSave?: OnSaveCallback;
|
| 73 |
+
className?: string;
|
| 74 |
+
settings?: EditorSettings;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
type EditorStates = Map<string, EditorState>;
|
| 78 |
+
|
| 79 |
+
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
|
| 80 |
+
|
| 81 |
+
const editableTooltipField = StateField.define<readonly Tooltip[]>({
|
| 82 |
+
create: () => [],
|
| 83 |
+
update(_tooltips, transaction) {
|
| 84 |
+
if (!transaction.state.readOnly) {
|
| 85 |
+
return [];
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
for (const effect of transaction.effects) {
|
| 89 |
+
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
|
| 90 |
+
return getReadOnlyTooltip(transaction.state);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return [];
|
| 95 |
+
},
|
| 96 |
+
provide: (field) => {
|
| 97 |
+
return showTooltip.computeN([field], (state) => state.field(field));
|
| 98 |
+
},
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
const editableStateEffect = StateEffect.define<boolean>();
|
| 102 |
+
|
| 103 |
+
const editableStateField = StateField.define<boolean>({
|
| 104 |
+
create() {
|
| 105 |
+
return true;
|
| 106 |
+
},
|
| 107 |
+
update(value, transaction) {
|
| 108 |
+
for (const effect of transaction.effects) {
|
| 109 |
+
if (effect.is(editableStateEffect)) {
|
| 110 |
+
return effect.value;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return value;
|
| 115 |
+
},
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
export const CodeMirrorEditor = memo(
|
| 119 |
+
({
|
| 120 |
+
id,
|
| 121 |
+
doc,
|
| 122 |
+
debounceScroll = 100,
|
| 123 |
+
debounceChange = 150,
|
| 124 |
+
autoFocusOnDocumentChange = false,
|
| 125 |
+
editable = true,
|
| 126 |
+
onScroll,
|
| 127 |
+
onChange,
|
| 128 |
+
onSave,
|
| 129 |
+
theme,
|
| 130 |
+
settings,
|
| 131 |
+
className = '',
|
| 132 |
+
}: Props) => {
|
| 133 |
+
renderLogger.trace('CodeMirrorEditor');
|
| 134 |
+
|
| 135 |
+
const [languageCompartment] = useState(new Compartment());
|
| 136 |
+
|
| 137 |
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
| 138 |
+
const viewRef = useRef<EditorView>();
|
| 139 |
+
const themeRef = useRef<Theme>();
|
| 140 |
+
const docRef = useRef<EditorDocument>();
|
| 141 |
+
const editorStatesRef = useRef<EditorStates>();
|
| 142 |
+
const onScrollRef = useRef(onScroll);
|
| 143 |
+
const onChangeRef = useRef(onChange);
|
| 144 |
+
const onSaveRef = useRef(onSave);
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* This effect is used to avoid side effects directly in the render function
|
| 148 |
+
* and instead the refs are updated after each render.
|
| 149 |
+
*/
|
| 150 |
+
useEffect(() => {
|
| 151 |
+
onScrollRef.current = onScroll;
|
| 152 |
+
onChangeRef.current = onChange;
|
| 153 |
+
onSaveRef.current = onSave;
|
| 154 |
+
docRef.current = doc;
|
| 155 |
+
themeRef.current = theme;
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
useEffect(() => {
|
| 159 |
+
const onUpdate = debounce((update: EditorUpdate) => {
|
| 160 |
+
onChangeRef.current?.(update);
|
| 161 |
+
}, debounceChange);
|
| 162 |
+
|
| 163 |
+
const view = new EditorView({
|
| 164 |
+
parent: containerRef.current!,
|
| 165 |
+
dispatchTransactions(transactions) {
|
| 166 |
+
const previousSelection = view.state.selection;
|
| 167 |
+
|
| 168 |
+
view.update(transactions);
|
| 169 |
+
|
| 170 |
+
const newSelection = view.state.selection;
|
| 171 |
+
|
| 172 |
+
const selectionChanged =
|
| 173 |
+
newSelection !== previousSelection &&
|
| 174 |
+
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
|
| 175 |
+
|
| 176 |
+
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
|
| 177 |
+
onUpdate({
|
| 178 |
+
selection: view.state.selection,
|
| 179 |
+
content: view.state.doc.toString(),
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
editorStatesRef.current!.set(docRef.current.filePath, view.state);
|
| 183 |
+
}
|
| 184 |
+
},
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
viewRef.current = view;
|
| 188 |
+
|
| 189 |
+
return () => {
|
| 190 |
+
viewRef.current?.destroy();
|
| 191 |
+
viewRef.current = undefined;
|
| 192 |
+
};
|
| 193 |
+
}, []);
|
| 194 |
+
|
| 195 |
+
useEffect(() => {
|
| 196 |
+
if (!viewRef.current) {
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
viewRef.current.dispatch({
|
| 201 |
+
effects: [reconfigureTheme(theme)],
|
| 202 |
+
});
|
| 203 |
+
}, [theme]);
|
| 204 |
+
|
| 205 |
+
useEffect(() => {
|
| 206 |
+
editorStatesRef.current = new Map<string, EditorState>();
|
| 207 |
+
}, [id]);
|
| 208 |
+
|
| 209 |
+
useEffect(() => {
|
| 210 |
+
const editorStates = editorStatesRef.current!;
|
| 211 |
+
const view = viewRef.current!;
|
| 212 |
+
const theme = themeRef.current!;
|
| 213 |
+
|
| 214 |
+
if (!doc) {
|
| 215 |
+
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
| 216 |
+
languageCompartment.of([]),
|
| 217 |
+
]);
|
| 218 |
+
|
| 219 |
+
view.setState(state);
|
| 220 |
+
|
| 221 |
+
setNoDocument(view);
|
| 222 |
+
|
| 223 |
+
return;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if (doc.isBinary) {
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
if (doc.filePath === '') {
|
| 231 |
+
logger.warn('File path should not be empty');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
let state = editorStates.get(doc.filePath);
|
| 235 |
+
|
| 236 |
+
if (!state) {
|
| 237 |
+
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
| 238 |
+
languageCompartment.of([]),
|
| 239 |
+
]);
|
| 240 |
+
|
| 241 |
+
editorStates.set(doc.filePath, state);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
view.setState(state);
|
| 245 |
+
|
| 246 |
+
setEditorDocument(
|
| 247 |
+
view,
|
| 248 |
+
theme,
|
| 249 |
+
editable,
|
| 250 |
+
languageCompartment,
|
| 251 |
+
autoFocusOnDocumentChange,
|
| 252 |
+
doc as TextEditorDocument,
|
| 253 |
+
);
|
| 254 |
+
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
| 255 |
+
|
| 256 |
+
return (
|
| 257 |
+
<div className={classNames('relative h-full', className)}>
|
| 258 |
+
{doc?.isBinary && <BinaryContent />}
|
| 259 |
+
<div className="h-full overflow-hidden" ref={containerRef} />
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
},
|
| 263 |
+
);
|
| 264 |
+
|
| 265 |
+
export default CodeMirrorEditor;
|
| 266 |
+
|
| 267 |
+
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
|
| 268 |
+
|
| 269 |
+
function newEditorState(
|
| 270 |
+
content: string,
|
| 271 |
+
theme: Theme,
|
| 272 |
+
settings: EditorSettings | undefined,
|
| 273 |
+
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
|
| 274 |
+
debounceScroll: number,
|
| 275 |
+
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
|
| 276 |
+
extensions: Extension[],
|
| 277 |
+
) {
|
| 278 |
+
return EditorState.create({
|
| 279 |
+
doc: content,
|
| 280 |
+
extensions: [
|
| 281 |
+
EditorView.domEventHandlers({
|
| 282 |
+
scroll: debounce((event, view) => {
|
| 283 |
+
if (event.target !== view.scrollDOM) {
|
| 284 |
+
return;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
| 288 |
+
}, debounceScroll),
|
| 289 |
+
keydown: (event, view) => {
|
| 290 |
+
if (view.state.readOnly) {
|
| 291 |
+
view.dispatch({
|
| 292 |
+
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
return true;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return false;
|
| 299 |
+
},
|
| 300 |
+
}),
|
| 301 |
+
getTheme(theme, settings),
|
| 302 |
+
history(),
|
| 303 |
+
keymap.of([
|
| 304 |
+
...defaultKeymap,
|
| 305 |
+
...historyKeymap,
|
| 306 |
+
...searchKeymap,
|
| 307 |
+
{ key: 'Tab', run: acceptCompletion },
|
| 308 |
+
{
|
| 309 |
+
key: 'Mod-s',
|
| 310 |
+
preventDefault: true,
|
| 311 |
+
run: () => {
|
| 312 |
+
onFileSaveRef.current?.();
|
| 313 |
+
return true;
|
| 314 |
+
},
|
| 315 |
+
},
|
| 316 |
+
indentKeyBinding,
|
| 317 |
+
]),
|
| 318 |
+
indentUnit.of('\t'),
|
| 319 |
+
autocompletion({
|
| 320 |
+
closeOnBlur: false,
|
| 321 |
+
}),
|
| 322 |
+
tooltips({
|
| 323 |
+
position: 'absolute',
|
| 324 |
+
parent: document.body,
|
| 325 |
+
tooltipSpace: (view) => {
|
| 326 |
+
const rect = view.dom.getBoundingClientRect();
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
top: rect.top - 50,
|
| 330 |
+
left: rect.left,
|
| 331 |
+
bottom: rect.bottom,
|
| 332 |
+
right: rect.right + 10,
|
| 333 |
+
};
|
| 334 |
+
},
|
| 335 |
+
}),
|
| 336 |
+
closeBrackets(),
|
| 337 |
+
lineNumbers(),
|
| 338 |
+
scrollPastEnd(),
|
| 339 |
+
dropCursor(),
|
| 340 |
+
drawSelection(),
|
| 341 |
+
bracketMatching(),
|
| 342 |
+
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
| 343 |
+
indentOnInput(),
|
| 344 |
+
editableTooltipField,
|
| 345 |
+
editableStateField,
|
| 346 |
+
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
| 347 |
+
highlightActiveLineGutter(),
|
| 348 |
+
highlightActiveLine(),
|
| 349 |
+
foldGutter({
|
| 350 |
+
markerDOM: (open) => {
|
| 351 |
+
const icon = document.createElement('div');
|
| 352 |
+
|
| 353 |
+
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
|
| 354 |
+
|
| 355 |
+
return icon;
|
| 356 |
+
},
|
| 357 |
+
}),
|
| 358 |
+
...extensions,
|
| 359 |
+
],
|
| 360 |
+
});
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function setNoDocument(view: EditorView) {
|
| 364 |
+
view.dispatch({
|
| 365 |
+
selection: { anchor: 0 },
|
| 366 |
+
changes: {
|
| 367 |
+
from: 0,
|
| 368 |
+
to: view.state.doc.length,
|
| 369 |
+
insert: '',
|
| 370 |
+
},
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
view.scrollDOM.scrollTo(0, 0);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function setEditorDocument(
|
| 377 |
+
view: EditorView,
|
| 378 |
+
theme: Theme,
|
| 379 |
+
editable: boolean,
|
| 380 |
+
languageCompartment: Compartment,
|
| 381 |
+
autoFocus: boolean,
|
| 382 |
+
doc: TextEditorDocument,
|
| 383 |
+
) {
|
| 384 |
+
if (doc.value !== view.state.doc.toString()) {
|
| 385 |
+
view.dispatch({
|
| 386 |
+
selection: { anchor: 0 },
|
| 387 |
+
changes: {
|
| 388 |
+
from: 0,
|
| 389 |
+
to: view.state.doc.length,
|
| 390 |
+
insert: doc.value,
|
| 391 |
+
},
|
| 392 |
+
});
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
view.dispatch({
|
| 396 |
+
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
getLanguage(doc.filePath).then((languageSupport) => {
|
| 400 |
+
if (!languageSupport) {
|
| 401 |
+
return;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
view.dispatch({
|
| 405 |
+
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
requestAnimationFrame(() => {
|
| 409 |
+
const currentLeft = view.scrollDOM.scrollLeft;
|
| 410 |
+
const currentTop = view.scrollDOM.scrollTop;
|
| 411 |
+
const newLeft = doc.scroll?.left ?? 0;
|
| 412 |
+
const newTop = doc.scroll?.top ?? 0;
|
| 413 |
+
|
| 414 |
+
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
|
| 415 |
+
|
| 416 |
+
if (autoFocus && editable) {
|
| 417 |
+
if (needsScrolling) {
|
| 418 |
+
// we have to wait until the scroll position was changed before we can set the focus
|
| 419 |
+
view.scrollDOM.addEventListener(
|
| 420 |
+
'scroll',
|
| 421 |
+
() => {
|
| 422 |
+
view.focus();
|
| 423 |
+
},
|
| 424 |
+
{ once: true },
|
| 425 |
+
);
|
| 426 |
+
} else {
|
| 427 |
+
// if the scroll position is still the same we can focus immediately
|
| 428 |
+
view.focus();
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
view.scrollDOM.scrollTo(newLeft, newTop);
|
| 433 |
+
});
|
| 434 |
+
});
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
function getReadOnlyTooltip(state: EditorState) {
|
| 438 |
+
if (!state.readOnly) {
|
| 439 |
+
return [];
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
return state.selection.ranges
|
| 443 |
+
.filter((range) => {
|
| 444 |
+
return range.empty;
|
| 445 |
+
})
|
| 446 |
+
.map((range) => {
|
| 447 |
+
return {
|
| 448 |
+
pos: range.head,
|
| 449 |
+
above: true,
|
| 450 |
+
strictSide: true,
|
| 451 |
+
arrow: true,
|
| 452 |
+
create: () => {
|
| 453 |
+
const divElement = document.createElement('div');
|
| 454 |
+
divElement.className = 'cm-readonly-tooltip';
|
| 455 |
+
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
| 456 |
+
|
| 457 |
+
return { dom: divElement };
|
| 458 |
+
},
|
| 459 |
+
};
|
| 460 |
+
});
|
| 461 |
+
}
|
app/components/editor/codemirror/cm-theme.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Compartment, type Extension } from '@codemirror/state';
|
| 2 |
+
import { EditorView } from '@codemirror/view';
|
| 3 |
+
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
|
| 4 |
+
import type { Theme } from '~/types/theme.js';
|
| 5 |
+
import type { EditorSettings } from './CodeMirrorEditor.js';
|
| 6 |
+
|
| 7 |
+
export const darkTheme = EditorView.theme({}, { dark: true });
|
| 8 |
+
export const themeSelection = new Compartment();
|
| 9 |
+
|
| 10 |
+
export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
|
| 11 |
+
return [
|
| 12 |
+
getEditorTheme(settings),
|
| 13 |
+
theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
|
| 14 |
+
];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function reconfigureTheme(theme: Theme) {
|
| 18 |
+
return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function getEditorTheme(settings: EditorSettings) {
|
| 22 |
+
return EditorView.theme({
|
| 23 |
+
'&': {
|
| 24 |
+
fontSize: settings.fontSize ?? '12px',
|
| 25 |
+
},
|
| 26 |
+
'&.cm-editor': {
|
| 27 |
+
height: '100%',
|
| 28 |
+
background: 'var(--cm-backgroundColor)',
|
| 29 |
+
color: 'var(--cm-textColor)',
|
| 30 |
+
},
|
| 31 |
+
'.cm-cursor': {
|
| 32 |
+
borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
|
| 33 |
+
},
|
| 34 |
+
'.cm-scroller': {
|
| 35 |
+
lineHeight: '1.5',
|
| 36 |
+
'&:focus-visible': {
|
| 37 |
+
outline: 'none',
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
'.cm-line': {
|
| 41 |
+
padding: '0 0 0 4px',
|
| 42 |
+
},
|
| 43 |
+
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
| 44 |
+
backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
|
| 45 |
+
opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
|
| 46 |
+
},
|
| 47 |
+
'&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
| 48 |
+
backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
|
| 49 |
+
opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
|
| 50 |
+
},
|
| 51 |
+
'&.cm-focused > .cm-scroller .cm-matchingBracket': {
|
| 52 |
+
backgroundColor: 'var(--cm-matching-bracket)',
|
| 53 |
+
},
|
| 54 |
+
'.cm-activeLine': {
|
| 55 |
+
background: 'var(--cm-activeLineBackgroundColor)',
|
| 56 |
+
},
|
| 57 |
+
'.cm-gutters': {
|
| 58 |
+
background: 'var(--cm-gutter-backgroundColor)',
|
| 59 |
+
borderRight: 0,
|
| 60 |
+
color: 'var(--cm-gutter-textColor)',
|
| 61 |
+
},
|
| 62 |
+
'.cm-gutter': {
|
| 63 |
+
'&.cm-lineNumbers': {
|
| 64 |
+
fontFamily: 'Roboto Mono, monospace',
|
| 65 |
+
fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
|
| 66 |
+
minWidth: '40px',
|
| 67 |
+
},
|
| 68 |
+
'& .cm-activeLineGutter': {
|
| 69 |
+
background: 'transparent',
|
| 70 |
+
color: 'var(--cm-gutter-activeLineTextColor)',
|
| 71 |
+
},
|
| 72 |
+
'&.cm-foldGutter .cm-gutterElement > .fold-icon': {
|
| 73 |
+
cursor: 'pointer',
|
| 74 |
+
color: 'var(--cm-foldGutter-textColor)',
|
| 75 |
+
transform: 'translateY(2px)',
|
| 76 |
+
'&:hover': {
|
| 77 |
+
color: 'var(--cm-foldGutter-textColorHover)',
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
},
|
| 81 |
+
'.cm-foldGutter .cm-gutterElement': {
|
| 82 |
+
padding: '0 4px',
|
| 83 |
+
},
|
| 84 |
+
'.cm-tooltip-autocomplete > ul > li': {
|
| 85 |
+
minHeight: '18px',
|
| 86 |
+
},
|
| 87 |
+
'.cm-panel.cm-search label': {
|
| 88 |
+
marginLeft: '2px',
|
| 89 |
+
fontSize: '12px',
|
| 90 |
+
},
|
| 91 |
+
'.cm-panel.cm-search .cm-button': {
|
| 92 |
+
fontSize: '12px',
|
| 93 |
+
},
|
| 94 |
+
'.cm-panel.cm-search .cm-textfield': {
|
| 95 |
+
fontSize: '12px',
|
| 96 |
+
},
|
| 97 |
+
'.cm-panel.cm-search input[type=checkbox]': {
|
| 98 |
+
position: 'relative',
|
| 99 |
+
transform: 'translateY(2px)',
|
| 100 |
+
marginRight: '4px',
|
| 101 |
+
},
|
| 102 |
+
'.cm-panels': {
|
| 103 |
+
borderColor: 'var(--cm-panels-borderColor)',
|
| 104 |
+
},
|
| 105 |
+
'.cm-panels-bottom': {
|
| 106 |
+
borderTop: '1px solid var(--cm-panels-borderColor)',
|
| 107 |
+
backgroundColor: 'transparent',
|
| 108 |
+
},
|
| 109 |
+
'.cm-panel.cm-search': {
|
| 110 |
+
background: 'var(--cm-search-backgroundColor)',
|
| 111 |
+
color: 'var(--cm-search-textColor)',
|
| 112 |
+
padding: '8px',
|
| 113 |
+
},
|
| 114 |
+
'.cm-search .cm-button': {
|
| 115 |
+
background: 'var(--cm-search-button-backgroundColor)',
|
| 116 |
+
borderColor: 'var(--cm-search-button-borderColor)',
|
| 117 |
+
color: 'var(--cm-search-button-textColor)',
|
| 118 |
+
borderRadius: '4px',
|
| 119 |
+
'&:hover': {
|
| 120 |
+
color: 'var(--cm-search-button-textColorHover)',
|
| 121 |
+
},
|
| 122 |
+
'&:focus-visible': {
|
| 123 |
+
outline: 'none',
|
| 124 |
+
borderColor: 'var(--cm-search-button-borderColorFocused)',
|
| 125 |
+
},
|
| 126 |
+
'&:hover:not(:focus-visible)': {
|
| 127 |
+
background: 'var(--cm-search-button-backgroundColorHover)',
|
| 128 |
+
borderColor: 'var(--cm-search-button-borderColorHover)',
|
| 129 |
+
},
|
| 130 |
+
'&:hover:focus-visible': {
|
| 131 |
+
background: 'var(--cm-search-button-backgroundColorHover)',
|
| 132 |
+
borderColor: 'var(--cm-search-button-borderColorFocused)',
|
| 133 |
+
},
|
| 134 |
+
},
|
| 135 |
+
'.cm-panel.cm-search [name=close]': {
|
| 136 |
+
top: '6px',
|
| 137 |
+
right: '6px',
|
| 138 |
+
padding: '0 6px',
|
| 139 |
+
fontSize: '1rem',
|
| 140 |
+
backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
|
| 141 |
+
color: 'var(--cm-search-closeButton-textColor)',
|
| 142 |
+
'&:hover': {
|
| 143 |
+
'border-radius': '6px',
|
| 144 |
+
color: 'var(--cm-search-closeButton-textColorHover)',
|
| 145 |
+
backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
|
| 146 |
+
},
|
| 147 |
+
},
|
| 148 |
+
'.cm-search input': {
|
| 149 |
+
background: 'var(--cm-search-input-backgroundColor)',
|
| 150 |
+
borderColor: 'var(--cm-search-input-borderColor)',
|
| 151 |
+
color: 'var(--cm-search-input-textColor)',
|
| 152 |
+
outline: 'none',
|
| 153 |
+
borderRadius: '4px',
|
| 154 |
+
'&:focus-visible': {
|
| 155 |
+
borderColor: 'var(--cm-search-input-borderColorFocused)',
|
| 156 |
+
},
|
| 157 |
+
},
|
| 158 |
+
'.cm-tooltip': {
|
| 159 |
+
background: 'var(--cm-tooltip-backgroundColor)',
|
| 160 |
+
border: '1px solid transparent',
|
| 161 |
+
borderColor: 'var(--cm-tooltip-borderColor)',
|
| 162 |
+
color: 'var(--cm-tooltip-textColor)',
|
| 163 |
+
},
|
| 164 |
+
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
|
| 165 |
+
background: 'var(--cm-tooltip-backgroundColorSelected)',
|
| 166 |
+
color: 'var(--cm-tooltip-textColorSelected)',
|
| 167 |
+
},
|
| 168 |
+
'.cm-searchMatch': {
|
| 169 |
+
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
|
| 170 |
+
},
|
| 171 |
+
'.cm-tooltip.cm-readonly-tooltip': {
|
| 172 |
+
padding: '4px',
|
| 173 |
+
whiteSpace: 'nowrap',
|
| 174 |
+
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
|
| 175 |
+
borderColor: 'var(--bolt-elements-borderColorActive)',
|
| 176 |
+
'& .cm-tooltip-arrow:before': {
|
| 177 |
+
borderTopColor: 'var(--bolt-elements-borderColorActive)',
|
| 178 |
+
},
|
| 179 |
+
'& .cm-tooltip-arrow:after': {
|
| 180 |
+
borderTopColor: 'transparent',
|
| 181 |
+
},
|
| 182 |
+
},
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function getLightTheme() {
|
| 187 |
+
return vscodeLight;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function getDarkTheme() {
|
| 191 |
+
return vscodeDark;
|
| 192 |
+
}
|
app/components/editor/codemirror/indent.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { indentLess } from '@codemirror/commands';
|
| 2 |
+
import { indentUnit } from '@codemirror/language';
|
| 3 |
+
import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
|
| 4 |
+
import { EditorView, type KeyBinding } from '@codemirror/view';
|
| 5 |
+
|
| 6 |
+
export const indentKeyBinding: KeyBinding = {
|
| 7 |
+
key: 'Tab',
|
| 8 |
+
run: indentMore,
|
| 9 |
+
shift: indentLess,
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function indentMore({ state, dispatch }: EditorView) {
|
| 13 |
+
if (state.readOnly) {
|
| 14 |
+
return false;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
dispatch(
|
| 18 |
+
state.update(
|
| 19 |
+
changeBySelectedLine(state, (from, to, changes) => {
|
| 20 |
+
changes.push({ from, to, insert: state.facet(indentUnit) });
|
| 21 |
+
}),
|
| 22 |
+
{ userEvent: 'input.indent' },
|
| 23 |
+
),
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
return true;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function changeBySelectedLine(
|
| 30 |
+
state: EditorState,
|
| 31 |
+
cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
|
| 32 |
+
) {
|
| 33 |
+
return state.changeByRange((range) => {
|
| 34 |
+
const changes: ChangeSpec[] = [];
|
| 35 |
+
|
| 36 |
+
const line = state.doc.lineAt(range.from);
|
| 37 |
+
|
| 38 |
+
// just insert single indent unit at the current cursor position
|
| 39 |
+
if (range.from === range.to) {
|
| 40 |
+
cb(range.from, undefined, changes, line);
|
| 41 |
+
}
|
| 42 |
+
// handle the case when multiple characters are selected in a single line
|
| 43 |
+
else if (range.from < range.to && range.to <= line.to) {
|
| 44 |
+
cb(range.from, range.to, changes, line);
|
| 45 |
+
} else {
|
| 46 |
+
let atLine = -1;
|
| 47 |
+
|
| 48 |
+
// handle the case when selection spans multiple lines
|
| 49 |
+
for (let pos = range.from; pos <= range.to; ) {
|
| 50 |
+
const line = state.doc.lineAt(pos);
|
| 51 |
+
|
| 52 |
+
if (line.number > atLine && (range.empty || range.to > line.from)) {
|
| 53 |
+
cb(line.from, undefined, changes, line);
|
| 54 |
+
atLine = line.number;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
pos = line.to + 1;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const changeSet = state.changes(changes);
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
changes,
|
| 65 |
+
range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
|
| 66 |
+
};
|
| 67 |
+
});
|
| 68 |
+
}
|
app/components/editor/codemirror/languages.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { LanguageDescription } from '@codemirror/language';
|
| 2 |
+
|
| 3 |
+
export const supportedLanguages = [
|
| 4 |
+
LanguageDescription.of({
|
| 5 |
+
name: 'TS',
|
| 6 |
+
extensions: ['ts'],
|
| 7 |
+
async load() {
|
| 8 |
+
return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
|
| 9 |
+
},
|
| 10 |
+
}),
|
| 11 |
+
LanguageDescription.of({
|
| 12 |
+
name: 'JS',
|
| 13 |
+
extensions: ['js', 'mjs', 'cjs'],
|
| 14 |
+
async load() {
|
| 15 |
+
return import('@codemirror/lang-javascript').then((module) => module.javascript());
|
| 16 |
+
},
|
| 17 |
+
}),
|
| 18 |
+
LanguageDescription.of({
|
| 19 |
+
name: 'TSX',
|
| 20 |
+
extensions: ['tsx'],
|
| 21 |
+
async load() {
|
| 22 |
+
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
|
| 23 |
+
},
|
| 24 |
+
}),
|
| 25 |
+
LanguageDescription.of({
|
| 26 |
+
name: 'JSX',
|
| 27 |
+
extensions: ['jsx'],
|
| 28 |
+
async load() {
|
| 29 |
+
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
|
| 30 |
+
},
|
| 31 |
+
}),
|
| 32 |
+
LanguageDescription.of({
|
| 33 |
+
name: 'HTML',
|
| 34 |
+
extensions: ['html'],
|
| 35 |
+
async load() {
|
| 36 |
+
return import('@codemirror/lang-html').then((module) => module.html());
|
| 37 |
+
},
|
| 38 |
+
}),
|
| 39 |
+
LanguageDescription.of({
|
| 40 |
+
name: 'CSS',
|
| 41 |
+
extensions: ['css'],
|
| 42 |
+
async load() {
|
| 43 |
+
return import('@codemirror/lang-css').then((module) => module.css());
|
| 44 |
+
},
|
| 45 |
+
}),
|
| 46 |
+
LanguageDescription.of({
|
| 47 |
+
name: 'SASS',
|
| 48 |
+
extensions: ['sass'],
|
| 49 |
+
async load() {
|
| 50 |
+
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
|
| 51 |
+
},
|
| 52 |
+
}),
|
| 53 |
+
LanguageDescription.of({
|
| 54 |
+
name: 'SCSS',
|
| 55 |
+
extensions: ['scss'],
|
| 56 |
+
async load() {
|
| 57 |
+
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
|
| 58 |
+
},
|
| 59 |
+
}),
|
| 60 |
+
LanguageDescription.of({
|
| 61 |
+
name: 'JSON',
|
| 62 |
+
extensions: ['json'],
|
| 63 |
+
async load() {
|
| 64 |
+
return import('@codemirror/lang-json').then((module) => module.json());
|
| 65 |
+
},
|
| 66 |
+
}),
|
| 67 |
+
LanguageDescription.of({
|
| 68 |
+
name: 'Markdown',
|
| 69 |
+
extensions: ['md'],
|
| 70 |
+
async load() {
|
| 71 |
+
return import('@codemirror/lang-markdown').then((module) => module.markdown());
|
| 72 |
+
},
|
| 73 |
+
}),
|
| 74 |
+
LanguageDescription.of({
|
| 75 |
+
name: 'Wasm',
|
| 76 |
+
extensions: ['wat'],
|
| 77 |
+
async load() {
|
| 78 |
+
return import('@codemirror/lang-wast').then((module) => module.wast());
|
| 79 |
+
},
|
| 80 |
+
}),
|
| 81 |
+
LanguageDescription.of({
|
| 82 |
+
name: 'Python',
|
| 83 |
+
extensions: ['py'],
|
| 84 |
+
async load() {
|
| 85 |
+
return import('@codemirror/lang-python').then((module) => module.python());
|
| 86 |
+
},
|
| 87 |
+
}),
|
| 88 |
+
LanguageDescription.of({
|
| 89 |
+
name: 'C++',
|
| 90 |
+
extensions: ['cpp'],
|
| 91 |
+
async load() {
|
| 92 |
+
return import('@codemirror/lang-cpp').then((module) => module.cpp());
|
| 93 |
+
},
|
| 94 |
+
}),
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
export async function getLanguage(fileName: string) {
|
| 98 |
+
const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
|
| 99 |
+
|
| 100 |
+
if (languageDescription) {
|
| 101 |
+
return await languageDescription.load();
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return undefined;
|
| 105 |
+
}
|
app/components/header/Header.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { ClientOnly } from 'remix-utils/client-only';
|
| 3 |
+
import { chatStore } from '~/lib/stores/chat';
|
| 4 |
+
import { classNames } from '~/utils/classNames';
|
| 5 |
+
import { HeaderActionButtons } from './HeaderActionButtons.client';
|
| 6 |
+
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
| 7 |
+
|
| 8 |
+
export function Header() {
|
| 9 |
+
const chat = useStore(chatStore);
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<header
|
| 13 |
+
className={classNames(
|
| 14 |
+
'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
|
| 15 |
+
{
|
| 16 |
+
'border-transparent': !chat.started,
|
| 17 |
+
'border-bolt-elements-borderColor': chat.started,
|
| 18 |
+
},
|
| 19 |
+
)}
|
| 20 |
+
>
|
| 21 |
+
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
|
| 22 |
+
<div className="i-ph:sidebar-simple-duotone text-xl" />
|
| 23 |
+
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
|
| 24 |
+
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
| 25 |
+
</a>
|
| 26 |
+
</div>
|
| 27 |
+
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
| 28 |
+
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
| 29 |
+
</span>
|
| 30 |
+
{chat.started && (
|
| 31 |
+
<ClientOnly>
|
| 32 |
+
{() => (
|
| 33 |
+
<div className="mr-1">
|
| 34 |
+
<HeaderActionButtons />
|
| 35 |
+
</div>
|
| 36 |
+
)}
|
| 37 |
+
</ClientOnly>
|
| 38 |
+
)}
|
| 39 |
+
</header>
|
| 40 |
+
);
|
| 41 |
+
}
|
app/components/header/HeaderActionButtons.client.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { chatStore } from '~/lib/stores/chat';
|
| 3 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 4 |
+
import { classNames } from '~/utils/classNames';
|
| 5 |
+
|
| 6 |
+
interface HeaderActionButtonsProps {}
|
| 7 |
+
|
| 8 |
+
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
| 9 |
+
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
| 10 |
+
const { showChat } = useStore(chatStore);
|
| 11 |
+
|
| 12 |
+
const canHideChat = showWorkbench || !showChat;
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="flex">
|
| 16 |
+
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
| 17 |
+
<Button
|
| 18 |
+
active={showChat}
|
| 19 |
+
disabled={!canHideChat}
|
| 20 |
+
onClick={() => {
|
| 21 |
+
if (canHideChat) {
|
| 22 |
+
chatStore.setKey('showChat', !showChat);
|
| 23 |
+
}
|
| 24 |
+
}}
|
| 25 |
+
>
|
| 26 |
+
<div className="i-bolt:chat text-sm" />
|
| 27 |
+
</Button>
|
| 28 |
+
<div className="w-[1px] bg-bolt-elements-borderColor" />
|
| 29 |
+
<Button
|
| 30 |
+
active={showWorkbench}
|
| 31 |
+
onClick={() => {
|
| 32 |
+
if (showWorkbench && !showChat) {
|
| 33 |
+
chatStore.setKey('showChat', true);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
workbenchStore.showWorkbench.set(!showWorkbench);
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
<div className="i-ph:code-bold" />
|
| 40 |
+
</Button>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
interface ButtonProps {
|
| 47 |
+
active?: boolean;
|
| 48 |
+
disabled?: boolean;
|
| 49 |
+
children?: any;
|
| 50 |
+
onClick?: VoidFunction;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
| 54 |
+
return (
|
| 55 |
+
<button
|
| 56 |
+
className={classNames('flex items-center p-1.5', {
|
| 57 |
+
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
| 58 |
+
!active,
|
| 59 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
| 60 |
+
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
| 61 |
+
disabled,
|
| 62 |
+
})}
|
| 63 |
+
onClick={onClick}
|
| 64 |
+
>
|
| 65 |
+
{children}
|
| 66 |
+
</button>
|
| 67 |
+
);
|
| 68 |
+
}
|
app/components/sidebar/HistoryItem.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
| 2 |
+
import { useEffect, useRef, useState } from 'react';
|
| 3 |
+
import { type ChatHistoryItem } from '~/lib/persistence';
|
| 4 |
+
|
| 5 |
+
interface HistoryItemProps {
|
| 6 |
+
item: ChatHistoryItem;
|
| 7 |
+
onDelete?: (event: React.UIEvent) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
| 11 |
+
const [hovering, setHovering] = useState(false);
|
| 12 |
+
const hoverRef = useRef<HTMLDivElement>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
let timeout: NodeJS.Timeout | undefined;
|
| 16 |
+
|
| 17 |
+
function mouseEnter() {
|
| 18 |
+
setHovering(true);
|
| 19 |
+
|
| 20 |
+
if (timeout) {
|
| 21 |
+
clearTimeout(timeout);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function mouseLeave() {
|
| 26 |
+
setHovering(false);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
hoverRef.current?.addEventListener('mouseenter', mouseEnter);
|
| 30 |
+
hoverRef.current?.addEventListener('mouseleave', mouseLeave);
|
| 31 |
+
|
| 32 |
+
return () => {
|
| 33 |
+
hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
|
| 34 |
+
hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
|
| 35 |
+
};
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
ref={hoverRef}
|
| 41 |
+
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
|
| 42 |
+
>
|
| 43 |
+
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
| 44 |
+
{item.description}
|
| 45 |
+
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
|
| 46 |
+
{hovering && (
|
| 47 |
+
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
|
| 48 |
+
<Dialog.Trigger asChild>
|
| 49 |
+
<button
|
| 50 |
+
className="i-ph:trash scale-110"
|
| 51 |
+
onClick={(event) => {
|
| 52 |
+
// we prevent the default so we don't trigger the anchor above
|
| 53 |
+
event.preventDefault();
|
| 54 |
+
onDelete?.(event);
|
| 55 |
+
}}
|
| 56 |
+
/>
|
| 57 |
+
</Dialog.Trigger>
|
| 58 |
+
</div>
|
| 59 |
+
)}
|
| 60 |
+
</div>
|
| 61 |
+
</a>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
app/components/sidebar/Menu.client.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion, type Variants } from 'framer-motion';
|
| 2 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
| 5 |
+
import { IconButton } from '~/components/ui/IconButton';
|
| 6 |
+
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
| 7 |
+
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
|
| 8 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 9 |
+
import { logger } from '~/utils/logger';
|
| 10 |
+
import { HistoryItem } from './HistoryItem';
|
| 11 |
+
import { binDates } from './date-binning';
|
| 12 |
+
|
| 13 |
+
const menuVariants = {
|
| 14 |
+
closed: {
|
| 15 |
+
opacity: 0,
|
| 16 |
+
visibility: 'hidden',
|
| 17 |
+
left: '-150px',
|
| 18 |
+
transition: {
|
| 19 |
+
duration: 0.2,
|
| 20 |
+
ease: cubicEasingFn,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
open: {
|
| 24 |
+
opacity: 1,
|
| 25 |
+
visibility: 'initial',
|
| 26 |
+
left: 0,
|
| 27 |
+
transition: {
|
| 28 |
+
duration: 0.2,
|
| 29 |
+
ease: cubicEasingFn,
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
} satisfies Variants;
|
| 33 |
+
|
| 34 |
+
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
| 35 |
+
|
| 36 |
+
export function Menu() {
|
| 37 |
+
const menuRef = useRef<HTMLDivElement>(null);
|
| 38 |
+
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
| 39 |
+
const [open, setOpen] = useState(false);
|
| 40 |
+
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
| 41 |
+
|
| 42 |
+
const loadEntries = useCallback(() => {
|
| 43 |
+
if (db) {
|
| 44 |
+
getAll(db)
|
| 45 |
+
.then((list) => list.filter((item) => item.urlId && item.description))
|
| 46 |
+
.then(setList)
|
| 47 |
+
.catch((error) => toast.error(error.message));
|
| 48 |
+
}
|
| 49 |
+
}, []);
|
| 50 |
+
|
| 51 |
+
const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
|
| 52 |
+
event.preventDefault();
|
| 53 |
+
|
| 54 |
+
if (db) {
|
| 55 |
+
deleteById(db, item.id)
|
| 56 |
+
.then(() => {
|
| 57 |
+
loadEntries();
|
| 58 |
+
|
| 59 |
+
if (chatId.get() === item.id) {
|
| 60 |
+
// hard page navigation to clear the stores
|
| 61 |
+
window.location.pathname = '/';
|
| 62 |
+
}
|
| 63 |
+
})
|
| 64 |
+
.catch((error) => {
|
| 65 |
+
toast.error('Failed to delete conversation');
|
| 66 |
+
logger.error(error);
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
}, []);
|
| 70 |
+
|
| 71 |
+
const closeDialog = () => {
|
| 72 |
+
setDialogContent(null);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
if (open) {
|
| 77 |
+
loadEntries();
|
| 78 |
+
}
|
| 79 |
+
}, [open]);
|
| 80 |
+
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
const enterThreshold = 40;
|
| 83 |
+
const exitThreshold = 40;
|
| 84 |
+
|
| 85 |
+
function onMouseMove(event: MouseEvent) {
|
| 86 |
+
if (event.pageX < enterThreshold) {
|
| 87 |
+
setOpen(true);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
|
| 91 |
+
setOpen(false);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
window.addEventListener('mousemove', onMouseMove);
|
| 96 |
+
|
| 97 |
+
return () => {
|
| 98 |
+
window.removeEventListener('mousemove', onMouseMove);
|
| 99 |
+
};
|
| 100 |
+
}, []);
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<motion.div
|
| 104 |
+
ref={menuRef}
|
| 105 |
+
initial="closed"
|
| 106 |
+
animate={open ? 'open' : 'closed'}
|
| 107 |
+
variants={menuVariants}
|
| 108 |
+
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
| 109 |
+
>
|
| 110 |
+
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
| 111 |
+
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
| 112 |
+
<div className="p-4">
|
| 113 |
+
<a
|
| 114 |
+
href="/"
|
| 115 |
+
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
| 116 |
+
>
|
| 117 |
+
<span className="inline-block i-bolt:chat scale-110" />
|
| 118 |
+
Start new chat
|
| 119 |
+
</a>
|
| 120 |
+
</div>
|
| 121 |
+
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
|
| 122 |
+
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
|
| 123 |
+
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
| 124 |
+
<DialogRoot open={dialogContent !== null}>
|
| 125 |
+
{binDates(list).map(({ category, items }) => (
|
| 126 |
+
<div key={category} className="mt-4 first:mt-0 space-y-1">
|
| 127 |
+
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
|
| 128 |
+
{category}
|
| 129 |
+
</div>
|
| 130 |
+
{items.map((item) => (
|
| 131 |
+
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
))}
|
| 135 |
+
<Dialog onBackdrop={closeDialog} onClose={closeDialog}>
|
| 136 |
+
{dialogContent?.type === 'delete' && (
|
| 137 |
+
<>
|
| 138 |
+
<DialogTitle>Delete Chat?</DialogTitle>
|
| 139 |
+
<DialogDescription asChild>
|
| 140 |
+
<div>
|
| 141 |
+
<p>
|
| 142 |
+
You are about to delete <strong>{dialogContent.item.description}</strong>.
|
| 143 |
+
</p>
|
| 144 |
+
<p className="mt-1">Are you sure you want to delete this chat?</p>
|
| 145 |
+
</div>
|
| 146 |
+
</DialogDescription>
|
| 147 |
+
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
|
| 148 |
+
<DialogButton type="secondary" onClick={closeDialog}>
|
| 149 |
+
Cancel
|
| 150 |
+
</DialogButton>
|
| 151 |
+
<DialogButton
|
| 152 |
+
type="danger"
|
| 153 |
+
onClick={(event) => {
|
| 154 |
+
deleteItem(event, dialogContent.item);
|
| 155 |
+
closeDialog();
|
| 156 |
+
}}
|
| 157 |
+
>
|
| 158 |
+
Delete
|
| 159 |
+
</DialogButton>
|
| 160 |
+
</div>
|
| 161 |
+
</>
|
| 162 |
+
)}
|
| 163 |
+
</Dialog>
|
| 164 |
+
</DialogRoot>
|
| 165 |
+
</div>
|
| 166 |
+
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
|
| 167 |
+
<ThemeSwitch className="ml-auto" />
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</motion.div>
|
| 171 |
+
);
|
| 172 |
+
}
|
app/components/sidebar/date-binning.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
|
| 2 |
+
import type { ChatHistoryItem } from '~/lib/persistence';
|
| 3 |
+
|
| 4 |
+
type Bin = { category: string; items: ChatHistoryItem[] };
|
| 5 |
+
|
| 6 |
+
export function binDates(_list: ChatHistoryItem[]) {
|
| 7 |
+
const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
| 8 |
+
|
| 9 |
+
const binLookup: Record<string, Bin> = {};
|
| 10 |
+
const bins: Array<Bin> = [];
|
| 11 |
+
|
| 12 |
+
list.forEach((item) => {
|
| 13 |
+
const category = dateCategory(new Date(item.timestamp));
|
| 14 |
+
|
| 15 |
+
if (!(category in binLookup)) {
|
| 16 |
+
const bin = {
|
| 17 |
+
category,
|
| 18 |
+
items: [item],
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
binLookup[category] = bin;
|
| 22 |
+
|
| 23 |
+
bins.push(bin);
|
| 24 |
+
} else {
|
| 25 |
+
binLookup[category].items.push(item);
|
| 26 |
+
}
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
return bins;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function dateCategory(date: Date) {
|
| 33 |
+
if (isToday(date)) {
|
| 34 |
+
return 'Today';
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
if (isYesterday(date)) {
|
| 38 |
+
return 'Yesterday';
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (isThisWeek(date)) {
|
| 42 |
+
// e.g., "Monday"
|
| 43 |
+
return format(date, 'eeee');
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const thirtyDaysAgo = subDays(new Date(), 30);
|
| 47 |
+
|
| 48 |
+
if (isAfter(date, thirtyDaysAgo)) {
|
| 49 |
+
return 'Last 30 Days';
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (isThisYear(date)) {
|
| 53 |
+
// e.g., "July"
|
| 54 |
+
return format(date, 'MMMM');
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// e.g., "July 2023"
|
| 58 |
+
return format(date, 'MMMM yyyy');
|
| 59 |
+
}
|
app/components/ui/Dialog.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as RadixDialog from '@radix-ui/react-dialog';
|
| 2 |
+
import { motion, type Variants } from 'framer-motion';
|
| 3 |
+
import React, { memo, type ReactNode } from 'react';
|
| 4 |
+
import { classNames } from '~/utils/classNames';
|
| 5 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 6 |
+
import { IconButton } from './IconButton';
|
| 7 |
+
|
| 8 |
+
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
| 9 |
+
|
| 10 |
+
const transition = {
|
| 11 |
+
duration: 0.15,
|
| 12 |
+
ease: cubicEasingFn,
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const dialogBackdropVariants = {
|
| 16 |
+
closed: {
|
| 17 |
+
opacity: 0,
|
| 18 |
+
transition,
|
| 19 |
+
},
|
| 20 |
+
open: {
|
| 21 |
+
opacity: 1,
|
| 22 |
+
transition,
|
| 23 |
+
},
|
| 24 |
+
} satisfies Variants;
|
| 25 |
+
|
| 26 |
+
export const dialogVariants = {
|
| 27 |
+
closed: {
|
| 28 |
+
x: '-50%',
|
| 29 |
+
y: '-40%',
|
| 30 |
+
scale: 0.96,
|
| 31 |
+
opacity: 0,
|
| 32 |
+
transition,
|
| 33 |
+
},
|
| 34 |
+
open: {
|
| 35 |
+
x: '-50%',
|
| 36 |
+
y: '-50%',
|
| 37 |
+
scale: 1,
|
| 38 |
+
opacity: 1,
|
| 39 |
+
transition,
|
| 40 |
+
},
|
| 41 |
+
} satisfies Variants;
|
| 42 |
+
|
| 43 |
+
interface DialogButtonProps {
|
| 44 |
+
type: 'primary' | 'secondary' | 'danger';
|
| 45 |
+
children: ReactNode;
|
| 46 |
+
onClick?: (event: React.UIEvent) => void;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
|
| 50 |
+
return (
|
| 51 |
+
<button
|
| 52 |
+
className={classNames(
|
| 53 |
+
'inline-flex h-[35px] items-center justify-center rounded-lg px-4 text-sm leading-none focus:outline-none',
|
| 54 |
+
{
|
| 55 |
+
'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text hover:bg-bolt-elements-button-primary-backgroundHover':
|
| 56 |
+
type === 'primary',
|
| 57 |
+
'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
|
| 58 |
+
type === 'secondary',
|
| 59 |
+
'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
|
| 60 |
+
type === 'danger',
|
| 61 |
+
},
|
| 62 |
+
)}
|
| 63 |
+
onClick={onClick}
|
| 64 |
+
>
|
| 65 |
+
{children}
|
| 66 |
+
</button>
|
| 67 |
+
);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
| 71 |
+
return (
|
| 72 |
+
<RadixDialog.Title
|
| 73 |
+
className={classNames(
|
| 74 |
+
'px-5 py-4 flex items-center justify-between border-b border-bolt-elements-borderColor text-lg font-semibold leading-6 text-bolt-elements-textPrimary',
|
| 75 |
+
className,
|
| 76 |
+
)}
|
| 77 |
+
{...props}
|
| 78 |
+
>
|
| 79 |
+
{children}
|
| 80 |
+
</RadixDialog.Title>
|
| 81 |
+
);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
| 85 |
+
return (
|
| 86 |
+
<RadixDialog.Description
|
| 87 |
+
className={classNames('px-5 py-4 text-bolt-elements-textPrimary text-md', className)}
|
| 88 |
+
{...props}
|
| 89 |
+
>
|
| 90 |
+
{children}
|
| 91 |
+
</RadixDialog.Description>
|
| 92 |
+
);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
interface DialogProps {
|
| 96 |
+
children: ReactNode | ReactNode[];
|
| 97 |
+
className?: string;
|
| 98 |
+
onBackdrop?: (event: React.UIEvent) => void;
|
| 99 |
+
onClose?: (event: React.UIEvent) => void;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => {
|
| 103 |
+
return (
|
| 104 |
+
<RadixDialog.Portal>
|
| 105 |
+
<RadixDialog.Overlay onClick={onBackdrop} asChild>
|
| 106 |
+
<motion.div
|
| 107 |
+
className="bg-black/50 fixed inset-0 z-max"
|
| 108 |
+
initial="closed"
|
| 109 |
+
animate="open"
|
| 110 |
+
exit="closed"
|
| 111 |
+
variants={dialogBackdropVariants}
|
| 112 |
+
/>
|
| 113 |
+
</RadixDialog.Overlay>
|
| 114 |
+
<RadixDialog.Content asChild>
|
| 115 |
+
<motion.div
|
| 116 |
+
className={classNames(
|
| 117 |
+
'fixed top-[50%] left-[50%] z-max max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-2 shadow-lg focus:outline-none overflow-hidden',
|
| 118 |
+
className,
|
| 119 |
+
)}
|
| 120 |
+
initial="closed"
|
| 121 |
+
animate="open"
|
| 122 |
+
exit="closed"
|
| 123 |
+
variants={dialogVariants}
|
| 124 |
+
>
|
| 125 |
+
{children}
|
| 126 |
+
<RadixDialog.Close asChild onClick={onClose}>
|
| 127 |
+
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
|
| 128 |
+
</RadixDialog.Close>
|
| 129 |
+
</motion.div>
|
| 130 |
+
</RadixDialog.Content>
|
| 131 |
+
</RadixDialog.Portal>
|
| 132 |
+
);
|
| 133 |
+
});
|
app/components/ui/IconButton.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
import { classNames } from '~/utils/classNames';
|
| 3 |
+
|
| 4 |
+
type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
|
| 5 |
+
|
| 6 |
+
interface BaseIconButtonProps {
|
| 7 |
+
size?: IconSize;
|
| 8 |
+
className?: string;
|
| 9 |
+
iconClassName?: string;
|
| 10 |
+
disabledClassName?: string;
|
| 11 |
+
title?: string;
|
| 12 |
+
disabled?: boolean;
|
| 13 |
+
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type IconButtonWithoutChildrenProps = {
|
| 17 |
+
icon: string;
|
| 18 |
+
children?: undefined;
|
| 19 |
+
} & BaseIconButtonProps;
|
| 20 |
+
|
| 21 |
+
type IconButtonWithChildrenProps = {
|
| 22 |
+
icon?: undefined;
|
| 23 |
+
children: string | JSX.Element | JSX.Element[];
|
| 24 |
+
} & BaseIconButtonProps;
|
| 25 |
+
|
| 26 |
+
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
|
| 27 |
+
|
| 28 |
+
export const IconButton = memo(
|
| 29 |
+
({
|
| 30 |
+
icon,
|
| 31 |
+
size = 'xl',
|
| 32 |
+
className,
|
| 33 |
+
iconClassName,
|
| 34 |
+
disabledClassName,
|
| 35 |
+
disabled = false,
|
| 36 |
+
title,
|
| 37 |
+
onClick,
|
| 38 |
+
children,
|
| 39 |
+
}: IconButtonProps) => {
|
| 40 |
+
return (
|
| 41 |
+
<button
|
| 42 |
+
className={classNames(
|
| 43 |
+
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
|
| 44 |
+
{
|
| 45 |
+
[classNames('opacity-30', disabledClassName)]: disabled,
|
| 46 |
+
},
|
| 47 |
+
className,
|
| 48 |
+
)}
|
| 49 |
+
title={title}
|
| 50 |
+
disabled={disabled}
|
| 51 |
+
onClick={(event) => {
|
| 52 |
+
if (disabled) {
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
onClick?.(event);
|
| 57 |
+
}}
|
| 58 |
+
>
|
| 59 |
+
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
|
| 60 |
+
</button>
|
| 61 |
+
);
|
| 62 |
+
},
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
function getIconSize(size: IconSize) {
|
| 66 |
+
if (size === 'sm') {
|
| 67 |
+
return 'text-sm';
|
| 68 |
+
} else if (size === 'md') {
|
| 69 |
+
return 'text-md';
|
| 70 |
+
} else if (size === 'lg') {
|
| 71 |
+
return 'text-lg';
|
| 72 |
+
} else if (size === 'xl') {
|
| 73 |
+
return 'text-xl';
|
| 74 |
+
} else {
|
| 75 |
+
return 'text-2xl';
|
| 76 |
+
}
|
| 77 |
+
}
|
app/components/ui/LoadingDots.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface LoadingDotsProps {
|
| 4 |
+
text: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
|
| 8 |
+
const [dotCount, setDotCount] = useState(0);
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const interval = setInterval(() => {
|
| 12 |
+
setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
|
| 13 |
+
}, 500);
|
| 14 |
+
|
| 15 |
+
return () => clearInterval(interval);
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex justify-center items-center h-full">
|
| 20 |
+
<div className="relative">
|
| 21 |
+
<span>{text}</span>
|
| 22 |
+
<span className="absolute left-[calc(100%-12px)]">{'.'.repeat(dotCount)}</span>
|
| 23 |
+
<span className="invisible">...</span>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
});
|
app/components/ui/PanelHeader.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
import { classNames } from '~/utils/classNames';
|
| 3 |
+
|
| 4 |
+
interface PanelHeaderProps {
|
| 5 |
+
className?: string;
|
| 6 |
+
children: React.ReactNode;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
|
| 10 |
+
return (
|
| 11 |
+
<div
|
| 12 |
+
className={classNames(
|
| 13 |
+
'flex items-center gap-2 bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary border-b border-bolt-elements-borderColor px-4 py-1 min-h-[34px] text-sm',
|
| 14 |
+
className,
|
| 15 |
+
)}
|
| 16 |
+
>
|
| 17 |
+
{children}
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
});
|
app/components/ui/PanelHeaderButton.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
import { classNames } from '~/utils/classNames';
|
| 3 |
+
|
| 4 |
+
interface PanelHeaderButtonProps {
|
| 5 |
+
className?: string;
|
| 6 |
+
disabledClassName?: string;
|
| 7 |
+
disabled?: boolean;
|
| 8 |
+
children: string | JSX.Element | Array<JSX.Element | string>;
|
| 9 |
+
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const PanelHeaderButton = memo(
|
| 13 |
+
({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
|
| 14 |
+
return (
|
| 15 |
+
<button
|
| 16 |
+
className={classNames(
|
| 17 |
+
'flex items-center shrink-0 gap-1.5 px-1.5 rounded-md py-0.5 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
|
| 18 |
+
{
|
| 19 |
+
[classNames('opacity-30', disabledClassName)]: disabled,
|
| 20 |
+
},
|
| 21 |
+
className,
|
| 22 |
+
)}
|
| 23 |
+
disabled={disabled}
|
| 24 |
+
onClick={(event) => {
|
| 25 |
+
if (disabled) {
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
onClick?.(event);
|
| 30 |
+
}}
|
| 31 |
+
>
|
| 32 |
+
{children}
|
| 33 |
+
</button>
|
| 34 |
+
);
|
| 35 |
+
},
|
| 36 |
+
);
|
app/components/ui/Slider.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'framer-motion';
|
| 2 |
+
import { memo } from 'react';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 5 |
+
import { genericMemo } from '~/utils/react';
|
| 6 |
+
|
| 7 |
+
interface SliderOption<T> {
|
| 8 |
+
value: T;
|
| 9 |
+
text: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface SliderOptions<T> {
|
| 13 |
+
left: SliderOption<T>;
|
| 14 |
+
right: SliderOption<T>;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface SliderProps<T> {
|
| 18 |
+
selected: T;
|
| 19 |
+
options: SliderOptions<T>;
|
| 20 |
+
setSelected?: (selected: T) => void;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
| 24 |
+
const isLeftSelected = selected === options.left.value;
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
| 28 |
+
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
| 29 |
+
{options.left.text}
|
| 30 |
+
</SliderButton>
|
| 31 |
+
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
| 32 |
+
{options.right.text}
|
| 33 |
+
</SliderButton>
|
| 34 |
+
</div>
|
| 35 |
+
);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
interface SliderButtonProps {
|
| 39 |
+
selected: boolean;
|
| 40 |
+
children: string | JSX.Element | Array<JSX.Element | string>;
|
| 41 |
+
setSelected: () => void;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
|
| 45 |
+
return (
|
| 46 |
+
<button
|
| 47 |
+
onClick={setSelected}
|
| 48 |
+
className={classNames(
|
| 49 |
+
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
|
| 50 |
+
selected
|
| 51 |
+
? 'text-bolt-elements-item-contentAccent'
|
| 52 |
+
: 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
|
| 53 |
+
)}
|
| 54 |
+
>
|
| 55 |
+
<span className="relative z-10">{children}</span>
|
| 56 |
+
{selected && (
|
| 57 |
+
<motion.span
|
| 58 |
+
layoutId="pill-tab"
|
| 59 |
+
transition={{ duration: 0.2, ease: cubicEasingFn }}
|
| 60 |
+
className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
|
| 61 |
+
></motion.span>
|
| 62 |
+
)}
|
| 63 |
+
</button>
|
| 64 |
+
);
|
| 65 |
+
});
|
app/components/ui/ThemeSwitch.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { memo, useEffect, useState } from 'react';
|
| 3 |
+
import { themeStore, toggleTheme } from '~/lib/stores/theme';
|
| 4 |
+
import { IconButton } from './IconButton';
|
| 5 |
+
|
| 6 |
+
interface ThemeSwitchProps {
|
| 7 |
+
className?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
|
| 11 |
+
const theme = useStore(themeStore);
|
| 12 |
+
const [domLoaded, setDomLoaded] = useState(false);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
setDomLoaded(true);
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
domLoaded && (
|
| 20 |
+
<IconButton
|
| 21 |
+
className={className}
|
| 22 |
+
icon={theme === 'dark' ? 'i-ph-sun-dim-duotone' : 'i-ph-moon-stars-duotone'}
|
| 23 |
+
size="xl"
|
| 24 |
+
title="Toggle Theme"
|
| 25 |
+
onClick={toggleTheme}
|
| 26 |
+
/>
|
| 27 |
+
)
|
| 28 |
+
);
|
| 29 |
+
});
|
app/components/workbench/EditorPanel.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
| 3 |
+
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
|
| 4 |
+
import {
|
| 5 |
+
CodeMirrorEditor,
|
| 6 |
+
type EditorDocument,
|
| 7 |
+
type EditorSettings,
|
| 8 |
+
type OnChangeCallback as OnEditorChange,
|
| 9 |
+
type OnSaveCallback as OnEditorSave,
|
| 10 |
+
type OnScrollCallback as OnEditorScroll,
|
| 11 |
+
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
| 12 |
+
import { IconButton } from '~/components/ui/IconButton';
|
| 13 |
+
import { PanelHeader } from '~/components/ui/PanelHeader';
|
| 14 |
+
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
| 15 |
+
import { shortcutEventEmitter } from '~/lib/hooks';
|
| 16 |
+
import type { FileMap } from '~/lib/stores/files';
|
| 17 |
+
import { themeStore } from '~/lib/stores/theme';
|
| 18 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 19 |
+
import { classNames } from '~/utils/classNames';
|
| 20 |
+
import { WORK_DIR } from '~/utils/constants';
|
| 21 |
+
import { renderLogger } from '~/utils/logger';
|
| 22 |
+
import { isMobile } from '~/utils/mobile';
|
| 23 |
+
import { FileBreadcrumb } from './FileBreadcrumb';
|
| 24 |
+
import { FileTree } from './FileTree';
|
| 25 |
+
import { Terminal, type TerminalRef } from './terminal/Terminal';
|
| 26 |
+
|
| 27 |
+
interface EditorPanelProps {
|
| 28 |
+
files?: FileMap;
|
| 29 |
+
unsavedFiles?: Set<string>;
|
| 30 |
+
editorDocument?: EditorDocument;
|
| 31 |
+
selectedFile?: string | undefined;
|
| 32 |
+
isStreaming?: boolean;
|
| 33 |
+
onEditorChange?: OnEditorChange;
|
| 34 |
+
onEditorScroll?: OnEditorScroll;
|
| 35 |
+
onFileSelect?: (value?: string) => void;
|
| 36 |
+
onFileSave?: OnEditorSave;
|
| 37 |
+
onFileReset?: () => void;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const MAX_TERMINALS = 3;
|
| 41 |
+
const DEFAULT_TERMINAL_SIZE = 25;
|
| 42 |
+
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
|
| 43 |
+
|
| 44 |
+
const editorSettings: EditorSettings = { tabSize: 2 };
|
| 45 |
+
|
| 46 |
+
export const EditorPanel = memo(
|
| 47 |
+
({
|
| 48 |
+
files,
|
| 49 |
+
unsavedFiles,
|
| 50 |
+
editorDocument,
|
| 51 |
+
selectedFile,
|
| 52 |
+
isStreaming,
|
| 53 |
+
onFileSelect,
|
| 54 |
+
onEditorChange,
|
| 55 |
+
onEditorScroll,
|
| 56 |
+
onFileSave,
|
| 57 |
+
onFileReset,
|
| 58 |
+
}: EditorPanelProps) => {
|
| 59 |
+
renderLogger.trace('EditorPanel');
|
| 60 |
+
|
| 61 |
+
const theme = useStore(themeStore);
|
| 62 |
+
const showTerminal = useStore(workbenchStore.showTerminal);
|
| 63 |
+
|
| 64 |
+
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
| 65 |
+
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
| 66 |
+
const terminalToggledByShortcut = useRef(false);
|
| 67 |
+
|
| 68 |
+
const [activeTerminal, setActiveTerminal] = useState(0);
|
| 69 |
+
const [terminalCount, setTerminalCount] = useState(1);
|
| 70 |
+
|
| 71 |
+
const activeFileSegments = useMemo(() => {
|
| 72 |
+
if (!editorDocument) {
|
| 73 |
+
return undefined;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return editorDocument.filePath.split('/');
|
| 77 |
+
}, [editorDocument]);
|
| 78 |
+
|
| 79 |
+
const activeFileUnsaved = useMemo(() => {
|
| 80 |
+
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
| 81 |
+
}, [editorDocument, unsavedFiles]);
|
| 82 |
+
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
| 85 |
+
terminalToggledByShortcut.current = true;
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
| 89 |
+
for (const ref of Object.values(terminalRefs.current)) {
|
| 90 |
+
ref?.reloadStyles();
|
| 91 |
+
}
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
return () => {
|
| 95 |
+
unsubscribeFromEventEmitter();
|
| 96 |
+
unsubscribeFromThemeStore();
|
| 97 |
+
};
|
| 98 |
+
}, []);
|
| 99 |
+
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
const { current: terminal } = terminalPanelRef;
|
| 102 |
+
|
| 103 |
+
if (!terminal) {
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const isCollapsed = terminal.isCollapsed();
|
| 108 |
+
|
| 109 |
+
if (!showTerminal && !isCollapsed) {
|
| 110 |
+
terminal.collapse();
|
| 111 |
+
} else if (showTerminal && isCollapsed) {
|
| 112 |
+
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
terminalToggledByShortcut.current = false;
|
| 116 |
+
}, [showTerminal]);
|
| 117 |
+
|
| 118 |
+
const addTerminal = () => {
|
| 119 |
+
if (terminalCount < MAX_TERMINALS) {
|
| 120 |
+
setTerminalCount(terminalCount + 1);
|
| 121 |
+
setActiveTerminal(terminalCount);
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<PanelGroup direction="vertical">
|
| 127 |
+
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
| 128 |
+
<PanelGroup direction="horizontal">
|
| 129 |
+
<Panel defaultSize={20} minSize={10} collapsible>
|
| 130 |
+
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
| 131 |
+
<PanelHeader>
|
| 132 |
+
<div className="i-ph:tree-structure-duotone shrink-0" />
|
| 133 |
+
Files
|
| 134 |
+
</PanelHeader>
|
| 135 |
+
<FileTree
|
| 136 |
+
className="h-full"
|
| 137 |
+
files={files}
|
| 138 |
+
hideRoot
|
| 139 |
+
unsavedFiles={unsavedFiles}
|
| 140 |
+
rootFolder={WORK_DIR}
|
| 141 |
+
selectedFile={selectedFile}
|
| 142 |
+
onFileSelect={onFileSelect}
|
| 143 |
+
/>
|
| 144 |
+
</div>
|
| 145 |
+
</Panel>
|
| 146 |
+
<PanelResizeHandle />
|
| 147 |
+
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
| 148 |
+
<PanelHeader className="overflow-x-auto">
|
| 149 |
+
{activeFileSegments?.length && (
|
| 150 |
+
<div className="flex items-center flex-1 text-sm">
|
| 151 |
+
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
|
| 152 |
+
{activeFileUnsaved && (
|
| 153 |
+
<div className="flex gap-1 ml-auto -mr-1.5">
|
| 154 |
+
<PanelHeaderButton onClick={onFileSave}>
|
| 155 |
+
<div className="i-ph:floppy-disk-duotone" />
|
| 156 |
+
Save
|
| 157 |
+
</PanelHeaderButton>
|
| 158 |
+
<PanelHeaderButton onClick={onFileReset}>
|
| 159 |
+
<div className="i-ph:clock-counter-clockwise-duotone" />
|
| 160 |
+
Reset
|
| 161 |
+
</PanelHeaderButton>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
</PanelHeader>
|
| 167 |
+
<div className="h-full flex-1 overflow-hidden">
|
| 168 |
+
<CodeMirrorEditor
|
| 169 |
+
theme={theme}
|
| 170 |
+
editable={!isStreaming && editorDocument !== undefined}
|
| 171 |
+
settings={editorSettings}
|
| 172 |
+
doc={editorDocument}
|
| 173 |
+
autoFocusOnDocumentChange={!isMobile()}
|
| 174 |
+
onScroll={onEditorScroll}
|
| 175 |
+
onChange={onEditorChange}
|
| 176 |
+
onSave={onFileSave}
|
| 177 |
+
/>
|
| 178 |
+
</div>
|
| 179 |
+
</Panel>
|
| 180 |
+
</PanelGroup>
|
| 181 |
+
</Panel>
|
| 182 |
+
<PanelResizeHandle />
|
| 183 |
+
<Panel
|
| 184 |
+
ref={terminalPanelRef}
|
| 185 |
+
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
| 186 |
+
minSize={10}
|
| 187 |
+
collapsible
|
| 188 |
+
onExpand={() => {
|
| 189 |
+
if (!terminalToggledByShortcut.current) {
|
| 190 |
+
workbenchStore.toggleTerminal(true);
|
| 191 |
+
}
|
| 192 |
+
}}
|
| 193 |
+
onCollapse={() => {
|
| 194 |
+
if (!terminalToggledByShortcut.current) {
|
| 195 |
+
workbenchStore.toggleTerminal(false);
|
| 196 |
+
}
|
| 197 |
+
}}
|
| 198 |
+
>
|
| 199 |
+
<div className="h-full">
|
| 200 |
+
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
| 201 |
+
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
| 202 |
+
{Array.from({ length: terminalCount }, (_, index) => {
|
| 203 |
+
const isActive = activeTerminal === index;
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<button
|
| 207 |
+
key={index}
|
| 208 |
+
className={classNames(
|
| 209 |
+
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
| 210 |
+
{
|
| 211 |
+
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
| 212 |
+
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
| 213 |
+
!isActive,
|
| 214 |
+
},
|
| 215 |
+
)}
|
| 216 |
+
onClick={() => setActiveTerminal(index)}
|
| 217 |
+
>
|
| 218 |
+
<div className="i-ph:terminal-window-duotone text-lg" />
|
| 219 |
+
Terminal {terminalCount > 1 && index + 1}
|
| 220 |
+
</button>
|
| 221 |
+
);
|
| 222 |
+
})}
|
| 223 |
+
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
| 224 |
+
<IconButton
|
| 225 |
+
className="ml-auto"
|
| 226 |
+
icon="i-ph:caret-down"
|
| 227 |
+
title="Close"
|
| 228 |
+
size="md"
|
| 229 |
+
onClick={() => workbenchStore.toggleTerminal(false)}
|
| 230 |
+
/>
|
| 231 |
+
</div>
|
| 232 |
+
{Array.from({ length: terminalCount }, (_, index) => {
|
| 233 |
+
const isActive = activeTerminal === index;
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<Terminal
|
| 237 |
+
key={index}
|
| 238 |
+
className={classNames('h-full overflow-hidden', {
|
| 239 |
+
hidden: !isActive,
|
| 240 |
+
})}
|
| 241 |
+
ref={(ref) => {
|
| 242 |
+
terminalRefs.current.push(ref);
|
| 243 |
+
}}
|
| 244 |
+
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
| 245 |
+
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
| 246 |
+
theme={theme}
|
| 247 |
+
/>
|
| 248 |
+
);
|
| 249 |
+
})}
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
</Panel>
|
| 253 |
+
</PanelGroup>
|
| 254 |
+
);
|
| 255 |
+
},
|
| 256 |
+
);
|
app/components/workbench/FileBreadcrumb.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
| 2 |
+
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
| 3 |
+
import { memo, useEffect, useRef, useState } from 'react';
|
| 4 |
+
import type { FileMap } from '~/lib/stores/files';
|
| 5 |
+
import { classNames } from '~/utils/classNames';
|
| 6 |
+
import { WORK_DIR } from '~/utils/constants';
|
| 7 |
+
import { cubicEasingFn } from '~/utils/easings';
|
| 8 |
+
import { renderLogger } from '~/utils/logger';
|
| 9 |
+
import FileTree from './FileTree';
|
| 10 |
+
|
| 11 |
+
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
|
| 12 |
+
|
| 13 |
+
interface FileBreadcrumbProps {
|
| 14 |
+
files?: FileMap;
|
| 15 |
+
pathSegments?: string[];
|
| 16 |
+
onFileSelect?: (filePath: string) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const contextMenuVariants = {
|
| 20 |
+
open: {
|
| 21 |
+
y: 0,
|
| 22 |
+
opacity: 1,
|
| 23 |
+
transition: {
|
| 24 |
+
duration: 0.15,
|
| 25 |
+
ease: cubicEasingFn,
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
close: {
|
| 29 |
+
y: 6,
|
| 30 |
+
opacity: 0,
|
| 31 |
+
transition: {
|
| 32 |
+
duration: 0.15,
|
| 33 |
+
ease: cubicEasingFn,
|
| 34 |
+
},
|
| 35 |
+
},
|
| 36 |
+
} satisfies Variants;
|
| 37 |
+
|
| 38 |
+
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
|
| 39 |
+
renderLogger.trace('FileBreadcrumb');
|
| 40 |
+
|
| 41 |
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
| 42 |
+
|
| 43 |
+
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
| 44 |
+
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
| 45 |
+
|
| 46 |
+
const handleSegmentClick = (index: number) => {
|
| 47 |
+
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
const handleOutsideClick = (event: MouseEvent) => {
|
| 52 |
+
if (
|
| 53 |
+
activeIndex !== null &&
|
| 54 |
+
!contextMenuRef.current?.contains(event.target as Node) &&
|
| 55 |
+
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
|
| 56 |
+
) {
|
| 57 |
+
setActiveIndex(null);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
document.addEventListener('mousedown', handleOutsideClick);
|
| 62 |
+
|
| 63 |
+
return () => {
|
| 64 |
+
document.removeEventListener('mousedown', handleOutsideClick);
|
| 65 |
+
};
|
| 66 |
+
}, [activeIndex]);
|
| 67 |
+
|
| 68 |
+
if (files === undefined || pathSegments.length === 0) {
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="flex">
|
| 74 |
+
{pathSegments.map((segment, index) => {
|
| 75 |
+
const isLast = index === pathSegments.length - 1;
|
| 76 |
+
|
| 77 |
+
const path = pathSegments.slice(0, index).join('/');
|
| 78 |
+
|
| 79 |
+
if (!WORK_DIR_REGEX.test(path)) {
|
| 80 |
+
return null;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const isActive = activeIndex === index;
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<div key={index} className="relative flex items-center">
|
| 87 |
+
<DropdownMenu.Root open={isActive} modal={false}>
|
| 88 |
+
<DropdownMenu.Trigger asChild>
|
| 89 |
+
<span
|
| 90 |
+
ref={(ref) => (segmentRefs.current[index] = ref)}
|
| 91 |
+
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
|
| 92 |
+
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
|
| 93 |
+
'text-bolt-elements-textPrimary underline': isActive,
|
| 94 |
+
'pr-4': isLast,
|
| 95 |
+
})}
|
| 96 |
+
onClick={() => handleSegmentClick(index)}
|
| 97 |
+
>
|
| 98 |
+
{isLast && <div className="i-ph:file-duotone" />}
|
| 99 |
+
{segment}
|
| 100 |
+
</span>
|
| 101 |
+
</DropdownMenu.Trigger>
|
| 102 |
+
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
|
| 103 |
+
<AnimatePresence>
|
| 104 |
+
{isActive && (
|
| 105 |
+
<DropdownMenu.Portal>
|
| 106 |
+
<DropdownMenu.Content
|
| 107 |
+
className="z-file-tree-breadcrumb"
|
| 108 |
+
asChild
|
| 109 |
+
align="start"
|
| 110 |
+
side="bottom"
|
| 111 |
+
avoidCollisions={false}
|
| 112 |
+
>
|
| 113 |
+
<motion.div
|
| 114 |
+
ref={contextMenuRef}
|
| 115 |
+
initial="close"
|
| 116 |
+
animate="open"
|
| 117 |
+
exit="close"
|
| 118 |
+
variants={contextMenuVariants}
|
| 119 |
+
>
|
| 120 |
+
<div className="rounded-lg overflow-hidden">
|
| 121 |
+
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
|
| 122 |
+
<FileTree
|
| 123 |
+
files={files}
|
| 124 |
+
hideRoot
|
| 125 |
+
rootFolder={path}
|
| 126 |
+
collapsed
|
| 127 |
+
allowFolderSelection
|
| 128 |
+
selectedFile={`${path}/${segment}`}
|
| 129 |
+
onFileSelect={(filePath) => {
|
| 130 |
+
setActiveIndex(null);
|
| 131 |
+
onFileSelect?.(filePath);
|
| 132 |
+
}}
|
| 133 |
+
/>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
|
| 137 |
+
</motion.div>
|
| 138 |
+
</DropdownMenu.Content>
|
| 139 |
+
</DropdownMenu.Portal>
|
| 140 |
+
)}
|
| 141 |
+
</AnimatePresence>
|
| 142 |
+
</DropdownMenu.Root>
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
})}
|
| 146 |
+
</div>
|
| 147 |
+
);
|
| 148 |
+
});
|
app/components/workbench/FileTree.tsx
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
| 2 |
+
import type { FileMap } from '~/lib/stores/files';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
| 5 |
+
|
| 6 |
+
const logger = createScopedLogger('FileTree');
|
| 7 |
+
|
| 8 |
+
const NODE_PADDING_LEFT = 8;
|
| 9 |
+
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
files?: FileMap;
|
| 13 |
+
selectedFile?: string;
|
| 14 |
+
onFileSelect?: (filePath: string) => void;
|
| 15 |
+
rootFolder?: string;
|
| 16 |
+
hideRoot?: boolean;
|
| 17 |
+
collapsed?: boolean;
|
| 18 |
+
allowFolderSelection?: boolean;
|
| 19 |
+
hiddenFiles?: Array<string | RegExp>;
|
| 20 |
+
unsavedFiles?: Set<string>;
|
| 21 |
+
className?: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export const FileTree = memo(
|
| 25 |
+
({
|
| 26 |
+
files = {},
|
| 27 |
+
onFileSelect,
|
| 28 |
+
selectedFile,
|
| 29 |
+
rootFolder,
|
| 30 |
+
hideRoot = false,
|
| 31 |
+
collapsed = false,
|
| 32 |
+
allowFolderSelection = false,
|
| 33 |
+
hiddenFiles,
|
| 34 |
+
className,
|
| 35 |
+
unsavedFiles,
|
| 36 |
+
}: Props) => {
|
| 37 |
+
renderLogger.trace('FileTree');
|
| 38 |
+
|
| 39 |
+
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
| 40 |
+
|
| 41 |
+
const fileList = useMemo(() => {
|
| 42 |
+
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
|
| 43 |
+
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
|
| 44 |
+
|
| 45 |
+
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
| 46 |
+
return collapsed
|
| 47 |
+
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
|
| 48 |
+
: new Set<string>();
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
if (collapsed) {
|
| 53 |
+
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
setCollapsedFolders((prevCollapsed) => {
|
| 58 |
+
const newCollapsed = new Set<string>();
|
| 59 |
+
|
| 60 |
+
for (const folder of fileList) {
|
| 61 |
+
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
|
| 62 |
+
newCollapsed.add(folder.fullPath);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return newCollapsed;
|
| 67 |
+
});
|
| 68 |
+
}, [fileList, collapsed]);
|
| 69 |
+
|
| 70 |
+
const filteredFileList = useMemo(() => {
|
| 71 |
+
const list = [];
|
| 72 |
+
|
| 73 |
+
let lastDepth = Number.MAX_SAFE_INTEGER;
|
| 74 |
+
|
| 75 |
+
for (const fileOrFolder of fileList) {
|
| 76 |
+
const depth = fileOrFolder.depth;
|
| 77 |
+
|
| 78 |
+
// if the depth is equal we reached the end of the collaped group
|
| 79 |
+
if (lastDepth === depth) {
|
| 80 |
+
lastDepth = Number.MAX_SAFE_INTEGER;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// ignore collapsed folders
|
| 84 |
+
if (collapsedFolders.has(fileOrFolder.fullPath)) {
|
| 85 |
+
lastDepth = Math.min(lastDepth, depth);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// ignore files and folders below the last collapsed folder
|
| 89 |
+
if (lastDepth < depth) {
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
list.push(fileOrFolder);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return list;
|
| 97 |
+
}, [fileList, collapsedFolders]);
|
| 98 |
+
|
| 99 |
+
const toggleCollapseState = (fullPath: string) => {
|
| 100 |
+
setCollapsedFolders((prevSet) => {
|
| 101 |
+
const newSet = new Set(prevSet);
|
| 102 |
+
|
| 103 |
+
if (newSet.has(fullPath)) {
|
| 104 |
+
newSet.delete(fullPath);
|
| 105 |
+
} else {
|
| 106 |
+
newSet.add(fullPath);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return newSet;
|
| 110 |
+
});
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<div className={classNames('text-sm', className)}>
|
| 115 |
+
{filteredFileList.map((fileOrFolder) => {
|
| 116 |
+
switch (fileOrFolder.kind) {
|
| 117 |
+
case 'file': {
|
| 118 |
+
return (
|
| 119 |
+
<File
|
| 120 |
+
key={fileOrFolder.id}
|
| 121 |
+
selected={selectedFile === fileOrFolder.fullPath}
|
| 122 |
+
file={fileOrFolder}
|
| 123 |
+
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
| 124 |
+
onClick={() => {
|
| 125 |
+
onFileSelect?.(fileOrFolder.fullPath);
|
| 126 |
+
}}
|
| 127 |
+
/>
|
| 128 |
+
);
|
| 129 |
+
}
|
| 130 |
+
case 'folder': {
|
| 131 |
+
return (
|
| 132 |
+
<Folder
|
| 133 |
+
key={fileOrFolder.id}
|
| 134 |
+
folder={fileOrFolder}
|
| 135 |
+
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
| 136 |
+
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
| 137 |
+
onClick={() => {
|
| 138 |
+
toggleCollapseState(fileOrFolder.fullPath);
|
| 139 |
+
}}
|
| 140 |
+
/>
|
| 141 |
+
);
|
| 142 |
+
}
|
| 143 |
+
default: {
|
| 144 |
+
return undefined;
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
})}
|
| 148 |
+
</div>
|
| 149 |
+
);
|
| 150 |
+
},
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
export default FileTree;
|
| 154 |
+
|
| 155 |
+
interface FolderProps {
|
| 156 |
+
folder: FolderNode;
|
| 157 |
+
collapsed: boolean;
|
| 158 |
+
selected?: boolean;
|
| 159 |
+
onClick: () => void;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
|
| 163 |
+
return (
|
| 164 |
+
<NodeButton
|
| 165 |
+
className={classNames('group', {
|
| 166 |
+
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
| 167 |
+
!selected,
|
| 168 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
| 169 |
+
})}
|
| 170 |
+
depth={depth}
|
| 171 |
+
iconClasses={classNames({
|
| 172 |
+
'i-ph:caret-right scale-98': collapsed,
|
| 173 |
+
'i-ph:caret-down scale-98': !collapsed,
|
| 174 |
+
})}
|
| 175 |
+
onClick={onClick}
|
| 176 |
+
>
|
| 177 |
+
{name}
|
| 178 |
+
</NodeButton>
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
interface FileProps {
|
| 183 |
+
file: FileNode;
|
| 184 |
+
selected: boolean;
|
| 185 |
+
unsavedChanges?: boolean;
|
| 186 |
+
onClick: () => void;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
|
| 190 |
+
return (
|
| 191 |
+
<NodeButton
|
| 192 |
+
className={classNames('group', {
|
| 193 |
+
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
|
| 194 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
| 195 |
+
})}
|
| 196 |
+
depth={depth}
|
| 197 |
+
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
| 198 |
+
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
| 199 |
+
})}
|
| 200 |
+
onClick={onClick}
|
| 201 |
+
>
|
| 202 |
+
<div
|
| 203 |
+
className={classNames('flex items-center', {
|
| 204 |
+
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
| 205 |
+
})}
|
| 206 |
+
>
|
| 207 |
+
<div className="flex-1 truncate pr-2">{name}</div>
|
| 208 |
+
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
| 209 |
+
</div>
|
| 210 |
+
</NodeButton>
|
| 211 |
+
);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
interface ButtonProps {
|
| 215 |
+
depth: number;
|
| 216 |
+
iconClasses: string;
|
| 217 |
+
children: ReactNode;
|
| 218 |
+
className?: string;
|
| 219 |
+
onClick?: () => void;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
|
| 223 |
+
return (
|
| 224 |
+
<button
|
| 225 |
+
className={classNames(
|
| 226 |
+
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
|
| 227 |
+
className,
|
| 228 |
+
)}
|
| 229 |
+
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
|
| 230 |
+
onClick={() => onClick?.()}
|
| 231 |
+
>
|
| 232 |
+
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
|
| 233 |
+
<div className="truncate w-full text-left">{children}</div>
|
| 234 |
+
</button>
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
type Node = FileNode | FolderNode;
|
| 239 |
+
|
| 240 |
+
interface BaseNode {
|
| 241 |
+
id: number;
|
| 242 |
+
depth: number;
|
| 243 |
+
name: string;
|
| 244 |
+
fullPath: string;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
interface FileNode extends BaseNode {
|
| 248 |
+
kind: 'file';
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
interface FolderNode extends BaseNode {
|
| 252 |
+
kind: 'folder';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function buildFileList(
|
| 256 |
+
files: FileMap,
|
| 257 |
+
rootFolder = '/',
|
| 258 |
+
hideRoot: boolean,
|
| 259 |
+
hiddenFiles: Array<string | RegExp>,
|
| 260 |
+
): Node[] {
|
| 261 |
+
const folderPaths = new Set<string>();
|
| 262 |
+
const fileList: Node[] = [];
|
| 263 |
+
|
| 264 |
+
let defaultDepth = 0;
|
| 265 |
+
|
| 266 |
+
if (rootFolder === '/' && !hideRoot) {
|
| 267 |
+
defaultDepth = 1;
|
| 268 |
+
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
for (const [filePath, dirent] of Object.entries(files)) {
|
| 272 |
+
const segments = filePath.split('/').filter((segment) => segment);
|
| 273 |
+
const fileName = segments.at(-1);
|
| 274 |
+
|
| 275 |
+
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
|
| 276 |
+
continue;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
let currentPath = '';
|
| 280 |
+
|
| 281 |
+
let i = 0;
|
| 282 |
+
let depth = 0;
|
| 283 |
+
|
| 284 |
+
while (i < segments.length) {
|
| 285 |
+
const name = segments[i];
|
| 286 |
+
const fullPath = (currentPath += `/${name}`);
|
| 287 |
+
|
| 288 |
+
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
|
| 289 |
+
i++;
|
| 290 |
+
continue;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
if (i === segments.length - 1 && dirent?.type === 'file') {
|
| 294 |
+
fileList.push({
|
| 295 |
+
kind: 'file',
|
| 296 |
+
id: fileList.length,
|
| 297 |
+
name,
|
| 298 |
+
fullPath,
|
| 299 |
+
depth: depth + defaultDepth,
|
| 300 |
+
});
|
| 301 |
+
} else if (!folderPaths.has(fullPath)) {
|
| 302 |
+
folderPaths.add(fullPath);
|
| 303 |
+
|
| 304 |
+
fileList.push({
|
| 305 |
+
kind: 'folder',
|
| 306 |
+
id: fileList.length,
|
| 307 |
+
name,
|
| 308 |
+
fullPath,
|
| 309 |
+
depth: depth + defaultDepth,
|
| 310 |
+
});
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
i++;
|
| 314 |
+
depth++;
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
return sortFileList(rootFolder, fileList, hideRoot);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
|
| 322 |
+
return hiddenFiles.some((pathOrRegex) => {
|
| 323 |
+
if (typeof pathOrRegex === 'string') {
|
| 324 |
+
return fileName === pathOrRegex;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
return pathOrRegex.test(filePath);
|
| 328 |
+
});
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
/**
|
| 332 |
+
* Sorts the given list of nodes into a tree structure (still a flat list).
|
| 333 |
+
*
|
| 334 |
+
* This function organizes the nodes into a hierarchical structure based on their paths,
|
| 335 |
+
* with folders appearing before files and all items sorted alphabetically within their level.
|
| 336 |
+
*
|
| 337 |
+
* @note This function mutates the given `nodeList` array for performance reasons.
|
| 338 |
+
*
|
| 339 |
+
* @param rootFolder - The path of the root folder to start the sorting from.
|
| 340 |
+
* @param nodeList - The list of nodes to be sorted.
|
| 341 |
+
*
|
| 342 |
+
* @returns A new array of nodes sorted in depth-first order.
|
| 343 |
+
*/
|
| 344 |
+
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
|
| 345 |
+
logger.trace('sortFileList');
|
| 346 |
+
|
| 347 |
+
const nodeMap = new Map<string, Node>();
|
| 348 |
+
const childrenMap = new Map<string, Node[]>();
|
| 349 |
+
|
| 350 |
+
// pre-sort nodes by name and type
|
| 351 |
+
nodeList.sort((a, b) => compareNodes(a, b));
|
| 352 |
+
|
| 353 |
+
for (const node of nodeList) {
|
| 354 |
+
nodeMap.set(node.fullPath, node);
|
| 355 |
+
|
| 356 |
+
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
|
| 357 |
+
|
| 358 |
+
if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
|
| 359 |
+
if (!childrenMap.has(parentPath)) {
|
| 360 |
+
childrenMap.set(parentPath, []);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
childrenMap.get(parentPath)?.push(node);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
const sortedList: Node[] = [];
|
| 368 |
+
|
| 369 |
+
const depthFirstTraversal = (path: string): void => {
|
| 370 |
+
const node = nodeMap.get(path);
|
| 371 |
+
|
| 372 |
+
if (node) {
|
| 373 |
+
sortedList.push(node);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
const children = childrenMap.get(path);
|
| 377 |
+
|
| 378 |
+
if (children) {
|
| 379 |
+
for (const child of children) {
|
| 380 |
+
if (child.kind === 'folder') {
|
| 381 |
+
depthFirstTraversal(child.fullPath);
|
| 382 |
+
} else {
|
| 383 |
+
sortedList.push(child);
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
};
|
| 388 |
+
|
| 389 |
+
if (hideRoot) {
|
| 390 |
+
// if root is hidden, start traversal from its immediate children
|
| 391 |
+
const rootChildren = childrenMap.get(rootFolder) || [];
|
| 392 |
+
|
| 393 |
+
for (const child of rootChildren) {
|
| 394 |
+
depthFirstTraversal(child.fullPath);
|
| 395 |
+
}
|
| 396 |
+
} else {
|
| 397 |
+
depthFirstTraversal(rootFolder);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
return sortedList;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function compareNodes(a: Node, b: Node): number {
|
| 404 |
+
if (a.kind !== b.kind) {
|
| 405 |
+
return a.kind === 'folder' ? -1 : 1;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
| 409 |
+
}
|