From 8deee04a637b589c9ba1acd736ea65a4b90fe216 Mon Sep 17 00:00:00 2001
From: bizrockman
Date: Fri, 28 Feb 2025 22:04:39 +0100
Subject: [PATCH 001/115] fix: handle empty content correctly in FilesStore
saveFile() (#1381)
* Fix FilesStore: Handle empty content correctly in saveFile(). Happens when user creates files in the web terminal
* updated logic
---------
Co-authored-by: Anirban Kar
---
app/lib/stores/files.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts
index 687b8037..60904a65 100644
--- a/app/lib/stores/files.ts
+++ b/app/lib/stores/files.ts
@@ -115,7 +115,7 @@ export class FilesStore {
const oldContent = this.getFile(filePath)?.content;
- if (!oldContent) {
+ if (!oldContent && oldContent !== '') {
unreachable('Expected content to be defined');
}
From 3368b7903e5feb56c008655b10884a1333e40631 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Sun, 2 Mar 2025 03:41:58 +0530
Subject: [PATCH 002/115] fix: OpenAILike api key not showing up (#1403)
---
app/components/chat/BaseChat.tsx | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx
index 12929b10..b8712202 100644
--- a/app/components/chat/BaseChat.tsx
+++ b/app/components/chat/BaseChat.tsx
@@ -413,15 +413,17 @@ export const BaseChat = React.forwardRef(
apiKeys={apiKeys}
modelLoading={isModelLoading}
/>
- {(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
- {
- onApiKeysChange(provider.name, key);
- }}
- />
- )}
+ {(providerList || []).length > 0 &&
+ provider &&
+ (!LOCAL_PROVIDERS.includes(provider.name) || 'OpenAILike') && (
+ {
+ onApiKeysChange(provider.name, key);
+ }}
+ />
+ )}
)}
From 4404f4a5c0a4bb5848d0f7121564b9e55007408a Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Sun, 2 Mar 2025 12:58:13 +0530
Subject: [PATCH 003/115] fix: git connection fix for starter template (#1411)
---
.../@settings/tabs/connections/GithubConnection.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx
index e2d8924f..9f433724 100644
--- a/app/components/@settings/tabs/connections/GithubConnection.tsx
+++ b/app/components/@settings/tabs/connections/GithubConnection.tsx
@@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
+import Cookies from 'js-cookie';
interface GitHubUserResponse {
login: string;
@@ -175,6 +176,8 @@ export function GithubConnection() {
if (parsed.user && parsed.token) {
fetchGitHubStats(parsed.token);
}
+ } else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
+ fetchGithubUser(import.meta.env.VITE_GITHUB_ACCESS_TOKEN);
}
setIsLoading(false);
@@ -206,6 +209,10 @@ export function GithubConnection() {
};
localStorage.setItem('github_connection', JSON.stringify(newConnection));
+ Cookies.set('githubToken', token);
+ Cookies.set('githubUsername', data.login);
+ Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
+
setConnection(newConnection);
await fetchGitHubStats(token);
From b01874205ebb294d88a2db57b9bad437ba4ee299 Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 3 Mar 2025 09:24:39 +0000
Subject: [PATCH 004/115] fix: support php language in diff view
---
app/components/workbench/DiffView.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx
index aa635ba6..73a5a339 100644
--- a/app/components/workbench/DiffView.tsx
+++ b/app/components/workbench/DiffView.tsx
@@ -556,7 +556,7 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }
useEffect(() => {
getHighlighter({
themes: ['github-dark', 'github-light'],
- langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx'],
+ langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx', 'python', 'php'],
}).then(setHighlighter);
}, []);
From 964e1973fbb6b211ce200f408af469eaace7f132 Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 3 Mar 2025 09:39:16 +0000
Subject: [PATCH 005/115] fix: added a bunch more common languages to diff view
including: java, c, cpp, csharp, go ruby, rust
---
app/components/workbench/DiffView.tsx | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx
index 73a5a339..146076c5 100644
--- a/app/components/workbench/DiffView.tsx
+++ b/app/components/workbench/DiffView.tsx
@@ -556,7 +556,25 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }
useEffect(() => {
getHighlighter({
themes: ['github-dark', 'github-light'],
- langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx', 'python', 'php'],
+ langs: [
+ 'typescript',
+ 'javascript',
+ 'json',
+ 'html',
+ 'css',
+ 'jsx',
+ 'tsx',
+ 'python',
+ 'php',
+ 'java',
+ 'c',
+ 'cpp',
+ 'csharp',
+ 'go',
+ 'ruby',
+ 'rust',
+ 'plaintext',
+ ],
}).then(setHighlighter);
}, []);
From 8c83c3c9aaaa5299bf04be5a34fbdf7359c31ac9 Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 3 Mar 2025 12:16:13 +0000
Subject: [PATCH 006/115] feat: add creation of files and folders in the
FileTree
- Drag and drop images directly in the file tree. Image will convert to base64 format
---
app/components/workbench/FileTree.tsx | 232 +++++++++++++++++++++++---
app/lib/stores/workbench.ts | 48 ++++++
2 files changed, 257 insertions(+), 23 deletions(-)
diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
index eed791ef..ff4f531d 100644
--- a/app/components/workbench/FileTree.tsx
+++ b/app/components/workbench/FileTree.tsx
@@ -1,10 +1,13 @@
-import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import * as ContextMenu from '@radix-ui/react-context-menu';
import type { FileHistory } from '~/types/actions';
import { diffLines, type Change } from 'diff';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { toast } from 'react-toastify';
+import { path } from '~/utils/path';
const logger = createScopedLogger('FileTree');
@@ -25,6 +28,13 @@ interface Props {
className?: string;
}
+interface InlineInputProps {
+ depth: number;
+ placeholder: string;
+ onSubmit: (value: string) => void;
+ onCancel: () => void;
+}
+
export const FileTree = memo(
({
files = {},
@@ -213,28 +223,204 @@ function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; childr
);
}
-function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
+function InlineInput({ depth, placeholder, onSubmit, onCancel }: InlineInputProps) {
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, 50);
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ const value = inputRef.current?.value.trim();
+
+ if (value) {
+ onSubmit(value);
+ }
+ } else if (e.key === 'Escape') {
+ onCancel();
+ }
+ };
+
return (
-
- {children}
-
-
-
- Copy path
- Copy relative path
-
-
-
-
+
+
+
{
+ setTimeout(() => {
+ if (document.activeElement !== inputRef.current) {
+ onCancel();
+ }
+ }, 100);
+ }}
+ />
+
);
}
+// Modify the FileContextMenu component
+function FileContextMenu({
+ onCopyPath,
+ onCopyRelativePath,
+ fullPath,
+ children,
+}: FolderContextMenuProps & { fullPath: string }) {
+ const [isCreatingFile, setIsCreatingFile] = useState(false);
+ const [isCreatingFolder, setIsCreatingFolder] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const items = Array.from(e.dataTransfer.items);
+ const imageFiles = items.filter((item) => item.type.startsWith('image/'));
+
+ for (const item of imageFiles) {
+ const file = item.getAsFile();
+
+ if (file) {
+ try {
+ const filePath = path.join(fullPath, file.name);
+ const success = await workbenchStore.createNewFile(filePath, file);
+
+ if (success) {
+ toast.success(`Image ${file.name} uploaded successfully`);
+ } else {
+ toast.error(`Failed to upload image ${file.name}`);
+ }
+ } catch (error) {
+ toast.error(`Error uploading ${file.name}`);
+ logger.error(error);
+ }
+ }
+ }
+
+ setIsDragging(false);
+ },
+ [fullPath],
+ );
+
+ const handleCreateFile = async (fileName: string) => {
+ const newFilePath = path.join(fullPath, fileName);
+ const success = await workbenchStore.createNewFile(newFilePath);
+
+ if (success) {
+ toast.success('File created successfully');
+ } else {
+ toast.error('Failed to create file');
+ }
+
+ setIsCreatingFile(false);
+ };
+
+ const handleCreateFolder = async (folderName: string) => {
+ const newFolderPath = path.join(fullPath, folderName);
+ const success = await workbenchStore.createNewFolder(newFolderPath);
+
+ if (success) {
+ toast.success('Folder created successfully');
+ } else {
+ toast.error('Failed to create folder');
+ }
+
+ setIsCreatingFolder(false);
+ };
+
+ return (
+ <>
+
+
+
+ {children}
+
+
+
+
+
+ setIsCreatingFile(true)}>
+
+
+ setIsCreatingFolder(true)}>
+
+
+
+
+ Copy path
+ Copy relative path
+
+
+
+
+ {isCreatingFile && (
+ setIsCreatingFile(false)}
+ />
+ )}
+ {isCreatingFolder && (
+ setIsCreatingFolder(false)}
+ />
+ )}
+ >
+ );
+}
+
+// Update the Folder component to pass the fullPath
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
return (
-
+
{
if (!fileModifications?.originalContent) {
return { additions: 0, deletions: 0 };
}
- // Usar a mesma lΓ³gica do DiffView para processar as mudanΓ§as
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
@@ -317,7 +503,7 @@ function File({
const showStats = additions > 0 || deletions > 0;
return (
-
+
Date: Mon, 3 Mar 2025 15:57:25 +0100
Subject: [PATCH 007/115] Delete wrangler.toml
---
wrangler.toml | 6 ------
1 file changed, 6 deletions(-)
delete mode 100644 wrangler.toml
diff --git a/wrangler.toml b/wrangler.toml
deleted file mode 100644
index addd1007..00000000
--- a/wrangler.toml
+++ /dev/null
@@ -1,6 +0,0 @@
-#:schema node_modules/wrangler/config-schema.json
-name = "bolt"
-compatibility_flags = ["nodejs_compat"]
-compatibility_date = "2024-07-01"
-pages_build_output_dir = "./build/client"
-send_metrics = false
\ No newline at end of file
From 2780b2ebe1226ff6f88df591e34d049d257653fe Mon Sep 17 00:00:00 2001
From: Leex
Date: Mon, 3 Mar 2025 15:57:41 +0100
Subject: [PATCH 008/115] Delete .tool-versions
---
.tool-versions | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 .tool-versions
diff --git a/.tool-versions b/.tool-versions
deleted file mode 100644
index 74c88f6a..00000000
--- a/.tool-versions
+++ /dev/null
@@ -1,2 +0,0 @@
-nodejs 20.15.1
-pnpm 9.4.0
\ No newline at end of file
From 8d1f1382243e801ae1b0fcfca7eac6f8ebb0cf0c Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Mon, 3 Mar 2025 21:21:04 +0530
Subject: [PATCH 009/115] Revert "Delete wrangler.toml"
This reverts commit 60b6f476d7515c34a1aafe65a55f6661e00ff8cc.
---
wrangler.toml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 wrangler.toml
diff --git a/wrangler.toml b/wrangler.toml
new file mode 100644
index 00000000..addd1007
--- /dev/null
+++ b/wrangler.toml
@@ -0,0 +1,6 @@
+#:schema node_modules/wrangler/config-schema.json
+name = "bolt"
+compatibility_flags = ["nodejs_compat"]
+compatibility_date = "2024-07-01"
+pages_build_output_dir = "./build/client"
+send_metrics = false
\ No newline at end of file
From b079a56788f7871a46f490cd79d1d3108e354bdb Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 3 Mar 2025 16:14:31 +0000
Subject: [PATCH 010/115] add: add file renaming and delete functionality
---
app/components/workbench/FileTree.tsx | 96 +++++++++++++++++++++++---
app/lib/stores/workbench.ts | 98 +++++++++++++++++++++++++++
2 files changed, 186 insertions(+), 8 deletions(-)
diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
index ff4f531d..d1087361 100644
--- a/app/components/workbench/FileTree.tsx
+++ b/app/components/workbench/FileTree.tsx
@@ -31,6 +31,7 @@ interface Props {
interface InlineInputProps {
depth: number;
placeholder: string;
+ initialValue?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
@@ -223,18 +224,23 @@ function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; childr
);
}
-function InlineInput({ depth, placeholder, onSubmit, onCancel }: InlineInputProps) {
+function InlineInput({ depth, placeholder, initialValue = '', onSubmit, onCancel }: InlineInputProps) {
const inputRef = useRef(null);
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
+
+ if (initialValue) {
+ inputRef.current.value = initialValue;
+ inputRef.current.select();
+ }
}
}, 50);
return () => clearTimeout(timer);
- }, []);
+ }, [initialValue]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
@@ -272,7 +278,6 @@ function InlineInput({ depth, placeholder, onSubmit, onCancel }: InlineInputProp
);
}
-// Modify the FileContextMenu component
function FileContextMenu({
onCopyPath,
onCopyRelativePath,
@@ -281,8 +286,10 @@ function FileContextMenu({
}: FolderContextMenuProps & { fullPath: string }) {
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
+ const [isRenaming, setIsRenaming] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
+ const fileName = useMemo(() => path.basename(fullPath), [fullPath]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -355,6 +362,59 @@ function FileContextMenu({
setIsCreatingFolder(false);
};
+ const handleDelete = async () => {
+ try {
+ if (confirm(`Are you sure you want to delete ${fileName}?`)) {
+ const isDirectory = path.extname(fullPath) === '';
+
+ const success = isDirectory
+ ? await workbenchStore.deleteFolder(fullPath)
+ : await workbenchStore.deleteFile(fullPath);
+
+ if (success) {
+ toast.success('Deleted successfully');
+ } else {
+ toast.error('Failed to delete');
+ }
+ }
+ } catch (error) {
+ toast.error('Error during delete operation');
+ logger.error(error);
+ }
+ };
+
+ const handleRename = async (newName: string) => {
+ if (newName === fileName) {
+ setIsRenaming(false);
+ return;
+ }
+
+ const parentDir = path.dirname(fullPath);
+ const newPath = path.join(parentDir, newName);
+
+ try {
+ const files = workbenchStore.files.get();
+ const fileEntry = files[fullPath];
+
+ const isDirectory = !fileEntry || fileEntry.type === 'folder';
+
+ const success = isDirectory
+ ? await workbenchStore.renameFolder(fullPath, newPath)
+ : await workbenchStore.renameFile(fullPath, newPath);
+
+ if (success) {
+ toast.success('Renamed successfully');
+ } else {
+ toast.error('Failed to rename');
+ }
+ } catch (error) {
+ toast.error('Error during rename operation');
+ logger.error(error);
+ }
+
+ setIsRenaming(false);
+ };
+
return (
<>
@@ -368,7 +428,17 @@ function FileContextMenu({
isDragging,
})}
>
- {children}
+ {!isRenaming && children}
+
+ {isRenaming && (
+ setIsRenaming(false)}
+ />
+ )}
@@ -389,6 +459,18 @@ function FileContextMenu({
New Folder
+ setIsRenaming(true)}>
+
+
+
+
+
Copy path
@@ -413,11 +495,11 @@ function FileContextMenu({
onCancel={() => setIsCreatingFolder(false)}
/>
)}
+ {/* Remove the isRenaming InlineInput from here since we moved it above */}
>
);
}
-// Update the Folder component to pass the fullPath
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
return (
@@ -440,7 +522,6 @@ function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativ
);
}
-// Add this interface after the FolderProps interface
interface FileProps {
file: FileNode;
selected: boolean;
@@ -461,7 +542,6 @@ function File({
fileHistory = {},
}: FileProps) {
const { depth, name, fullPath } = file;
- const parentPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
const fileModifications = fileHistory[fullPath];
@@ -503,7 +583,7 @@ function File({
const showStats = additions > 0 || deletions > 0;
return (
-
+
filePath.startsWith(oldPath))
+ .map(([filePath, dirent]) => ({ path: filePath, dirent }));
+
+ for (const { path: filePath, dirent } of filesToMove) {
+ if (dirent?.type === 'file') {
+ const relativePath = filePath.substring(oldPath.length);
+ const newFilePath = path.join(newPath, relativePath);
+
+ await this.createNewFile(newFilePath, dirent.content);
+ }
+ }
+
+ await this.deleteFolder(oldPath);
+
+ const selectedFile = this.selectedFile.get();
+
+ if (selectedFile && selectedFile.startsWith(oldPath)) {
+ const relativePath = selectedFile.substring(oldPath.length);
+ const newSelectedPath = path.join(newPath, relativePath);
+ this.setSelectedFile(newSelectedPath);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error renaming folder:', error);
+ return false;
+ }
+ }
}
export const workbenchStore = new WorkbenchStore();
From 9b2a204ddc3ff0cb75236fe4e56ee103e32dcbb2 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Tue, 4 Mar 2025 20:28:51 +0530
Subject: [PATCH 011/115] ci: added arm64 build and tags build
---
.github/workflows/docker.yaml | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 42070f9f..b44e63a0 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -5,6 +5,9 @@ on:
branches:
- main
- stable
+ tags:
+ - 'v*'
+ - '*.*.*'
workflow_dispatch:
permissions:
@@ -21,6 +24,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -37,12 +44,17 @@ jobs:
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha
- name: Build and push Docker image for main
if: github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: .
+ platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
@@ -54,8 +66,20 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
+ platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Build and push Docker image for tags
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
+ labels: ${{ steps.meta.outputs.labels }}
From 2452f9413d835508df3e6ea50b57bcbccd0645c8 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Tue, 4 Mar 2025 20:37:33 +0530
Subject: [PATCH 012/115] ci: updated to have concise and parallel builds
---
.github/workflows/docker.yaml | 58 ++++++++++-------------------------
1 file changed, 17 insertions(+), 41 deletions(-)
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index b44e63a0..05ab7831 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -2,14 +2,14 @@ name: Docker Publish
on:
push:
- branches:
- - main
- - stable
- tags:
- - 'v*'
- - '*.*.*'
+ branches: [main, stable]
+ tags: ['v*', '*.*.*']
workflow_dispatch:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
permissions:
packages: write
contents: read
@@ -21,17 +21,14 @@ env:
jobs:
docker-build-publish:
runs-on: ubuntu-latest
+ # timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
+
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -45,41 +42,20 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
- type=ref,event=branch
+ type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
+ type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }}
type=ref,event=tag
- type=sha
+ type=sha,format=short
+ type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' }}
- - name: Build and push Docker image for main
- if: github.ref == 'refs/heads/main'
+ - name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- labels: ${{ steps.meta.outputs.labels }}
-
- - name: Build and push Docker image for stable
- if: github.ref == 'refs/heads/stable'
- uses: docker/build-push-action@v6
- with:
- context: .
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- labels: ${{ steps.meta.outputs.labels }}
-
- - name: Build and push Docker image for tags
- if: startsWith(github.ref, 'refs/tags/')
- uses: docker/build-push-action@v6
- with:
- context: .
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
+ tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Check manifest
+ run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
\ No newline at end of file
From f9436d4929bbb41a316dee69472145d8b71cca0a Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Wed, 5 Mar 2025 03:58:01 +0530
Subject: [PATCH 013/115] ci: updated target for docker build (#1451)
---
.github/workflows/docker.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 05ab7831..a038e02f 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -53,6 +53,7 @@ jobs:
with:
context: .
platforms: linux/amd64,linux/arm64
+ target: bolt-ai-production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
From 73a0f3ae24c3dc79c68d075b49dea3883d4ce694 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Wed, 5 Mar 2025 04:23:01 +0530
Subject: [PATCH 014/115] fix: git clone modal to work with non main as default
branch (#1428)
---
.../components/RepositorySelectionDialog.tsx | 19 ++++++++++++++++---
1 file changed, 16 insertions(+), 3 deletions(-)
diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx
index 06202850..5f07e727 100644
--- a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx
+++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx
@@ -292,11 +292,24 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
-
- // Fetch repository tree
- const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
+ const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
+ const repoObjData = (await repoObjResponse.json()) as any;
+
+ if (!repoObjData.default_branch) {
+ throw new Error('Failed to fetch repository branch');
+ }
+
+ const defaultBranch = repoObjData.default_branch;
+
+ // Fetch repository tree
+ const treeResponse = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
+ {
+ headers,
+ },
+ );
if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure');
From 1f940391b19e7187890623150136d24f0adb6322 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Wed, 5 Mar 2025 10:59:48 +0530
Subject: [PATCH 015/115] feat: restoring project from snapshot on reload
(#444)
* feat:(project-snapshot) restoring project from snapshot on reload
* minor bugfix
* updated message
* added snapshot reload with auto run dev commands
* added message context
* snapshot updated
---
app/lib/persistence/types.ts | 7 +
app/lib/persistence/useChatHistory.ts | 218 +++++++++++++++++++++++++-
2 files changed, 217 insertions(+), 8 deletions(-)
create mode 100644 app/lib/persistence/types.ts
diff --git a/app/lib/persistence/types.ts b/app/lib/persistence/types.ts
new file mode 100644
index 00000000..56dacd61
--- /dev/null
+++ b/app/lib/persistence/types.ts
@@ -0,0 +1,7 @@
+import type { FileMap } from '~/lib/stores/files';
+
+export interface Snapshot {
+ chatIndex: string;
+ files: FileMap;
+ summary?: string;
+}
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index 7baefa56..b8b5c833 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -1,7 +1,7 @@
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { atom } from 'nanostores';
-import type { Message } from 'ai';
+import { generateId, type JSONValue, type Message } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { logStore } from '~/lib/stores/logs'; // Import logStore
@@ -15,6 +15,11 @@ import {
createChatFromMessages,
type IChatMetadata,
} from './db';
+import type { FileMap } from '~/lib/stores/files';
+import type { Snapshot } from './types';
+import { webcontainer } from '~/lib/webcontainer';
+import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
+import type { ContextAnnotation } from '~/types/context';
export interface ChatHistoryItem {
id: string;
@@ -37,6 +42,7 @@ export function useChatHistory() {
const { id: mixedId } = useLoaderData<{ id?: string }>();
const [searchParams] = useSearchParams();
+ const [archivedMessages, setArchivedMessages] = useState([]);
const [initialMessages, setInitialMessages] = useState([]);
const [ready, setReady] = useState(false);
const [urlId, setUrlId] = useState();
@@ -56,14 +62,128 @@ export function useChatHistory() {
if (mixedId) {
getMessages(db, mixedId)
- .then((storedMessages) => {
+ .then(async (storedMessages) => {
if (storedMessages && storedMessages.messages.length > 0) {
+ const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
+ const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
+ const summary = snapshot.summary;
+
const rewindId = searchParams.get('rewindTo');
- const filteredMessages = rewindId
- ? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
- : storedMessages.messages;
+ let startingIdx = 0;
+ const endingIdx = rewindId
+ ? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
+ : storedMessages.messages.length;
+ const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
+
+ if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
+ startingIdx = snapshotIndex;
+ }
+
+ if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
+ startingIdx = 0;
+ }
+
+ let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
+ let archivedMessages: Message[] = [];
+
+ if (startingIdx > 0) {
+ archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
+ }
+
+ setArchivedMessages(archivedMessages);
+
+ if (startingIdx > 0) {
+ const files = Object.entries(snapshot?.files || {})
+ .map(([key, value]) => {
+ if (value?.type !== 'file') {
+ return null;
+ }
+
+ return {
+ content: value.content,
+ path: key,
+ };
+ })
+ .filter((x) => !!x);
+ const projectCommands = await detectProjectCommands(files);
+ const commands = createCommandsMessage(projectCommands);
+
+ filteredMessages = [
+ {
+ id: generateId(),
+ role: 'user',
+ content: `Restore project from snapshot
+ `,
+ annotations: ['no-store', 'hidden'],
+ },
+ {
+ id: storedMessages.messages[snapshotIndex].id,
+ role: 'assistant',
+ content: ` π¦ Chat Restored from snapshot, You can revert this message to load the full chat history
+
+ ${Object.entries(snapshot?.files || {})
+ .filter((x) => !x[0].endsWith('lock.json'))
+ .map(([key, value]) => {
+ if (value?.type === 'file') {
+ return `
+
+${value.content}
+
+ `;
+ } else {
+ return ``;
+ }
+ })
+ .join('\n')}
+
+ `,
+ annotations: [
+ 'no-store',
+ ...(summary
+ ? [
+ {
+ chatId: storedMessages.messages[snapshotIndex].id,
+ type: 'chatSummary',
+ summary,
+ } satisfies ContextAnnotation,
+ ]
+ : []),
+ ],
+ },
+ ...(commands !== null
+ ? [
+ {
+ id: `${storedMessages.messages[snapshotIndex].id}-2`,
+ role: 'user' as const,
+ content: `setup project`,
+ annotations: ['no-store', 'hidden'],
+ },
+ {
+ ...commands,
+ id: `${storedMessages.messages[snapshotIndex].id}-3`,
+ annotations: [
+ 'no-store',
+ ...(commands.annotations || []),
+ ...(summary
+ ? [
+ {
+ chatId: `${storedMessages.messages[snapshotIndex].id}-3`,
+ type: 'chatSummary',
+ summary,
+ } satisfies ContextAnnotation,
+ ]
+ : []),
+ ],
+ },
+ ]
+ : []),
+ ...filteredMessages,
+ ];
+ restoreSnapshot(mixedId);
+ }
setInitialMessages(filteredMessages);
+
setUrlId(storedMessages.urlId);
description.set(storedMessages.description);
chatId.set(storedMessages.id);
@@ -75,10 +195,64 @@ export function useChatHistory() {
setReady(true);
})
.catch((error) => {
+ console.error(error);
+
logStore.logError('Failed to load chat messages', error);
toast.error(error.message);
});
}
+ }, [mixedId]);
+
+ const takeSnapshot = useCallback(
+ async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
+ const id = _chatId || chatId;
+
+ if (!id) {
+ return;
+ }
+
+ const snapshot: Snapshot = {
+ chatIndex: chatIdx,
+ files,
+ summary: chatSummary,
+ };
+ localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
+ },
+ [chatId],
+ );
+
+ const restoreSnapshot = useCallback(async (id: string) => {
+ const snapshotStr = localStorage.getItem(`snapshot:${id}`);
+ const container = await webcontainer;
+
+ // if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
+ const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
+
+ if (!snapshot?.files) {
+ return;
+ }
+
+ Object.entries(snapshot.files).forEach(async ([key, value]) => {
+ if (key.startsWith(container.workdir)) {
+ key = key.replace(container.workdir, '');
+ }
+
+ if (value?.type === 'folder') {
+ await container.fs.mkdir(key, { recursive: true });
+ }
+ });
+ Object.entries(snapshot.files).forEach(async ([key, value]) => {
+ if (value?.type === 'file') {
+ if (key.startsWith(container.workdir)) {
+ key = key.replace(container.workdir, '');
+ }
+
+ await container.fs.writeFile(key, value.content, { encoding: value.isBinary ? undefined : 'utf8' });
+ } else {
+ }
+ });
+
+ // workbenchStore.files.setKey(snapshot?.files)
}, []);
return {
@@ -105,14 +279,34 @@ export function useChatHistory() {
}
const { firstArtifact } = workbenchStore;
+ messages = messages.filter((m) => !m.annotations?.includes('no-store'));
+
+ let _urlId = urlId;
if (!urlId && firstArtifact?.id) {
const urlId = await getUrlId(db, firstArtifact.id);
-
+ _urlId = urlId;
navigateChat(urlId);
setUrlId(urlId);
}
+ let chatSummary: string | undefined = undefined;
+ const lastMessage = messages[messages.length - 1];
+
+ if (lastMessage.role === 'assistant') {
+ const annotations = lastMessage.annotations as JSONValue[];
+ const filteredAnnotations = (annotations?.filter(
+ (annotation: JSONValue) =>
+ annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
+ ) || []) as { type: string; value: any } & { [key: string]: any }[];
+
+ if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
+ chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
+ }
+ }
+
+ takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
+
if (!description.get() && firstArtifact?.title) {
description.set(firstArtifact?.title);
}
@@ -127,7 +321,15 @@ export function useChatHistory() {
}
}
- await setMessages(db, chatId.get() as string, messages, urlId, description.get(), undefined, chatMetadata.get());
+ await setMessages(
+ db,
+ chatId.get() as string,
+ [...archivedMessages, ...messages],
+ urlId,
+ description.get(),
+ undefined,
+ chatMetadata.get(),
+ );
},
duplicateCurrentChat: async (listItemId: string) => {
if (!db || (!mixedId && !listItemId)) {
From 20722a108ca619c7eca68b063ae6c7af786d4efe Mon Sep 17 00:00:00 2001
From: Burhanuddin Khatri <144617735+BurhanCantCode@users.noreply.github.com>
Date: Wed, 5 Mar 2025 18:42:52 +0500
Subject: [PATCH 016/115] feat: add Claude 3.7 Sonnet model as static list and
update API key reference (#1449)
---
app/lib/modules/llm/providers/anthropic.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/lib/modules/llm/providers/anthropic.ts b/app/lib/modules/llm/providers/anthropic.ts
index f8dca5a8..70f93c07 100644
--- a/app/lib/modules/llm/providers/anthropic.ts
+++ b/app/lib/modules/llm/providers/anthropic.ts
@@ -13,6 +13,12 @@ export default class AnthropicProvider extends BaseProvider {
};
staticModels: ModelInfo[] = [
+ {
+ name: 'claude-3-7-sonnet-20250219',
+ label: 'Claude 3.7 Sonnet',
+ provider: 'Anthropic',
+ maxTokenAllowed: 8000,
+ },
{
name: 'claude-3-5-sonnet-latest',
label: 'Claude 3.5 Sonnet (new)',
@@ -46,7 +52,7 @@ export default class AnthropicProvider extends BaseProvider {
providerSettings: settings,
serverEnv: serverEnv as any,
defaultBaseUrlKey: '',
- defaultApiTokenKey: 'OPENAI_API_KEY',
+ defaultApiTokenKey: 'ANTHROPIC_API_KEY',
});
if (!apiKey) {
From 9780393b17de28c9bad6fecee4a5ce713541eb0f Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Fri, 7 Mar 2025 00:29:44 +0530
Subject: [PATCH 017/115] fix: git cookies are auto set anytime connects
changed or loaded (#1461)
---
.../tabs/connections/GithubConnection.tsx | 94 +++++++++++--------
1 file changed, 54 insertions(+), 40 deletions(-)
diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx
index 9f433724..36197f2e 100644
--- a/app/components/@settings/tabs/connections/GithubConnection.tsx
+++ b/app/components/@settings/tabs/connections/GithubConnection.tsx
@@ -77,6 +77,46 @@ export function GithubConnection() {
const [isFetchingStats, setIsFetchingStats] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
+ const fetchGithubUser = async (token: string) => {
+ try {
+ setIsConnecting(true);
+
+ const response = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Invalid token or unauthorized');
+ }
+
+ const data = (await response.json()) as GitHubUserResponse;
+ const newConnection: GitHubConnection = {
+ user: data,
+ token,
+ tokenType: connection.tokenType,
+ };
+
+ localStorage.setItem('github_connection', JSON.stringify(newConnection));
+ Cookies.set('githubToken', token);
+ Cookies.set('githubUsername', data.login);
+ Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
+
+ setConnection(newConnection);
+
+ await fetchGitHubStats(token);
+
+ toast.success('Successfully connected to GitHub');
+ } catch (error) {
+ logStore.logError('Failed to authenticate with GitHub', { error });
+ toast.error('Failed to connect to GitHub');
+ setConnection({ user: null, token: '', tokenType: 'classic' });
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
const fetchGitHubStats = async (token: string) => {
try {
setIsFetchingStats(true);
@@ -182,51 +222,25 @@ export function GithubConnection() {
setIsLoading(false);
}, []);
+ useEffect(() => {
+ if (!connection) {
+ return;
+ }
+
+ const token = connection.token;
+ const data = connection.user;
+ Cookies.set('githubToken', token);
+ Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
+
+ if (data) {
+ Cookies.set('githubUsername', data.login);
+ }
+ }, [connection]);
if (isLoading || isConnecting || isFetchingStats) {
return ;
}
- const fetchGithubUser = async (token: string) => {
- try {
- setIsConnecting(true);
-
- const response = await fetch('https://api.github.com/user', {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-
- if (!response.ok) {
- throw new Error('Invalid token or unauthorized');
- }
-
- const data = (await response.json()) as GitHubUserResponse;
- const newConnection: GitHubConnection = {
- user: data,
- token,
- tokenType: connection.tokenType,
- };
-
- localStorage.setItem('github_connection', JSON.stringify(newConnection));
- Cookies.set('githubToken', token);
- Cookies.set('githubUsername', data.login);
- Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
-
- setConnection(newConnection);
-
- await fetchGitHubStats(token);
-
- toast.success('Successfully connected to GitHub');
- } catch (error) {
- logStore.logError('Failed to authenticate with GitHub', { error });
- toast.error('Failed to connect to GitHub');
- setConnection({ user: null, token: '', tokenType: 'classic' });
- } finally {
- setIsConnecting(false);
- }
- };
-
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
await fetchGithubUser(connection.token);
From cd4a5e83809759efb82ed0e26abc7a9349ae47ff Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Fri, 7 Mar 2025 14:10:37 +0530
Subject: [PATCH 018/115] fix: fix git proxy to work with other git provider
(#1466)
---
app/lib/persistence/useChatHistory.ts | 6 +-
app/routes/api.git-proxy.$.ts | 176 +++++++++++++++++++++-----
2 files changed, 145 insertions(+), 37 deletions(-)
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index b8b5c833..3077ca44 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -69,7 +69,7 @@ export function useChatHistory() {
const summary = snapshot.summary;
const rewindId = searchParams.get('rewindTo');
- let startingIdx = 0;
+ let startingIdx = -1;
const endingIdx = rewindId
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
: storedMessages.messages.length;
@@ -80,13 +80,13 @@ export function useChatHistory() {
}
if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
- startingIdx = 0;
+ startingIdx = -1;
}
let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
let archivedMessages: Message[] = [];
- if (startingIdx > 0) {
+ if (startingIdx >= 0) {
archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
}
diff --git a/app/routes/api.git-proxy.$.ts b/app/routes/api.git-proxy.$.ts
index 9e6cb3b1..45230520 100644
--- a/app/routes/api.git-proxy.$.ts
+++ b/app/routes/api.git-proxy.$.ts
@@ -1,6 +1,47 @@
import { json } from '@remix-run/cloudflare';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
+// Allowed headers to forward to the target server
+const ALLOW_HEADERS = [
+ 'accept-encoding',
+ 'accept-language',
+ 'accept',
+ 'access-control-allow-origin',
+ 'authorization',
+ 'cache-control',
+ 'connection',
+ 'content-length',
+ 'content-type',
+ 'dnt',
+ 'pragma',
+ 'range',
+ 'referer',
+ 'user-agent',
+ 'x-authorization',
+ 'x-http-method-override',
+ 'x-requested-with',
+];
+
+// Headers to expose from the target server's response
+const EXPOSE_HEADERS = [
+ 'accept-ranges',
+ 'age',
+ 'cache-control',
+ 'content-length',
+ 'content-language',
+ 'content-type',
+ 'date',
+ 'etag',
+ 'expires',
+ 'last-modified',
+ 'pragma',
+ 'server',
+ 'transfer-encoding',
+ 'vary',
+ 'x-github-request-id',
+ 'x-redirected-url',
+];
+
// Handle all HTTP methods
export async function action({ request, params }: ActionFunctionArgs) {
return handleProxyRequest(request, params['*']);
@@ -16,50 +57,117 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
}
- const url = new URL(request.url);
-
- // Reconstruct the target URL
- const targetURL = `https://${path}${url.search}`;
-
- // Forward the request to the target URL
- const response = await fetch(targetURL, {
- method: request.method,
- headers: {
- ...Object.fromEntries(request.headers),
-
- // Override host header with the target host
- host: new URL(targetURL).host,
- },
- body: ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer(),
- });
-
- // Create response with CORS headers
- const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
- 'Access-Control-Allow-Headers': '*',
- };
-
- // Handle preflight requests
+ // Handle CORS preflight request
if (request.method === 'OPTIONS') {
return new Response(null, {
- headers: corsHeaders,
- status: 204,
+ status: 200,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
+ 'Access-Control-Allow-Headers': ALLOW_HEADERS.join(', '),
+ 'Access-Control-Expose-Headers': EXPOSE_HEADERS.join(', '),
+ 'Access-Control-Max-Age': '86400',
+ },
});
}
- // Forward the response with CORS headers
- const responseHeaders = new Headers(response.headers);
- Object.entries(corsHeaders).forEach(([key, value]) => {
- responseHeaders.set(key, value);
- });
+ // Extract domain and remaining path
+ const parts = path.match(/([^\/]+)\/?(.*)/);
+ if (!parts) {
+ return json({ error: 'Invalid path format' }, { status: 400 });
+ }
+
+ const domain = parts[1];
+ const remainingPath = parts[2] || '';
+
+ // Reconstruct the target URL with query parameters
+ const url = new URL(request.url);
+ const targetURL = `https://${domain}/${remainingPath}${url.search}`;
+
+ console.log('Target URL:', targetURL);
+
+ // Filter and prepare headers
+ const headers = new Headers();
+
+ // Only forward allowed headers
+ for (const header of ALLOW_HEADERS) {
+ if (request.headers.has(header)) {
+ headers.set(header, request.headers.get(header)!);
+ }
+ }
+
+ // Set the host header
+ headers.set('Host', domain);
+
+ // Set Git user agent if not already present
+ if (!headers.has('user-agent') || !headers.get('user-agent')?.startsWith('git/')) {
+ headers.set('User-Agent', 'git/@isomorphic-git/cors-proxy');
+ }
+
+ console.log('Request headers:', Object.fromEntries(headers.entries()));
+
+ // Prepare fetch options
+ const fetchOptions: RequestInit = {
+ method: request.method,
+ headers,
+ redirect: 'follow',
+ };
+
+ // Add body and duplex option for non-GET/HEAD requests
+ if (!['GET', 'HEAD'].includes(request.method)) {
+ fetchOptions.body = request.body;
+ fetchOptions.duplex = 'half'; // This fixes the "duplex option is required when sending a body" error
+ }
+
+ // Forward the request to the target URL
+ const response = await fetch(targetURL, fetchOptions);
+
+ console.log('Response status:', response.status);
+
+ // Create response headers
+ const responseHeaders = new Headers();
+
+ // Add CORS headers
+ responseHeaders.set('Access-Control-Allow-Origin', '*');
+ responseHeaders.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
+ responseHeaders.set('Access-Control-Allow-Headers', ALLOW_HEADERS.join(', '));
+ responseHeaders.set('Access-Control-Expose-Headers', EXPOSE_HEADERS.join(', '));
+
+ // Copy exposed headers from the target response
+ for (const header of EXPOSE_HEADERS) {
+ // Skip content-length as we'll use the original response's content-length
+ if (header === 'content-length') {
+ continue;
+ }
+
+ if (response.headers.has(header)) {
+ responseHeaders.set(header, response.headers.get(header)!);
+ }
+ }
+
+ // If the response was redirected, add the x-redirected-url header
+ if (response.redirected) {
+ responseHeaders.set('x-redirected-url', response.url);
+ }
+
+ console.log('Response headers:', Object.fromEntries(responseHeaders.entries()));
+
+ // Return the response with the target's body stream piped directly
return new Response(response.body, {
status: response.status,
+ statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
- console.error('Git proxy error:', error);
- return json({ error: 'Proxy error' }, { status: 500 });
+ console.error('Proxy error:', error);
+ return json(
+ {
+ error: 'Proxy error',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ url: path ? `https://${path}` : 'Invalid URL',
+ },
+ { status: 500 },
+ );
}
}
From 7ff48e1d45ff3ab9b7394218c230948b318ed2e1 Mon Sep 17 00:00:00 2001
From: Anirban Kar
Date: Sat, 8 Mar 2025 12:44:46 +0530
Subject: [PATCH 019/115] fix: attachment not getting sent on first message if
starter template is turned on (#1472)
---
app/components/chat/Chat.client.tsx | 29 ++++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index c08dd6d8..8ac5d286 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -323,7 +323,16 @@ export const ChatImpl = memo(
{
id: `1-${new Date().getTime()}`,
role: 'user',
- content: messageContent,
+ content: [
+ {
+ type: 'text',
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
+ },
+ ...imageDataList.map((imageData) => ({
+ type: 'image',
+ image: imageData,
+ })),
+ ] as any,
},
{
id: `2-${new Date().getTime()}`,
@@ -338,6 +347,15 @@ export const ChatImpl = memo(
},
]);
reload();
+ setInput('');
+ Cookies.remove(PROMPT_COOKIE_KEY);
+
+ setUploadedFiles([]);
+ setImageDataList([]);
+
+ resetEnhancer();
+
+ textareaRef.current?.blur();
setFakeLoading(false);
return;
@@ -364,6 +382,15 @@ export const ChatImpl = memo(
]);
reload();
setFakeLoading(false);
+ setInput('');
+ Cookies.remove(PROMPT_COOKIE_KEY);
+
+ setUploadedFiles([]);
+ setImageDataList([]);
+
+ resetEnhancer();
+
+ textareaRef.current?.blur();
return;
}
From 50dd74de071dba4ed90a42a25884a2e9c0128cc2 Mon Sep 17 00:00:00 2001
From: Stijnus <72551117+Stijnus@users.noreply.github.com>
Date: Sat, 8 Mar 2025 20:37:56 +0100
Subject: [PATCH 020/115] fix: settings bugfix error building my application
issue #1414 (#1436)
* Fix: error building my application #1414
* fix for vite
* Update vite.config.ts
* Update root.tsx
* fix the root.tsx and the debugtab
* lm studio fix and fix for the api key
* Update api.enhancer for prompt enhancement
* bugfixes
* Revert api.enhancer.ts back to original code
* Update api.enhancer.ts
* Update api.git-proxy.$.ts
* Update api.git-proxy.$.ts
* Update api.enhancer.ts
---
.../tabs/connections/ConnectionsTab.tsx | 25 +-
.../tabs/connections/GithubConnection.tsx | 2 +-
.../tabs/connections/NetlifyConnection.tsx | 2 +-
app/lib/hooks/useShortcuts.ts | 2 +-
app/lib/modules/llm/providers/lmstudio.ts | 2 +-
app/lib/modules/llm/providers/ollama.ts | 31 +-
app/root.tsx | 7 +-
app/routes/api.check-env-key.ts | 33 +-
app/routes/api.deploy.ts | 12 +-
app/routes/api.enhancer.ts | 18 +-
app/routes/api.git-proxy.$.ts | 8 +-
app/routes/api.health.ts | 20 +-
app/routes/api.system.app-info.ts | 47 +-
app/routes/api.system.git-info.ts | 168 ++---
app/routes/api.update.ts | 580 +-----------------
package.json | 1 +
pnpm-lock.yaml | 3 +
vite.config.ts | 43 +-
18 files changed, 233 insertions(+), 771 deletions(-)
diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx
index 450d241a..72ff6434 100644
--- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx
+++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx
@@ -1,6 +1,19 @@
import { motion } from 'framer-motion';
-import { GithubConnection } from './GithubConnection';
-import { NetlifyConnection } from './NetlifyConnection';
+import React, { Suspense } from 'react';
+
+// Use React.lazy for dynamic imports
+const GithubConnection = React.lazy(() => import('./GithubConnection'));
+const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
+
+// Loading fallback component
+const LoadingFallback = () => (
+
+
+
+
Loading connection...
+
+
+);
export default function ConnectionsTab() {
return (
@@ -20,8 +33,12 @@ export default function ConnectionsTab() {
-
-
+ }>
+
+
+ }>
+
+
);
diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx
index 36197f2e..789cf0b1 100644
--- a/app/components/@settings/tabs/connections/GithubConnection.tsx
+++ b/app/components/@settings/tabs/connections/GithubConnection.tsx
@@ -66,7 +66,7 @@ interface GitHubConnection {
stats?: GitHubStats;
}
-export function GithubConnection() {
+export default function GithubConnection() {
const [connection, setConnection] = useState({
user: null,
token: '',
diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx
index 5881b761..d811602e 100644
--- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx
+++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx
@@ -13,7 +13,7 @@ import {
} from '~/lib/stores/netlify';
import type { NetlifyUser } from '~/types/netlify';
-export function NetlifyConnection() {
+export default function NetlifyConnection() {
const connection = useStore(netlifyConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
diff --git a/app/lib/hooks/useShortcuts.ts b/app/lib/hooks/useShortcuts.ts
index 39308fcd..447a97e3 100644
--- a/app/lib/hooks/useShortcuts.ts
+++ b/app/lib/hooks/useShortcuts.ts
@@ -41,7 +41,7 @@ export function useShortcuts(): void {
}
// Debug logging in development only
- if (process.env.NODE_ENV === 'development') {
+ if (import.meta.env.DEV) {
console.log('Key pressed:', {
key: event.key,
code: event.code,
diff --git a/app/lib/modules/llm/providers/lmstudio.ts b/app/lib/modules/llm/providers/lmstudio.ts
index 9dabc3eb..fe5b27cd 100644
--- a/app/lib/modules/llm/providers/lmstudio.ts
+++ b/app/lib/modules/llm/providers/lmstudio.ts
@@ -75,7 +75,7 @@ export default class LMStudioProvider extends BaseProvider {
throw new Error('No baseUrl found for LMStudio provider');
}
- const isDocker = process.env.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
+ const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
if (typeof window === 'undefined') {
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
diff --git a/app/lib/modules/llm/providers/ollama.ts b/app/lib/modules/llm/providers/ollama.ts
index a3974ab3..e50ecae5 100644
--- a/app/lib/modules/llm/providers/ollama.ts
+++ b/app/lib/modules/llm/providers/ollama.ts
@@ -27,8 +27,6 @@ export interface OllamaApiResponse {
models: OllamaModel[];
}
-export const DEFAULT_NUM_CTX = process?.env?.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
-
export default class OllamaProvider extends BaseProvider {
name = 'Ollama';
getApiKeyLink = 'https://ollama.com/download';
@@ -41,6 +39,26 @@ export default class OllamaProvider extends BaseProvider {
staticModels: ModelInfo[] = [];
+ private _convertEnvToRecord(env?: Env): Record {
+ if (!env) {
+ return {};
+ }
+
+ // Convert Env to a plain object with string values
+ return Object.entries(env).reduce(
+ (acc, [key, value]) => {
+ acc[key] = String(value);
+ return acc;
+ },
+ {} as Record,
+ );
+ }
+
+ getDefaultNumCtx(serverEnv?: Env): number {
+ const envRecord = this._convertEnvToRecord(serverEnv);
+ return envRecord.DEFAULT_NUM_CTX ? parseInt(envRecord.DEFAULT_NUM_CTX, 10) : 32768;
+ }
+
async getDynamicModels(
apiKeys?: Record,
settings?: IProviderSetting,
@@ -81,6 +99,7 @@ export default class OllamaProvider extends BaseProvider {
maxTokenAllowed: 8000,
}));
}
+
getModelInstance: (options: {
model: string;
serverEnv?: Env;
@@ -88,10 +107,12 @@ export default class OllamaProvider extends BaseProvider {
providerSettings?: Record;
}) => LanguageModelV1 = (options) => {
const { apiKeys, providerSettings, serverEnv, model } = options;
+ const envRecord = this._convertEnvToRecord(serverEnv);
+
let { baseUrl } = this.getProviderBaseUrlAndKey({
apiKeys,
providerSettings: providerSettings?.[this.name],
- serverEnv: serverEnv as any,
+ serverEnv: envRecord,
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
defaultApiTokenKey: '',
});
@@ -101,14 +122,14 @@ export default class OllamaProvider extends BaseProvider {
throw new Error('No baseUrl found for OLLAMA provider');
}
- const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';
+ const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || envRecord.RUNNING_IN_DOCKER === 'true';
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
logger.debug('Ollama Base Url used: ', baseUrl);
const ollamaInstance = ollama(model, {
- numCtx: DEFAULT_NUM_CTX,
+ numCtx: this.getDefaultNumCtx(serverEnv),
}) as LanguageModelV1 & { config: any };
ollamaInstance.config.baseURL = `${baseUrl}/api`;
diff --git a/app/root.tsx b/app/root.tsx
index b49d7355..a7ccb285 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -8,6 +8,7 @@ import { createHead } from 'remix-island';
import { useEffect } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
+import { ClientOnly } from 'remix-utils/client-only';
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
import globalStyles from './styles/index.scss?url';
@@ -72,11 +73,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
}, [theme]);
return (
-
- {children}
+ <>
+ {() => {children}}
-
+ >
);
}
diff --git a/app/routes/api.check-env-key.ts b/app/routes/api.check-env-key.ts
index 14d21236..70f65bf0 100644
--- a/app/routes/api.check-env-key.ts
+++ b/app/routes/api.check-env-key.ts
@@ -1,16 +1,41 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
-import { providerBaseUrlEnvKeys } from '~/utils/constants';
+import { LLMManager } from '~/lib/modules/llm/manager';
+import { getApiKeysFromCookie } from '~/lib/api/cookies';
export const loader: LoaderFunction = async ({ context, request }) => {
const url = new URL(request.url);
const provider = url.searchParams.get('provider');
- if (!provider || !providerBaseUrlEnvKeys[provider].apiTokenKey) {
+ if (!provider) {
return Response.json({ isSet: false });
}
- const envVarName = providerBaseUrlEnvKeys[provider].apiTokenKey;
- const isSet = !!(process.env[envVarName] || (context?.cloudflare?.env as Record)?.[envVarName]);
+ const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any);
+ const providerInstance = llmManager.getProvider(provider);
+
+ if (!providerInstance || !providerInstance.config.apiTokenKey) {
+ return Response.json({ isSet: false });
+ }
+
+ const envVarName = providerInstance.config.apiTokenKey;
+
+ // Get API keys from cookie
+ const cookieHeader = request.headers.get('Cookie');
+ const apiKeys = getApiKeysFromCookie(cookieHeader);
+
+ /*
+ * Check API key in order of precedence:
+ * 1. Client-side API keys (from cookies)
+ * 2. Server environment variables (from Cloudflare env)
+ * 3. Process environment variables (from .env.local)
+ * 4. LLMManager environment variables
+ */
+ const isSet = !!(
+ apiKeys?.[provider] ||
+ (context?.cloudflare?.env as Record)?.[envVarName] ||
+ process.env[envVarName] ||
+ llmManager.env[envVarName]
+ );
return Response.json({ isSet });
};
diff --git a/app/routes/api.deploy.ts b/app/routes/api.deploy.ts
index 48543e97..0bc1c5e4 100644
--- a/app/routes/api.deploy.ts
+++ b/app/routes/api.deploy.ts
@@ -1,5 +1,4 @@
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
-import crypto from 'crypto';
import type { NetlifySiteInfo } from '~/types/netlify';
interface DeployRequestBody {
@@ -8,6 +7,15 @@ interface DeployRequestBody {
chatId: string;
}
+async function sha1(message: string) {
+ const msgBuffer = new TextEncoder().encode(message);
+ const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
+
+ return hashHex;
+}
+
export async function action({ request }: ActionFunctionArgs) {
try {
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
@@ -104,7 +112,7 @@ export async function action({ request }: ActionFunctionArgs) {
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
- const hash = crypto.createHash('sha1').update(content).digest('hex');
+ const hash = await sha1(content);
fileDigests[normalizedPath] = hash;
}
diff --git a/app/routes/api.enhancer.ts b/app/routes/api.enhancer.ts
index 4ab54f31..1e7bad5f 100644
--- a/app/routes/api.enhancer.ts
+++ b/app/routes/api.enhancer.ts
@@ -95,24 +95,28 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
},
});
+ // Handle streaming errors in a non-blocking way
(async () => {
- for await (const part of result.fullStream) {
- if (part.type === 'error') {
- const error: any = part.error;
- logger.error(error);
-
- return;
+ try {
+ for await (const part of result.fullStream) {
+ if (part.type === 'error') {
+ const error: any = part.error;
+ logger.error('Streaming error:', error);
+ break;
+ }
}
+ } catch (error) {
+ logger.error('Error processing stream:', error);
}
})();
+ // Return the text stream directly since it's already text data
return new Response(result.textStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache',
- 'Text-Encoding': 'chunked',
},
});
} catch (error: unknown) {
diff --git a/app/routes/api.git-proxy.$.ts b/app/routes/api.git-proxy.$.ts
index 45230520..a4e8f1e0 100644
--- a/app/routes/api.git-proxy.$.ts
+++ b/app/routes/api.git-proxy.$.ts
@@ -114,10 +114,14 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
redirect: 'follow',
};
- // Add body and duplex option for non-GET/HEAD requests
+ // Add body for non-GET/HEAD requests
if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
- fetchOptions.duplex = 'half'; // This fixes the "duplex option is required when sending a body" error
+
+ /*
+ * Note: duplex property is removed to ensure TypeScript compatibility
+ * across different environments and versions
+ */
}
// Forward the request to the target URL
diff --git a/app/routes/api.health.ts b/app/routes/api.health.ts
index 9d3bd839..e5f5a30d 100644
--- a/app/routes/api.health.ts
+++ b/app/routes/api.health.ts
@@ -1,18 +1,8 @@
-import type { LoaderFunctionArgs } from '@remix-run/node';
+import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
- // Return a simple 200 OK response with some basic health information
- return new Response(
- JSON.stringify({
- status: 'healthy',
- timestamp: new Date().toISOString(),
- uptime: process.uptime(),
- }),
- {
- status: 200,
- headers: {
- 'Content-Type': 'application/json',
- },
- },
- );
+ return json({
+ status: 'healthy',
+ timestamp: new Date().toISOString(),
+ });
};
diff --git a/app/routes/api.system.app-info.ts b/app/routes/api.system.app-info.ts
index d3ed0185..01953b68 100644
--- a/app/routes/api.system.app-info.ts
+++ b/app/routes/api.system.app-info.ts
@@ -1,6 +1,5 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
-import { execSync } from 'child_process';
// These are injected by Vite at build time
declare const __APP_VERSION: string;
@@ -11,34 +10,24 @@ declare const __PKG_DEPENDENCIES: Record;
declare const __PKG_DEV_DEPENDENCIES: Record;
declare const __PKG_PEER_DEPENDENCIES: Record;
declare const __PKG_OPTIONAL_DEPENDENCIES: Record;
+declare const __COMMIT_HASH: string;
+declare const __GIT_BRANCH: string;
+declare const __GIT_COMMIT_TIME: string;
+declare const __GIT_AUTHOR: string;
+declare const __GIT_EMAIL: string;
+declare const __GIT_REMOTE_URL: string;
+declare const __GIT_REPO_NAME: string;
const getGitInfo = () => {
- try {
- return {
- commitHash: execSync('git rev-parse --short HEAD').toString().trim(),
- branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
- commitTime: execSync('git log -1 --format=%cd').toString().trim(),
- author: execSync('git log -1 --format=%an').toString().trim(),
- email: execSync('git log -1 --format=%ae').toString().trim(),
- remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
- repoName: execSync('git config --get remote.origin.url')
- .toString()
- .trim()
- .replace(/^.*github.com[:/]/, '')
- .replace(/\.git$/, ''),
- };
- } catch (error) {
- console.error('Failed to get git info:', error);
- return {
- commitHash: 'unknown',
- branch: 'unknown',
- commitTime: 'unknown',
- author: 'unknown',
- email: 'unknown',
- remoteUrl: 'unknown',
- repoName: 'unknown',
- };
- }
+ return {
+ commitHash: __COMMIT_HASH || 'unknown',
+ branch: __GIT_BRANCH || 'unknown',
+ commitTime: __GIT_COMMIT_TIME || 'unknown',
+ author: __GIT_AUTHOR || 'unknown',
+ email: __GIT_EMAIL || 'unknown',
+ remoteUrl: __GIT_REMOTE_URL || 'unknown',
+ repoName: __GIT_REPO_NAME || 'unknown',
+ };
};
const formatDependencies = (
@@ -60,11 +49,11 @@ const getAppResponse = () => {
version: __APP_VERSION || '0.1.0',
description: __PKG_DESCRIPTION || 'A DIY LLM interface',
license: __PKG_LICENSE || 'MIT',
- environment: process.env.NODE_ENV || 'development',
+ environment: 'cloudflare',
gitInfo,
timestamp: new Date().toISOString(),
runtimeInfo: {
- nodeVersion: process.version || 'unknown',
+ nodeVersion: 'cloudflare',
},
dependencies: {
production: formatDependencies(__PKG_DEPENDENCIES, 'production'),
diff --git a/app/routes/api.system.git-info.ts b/app/routes/api.system.git-info.ts
index d6bf9975..63c879ce 100644
--- a/app/routes/api.system.git-info.ts
+++ b/app/routes/api.system.git-info.ts
@@ -1,138 +1,48 @@
-import type { LoaderFunction } from '@remix-run/cloudflare';
-import { json } from '@remix-run/cloudflare';
-import { execSync } from 'child_process';
+import { json, type LoaderFunction } from '@remix-run/cloudflare';
-interface GitHubRepoInfo {
- name: string;
- full_name: string;
- default_branch: string;
- stargazers_count: number;
- forks_count: number;
- open_issues_count: number;
- parent?: {
- full_name: string;
- default_branch: string;
- stargazers_count: number;
- forks_count: number;
+interface GitInfo {
+ local: {
+ commitHash: string;
+ branch: string;
+ commitTime: string;
+ author: string;
+ email: string;
+ remoteUrl: string;
+ repoName: string;
};
+ github?: {
+ currentRepo?: {
+ fullName: string;
+ defaultBranch: string;
+ stars: number;
+ forks: number;
+ openIssues?: number;
+ };
+ };
+ isForked?: boolean;
}
-const getLocalGitInfo = () => {
- try {
- return {
- commitHash: execSync('git rev-parse HEAD').toString().trim(),
- branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
- commitTime: execSync('git log -1 --format=%cd').toString().trim(),
- author: execSync('git log -1 --format=%an').toString().trim(),
- email: execSync('git log -1 --format=%ae').toString().trim(),
- remoteUrl: execSync('git config --get remote.origin.url').toString().trim(),
- repoName: execSync('git config --get remote.origin.url')
- .toString()
- .trim()
- .replace(/^.*github.com[:/]/, '')
- .replace(/\.git$/, ''),
- };
- } catch (error) {
- console.error('Failed to get local git info:', error);
- return null;
- }
-};
+// These values will be replaced at build time
+declare const __COMMIT_HASH: string;
+declare const __GIT_BRANCH: string;
+declare const __GIT_COMMIT_TIME: string;
+declare const __GIT_AUTHOR: string;
+declare const __GIT_EMAIL: string;
+declare const __GIT_REMOTE_URL: string;
+declare const __GIT_REPO_NAME: string;
-const getGitHubInfo = async (repoFullName: string) => {
- try {
- // Add GitHub token if available
- const headers: Record = {
- Accept: 'application/vnd.github.v3+json',
- };
-
- const githubToken = process.env.GITHUB_TOKEN;
-
- if (githubToken) {
- headers.Authorization = `token ${githubToken}`;
- }
-
- console.log('Fetching GitHub info for:', repoFullName); // Debug log
-
- const response = await fetch(`https://api.github.com/repos/${repoFullName}`, {
- headers,
- });
-
- if (!response.ok) {
- console.error('GitHub API error:', {
- status: response.status,
- statusText: response.statusText,
- repoFullName,
- });
-
- // If we get a 404, try the main repo as fallback
- if (response.status === 404 && repoFullName !== 'stackblitz-labs/bolt.diy') {
- return getGitHubInfo('stackblitz-labs/bolt.diy');
- }
-
- throw new Error(`GitHub API error: ${response.statusText}`);
- }
-
- const data = await response.json();
- console.log('GitHub API response:', data); // Debug log
-
- return data as GitHubRepoInfo;
- } catch (error) {
- console.error('Failed to get GitHub info:', error);
- return null;
- }
-};
-
-export const loader: LoaderFunction = async ({ request: _request }) => {
- const localInfo = getLocalGitInfo();
- console.log('Local git info:', localInfo); // Debug log
-
- // If we have local info, try to get GitHub info for both our fork and upstream
- let githubInfo = null;
-
- if (localInfo?.repoName) {
- githubInfo = await getGitHubInfo(localInfo.repoName);
- }
-
- // If no local info or GitHub info, try the main repo
- if (!githubInfo) {
- githubInfo = await getGitHubInfo('stackblitz-labs/bolt.diy');
- }
-
- const response = {
- local: localInfo || {
- commitHash: 'unknown',
- branch: 'unknown',
- commitTime: 'unknown',
- author: 'unknown',
- email: 'unknown',
- remoteUrl: 'unknown',
- repoName: 'unknown',
+export const loader: LoaderFunction = async () => {
+ const gitInfo: GitInfo = {
+ local: {
+ commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
+ branch: typeof __GIT_BRANCH !== 'undefined' ? __GIT_BRANCH : 'main',
+ commitTime: typeof __GIT_COMMIT_TIME !== 'undefined' ? __GIT_COMMIT_TIME : new Date().toISOString(),
+ author: typeof __GIT_AUTHOR !== 'undefined' ? __GIT_AUTHOR : 'development',
+ email: typeof __GIT_EMAIL !== 'undefined' ? __GIT_EMAIL : 'development@local',
+ remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
+ repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
},
- github: githubInfo
- ? {
- currentRepo: {
- fullName: githubInfo.full_name,
- defaultBranch: githubInfo.default_branch,
- stars: githubInfo.stargazers_count,
- forks: githubInfo.forks_count,
- openIssues: githubInfo.open_issues_count,
- },
- upstream: githubInfo.parent
- ? {
- fullName: githubInfo.parent.full_name,
- defaultBranch: githubInfo.parent.default_branch,
- stars: githubInfo.parent.stargazers_count,
- forks: githubInfo.parent.forks_count,
- }
- : null,
- }
- : null,
- isForked: Boolean(githubInfo?.parent),
- timestamp: new Date().toISOString(),
};
- console.log('Final response:', response);
-
- // Debug log
- return json(response);
+ return json(gitInfo);
};
diff --git a/app/routes/api.update.ts b/app/routes/api.update.ts
index 9f79d4ae..97d28ce0 100644
--- a/app/routes/api.update.ts
+++ b/app/routes/api.update.ts
@@ -1,573 +1,21 @@
-import { json } from '@remix-run/node';
-import type { ActionFunction } from '@remix-run/node';
-import { exec } from 'child_process';
-import { promisify } from 'util';
-
-const execAsync = promisify(exec);
-
-interface UpdateRequestBody {
- branch: string;
- autoUpdate?: boolean;
-}
-
-interface UpdateProgress {
- stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
- message: string;
- progress?: number;
- error?: string;
- details?: {
- changedFiles?: string[];
- additions?: number;
- deletions?: number;
- commitMessages?: string[];
- totalSize?: string;
- currentCommit?: string;
- remoteCommit?: string;
- updateReady?: boolean;
- changelog?: string;
- compareUrl?: string;
- };
-}
+import { json, type ActionFunction } from '@remix-run/cloudflare';
export const action: ActionFunction = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
- try {
- const body = await request.json();
-
- if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') {
- return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 });
- }
-
- const { branch, autoUpdate = false } = body as UpdateRequestBody;
-
- // Create a ReadableStream to send progress updates
- const stream = new ReadableStream({
- async start(controller) {
- const encoder = new TextEncoder();
- const sendProgress = (update: UpdateProgress) => {
- controller.enqueue(encoder.encode(JSON.stringify(update) + '\n'));
- };
-
- try {
- // Initial check stage
- sendProgress({
- stage: 'fetch',
- message: 'Checking repository status...',
- progress: 0,
- });
-
- // Check if remote exists
- let defaultBranch = branch || 'main'; // Make branch mutable
-
- try {
- await execAsync('git remote get-url upstream');
- sendProgress({
- stage: 'fetch',
- message: 'Repository remote verified',
- progress: 10,
- });
- } catch {
- throw new Error(
- 'No upstream repository found. Please set up the upstream repository first by running:\ngit remote add upstream https://github.com/stackblitz-labs/bolt.diy.git',
- );
- }
-
- // Get default branch if not specified
- if (!branch) {
- sendProgress({
- stage: 'fetch',
- message: 'Detecting default branch...',
- progress: 20,
- });
-
- try {
- const { stdout } = await execAsync('git remote show upstream | grep "HEAD branch" | cut -d" " -f5');
- defaultBranch = stdout.trim() || 'main';
- sendProgress({
- stage: 'fetch',
- message: `Using branch: ${defaultBranch}`,
- progress: 30,
- });
- } catch {
- defaultBranch = 'main'; // Fallback to main if we can't detect
- sendProgress({
- stage: 'fetch',
- message: 'Using default branch: main',
- progress: 30,
- });
- }
- }
-
- // Fetch stage
- sendProgress({
- stage: 'fetch',
- message: 'Fetching latest changes...',
- progress: 40,
- });
-
- // Fetch all remotes
- await execAsync('git fetch --all');
- sendProgress({
- stage: 'fetch',
- message: 'Remote changes fetched',
- progress: 50,
- });
-
- // Check if remote branch exists
- try {
- await execAsync(`git rev-parse --verify upstream/${defaultBranch}`);
- sendProgress({
- stage: 'fetch',
- message: 'Remote branch verified',
- progress: 60,
- });
- } catch {
- throw new Error(
- `Remote branch 'upstream/${defaultBranch}' not found. Please ensure the upstream repository is properly configured.`,
- );
- }
-
- // Get current commit hash and remote commit hash
- sendProgress({
- stage: 'fetch',
- message: 'Comparing versions...',
- progress: 70,
- });
-
- const { stdout: currentCommit } = await execAsync('git rev-parse HEAD');
- const { stdout: remoteCommit } = await execAsync(`git rev-parse upstream/${defaultBranch}`);
-
- // If we're on the same commit, no update is available
- if (currentCommit.trim() === remoteCommit.trim()) {
- sendProgress({
- stage: 'complete',
- message: 'No updates available. You are on the latest version.',
- progress: 100,
- details: {
- currentCommit: currentCommit.trim().substring(0, 7),
- remoteCommit: remoteCommit.trim().substring(0, 7),
- },
- });
- return;
- }
-
- sendProgress({
- stage: 'fetch',
- message: 'Analyzing changes...',
- progress: 80,
- });
-
- // Initialize variables
- let changedFiles: string[] = [];
- let commitMessages: string[] = [];
- let stats: RegExpMatchArray | null = null;
- let totalSizeInBytes = 0;
-
- // Format size for display
- const formatSize = (bytes: number) => {
- if (bytes === 0) {
- return '0 B';
- }
-
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
- };
-
- // Get list of changed files and their sizes
- try {
- const { stdout: diffOutput } = await execAsync(
- `git diff --name-status ${currentCommit.trim()}..${remoteCommit.trim()}`,
- );
- const files = diffOutput.split('\n').filter(Boolean);
-
- if (files.length === 0) {
- sendProgress({
- stage: 'complete',
- message: `No file changes detected between your version and upstream/${defaultBranch}. You might be on a different branch.`,
- progress: 100,
- details: {
- currentCommit: currentCommit.trim().substring(0, 7),
- remoteCommit: remoteCommit.trim().substring(0, 7),
- },
- });
- return;
- }
-
- sendProgress({
- stage: 'fetch',
- message: `Found ${files.length} changed files, calculating sizes...`,
- progress: 90,
- });
-
- // Get size information for each changed file
- for (const line of files) {
- const [status, file] = line.split('\t');
-
- if (status !== 'D') {
- // Skip deleted files
- try {
- const { stdout: sizeOutput } = await execAsync(`git cat-file -s ${remoteCommit.trim()}:${file}`);
- const size = parseInt(sizeOutput) || 0;
- totalSizeInBytes += size;
- } catch {
- console.debug(`Could not get size for file: ${file}`);
- }
- }
- }
-
- changedFiles = files.map((line) => {
- const [status, file] = line.split('\t');
- return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`;
- });
- } catch (err) {
- console.debug('Failed to get changed files:', err);
- throw new Error(`Failed to compare changes with upstream/${defaultBranch}. Are you on the correct branch?`);
- }
-
- // Get commit messages between current and remote
- try {
- const { stdout: logOutput } = await execAsync(
- `git log --pretty=format:"%h|%s|%aI" ${currentCommit.trim()}..${remoteCommit.trim()}`,
- );
-
- // Parse and group commits by type
- const commits = logOutput
- .split('\n')
- .filter(Boolean)
- .map((line) => {
- const [hash, subject, timestamp] = line.split('|');
- let type = 'other';
- let message = subject;
-
- if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
- type = 'feature';
- message = subject.replace(/^feat(?:ure)?:/, '').trim();
- } else if (subject.startsWith('fix:')) {
- type = 'fix';
- message = subject.replace(/^fix:/, '').trim();
- } else if (subject.startsWith('docs:')) {
- type = 'docs';
- message = subject.replace(/^docs:/, '').trim();
- } else if (subject.startsWith('style:')) {
- type = 'style';
- message = subject.replace(/^style:/, '').trim();
- } else if (subject.startsWith('refactor:')) {
- type = 'refactor';
- message = subject.replace(/^refactor:/, '').trim();
- } else if (subject.startsWith('perf:')) {
- type = 'perf';
- message = subject.replace(/^perf:/, '').trim();
- } else if (subject.startsWith('test:')) {
- type = 'test';
- message = subject.replace(/^test:/, '').trim();
- } else if (subject.startsWith('build:')) {
- type = 'build';
- message = subject.replace(/^build:/, '').trim();
- } else if (subject.startsWith('ci:')) {
- type = 'ci';
- message = subject.replace(/^ci:/, '').trim();
- }
-
- return {
- hash,
- type,
- message,
- timestamp: new Date(timestamp),
- };
- });
-
- // Group commits by type
- const groupedCommits = commits.reduce(
- (acc, commit) => {
- if (!acc[commit.type]) {
- acc[commit.type] = [];
- }
-
- acc[commit.type].push(commit);
-
- return acc;
- },
- {} as Record,
- );
-
- // Format commit messages with emojis and timestamps
- const formattedMessages = Object.entries(groupedCommits).map(([type, commits]) => {
- const emoji = {
- feature: 'β¨',
- fix: 'π',
- docs: 'π',
- style: 'π',
- refactor: 'β»οΈ',
- perf: 'β‘',
- test: 'π§ͺ',
- build: 'π οΈ',
- ci: 'βοΈ',
- other: 'π',
- }[type];
-
- const title = {
- feature: 'Features',
- fix: 'Bug Fixes',
- docs: 'Documentation',
- style: 'Styles',
- refactor: 'Code Refactoring',
- perf: 'Performance',
- test: 'Tests',
- build: 'Build',
- ci: 'CI',
- other: 'Other Changes',
- }[type];
-
- return `### ${emoji} ${title}\n\n${commits
- .map((c) => `* ${c.message} (${c.hash.substring(0, 7)}) - ${c.timestamp.toLocaleString()}`)
- .join('\n')}`;
- });
-
- commitMessages = formattedMessages;
- } catch {
- // Handle silently - empty commitMessages array will be used
- }
-
- // Get diff stats using the specific commits
- try {
- const { stdout: diffStats } = await execAsync(
- `git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`,
- );
- stats = diffStats.match(
- /(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/,
- );
- } catch {
- // Handle silently - null stats will be used
- }
-
- // If we somehow still have no changes detected
- if (!stats && changedFiles.length === 0) {
- sendProgress({
- stage: 'complete',
- message: `No changes detected between your version and upstream/${defaultBranch}. This might be unexpected - please check your git status.`,
- progress: 100,
- });
- return;
- }
-
- // Fetch changelog
- sendProgress({
- stage: 'fetch',
- message: 'Fetching changelog...',
- progress: 95,
- });
-
- const changelog = await fetchChangelog(currentCommit.trim(), remoteCommit.trim());
-
- // We have changes, send the details
- sendProgress({
- stage: 'fetch',
- message: `Changes detected on upstream/${defaultBranch}`,
- progress: 100,
- details: {
- changedFiles,
- additions: stats?.[2] ? parseInt(stats[2]) : 0,
- deletions: stats?.[3] ? parseInt(stats[3]) : 0,
- commitMessages,
- totalSize: formatSize(totalSizeInBytes),
- currentCommit: currentCommit.trim().substring(0, 7),
- remoteCommit: remoteCommit.trim().substring(0, 7),
- updateReady: true,
- changelog,
- compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
- },
- });
-
- // Only proceed with update if autoUpdate is true
- if (!autoUpdate) {
- sendProgress({
- stage: 'complete',
- message: 'Update is ready to be applied. Click "Update Now" to proceed.',
- progress: 100,
- details: {
- changedFiles,
- additions: stats?.[2] ? parseInt(stats[2]) : 0,
- deletions: stats?.[3] ? parseInt(stats[3]) : 0,
- commitMessages,
- totalSize: formatSize(totalSizeInBytes),
- currentCommit: currentCommit.trim().substring(0, 7),
- remoteCommit: remoteCommit.trim().substring(0, 7),
- updateReady: true,
- changelog,
- compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`,
- },
- });
- return;
- }
-
- // Pull stage
- sendProgress({
- stage: 'pull',
- message: `Pulling changes from upstream/${defaultBranch}...`,
- progress: 0,
- });
-
- await execAsync(`git pull upstream ${defaultBranch}`);
-
- sendProgress({
- stage: 'pull',
- message: 'Changes pulled successfully',
- progress: 100,
- });
-
- // Install stage
- sendProgress({
- stage: 'install',
- message: 'Installing dependencies...',
- progress: 0,
- });
-
- await execAsync('pnpm install');
-
- sendProgress({
- stage: 'install',
- message: 'Dependencies installed successfully',
- progress: 100,
- });
-
- // Build stage
- sendProgress({
- stage: 'build',
- message: 'Building application...',
- progress: 0,
- });
-
- await execAsync('pnpm build');
-
- sendProgress({
- stage: 'build',
- message: 'Build completed successfully',
- progress: 100,
- });
-
- // Complete
- sendProgress({
- stage: 'complete',
- message: 'Update completed successfully! Click Restart to apply changes.',
- progress: 100,
- });
- } catch (err) {
- sendProgress({
- stage: 'complete',
- message: 'Update failed',
- error: err instanceof Error ? err.message : 'Unknown error occurred',
- });
- } finally {
- controller.close();
- }
- },
- });
-
- return new Response(stream, {
- headers: {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- Connection: 'keep-alive',
- },
- });
- } catch (err) {
- console.error('Update preparation failed:', err);
- return json(
- {
- success: false,
- error: err instanceof Error ? err.message : 'Unknown error occurred while preparing update',
- },
- { status: 500 },
- );
- }
+ return json(
+ {
+ error: 'Updates must be performed manually in a server environment',
+ instructions: [
+ '1. Navigate to the project directory',
+ '2. Run: git fetch upstream',
+ '3. Run: git pull upstream main',
+ '4. Run: pnpm install',
+ '5. Run: pnpm run build',
+ ],
+ },
+ { status: 400 },
+ );
};
-
-// Add this function to fetch the changelog
-async function fetchChangelog(currentCommit: string, remoteCommit: string): Promise {
- try {
- // First try to get the changelog.md content
- const { stdout: changelogContent } = await execAsync('git show upstream/main:changelog.md');
-
- // If we have a changelog, return it
- if (changelogContent) {
- return changelogContent;
- }
-
- // If no changelog.md, generate one in a similar format
- let changelog = '# Changes in this Update\n\n';
-
- // Get commit messages grouped by type
- const { stdout: commitLog } = await execAsync(
- `git log --pretty=format:"%h|%s|%b" ${currentCommit.trim()}..${remoteCommit.trim()}`,
- );
-
- const commits = commitLog.split('\n').filter(Boolean);
- const categorizedCommits: Record = {
- 'β¨ Features': [],
- 'π Bug Fixes': [],
- 'π Documentation': [],
- 'π Styles': [],
- 'β»οΈ Code Refactoring': [],
- 'β‘ Performance': [],
- 'π§ͺ Tests': [],
- 'π οΈ Build': [],
- 'βοΈ CI': [],
- 'π Other Changes': [],
- };
-
- // Categorize commits
- for (const commit of commits) {
- const [hash, subject] = commit.split('|');
- let category = 'π Other Changes';
-
- if (subject.startsWith('feat:') || subject.startsWith('feature:')) {
- category = 'β¨ Features';
- } else if (subject.startsWith('fix:')) {
- category = 'π Bug Fixes';
- } else if (subject.startsWith('docs:')) {
- category = 'π Documentation';
- } else if (subject.startsWith('style:')) {
- category = 'π Styles';
- } else if (subject.startsWith('refactor:')) {
- category = 'β»οΈ Code Refactoring';
- } else if (subject.startsWith('perf:')) {
- category = 'β‘ Performance';
- } else if (subject.startsWith('test:')) {
- category = 'π§ͺ Tests';
- } else if (subject.startsWith('build:')) {
- category = 'π οΈ Build';
- } else if (subject.startsWith('ci:')) {
- category = 'βοΈ CI';
- }
-
- const message = subject.includes(':') ? subject.split(':')[1].trim() : subject.trim();
- categorizedCommits[category].push(`* ${message} (${hash.substring(0, 7)})`);
- }
-
- // Build changelog content
- for (const [category, commits] of Object.entries(categorizedCommits)) {
- if (commits.length > 0) {
- changelog += `\n## ${category}\n\n${commits.join('\n')}\n`;
- }
- }
-
- // Add stats
- const { stdout: stats } = await execAsync(`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`);
-
- if (stats) {
- changelog += '\n## π Stats\n\n';
- changelog += `${stats.trim()}\n`;
- }
-
- return changelog;
- } catch (error) {
- console.error('Error fetching changelog:', error);
- return 'Unable to fetch changelog';
- }
-}
diff --git a/package.json b/package.json
index e287f0c5..144831fb 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,7 @@
"remark-gfm": "^4.0.0",
"remix-island": "^0.2.0",
"remix-utils": "^7.7.0",
+ "rollup-plugin-node-polyfills": "^0.2.1",
"shiki": "^1.24.0",
"tailwind-merge": "^2.2.1",
"unist-util-visit": "^5.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc491791..bb37289b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -287,6 +287,9 @@ importers:
remix-utils:
specifier: ^7.7.0
version: 7.7.0(@remix-run/cloudflare@2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3))(@remix-run/node@2.15.3(typescript@5.7.3))(@remix-run/react@2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3))(@remix-run/router@1.22.0)(react@18.3.1)(zod@3.24.1)
+ rollup-plugin-node-polyfills:
+ specifier: ^0.2.1
+ version: 0.2.1
shiki:
specifier: ^1.24.0
version: 1.29.2
diff --git a/vite.config.ts b/vite.config.ts
index 01fb3b2e..a9351c12 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -89,14 +89,55 @@ export default defineConfig((config) => {
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
+ // Define global values
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
build: {
target: 'esnext',
+ rollupOptions: {
+ output: {
+ format: 'esm',
+ },
+ },
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ define: {
+ global: 'globalThis',
+ },
+ },
+ },
+ resolve: {
+ alias: {
+ buffer: 'vite-plugin-node-polyfills/polyfills/buffer',
+ },
},
plugins: [
nodePolyfills({
- include: ['path', 'buffer', 'process'],
+ include: ['buffer', 'process', 'util', 'stream'],
+ globals: {
+ Buffer: true,
+ process: true,
+ global: true,
+ },
+ protocolImports: true,
+ // Exclude Node.js modules that shouldn't be polyfilled in Cloudflare
+ exclude: ['child_process', 'fs', 'path'],
}),
+ {
+ name: 'buffer-polyfill',
+ transform(code, id) {
+ if (id.includes('env.mjs')) {
+ return {
+ code: `import { Buffer } from 'buffer';\n${code}`,
+ map: null,
+ };
+ }
+ },
+ },
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {
From fc779b5b1f49181d9a5f63130dd7815002692cef Mon Sep 17 00:00:00 2001
From: Leex
Date: Sat, 8 Mar 2025 23:08:40 +0100
Subject: [PATCH 021/115] update: docs README.md
- changed git clone for pulling stable branch, not main anymore
- Removed some not needed stuff
---
README.md | 31 ++++++++++++++++++-------------
1 file changed, 18 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 3071fcee..f92a21ff 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# bolt.diy (Previously oTToDev)
+# bolt.diy
[](https://bolt.diy)
-Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
+Welcome to bolt.diy, the official open source version of Bolt.new, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
-----
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
@@ -83,7 +83,8 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- β¬ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
- β¬ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
- β¬ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
-- β¬ Deploy directly to Vercel/Netlify/other similar platforms
+- β
Deploy directly to Netlify (@xKevIsDev)
+- β¬ Supabase Integration
- β¬ Have LLM plan the project in a MD file for better results/transparency
- β¬ VSCode Integration with git-like confirmations
- β¬ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
@@ -101,8 +102,9 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- **Attach images to prompts** for better contextual understanding.
- **Integrated terminal** to view output of LLM-run commands.
- **Revert code to earlier versions** for easier debugging and quicker changes.
-- **Download projects as ZIP** for easy portability.
+- **Download projects as ZIP** for easy portabilitr Sync to a folder on the host.
- **Integration-ready Docker support** for a hassle-free setup.
+- **Deploy** directly to **Netlify**
## Setup
@@ -241,8 +243,7 @@ This method is recommended for developers who want to:
1. **Clone the Repository**:
```bash
- # Using HTTPS
- git clone https://github.com/stackblitz-labs/bolt.diy.git
+ git clone -b stable https://github.com/stackblitz-labs/bolt.diy.git
```
2. **Navigate to Project Directory**:
@@ -251,21 +252,25 @@ This method is recommended for developers who want to:
cd bolt.diy
```
-3. **Switch to the Main Branch**:
- ```bash
- git checkout main
- ```
-4. **Install Dependencies**:
+3. **Install Dependencies**:
```bash
pnpm install
```
-5. **Start the Development Server**:
+4. **Start the Development Server**:
```bash
pnpm run dev
```
+5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch):
+ ```bash
+ git checkout main
+ ```
+ Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
+
+
+
#### Staying Updated
To get the latest changes from the repository:
@@ -279,7 +284,7 @@ To get the latest changes from the repository:
2. **Pull Latest Updates**:
```bash
- git pull origin main
+ git pull
```
3. **Update Dependencies**:
From a696d5f254acc7e9ac0ad4c5e17021c2780e1074 Mon Sep 17 00:00:00 2001
From: Leex
Date: Sun, 9 Mar 2025 23:44:23 +0100
Subject: [PATCH 022/115] Update README.md
---
README.md | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index f92a21ff..f38e23c0 100644
--- a/README.md
+++ b/README.md
@@ -263,13 +263,19 @@ This method is recommended for developers who want to:
pnpm run dev
```
-5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch):
+5. **(OPTIONAL)** Switch to the Main Branch if you want to use pre-release/testbranch:
```bash
git checkout main
+ pnpm install
+ pnpm run dev
```
Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
-
+>**Open the WebUI to test (Default: http://localhost:5173)**
+> - Beginngers:
+> - Try to use a sophisticated Provider/Model like Anthropic with Claude Sonnet 3.x Models to get best results
+> - Explanation: The System Prompt currently implemented in bolt.diy cant cover the best performance for all providers and models out there. So it works better with some models, then other, even if the models itself are perfect for >programming
+> - Future: Planned is a Plugin/Extentions-Library so there can be different System Prompts for different Models, which will help to get better results
#### Staying Updated
From 27fbfb76c4b95d55d837e78d399ea20de11e86dc Mon Sep 17 00:00:00 2001
From: Leex
Date: Mon, 10 Mar 2025 00:05:36 +0100
Subject: [PATCH 023/115] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f38e23c0..c14974b2 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- **Attach images to prompts** for better contextual understanding.
- **Integrated terminal** to view output of LLM-run commands.
- **Revert code to earlier versions** for easier debugging and quicker changes.
-- **Download projects as ZIP** for easy portabilitr Sync to a folder on the host.
+- **Download projects as ZIP** for easy portability Sync to a folder on the host.
- **Integration-ready Docker support** for a hassle-free setup.
- **Deploy** directly to **Netlify**
From f02e10c9ace54c912eb4f5405ef1e02b8fa06f83 Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 10 Mar 2025 11:12:25 +0000
Subject: [PATCH 024/115] fix: remove rename, creations and deletions now
persist across reloads
removed rename files until a better solution is found and made file/folder create/delete be persistent across reloads
---
app/components/workbench/FileTree.tsx | 129 +++++-------
app/lib/stores/files.ts | 293 +++++++++++++++++++++++++-
app/lib/stores/workbench.ts | 282 ++++++++++++-------------
3 files changed, 474 insertions(+), 230 deletions(-)
diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
index d1087361..cb12dd99 100644
--- a/app/components/workbench/FileTree.tsx
+++ b/app/components/workbench/FileTree.tsx
@@ -286,11 +286,23 @@ function FileContextMenu({
}: FolderContextMenuProps & { fullPath: string }) {
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
- const [isRenaming, setIsRenaming] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
const fileName = useMemo(() => path.basename(fullPath), [fullPath]);
+ // Add this to determine if the path is a file or folder
+ const isFolder = useMemo(() => {
+ const files = workbenchStore.files.get();
+ const fileEntry = files[fullPath];
+
+ return !fileEntry || fileEntry.type === 'folder';
+ }, [fullPath]);
+
+ // Get the parent directory for files
+ const targetPath = useMemo(() => {
+ return isFolder ? fullPath : path.dirname(fullPath);
+ }, [fullPath, isFolder]);
+
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -309,20 +321,25 @@ function FileContextMenu({
e.stopPropagation();
const items = Array.from(e.dataTransfer.items);
- const imageFiles = items.filter((item) => item.type.startsWith('image/'));
+ const files = items.filter((item) => item.kind === 'file');
- for (const item of imageFiles) {
+ for (const item of files) {
const file = item.getAsFile();
if (file) {
try {
const filePath = path.join(fullPath, file.name);
- const success = await workbenchStore.createNewFile(filePath, file);
+
+ // Convert file to binary data (Uint8Array)
+ const arrayBuffer = await file.arrayBuffer();
+ const binaryContent = new Uint8Array(arrayBuffer);
+
+ const success = await workbenchStore.createFile(filePath, binaryContent);
if (success) {
- toast.success(`Image ${file.name} uploaded successfully`);
+ toast.success(`File ${file.name} uploaded successfully`);
} else {
- toast.error(`Failed to upload image ${file.name}`);
+ toast.error(`Failed to upload file ${file.name}`);
}
} catch (error) {
toast.error(`Error uploading ${file.name}`);
@@ -337,8 +354,11 @@ function FileContextMenu({
);
const handleCreateFile = async (fileName: string) => {
- const newFilePath = path.join(fullPath, fileName);
- const success = await workbenchStore.createNewFile(newFilePath);
+ // Use targetPath instead of fullPath
+ const newFilePath = path.join(targetPath, fileName);
+
+ // Change from createNewFile to createFile
+ const success = await workbenchStore.createFile(newFilePath, '');
if (success) {
toast.success('File created successfully');
@@ -350,8 +370,11 @@ function FileContextMenu({
};
const handleCreateFolder = async (folderName: string) => {
- const newFolderPath = path.join(fullPath, folderName);
- const success = await workbenchStore.createNewFolder(newFolderPath);
+ // Use targetPath instead of fullPath
+ const newFolderPath = path.join(targetPath, folderName);
+
+ // Change from createNewFolder to createFolder
+ const success = await workbenchStore.createFolder(newFolderPath);
if (success) {
toast.success('Folder created successfully');
@@ -362,57 +385,31 @@ function FileContextMenu({
setIsCreatingFolder(false);
};
+ // Add delete handler function
const handleDelete = async () => {
try {
- if (confirm(`Are you sure you want to delete ${fileName}?`)) {
- const isDirectory = path.extname(fullPath) === '';
-
- const success = isDirectory
- ? await workbenchStore.deleteFolder(fullPath)
- : await workbenchStore.deleteFile(fullPath);
-
- if (success) {
- toast.success('Deleted successfully');
- } else {
- toast.error('Failed to delete');
- }
+ // Confirm deletion with the user
+ if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) {
+ return;
}
- } catch (error) {
- toast.error('Error during delete operation');
- logger.error(error);
- }
- };
- const handleRename = async (newName: string) => {
- if (newName === fileName) {
- setIsRenaming(false);
- return;
- }
+ let success;
- const parentDir = path.dirname(fullPath);
- const newPath = path.join(parentDir, newName);
-
- try {
- const files = workbenchStore.files.get();
- const fileEntry = files[fullPath];
-
- const isDirectory = !fileEntry || fileEntry.type === 'folder';
-
- const success = isDirectory
- ? await workbenchStore.renameFolder(fullPath, newPath)
- : await workbenchStore.renameFile(fullPath, newPath);
+ if (isFolder) {
+ success = await workbenchStore.deleteFolder(fullPath);
+ } else {
+ success = await workbenchStore.deleteFile(fullPath);
+ }
if (success) {
- toast.success('Renamed successfully');
+ toast.success(`${isFolder ? 'Folder' : 'File'} deleted successfully`);
} else {
- toast.error('Failed to rename');
+ toast.error(`Failed to delete ${isFolder ? 'folder' : 'file'}`);
}
} catch (error) {
- toast.error('Error during rename operation');
+ toast.error(`Error deleting ${isFolder ? 'folder' : 'file'}`);
logger.error(error);
}
-
- setIsRenaming(false);
};
return (
@@ -428,17 +425,7 @@ function FileContextMenu({
isDragging,
})}
>
- {!isRenaming && children}
-
- {isRenaming && (
- setIsRenaming(false)}
- />
- )}
+ {children}
@@ -459,23 +446,20 @@ function FileContextMenu({
New Folder
- setIsRenaming(true)}>
-
-
-
-
-
Copy path
Copy relative path
+ {/* Add delete option in a new group */}
+
+
+
+
+ Delete {isFolder ? 'Folder' : 'File'}
+
+
+
@@ -495,7 +479,6 @@ function FileContextMenu({
onCancel={() => setIsCreatingFolder(false)}
/>
)}
- {/* Remove the isRenaming InlineInput from here since we moved it above */}
>
);
}
diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts
index 60904a65..8227f94f 100644
--- a/app/lib/stores/files.ts
+++ b/app/lib/stores/files.ts
@@ -42,6 +42,11 @@ export class FilesStore {
*/
#modifiedFiles: Map = import.meta.hot?.data.modifiedFiles ?? new Map();
+ /**
+ * Keeps track of deleted files and folders to prevent them from reappearing on reload
+ */
+ #deletedPaths: Set = import.meta.hot?.data.deletedPaths ?? new Set();
+
/**
* Map of files that matches the state of WebContainer.
*/
@@ -54,9 +59,28 @@ export class FilesStore {
constructor(webcontainerPromise: Promise) {
this.#webcontainer = webcontainerPromise;
+ // Load deleted paths from localStorage if available
+ try {
+ if (typeof localStorage !== 'undefined') {
+ const deletedPathsJson = localStorage.getItem('bolt-deleted-paths');
+
+ if (deletedPathsJson) {
+ const deletedPaths = JSON.parse(deletedPathsJson);
+
+ if (Array.isArray(deletedPaths)) {
+ deletedPaths.forEach((path) => this.#deletedPaths.add(path));
+ }
+ }
+ }
+ } catch (error) {
+ logger.error('Failed to load deleted paths from localStorage', error);
+ }
+
if (import.meta.hot) {
+ // Persist our state across hot reloads
import.meta.hot.data.files = this.files;
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
+ import.meta.hot.data.deletedPaths = this.#deletedPaths;
}
this.#init();
@@ -139,18 +163,81 @@ export class FilesStore {
async #init() {
const webcontainer = await this.#webcontainer;
+ // Clean up any files that were previously deleted
+ this.#cleanupDeletedFiles();
+
webcontainer.internal.watchPaths(
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
);
}
+ /**
+ * Removes any deleted files/folders from the store
+ */
+ #cleanupDeletedFiles() {
+ if (this.#deletedPaths.size === 0) {
+ return;
+ }
+
+ const currentFiles = this.files.get();
+
+ // Process each deleted path
+ for (const deletedPath of this.#deletedPaths) {
+ // Remove the path itself
+ if (currentFiles[deletedPath]) {
+ this.files.setKey(deletedPath, undefined);
+
+ // Adjust file count if it was a file
+ if (currentFiles[deletedPath]?.type === 'file') {
+ this.#size--;
+ }
+ }
+
+ // Also remove any files/folders inside deleted folders
+ for (const [path, dirent] of Object.entries(currentFiles)) {
+ if (path.startsWith(deletedPath + '/')) {
+ this.files.setKey(path, undefined);
+
+ // Adjust file count if it was a file
+ if (dirent?.type === 'file') {
+ this.#size--;
+ }
+
+ // Remove from modified files tracking if present
+ if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
+ this.#modifiedFiles.delete(path);
+ }
+ }
+ }
+ }
+ }
+
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
const watchEvents = events.flat(2);
- for (const { type, path, buffer } of watchEvents) {
+ for (const { type, path: eventPath, buffer } of watchEvents) {
// remove any trailing slashes
- const sanitizedPath = path.replace(/\/+$/g, '');
+ const sanitizedPath = eventPath.replace(/\/+$/g, '');
+
+ // Skip processing if this file/folder was explicitly deleted
+ if (this.#deletedPaths.has(sanitizedPath)) {
+ continue;
+ }
+
+ // Also skip if this is a file/folder inside a deleted folder
+ let isInDeletedFolder = false;
+
+ for (const deletedPath of this.#deletedPaths) {
+ if (sanitizedPath.startsWith(deletedPath + '/')) {
+ isInDeletedFolder = true;
+ break;
+ }
+ }
+
+ if (isInDeletedFolder) {
+ continue;
+ }
switch (type) {
case 'add_dir': {
@@ -176,21 +263,32 @@ export class FilesStore {
}
let content = '';
-
- /**
- * @note This check is purely for the editor. The way we detect this is not
- * bullet-proof and it's a best guess so there might be false-positives.
- * The reason we do this is because we don't want to display binary files
- * in the editor nor allow to edit them.
- */
const isBinary = isBinaryFile(buffer);
- if (!isBinary) {
+ if (isBinary && buffer) {
+ // For binary files, we need to preserve the content as base64
+ content = Buffer.from(buffer).toString('base64');
+ } else if (!isBinary) {
content = this.#decodeFileContent(buffer);
+
+ /*
+ * If the content is a single space and this is from our empty file workaround,
+ * convert it back to an actual empty string
+ */
+ if (content === ' ' && type === 'add_file') {
+ content = '';
+ }
+ }
+
+ // Check if we already have this file with content
+ const existingFile = this.files.get()[sanitizedPath];
+
+ if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) {
+ // Keep existing binary content if new content is empty
+ content = existingFile.content;
}
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
-
break;
}
case 'remove_file': {
@@ -218,6 +316,179 @@ export class FilesStore {
return '';
}
}
+
+ async createFile(filePath: string, content: string | Uint8Array = '') {
+ const webcontainer = await this.#webcontainer;
+
+ try {
+ const relativePath = path.relative(webcontainer.workdir, filePath);
+
+ if (!relativePath) {
+ throw new Error(`EINVAL: invalid file path, create '${relativePath}'`);
+ }
+
+ // Create parent directories if they don't exist
+ const dirPath = path.dirname(relativePath);
+
+ if (dirPath !== '.') {
+ await webcontainer.fs.mkdir(dirPath, { recursive: true });
+ }
+
+ // Detect binary content
+ const isBinary = content instanceof Uint8Array;
+
+ if (isBinary) {
+ await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
+
+ // Store Base64 encoded data instead of an empty string
+ const base64Content = Buffer.from(content).toString('base64');
+ this.files.setKey(filePath, { type: 'file', content: base64Content, isBinary: true });
+
+ // Store the base64 content as the original content for tracking modifications
+ this.#modifiedFiles.set(filePath, base64Content);
+ } else {
+ // Ensure we write at least a space character for empty files to ensure they're tracked
+ const contentToWrite = (content as string).length === 0 ? ' ' : content;
+ await webcontainer.fs.writeFile(relativePath, contentToWrite);
+
+ // But store the actual empty string in our file map if that's what was requested
+ this.files.setKey(filePath, { type: 'file', content: content as string, isBinary: false });
+
+ // Store the text content as the original content
+ this.#modifiedFiles.set(filePath, content as string);
+ }
+
+ logger.info(`File created: ${filePath}`);
+
+ return true;
+ } catch (error) {
+ logger.error('Failed to create file\n\n', error);
+ throw error;
+ }
+ }
+
+ async createFolder(folderPath: string) {
+ const webcontainer = await this.#webcontainer;
+
+ try {
+ const relativePath = path.relative(webcontainer.workdir, folderPath);
+
+ if (!relativePath) {
+ throw new Error(`EINVAL: invalid folder path, create '${relativePath}'`);
+ }
+
+ await webcontainer.fs.mkdir(relativePath, { recursive: true });
+
+ // Immediately update the folder in our store without waiting for the watcher
+ this.files.setKey(folderPath, { type: 'folder' });
+
+ logger.info(`Folder created: ${folderPath}`);
+
+ return true;
+ } catch (error) {
+ logger.error('Failed to create folder\n\n', error);
+ throw error;
+ }
+ }
+
+ async deleteFile(filePath: string) {
+ const webcontainer = await this.#webcontainer;
+
+ try {
+ const relativePath = path.relative(webcontainer.workdir, filePath);
+
+ if (!relativePath) {
+ throw new Error(`EINVAL: invalid file path, delete '${relativePath}'`);
+ }
+
+ await webcontainer.fs.rm(relativePath);
+
+ // Add to deleted paths set
+ this.#deletedPaths.add(filePath);
+
+ // Immediately update our store without waiting for the watcher
+ this.files.setKey(filePath, undefined);
+ this.#size--;
+
+ // Remove from modified files tracking if present
+ if (this.#modifiedFiles.has(filePath)) {
+ this.#modifiedFiles.delete(filePath);
+ }
+
+ // Persist the deleted paths to localStorage for extra durability
+ this.#persistDeletedPaths();
+
+ logger.info(`File deleted: ${filePath}`);
+
+ return true;
+ } catch (error) {
+ logger.error('Failed to delete file\n\n', error);
+ throw error;
+ }
+ }
+
+ async deleteFolder(folderPath: string) {
+ const webcontainer = await this.#webcontainer;
+
+ try {
+ const relativePath = path.relative(webcontainer.workdir, folderPath);
+
+ if (!relativePath) {
+ throw new Error(`EINVAL: invalid folder path, delete '${relativePath}'`);
+ }
+
+ await webcontainer.fs.rm(relativePath, { recursive: true });
+
+ // Add to deleted paths set
+ this.#deletedPaths.add(folderPath);
+
+ // Immediately update our store without waiting for the watcher
+ this.files.setKey(folderPath, undefined);
+
+ // Also remove all files and subfolders from our store
+ const allFiles = this.files.get();
+
+ for (const [path, dirent] of Object.entries(allFiles)) {
+ if (path.startsWith(folderPath + '/')) {
+ this.files.setKey(path, undefined);
+
+ // Also add these paths to the deleted paths set
+ this.#deletedPaths.add(path);
+
+ // Decrement file count for each file (not folder) removed
+ if (dirent?.type === 'file') {
+ this.#size--;
+ }
+
+ // Remove from modified files tracking if present
+ if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
+ this.#modifiedFiles.delete(path);
+ }
+ }
+ }
+
+ // Persist the deleted paths to localStorage for extra durability
+ this.#persistDeletedPaths();
+
+ logger.info(`Folder deleted: ${folderPath}`);
+
+ return true;
+ } catch (error) {
+ logger.error('Failed to delete folder\n\n', error);
+ throw error;
+ }
+ }
+
+ // Add a method to persist deleted paths to localStorage
+ #persistDeletedPaths() {
+ try {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem('bolt-deleted-paths', JSON.stringify([...this.#deletedPaths]));
+ }
+ } catch (error) {
+ logger.error('Failed to persist deleted paths to localStorage', error);
+ }
+ }
}
function isBinaryFile(buffer: Uint8Array | undefined) {
diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts
index 2d5bbc08..37a7a59d 100644
--- a/app/lib/stores/workbench.ts
+++ b/app/lib/stores/workbench.ts
@@ -60,6 +60,16 @@ export class WorkbenchStore {
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
+
+ // Ensure binary files are properly preserved across hot reloads
+ const filesMap = this.files.get();
+
+ for (const [path, dirent] of Object.entries(filesMap)) {
+ if (dirent?.type === 'file' && dirent.isBinary && dirent.content) {
+ // Make sure binary content is preserved
+ this.files.setKey(path, { ...dirent });
+ }
+ }
}
}
@@ -238,6 +248,7 @@ export class WorkbenchStore {
getFileModifcations() {
return this.#filesStore.getFileModifications();
}
+
getModifiedFiles() {
return this.#filesStore.getModifiedFiles();
}
@@ -246,6 +257,131 @@ export class WorkbenchStore {
this.#filesStore.resetFileModifications();
}
+ async createFile(filePath: string, content: string | Uint8Array = '') {
+ try {
+ const success = await this.#filesStore.createFile(filePath, content);
+
+ if (success) {
+ // If the file is created successfully, select it in the editor
+ this.setSelectedFile(filePath);
+
+ /*
+ * For empty files, we need to ensure they're not marked as unsaved
+ * Only check for empty string, not empty Uint8Array
+ */
+ if (typeof content === 'string' && content === '') {
+ const newUnsavedFiles = new Set(this.unsavedFiles.get());
+ newUnsavedFiles.delete(filePath);
+ this.unsavedFiles.set(newUnsavedFiles);
+ }
+ }
+
+ return success;
+ } catch (error) {
+ console.error('Failed to create file:', error);
+ throw error;
+ }
+ }
+
+ async createFolder(folderPath: string) {
+ try {
+ return await this.#filesStore.createFolder(folderPath);
+ } catch (error) {
+ console.error('Failed to create folder:', error);
+ throw error;
+ }
+ }
+
+ async deleteFile(filePath: string) {
+ try {
+ // Check if the file is currently open in the editor
+ const currentDocument = this.currentDocument.get();
+ const isCurrentFile = currentDocument?.filePath === filePath;
+
+ // Delete the file
+ const success = await this.#filesStore.deleteFile(filePath);
+
+ if (success) {
+ // Remove from unsaved files if present
+ const newUnsavedFiles = new Set(this.unsavedFiles.get());
+
+ if (newUnsavedFiles.has(filePath)) {
+ newUnsavedFiles.delete(filePath);
+ this.unsavedFiles.set(newUnsavedFiles);
+ }
+
+ // If this was the current file, select another file
+ if (isCurrentFile) {
+ // Find another file to select
+ const files = this.files.get();
+ let nextFile: string | undefined = undefined;
+
+ for (const [path, dirent] of Object.entries(files)) {
+ if (dirent?.type === 'file') {
+ nextFile = path;
+ break;
+ }
+ }
+
+ this.setSelectedFile(nextFile);
+ }
+ }
+
+ return success;
+ } catch (error) {
+ console.error('Failed to delete file:', error);
+ throw error;
+ }
+ }
+
+ async deleteFolder(folderPath: string) {
+ try {
+ // Check if any file in this folder is currently open
+ const currentDocument = this.currentDocument.get();
+ const isInCurrentFolder = currentDocument?.filePath?.startsWith(folderPath + '/');
+
+ // Delete the folder
+ const success = await this.#filesStore.deleteFolder(folderPath);
+
+ if (success) {
+ // Remove any files in this folder from unsaved files
+ const unsavedFiles = this.unsavedFiles.get();
+ const newUnsavedFiles = new Set();
+
+ for (const file of unsavedFiles) {
+ if (!file.startsWith(folderPath + '/')) {
+ newUnsavedFiles.add(file);
+ }
+ }
+
+ if (newUnsavedFiles.size !== unsavedFiles.size) {
+ this.unsavedFiles.set(newUnsavedFiles);
+ }
+
+ // If current file was in this folder, select another file
+ if (isInCurrentFolder) {
+ // Find another file to select
+ const files = this.files.get();
+ let nextFile: string | undefined = undefined;
+
+ for (const [path, dirent] of Object.entries(files)) {
+ if (dirent?.type === 'file') {
+ nextFile = path;
+ break;
+ }
+ }
+
+ this.setSelectedFile(nextFile);
+ }
+ }
+
+ return success;
+ } catch (error) {
+ console.error('Failed to delete folder:', error);
+ throw error;
+ }
+ }
+
abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
@@ -547,152 +683,6 @@ export class WorkbenchStore {
throw error; // Rethrow the error for further handling
}
}
-
- async createNewFile(filePath: string, content: string | File | ArrayBuffer = '') {
- try {
- const wc = await webcontainer;
- const relativePath = extractRelativePath(filePath);
-
- const dirPath = path.dirname(relativePath);
-
- if (dirPath !== '.') {
- await wc.fs.mkdir(dirPath, { recursive: true });
- }
-
- let fileContent: string | Uint8Array;
-
- if (content instanceof File) {
- const buffer = await content.arrayBuffer();
- fileContent = new Uint8Array(buffer);
- } else if (content instanceof ArrayBuffer) {
- fileContent = new Uint8Array(content);
- } else {
- fileContent = content || '';
- }
-
- await wc.fs.writeFile(relativePath, fileContent);
-
- const fullPath = path.join(wc.workdir, relativePath);
- this.setSelectedFile(fullPath);
-
- return true;
- } catch (error) {
- console.error('Error creating file:', error);
- return false;
- }
- }
-
- async createNewFolder(folderPath: string) {
- try {
- const wc = await webcontainer;
- const relativePath = extractRelativePath(folderPath);
-
- await wc.fs.mkdir(relativePath, { recursive: true });
-
- return true;
- } catch (error) {
- console.error('Error creating folder:', error);
- return false;
- }
- }
-
- async deleteFile(filePath: string) {
- try {
- const wc = await webcontainer;
- const relativePath = extractRelativePath(filePath);
-
- await wc.fs.rm(relativePath);
-
- // If the deleted file was selected, clear the selection
- if (this.selectedFile.get() === filePath) {
- this.setSelectedFile(undefined);
- }
-
- return true;
- } catch (error) {
- console.error('Error deleting file:', error);
- return false;
- }
- }
-
- async deleteFolder(folderPath: string) {
- try {
- const wc = await webcontainer;
- const relativePath = extractRelativePath(folderPath);
-
- await wc.fs.rm(relativePath, { recursive: true });
-
- const selectedFile = this.selectedFile.get();
-
- if (selectedFile && selectedFile.startsWith(folderPath)) {
- this.setSelectedFile(undefined);
- }
-
- return true;
- } catch (error) {
- console.error('Error deleting folder:', error);
- return false;
- }
- }
-
- async renameFile(oldPath: string, newPath: string) {
- try {
- const wc = await webcontainer;
- const oldRelativePath = extractRelativePath(oldPath);
- const newRelativePath = extractRelativePath(newPath);
-
- const fileContent = await wc.fs.readFile(oldRelativePath, 'utf-8');
-
- await this.createNewFile(newPath, fileContent);
-
- await this.deleteFile(oldPath);
-
- if (this.selectedFile.get() === oldPath) {
- const fullNewPath = path.join(wc.workdir, newRelativePath);
- this.setSelectedFile(fullNewPath);
- }
-
- return true;
- } catch (error) {
- console.error('Error renaming file:', error);
- return false;
- }
- }
-
- async renameFolder(oldPath: string, newPath: string) {
- try {
- await this.createNewFolder(newPath);
-
- const files = this.files.get();
- const filesToMove = Object.entries(files)
- .filter(([filePath]) => filePath.startsWith(oldPath))
- .map(([filePath, dirent]) => ({ path: filePath, dirent }));
-
- for (const { path: filePath, dirent } of filesToMove) {
- if (dirent?.type === 'file') {
- const relativePath = filePath.substring(oldPath.length);
- const newFilePath = path.join(newPath, relativePath);
-
- await this.createNewFile(newFilePath, dirent.content);
- }
- }
-
- await this.deleteFolder(oldPath);
-
- const selectedFile = this.selectedFile.get();
-
- if (selectedFile && selectedFile.startsWith(oldPath)) {
- const relativePath = selectedFile.substring(oldPath.length);
- const newSelectedPath = path.join(newPath, relativePath);
- this.setSelectedFile(newSelectedPath);
- }
-
- return true;
- } catch (error) {
- console.error('Error renaming folder:', error);
- return false;
- }
- }
}
export const workbenchStore = new WorkbenchStore();
From 4665fa67fa71af233b169e1b50f5c2896e7d24f1 Mon Sep 17 00:00:00 2001
From: KevIsDev
Date: Mon, 10 Mar 2025 11:20:01 +0000
Subject: [PATCH 025/115] fix: remove excessive commenting
---
app/components/workbench/FileTree.tsx | 10 ---------
app/lib/stores/files.ts | 30 +--------------------------
app/lib/stores/workbench.ts | 11 ----------
3 files changed, 1 insertion(+), 50 deletions(-)
diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
index cb12dd99..197fa4f7 100644
--- a/app/components/workbench/FileTree.tsx
+++ b/app/components/workbench/FileTree.tsx
@@ -290,7 +290,6 @@ function FileContextMenu({
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
const fileName = useMemo(() => path.basename(fullPath), [fullPath]);
- // Add this to determine if the path is a file or folder
const isFolder = useMemo(() => {
const files = workbenchStore.files.get();
const fileEntry = files[fullPath];
@@ -298,7 +297,6 @@ function FileContextMenu({
return !fileEntry || fileEntry.type === 'folder';
}, [fullPath]);
- // Get the parent directory for files
const targetPath = useMemo(() => {
return isFolder ? fullPath : path.dirname(fullPath);
}, [fullPath, isFolder]);
@@ -354,10 +352,7 @@ function FileContextMenu({
);
const handleCreateFile = async (fileName: string) => {
- // Use targetPath instead of fullPath
const newFilePath = path.join(targetPath, fileName);
-
- // Change from createNewFile to createFile
const success = await workbenchStore.createFile(newFilePath, '');
if (success) {
@@ -370,10 +365,7 @@ function FileContextMenu({
};
const handleCreateFolder = async (folderName: string) => {
- // Use targetPath instead of fullPath
const newFolderPath = path.join(targetPath, folderName);
-
- // Change from createNewFolder to createFolder
const success = await workbenchStore.createFolder(newFolderPath);
if (success) {
@@ -385,10 +377,8 @@ function FileContextMenu({
setIsCreatingFolder(false);
};
- // Add delete handler function
const handleDelete = async () => {
try {
- // Confirm deletion with the user
if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) {
return;
}
diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts
index 8227f94f..2ec73a03 100644
--- a/app/lib/stores/files.ts
+++ b/app/lib/stores/files.ts
@@ -182,29 +182,23 @@ export class FilesStore {
const currentFiles = this.files.get();
- // Process each deleted path
for (const deletedPath of this.#deletedPaths) {
- // Remove the path itself
if (currentFiles[deletedPath]) {
this.files.setKey(deletedPath, undefined);
- // Adjust file count if it was a file
if (currentFiles[deletedPath]?.type === 'file') {
this.#size--;
}
}
- // Also remove any files/folders inside deleted folders
for (const [path, dirent] of Object.entries(currentFiles)) {
if (path.startsWith(deletedPath + '/')) {
this.files.setKey(path, undefined);
- // Adjust file count if it was a file
if (dirent?.type === 'file') {
this.#size--;
}
- // Remove from modified files tracking if present
if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
this.#modifiedFiles.delete(path);
}
@@ -225,7 +219,6 @@ export class FilesStore {
continue;
}
- // Also skip if this is a file/folder inside a deleted folder
let isInDeletedFolder = false;
for (const deletedPath of this.#deletedPaths) {
@@ -280,11 +273,9 @@ export class FilesStore {
}
}
- // Check if we already have this file with content
const existingFile = this.files.get()[sanitizedPath];
if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) {
- // Keep existing binary content if new content is empty
content = existingFile.content;
}
@@ -327,34 +318,27 @@ export class FilesStore {
throw new Error(`EINVAL: invalid file path, create '${relativePath}'`);
}
- // Create parent directories if they don't exist
const dirPath = path.dirname(relativePath);
if (dirPath !== '.') {
await webcontainer.fs.mkdir(dirPath, { recursive: true });
}
- // Detect binary content
const isBinary = content instanceof Uint8Array;
if (isBinary) {
await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
- // Store Base64 encoded data instead of an empty string
const base64Content = Buffer.from(content).toString('base64');
this.files.setKey(filePath, { type: 'file', content: base64Content, isBinary: true });
- // Store the base64 content as the original content for tracking modifications
this.#modifiedFiles.set(filePath, base64Content);
} else {
- // Ensure we write at least a space character for empty files to ensure they're tracked
const contentToWrite = (content as string).length === 0 ? ' ' : content;
await webcontainer.fs.writeFile(relativePath, contentToWrite);
- // But store the actual empty string in our file map if that's what was requested
this.files.setKey(filePath, { type: 'file', content: content as string, isBinary: false });
- // Store the text content as the original content
this.#modifiedFiles.set(filePath, content as string);
}
@@ -379,7 +363,6 @@ export class FilesStore {
await webcontainer.fs.mkdir(relativePath, { recursive: true });
- // Immediately update the folder in our store without waiting for the watcher
this.files.setKey(folderPath, { type: 'folder' });
logger.info(`Folder created: ${folderPath}`);
@@ -403,19 +386,15 @@ export class FilesStore {
await webcontainer.fs.rm(relativePath);
- // Add to deleted paths set
this.#deletedPaths.add(filePath);
- // Immediately update our store without waiting for the watcher
this.files.setKey(filePath, undefined);
this.#size--;
- // Remove from modified files tracking if present
if (this.#modifiedFiles.has(filePath)) {
this.#modifiedFiles.delete(filePath);
}
- // Persist the deleted paths to localStorage for extra durability
this.#persistDeletedPaths();
logger.info(`File deleted: ${filePath}`);
@@ -439,35 +418,28 @@ export class FilesStore {
await webcontainer.fs.rm(relativePath, { recursive: true });
- // Add to deleted paths set
this.#deletedPaths.add(folderPath);
- // Immediately update our store without waiting for the watcher
this.files.setKey(folderPath, undefined);
- // Also remove all files and subfolders from our store
const allFiles = this.files.get();
for (const [path, dirent] of Object.entries(allFiles)) {
if (path.startsWith(folderPath + '/')) {
this.files.setKey(path, undefined);
- // Also add these paths to the deleted paths set
this.#deletedPaths.add(path);
- // Decrement file count for each file (not folder) removed
if (dirent?.type === 'file') {
this.#size--;
}
- // Remove from modified files tracking if present
if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
this.#modifiedFiles.delete(path);
}
}
}
- // Persist the deleted paths to localStorage for extra durability
this.#persistDeletedPaths();
logger.info(`Folder deleted: ${folderPath}`);
@@ -479,7 +451,7 @@ export class FilesStore {
}
}
- // Add a method to persist deleted paths to localStorage
+ // method to persist deleted paths to localStorage
#persistDeletedPaths() {
try {
if (typeof localStorage !== 'undefined') {
diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts
index 37a7a59d..870a9d6e 100644
--- a/app/lib/stores/workbench.ts
+++ b/app/lib/stores/workbench.ts
@@ -262,7 +262,6 @@ export class WorkbenchStore {
const success = await this.#filesStore.createFile(filePath, content);
if (success) {
- // If the file is created successfully, select it in the editor
this.setSelectedFile(filePath);
/*
@@ -294,15 +293,12 @@ export class WorkbenchStore {
async deleteFile(filePath: string) {
try {
- // Check if the file is currently open in the editor
const currentDocument = this.currentDocument.get();
const isCurrentFile = currentDocument?.filePath === filePath;
- // Delete the file
const success = await this.#filesStore.deleteFile(filePath);
if (success) {
- // Remove from unsaved files if present
const newUnsavedFiles = new Set(this.unsavedFiles.get());
if (newUnsavedFiles.has(filePath)) {
@@ -310,9 +306,7 @@ export class WorkbenchStore {
this.unsavedFiles.set(newUnsavedFiles);
}
- // If this was the current file, select another file
if (isCurrentFile) {
- // Find another file to select
const files = this.files.get();
let nextFile: string | undefined = undefined;
@@ -336,15 +330,12 @@ export class WorkbenchStore {
async deleteFolder(folderPath: string) {
try {
- // Check if any file in this folder is currently open
const currentDocument = this.currentDocument.get();
const isInCurrentFolder = currentDocument?.filePath?.startsWith(folderPath + '/');
- // Delete the folder
const success = await this.#filesStore.deleteFolder(folderPath);
if (success) {
- // Remove any files in this folder from unsaved files
const unsavedFiles = this.unsavedFiles.get();
const newUnsavedFiles = new Set();
@@ -358,9 +349,7 @@ export class WorkbenchStore {
this.unsavedFiles.set(newUnsavedFiles);
}
- // If current file was in this folder, select another file
if (isInCurrentFolder) {
- // Find another file to select
const files = this.files.get();
let nextFile: string | undefined = undefined;
From 7107163f3103eddcc209f6ac552f9fd85d54409c Mon Sep 17 00:00:00 2001
From: Leex
Date: Tue, 18 Mar 2025 10:54:39 +0100
Subject: [PATCH 026/115] Update README.md
---
README.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/README.md b/README.md
index c14974b2..15a45169 100644
--- a/README.md
+++ b/README.md
@@ -360,3 +360,9 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
## FAQ
For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).
+
+
+# Licensing
+**Who needs a commercial WebContainer API license?**
+
+Licensing is required for production usage of the API in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
From 64afda10781d96749cbae44b9b7e3e62d8e02b53 Mon Sep 17 00:00:00 2001
From: Leex
Date: Tue, 18 Mar 2025 11:40:49 +0100
Subject: [PATCH 027/115] Update README.md
Co-authored-by: patak <583075+patak-dev@users.noreply.github.com>
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 15a45169..74f807b5 100644
--- a/README.md
+++ b/README.md
@@ -365,4 +365,4 @@ For answers to common questions, issues, and to see a list of recommended models
# Licensing
**Who needs a commercial WebContainer API license?**
-Licensing is required for production usage of the API in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
+bolt.diy source code is distributed as MIT, but it uses WebContainers API that [requires licensing](https://webcontainers.io/enterprise) for production usage in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
From 1ce6ad6b595fb2bcec9b68fa340c038337f6e5f7 Mon Sep 17 00:00:00 2001
From: Derek Wang
Date: Wed, 19 Mar 2025 11:52:06 -0700
Subject: [PATCH 028/115] feat: electron desktop app without express server
(#1136)
* feat: add electron app
* refactor: using different approach
* chore: update commit hash to 02621e3545511ca8bc0279b70f92083218548655
* fix: working dev but prod showing not found and lint fix
* fix: add icon
* fix: resolve server file load issue
* fix: eslint and prettier wip
* fix: only load server build once
* fix: forward request for other ports
* fix: use cloudflare {} to avoid crash
* fix: no need for appLogger
* fix: forward cookie
* fix: update script and update preload loading path
* chore: minor update for appId
* fix: store and load all cookies
* refactor: split main/index.ts
* refactor: group electron main files into two folders
* fix: update electron build configs
* fix: update auto update feat
* fix: vite-plugin-node-polyfills need to be in dependencies for dmg version to work
* ci: trigger build for electron branch
* ci: mark draft if it's from branch commit
* ci: add icons for windows and linux
* fix: update icons for windows
* fix: add author in package.json
* ci: use softprops/action-gh-release@v2
* fix: use path to join
* refactor: refactor path logic for working in both mac and windows
* fix: still need vite-plugin-node-polyfills dependencies
* fix: update vite-electron.config.ts
* ci: sign mac app
* refactor: assets folder
* ci: notarization
* ci: add NODE_OPTIONS
* ci: window only nsis dist
---------
Co-authored-by: github-actions[bot]
---
.github/workflows/electron.yml | 91 +
.../@settings/tabs/debug/DebugTab.tsx | 4 +-
assets/entitlements.mac.plist | 25 +
assets/icons/icon.icns | Bin 0 -> 167243 bytes
assets/icons/icon.ico | Bin 0 -> 171717 bytes
assets/icons/icon.png | Bin 0 -> 18915 bytes
electron-builder.yml | 65 +
electron-update.yml | 4 +
electron/main/index.ts | 201 +
electron/main/tsconfig.json | 30 +
electron/main/ui/menu.ts | 29 +
electron/main/ui/window.ts | 51 +
electron/main/utils/auto-update.ts | 110 +
electron/main/utils/constants.ts | 4 +
electron/main/utils/cookie.ts | 40 +
electron/main/utils/reload.ts | 35 +
electron/main/utils/serve.ts | 71 +
electron/main/utils/store.ts | 3 +
electron/main/utils/vite-server.ts | 44 +
electron/main/vite.config.ts | 44 +
electron/preload/index.ts | 22 +
electron/preload/tsconfig.json | 7 +
electron/preload/vite.config.ts | 31 +
eslint.config.mjs | 2 +-
notarize.cjs | 31 +
package.json | 38 +-
pnpm-lock.yaml | 8036 +++++++++++------
tsconfig.json | 4 +-
vite-electron.config.ts | 75 +
29 files changed, 6215 insertions(+), 2882 deletions(-)
create mode 100644 .github/workflows/electron.yml
create mode 100644 assets/entitlements.mac.plist
create mode 100644 assets/icons/icon.icns
create mode 100644 assets/icons/icon.ico
create mode 100644 assets/icons/icon.png
create mode 100644 electron-builder.yml
create mode 100644 electron-update.yml
create mode 100644 electron/main/index.ts
create mode 100644 electron/main/tsconfig.json
create mode 100644 electron/main/ui/menu.ts
create mode 100644 electron/main/ui/window.ts
create mode 100644 electron/main/utils/auto-update.ts
create mode 100644 electron/main/utils/constants.ts
create mode 100644 electron/main/utils/cookie.ts
create mode 100644 electron/main/utils/reload.ts
create mode 100644 electron/main/utils/serve.ts
create mode 100644 electron/main/utils/store.ts
create mode 100644 electron/main/utils/vite-server.ts
create mode 100644 electron/main/vite.config.ts
create mode 100644 electron/preload/index.ts
create mode 100644 electron/preload/tsconfig.json
create mode 100644 electron/preload/vite.config.ts
create mode 100644 notarize.cjs
create mode 100644 vite-electron.config.ts
diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml
new file mode 100644
index 00000000..29ff2a67
--- /dev/null
+++ b/.github/workflows/electron.yml
@@ -0,0 +1,91 @@
+name: Electron Build and Release
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - electron
+ tags:
+ - 'v*'
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ os: [macos-latest, ubuntu-latest, windows-latest]
+ node-version: [18.18.0]
+ fail-fast: false
+
+ steps:
+ - name: Check out Git repository
+ uses: actions/checkout@v4
+
+ - name: Install Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9.14.4
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - name: Setup pnpm cache
+ uses: actions/cache@v3
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install
+
+ # Install Linux dependencies
+ - name: Install Linux dependencies
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y rpm
+
+ # Build
+ - name: Build Electron app
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CSC_LINK: ${{ secrets.CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ NODE_OPTIONS: "--max_old_space_size=4096"
+ run: |
+ if [ "$RUNNER_OS" == "Windows" ]; then
+ pnpm run electron:build:win
+ elif [ "$RUNNER_OS" == "macOS" ]; then
+ pnpm run electron:build:mac
+ else
+ pnpm run electron:build:linux
+ fi
+ shell: bash
+
+ # Create Release
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: ${{ github.ref_type == 'branch' }}
+ files: |
+ dist/*.exe
+ dist/*.dmg
+ dist/*.deb
+ dist/*.AppImage
+ dist/*.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/app/components/@settings/tabs/debug/DebugTab.tsx b/app/components/@settings/tabs/debug/DebugTab.tsx
index e31ae473..25a86623 100644
--- a/app/components/@settings/tabs/debug/DebugTab.tsx
+++ b/app/components/@settings/tabs/debug/DebugTab.tsx
@@ -1353,7 +1353,9 @@ export default function DebugTab() {
- DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
+ DOM Ready: {systemInfo
+ ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)
+ : '-'}s
diff --git a/assets/entitlements.mac.plist b/assets/entitlements.mac.plist
new file mode 100644
index 00000000..0b2a95cb
--- /dev/null
+++ b/assets/entitlements.mac.plist
@@ -0,0 +1,25 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+
+ com.apple.security.network.client
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/icons/icon.icns b/assets/icons/icon.icns
new file mode 100644
index 0000000000000000000000000000000000000000..b3fbe2b676eb0da65e3789ddcc5c090fdafabb5a
GIT binary patch
literal 167243
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
dPNTxxg1PJWHF;)k$#^zy)L#m*Z4OQnUhTr
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