From 8c83c3c9aaaa5299bf04be5a34fbdf7359c31ac9 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Mon, 3 Mar 2025 12:16:13 +0000 Subject: [PATCH 01/12] 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 eed791e..ff4f531 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)}> +
+
+ New File +
+ + setIsCreatingFolder(true)}> +
+
+ New Folder +
+ + + + 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 16:14:31 +0000 Subject: [PATCH 02/12] 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 ff4f531..d108736 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)}> +
+
+ Rename +
+ + +
+
+ Delete +
+ 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 f02e10c9ace54c912eb4f5405ef1e02b8fa06f83 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Mon, 10 Mar 2025 11:12:25 +0000 Subject: [PATCH 03/12] 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 d108736..cb12dd9 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)}> -
-
- Rename -
- - -
-
- Delete -
- 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 60904a6..8227f94 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 2d5bbc0..37a7a59 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 04/12] 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 cb12dd9..197fa4f 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 8227f94..2ec73a0 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 37a7a59..870a9d6 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 02974089def78c33edf82268b3823de7fef666c3 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Wed, 19 Mar 2025 23:11:31 +0000 Subject: [PATCH 05/12] feat: integrate Supabase for database operations and migrations Add support for Supabase database operations, including migrations and queries. Implement new Supabase-related types, actions, and components to handle database interactions. Enhance the prompt system to include Supabase-specific instructions and constraints. Ensure data integrity and security by enforcing row-level security and proper migration practices. --- app/components/chat/Artifact.tsx | 6 +- app/components/chat/BaseChat.tsx | 19 +- app/components/chat/Chat.client.tsx | 12 + app/components/chat/SupabaseAlert.tsx | 199 +++++++++++++ app/components/chat/SupabaseConnection.tsx | 324 +++++++++++++++++++++ app/lib/.server/llm/stream-text.ts | 11 +- app/lib/common/prompt-library.ts | 6 +- app/lib/common/prompts/prompts.ts | 194 +++++++++++- app/lib/hooks/useSupabaseConnection.ts | 111 +++++++ app/lib/runtime/action-runner.ts | 66 ++++- app/lib/runtime/message-parser.ts | 24 +- app/lib/stores/supabase.ts | 130 +++++++++ app/lib/stores/workbench.ts | 20 +- app/routes/api.chat.ts | 7 +- app/routes/api.supabase.query.ts | 92 ++++++ app/routes/api.supabase.ts | 67 +++++ app/types/actions.ts | 19 +- app/types/supabase.ts | 21 ++ 18 files changed, 1316 insertions(+), 12 deletions(-) create mode 100644 app/components/chat/SupabaseAlert.tsx create mode 100644 app/components/chat/SupabaseConnection.tsx create mode 100644 app/lib/hooks/useSupabaseConnection.ts create mode 100644 app/lib/stores/supabase.ts create mode 100644 app/routes/api.supabase.query.ts create mode 100644 app/routes/api.supabase.ts create mode 100644 app/types/supabase.ts diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 5f0c991..d164689 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -35,7 +35,11 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { const actions = useStore( computed(artifact.runner.actions, (actions) => { - return Object.values(actions); + // Filter out Supabase actions except for migrations + return Object.values(actions).filter((action) => { + // Exclude actions with type 'supabase' or actions that contain 'supabase' in their content + return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase')); + }); }), ); diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index b871220..168fbed 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -29,13 +29,15 @@ import type { ProviderInfo } from '~/types/model'; import { ScreenshotStateManager } from './ScreenshotStateManager'; import { toast } from 'react-toastify'; import StarterTemplates from './StarterTemplates'; -import type { ActionAlert } from '~/types/actions'; +import type { ActionAlert, SupabaseAlert } from '~/types/actions'; import ChatAlert from './ChatAlert'; import type { ModelInfo } from '~/lib/modules/llm/types'; import ProgressCompilation from './ProgressCompilation'; import type { ProgressAnnotation } from '~/types/context'; import type { ActionRunner } from '~/lib/runtime/action-runner'; import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; +import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert'; +import { SupabaseConnection } from './SupabaseConnection'; const TEXTAREA_MIN_HEIGHT = 76; @@ -69,6 +71,8 @@ interface BaseChatProps { setImageDataList?: (dataList: string[]) => void; actionAlert?: ActionAlert; clearAlert?: () => void; + supabaseAlert?: SupabaseAlert; + clearSupabaseAlert?: () => void; data?: JSONValue[] | undefined; actionRunner?: ActionRunner; } @@ -105,6 +109,8 @@ export const BaseChat = React.forwardRef( messages, actionAlert, clearAlert, + supabaseAlert, + clearSupabaseAlert, data, actionRunner, }, @@ -343,6 +349,16 @@ export const BaseChat = React.forwardRef( ) : null; }} + {supabaseAlert && ( + clearSupabaseAlert?.()} + postMessage={(message) => { + sendMessage?.({} as any, message); + clearSupabaseAlert?.(); + }} + /> + )}
( a new line
) : null} +
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 8ac5d28..af5f2f4 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -26,6 +26,7 @@ import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTempla import { logStore } from '~/lib/stores/logs'; import { streamingState } from '~/lib/stores/streaming'; import { filesToArtifacts } from '~/utils/fileUtils'; +import { supabaseConnection } from '~/lib/stores/supabase'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -123,6 +124,11 @@ export const ChatImpl = memo( const [fakeLoading, setFakeLoading] = useState(false); const files = useStore(workbenchStore.files); const actionAlert = useStore(workbenchStore.alert); + const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection + const selectedProject = supabaseConn.stats?.projects?.find( + (project) => project.id === supabaseConn.selectedProjectId, + ); + const supabaseAlert = useStore(workbenchStore.supabaseAlert); const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); const [model, setModel] = useState(() => { @@ -160,6 +166,10 @@ export const ChatImpl = memo( files, promptId, contextOptimization: contextOptimizationEnabled, + supabase: { + isConnected: supabaseConn.isConnected, + hasSelectedProject: !!selectedProject, + }, }, sendExtraMessageFields: true, onError: (e) => { @@ -544,6 +554,8 @@ export const ChatImpl = memo( setImageDataList={setImageDataList} actionAlert={actionAlert} clearAlert={() => workbenchStore.clearAlert()} + supabaseAlert={supabaseAlert} + clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()} data={chatData} /> ); diff --git a/app/components/chat/SupabaseAlert.tsx b/app/components/chat/SupabaseAlert.tsx new file mode 100644 index 0000000..d86e5e5 --- /dev/null +++ b/app/components/chat/SupabaseAlert.tsx @@ -0,0 +1,199 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import type { SupabaseAlert } from '~/types/actions'; +import { classNames } from '~/utils/classNames'; +import { supabaseConnection } from '~/lib/stores/supabase'; +import { useStore } from '@nanostores/react'; +import { useState } from 'react'; + +interface Props { + alert: SupabaseAlert; + clearAlert: () => void; + postMessage: (message: string) => void; +} + +export function SupabaseChatAlert({ alert, clearAlert, postMessage }: Props) { + const { content } = alert; + const connection = useStore(supabaseConnection); + const [isExecuting, setIsExecuting] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); + + // Determine connection state + const isConnected = !!(connection.token && connection.selectedProjectId); + + // Set title and description based on connection state + const title = isConnected ? 'Supabase Query' : 'Supabase Connection Required'; + const description = isConnected ? 'Execute database query' : 'Supabase connection required'; + const message = isConnected + ? 'Please review the proposed changes and apply them to your database.' + : 'Please connect to Supabase to continue with this operation.'; + + const handleConnectClick = () => { + // Dispatch an event to open the Supabase connection dialog + document.dispatchEvent(new CustomEvent('open-supabase-connection')); + }; + + // Determine if we should show the Connect button or Apply Changes button + const showConnectButton = !isConnected; + + const executeSupabaseAction = async (sql: string) => { + if (!connection.token || !connection.selectedProjectId) { + console.error('No Supabase token or project selected'); + return; + } + + setIsExecuting(true); + + try { + const response = await fetch('/api/supabase/query', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${connection.token}`, + }, + body: JSON.stringify({ + projectId: connection.selectedProjectId, + query: sql, + }), + }); + + if (!response.ok) { + const errorData = (await response.json()) as any; + throw new Error(`Supabase query failed: ${errorData.error?.message || response.statusText}`); + } + + const result = await response.json(); + console.log('Supabase query executed successfully:', result); + clearAlert(); + } catch (error) { + console.error('Failed to execute Supabase action:', error); + postMessage( + `*Error executing Supabase query please fix and return the query again*\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\`\n`, + ); + } finally { + setIsExecuting(false); + } + }; + + const cleanSqlContent = (content: string) => { + if (!content) { + return ''; + } + + let cleaned = content.replace(/\/\*[\s\S]*?\*\//g, ''); + + cleaned = cleaned.replace(/(--).*$/gm, '').replace(/(#).*$/gm, ''); + + const statements = cleaned + .split(';') + .map((stmt) => stmt.trim()) + .filter((stmt) => stmt.length > 0) + .join(';\n\n'); + + return statements; + }; + + return ( + + + {/* Header */} +
+
+ +

{title}

+
+
+ + {/* SQL Content */} +
+ {!isConnected ? ( +
+ + You must first connect to Supabase and select a project. + +
+ ) : ( + <> +
setIsCollapsed(!isCollapsed)} + > +
+ + {description || 'Create table and setup auth'} + +
+
+ + {!isCollapsed && content && ( +
+
{cleanSqlContent(content)}
+
+ )} + + )} +
+ + {/* Message and Actions */} +
+

{message}

+ +
+ {showConnectButton ? ( + + ) : ( + + )} + +
+
+
+
+ ); +} diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx new file mode 100644 index 0000000..2b03f44 --- /dev/null +++ b/app/components/chat/SupabaseConnection.tsx @@ -0,0 +1,324 @@ +import { useEffect } from 'react'; +import { useSupabaseConnection } from '~/lib/hooks/useSupabaseConnection'; +import { classNames } from '~/utils/classNames'; +import { useStore } from '@nanostores/react'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import { fetchSupabaseStats } from '~/lib/stores/supabase'; +import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog'; + +export function SupabaseConnection() { + const { + connection: supabaseConn, + connecting, + fetchingStats, + isProjectsExpanded, + setIsProjectsExpanded, + isDropdownOpen: isDialogOpen, + setIsDropdownOpen: setIsDialogOpen, + handleConnect, + handleDisconnect, + selectProject, + handleCreateProject, + updateToken, + isConnected, + } = useSupabaseConnection(); + + const currentChatId = useStore(chatId); + + // Add event listener for opening the connection dialog + useEffect(() => { + const handleOpenConnectionDialog = () => { + setIsDialogOpen(true); + }; + + document.addEventListener('open-supabase-connection', handleOpenConnectionDialog); + + return () => { + document.removeEventListener('open-supabase-connection', handleOpenConnectionDialog); + }; + }, [setIsDialogOpen]); + + // Load the selected project from localStorage when connected or chat changes + useEffect(() => { + if (isConnected && currentChatId) { + const savedProjectId = localStorage.getItem(`supabase-project-${currentChatId}`); + + /* + * If there's no saved project for this chat but there is a global selected project, + * use the global one instead of clearing it + */ + if (!savedProjectId && supabaseConn.selectedProjectId) { + // Save the current global project to this chat + localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId); + } else if (savedProjectId && savedProjectId !== supabaseConn.selectedProjectId) { + selectProject(savedProjectId); + } + } + }, [isConnected, currentChatId]); + + useEffect(() => { + if (currentChatId && supabaseConn.selectedProjectId) { + localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId); + } else if (currentChatId && !supabaseConn.selectedProjectId) { + localStorage.removeItem(`supabase-project-${currentChatId}`); + } + }, [currentChatId, supabaseConn.selectedProjectId]); + + useEffect(() => { + if (isConnected && supabaseConn.token) { + fetchSupabaseStats(supabaseConn.token).catch(console.error); + } + }, [isConnected, supabaseConn.token]); + + return ( +
+
+ +
+ + + {isDialogOpen && ( + + {!isConnected ? ( +
+ + + Connect to Supabase + + +
+ + updateToken(e.target.value)} + disabled={connecting} + placeholder="Enter your Supabase access token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-[#3ECF8E]', + 'disabled:opacity-50', + )} + /> + + +
+ + Cancel + + +
+
+ ) : ( +
+
+ + + Supabase Connection + +
+ +
+
+

{supabaseConn.user?.email}

+

Role: {supabaseConn.user?.role}

+
+
+ + {fetchingStats ? ( +
+
+ Fetching projects... +
+ ) : ( +
+
+ + +
+ + {isProjectsExpanded && ( + <> + {!supabaseConn.selectedProjectId && ( +
+ Select a project or create a new one for this chat +
+ )} + + {supabaseConn.stats?.projects?.length ? ( +
+ {supabaseConn.stats.projects.map((project) => ( +
+
+
+
+
+ {project.name} +
+
+ {project.region} +
+
+ +
+
+ ))} +
+ ) : ( +
+
+ No projects found +
+ )} + + )} +
+ )} + +
+ + Close + + +
+ Disconnect + +
+
+ )} +
+ )} +
+
+ ); +} + +interface ButtonProps { + active?: boolean; + disabled?: boolean; + children?: any; + onClick?: VoidFunction; + className?: string; +} + +function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) { + return ( + + ); +} diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 29579c9..1958dfa 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -12,7 +12,12 @@ import { getFilePaths } from './select-context'; export type Messages = Message[]; -export type StreamingOptions = Omit[0], 'model'>; +export interface StreamingOptions extends Omit[0], 'model'> { + supabaseConnection?: { + isConnected: boolean; + hasSelectedProject: boolean; + }; +} const logger = createScopedLogger('stream-text'); @@ -97,6 +102,10 @@ export async function streamText(props: { cwd: WORK_DIR, allowedHtmlElements: allowedHTMLElements, modificationTagName: MODIFICATIONS_TAG_NAME, + supabase: { + isConnected: options?.supabaseConnection?.isConnected || false, + hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false, + }, }) ?? getSystemPrompt(); if (files && contextFiles && contextOptimization) { diff --git a/app/lib/common/prompt-library.ts b/app/lib/common/prompt-library.ts index 7513e81..084925b 100644 --- a/app/lib/common/prompt-library.ts +++ b/app/lib/common/prompt-library.ts @@ -5,6 +5,10 @@ export interface PromptOptions { cwd: string; allowedHtmlElements: string[]; modificationTagName: string; + supabase?: { + isConnected: boolean; + hasSelectedProject: boolean; + }; } export class PromptLibrary { @@ -19,7 +23,7 @@ export class PromptLibrary { default: { label: 'Default Prompt', description: 'This is the battle tested default system Prompt', - get: (options) => getSystemPrompt(options.cwd), + get: (options) => getSystemPrompt(options.cwd, options.supabase), }, optimized: { label: 'Optimized Prompt (experimental)', diff --git a/app/lib/common/prompts/prompts.ts b/app/lib/common/prompts/prompts.ts index 1e9134a..149f1b5 100644 --- a/app/lib/common/prompts/prompts.ts +++ b/app/lib/common/prompts/prompts.ts @@ -2,7 +2,10 @@ import { WORK_DIR } from '~/utils/constants'; import { allowedHTMLElements } from '~/utils/markdown'; import { stripIndents } from '~/utils/stripIndent'; -export const getSystemPrompt = (cwd: string = WORK_DIR) => ` +export const getSystemPrompt = ( + cwd: string = WORK_DIR, + supabase?: { isConnected: boolean; hasSelectedProject: boolean }, +) => ` You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. @@ -59,6 +62,195 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w - curl, head, sort, tail, clear, which, export, chmod, scho, hostname, kill, ln, xxd, alias, false, getconf, true, loadenv, wasm, xdg-open, command, exit, source + + The following instructions guide how you should handle database operations in projects. + + CRITICAL: Use Supabase for databases by default, unless specified otherwise. + + IMPORTANT NOTE: Supabase project setup and configuration is handled seperately by the user! ${ + supabase + ? !supabase.isConnected + ? 'You are not connected to Supabase. Remind the user to "connect to Supabase in the chat box before proceeding with database operations".' + : !supabase.hasSelectedProject + ? 'Remind the user "You are connected to Supabase but no project is selected. Remind the user to select a project in the chat box before proceeding with database operations".' + : '' + : '' + } + The environment variables for Supabase connection will be available in the project's \`.env\` file. + IMPORTANT: Create a .env file if it doesnt exist. + NEVER modify any Supabase configuration or \`.env\` files. + + CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS: + - DATA INTEGRITY IS THE HIGHEST PRIORITY, users must NEVER lose their data + - FORBIDDEN: Any destructive operations like \`DROP\` or \`DELETE\` that could result in data loss (e.g., when dropping columns, changing column types, renaming tables, etc.) + - FORBIDDEN: Any transaction control statements (e.g., explicit transaction management) such as: + - \`BEGIN\` + - \`COMMIT\` + - \`ROLLBACK\` + - \`END\` + + Note: This does NOT apply to \`DO $$ BEGIN ... END $$\` blocks, which are PL/pgSQL anonymous blocks! + + Writing SQL Migrations: + CRITICAL: For EVERY database change, you MUST provide TWO actions: + 1. Migration File Creation: + + /* SQL migration content */ + + + 2. Immediate Query Execution: + + /* Same SQL content as migration */ + + + Example: + + + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL + ); + + + + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL + ); + + + + - IMPORTANT: The SQL content must be identical in both actions to ensure consistency between the migration file and the executed query. + - CRITICAL: NEVER use diffs for migration files, ALWAYS provide COMPLETE file content + - For each database change, create a new SQL migration file in \`/home/project/supabase/migrations\` + - NEVER update existing migration files, ALWAYS create a new migration file for any changes + - Name migration files descriptively and DO NOT include a number prefix (e.g., \`create_users.sql\`, \`add_posts_table.sql\`). + + - DO NOT worry about ordering as the files will be renamed correctly! + + - ALWAYS enable row level security (RLS) for new tables: + + + alter table users enable row level security; + + + - Add appropriate RLS policies for CRUD operations for each table + + - Use default values for columns: + - Set default values for columns where appropriate to ensure data consistency and reduce null handling + - Common default values include: + - Booleans: \`DEFAULT false\` or \`DEFAULT true\` + - Numbers: \`DEFAULT 0\` + - Strings: \`DEFAULT ''\` or meaningful defaults like \`'user'\` + - Dates/Timestamps: \`DEFAULT now()\` or \`DEFAULT CURRENT_TIMESTAMP\` + - Be cautious not to set default values that might mask problems; sometimes it's better to allow an error than to proceed with incorrect data + + - CRITICAL: Each migration file MUST follow these rules: + - ALWAYS Start with a markdown summary block (in a multi-line comment) that: + - Include a short, descriptive title (using a headline) that summarizes the changes (e.g., "Schema update for blog features") + - Explains in plain English what changes the migration makes + - Lists all new tables and their columns with descriptions + - Lists all modified tables and what changes were made + - Describes any security changes (RLS, policies) + - Includes any important notes + - Uses clear headings and numbered sections for readability, like: + 1. New Tables + 2. Security + 3. Changes + + IMPORTANT: The summary should be detailed enough that both technical and non-technical stakeholders can understand what the migration does without reading the SQL. + + - Include all necessary operations (e.g., table creation and updates, RLS, policies) + + Here is an example of a migration file: + + + /* + # Create users table + + 1. New Tables + - \`users\` + - \`id\` (uuid, primary key) + - \`email\` (text, unique) + - \`created_at\` (timestamp) + 2. Security + - Enable RLS on \`users\` table + - Add policy for authenticated users to read their own data + */ + + CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + created_at timestamptz DEFAULT now() + ); + + ALTER TABLE users ENABLE ROW LEVEL SECURITY; + + CREATE POLICY "Users can read own data" + ON users + FOR SELECT + TO authenticated + USING (auth.uid() = id); + + + - Ensure SQL statements are safe and robust: + - Use \`IF EXISTS\` or \`IF NOT EXISTS\` to prevent errors when creating or altering database objects. Here are examples: + + + CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + created_at timestamptz DEFAULT now() + ); + + + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'last_login' + ) THEN + ALTER TABLE users ADD COLUMN last_login timestamptz; + END IF; + END $$; + + + Client Setup: + - Use \`@supabase/supabase-js\` + - Create a singleton client instance + - Use the environment variables from the project's \`.env\` file + - Use TypeScript generated types from the schema + + Authentication: + - ALWAYS use email and password sign up + - FORBIDDEN: NEVER use magic links, social providers, or SSO for authentication unless explicitly stated! + - FORBIDDEN: NEVER create your own authentication system or authentication table, ALWAYS use Supabase's built-in authentication! + - Email confirmation is ALWAYS disabled unless explicitly stated! + + Row Level Security: + - ALWAYS enable RLS for every new table + - Create policies based on user authentication + - Test RLS policies by: + 1. Verifying authenticated users can only access their allowed data + 2. Confirming unauthenticated users cannot access protected data + 3. Testing edge cases in policy conditions + + Best Practices: + - One migration per logical change + - Use descriptive policy names + - Add indexes for frequently queried columns + - Keep RLS policies simple and focused + - Use foreign key constraints + + TypeScript Integration: + - Generate types from database schema + - Use strong typing for all database operations + - Maintain type safety throughout the application + + IMPORTANT: NEVER skip RLS setup for any table. Security is non-negotiable! + + Use 2 spaces for code indentation diff --git a/app/lib/hooks/useSupabaseConnection.ts b/app/lib/hooks/useSupabaseConnection.ts new file mode 100644 index 0000000..5bbe12e --- /dev/null +++ b/app/lib/hooks/useSupabaseConnection.ts @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { logStore } from '~/lib/stores/logs'; +import { supabaseConnection, isConnecting, isFetchingStats, updateSupabaseConnection } from '~/lib/stores/supabase'; + +export function useSupabaseConnection() { + const connection = useStore(supabaseConnection); + const connecting = useStore(isConnecting); + const fetchingStats = useStore(isFetchingStats); + const [isProjectsExpanded, setIsProjectsExpanded] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + useEffect(() => { + const savedConnection = localStorage.getItem('supabase_connection'); + + if (savedConnection) { + const parsed = JSON.parse(savedConnection); + updateSupabaseConnection(parsed); + } + }, []); + + const handleConnect = async () => { + isConnecting.set(true); + + try { + const cleanToken = connection.token.trim(); + + const response = await fetch('/api/supabase', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token: cleanToken, + }), + }); + + const data = (await response.json()) as any; + + if (!response.ok) { + throw new Error(data.error || 'Failed to connect'); + } + + updateSupabaseConnection({ + user: data.user, + token: connection.token, + stats: data.stats, + }); + + toast.success('Successfully connected to Supabase'); + + // Keep the dialog open and expand the projects section + setIsProjectsExpanded(true); + + return true; + } catch (error) { + console.error('Connection error:', error); + logStore.logError('Failed to authenticate with Supabase', { error }); + toast.error(error instanceof Error ? error.message : 'Failed to connect to Supabase'); + updateSupabaseConnection({ user: null, token: '' }); + + return false; + } finally { + isConnecting.set(false); + } + }; + + const handleDisconnect = () => { + updateSupabaseConnection({ user: null, token: '' }); + toast.success('Disconnected from Supabase'); + setIsDropdownOpen(false); + }; + + const selectProject = (projectId: string) => { + const currentState = supabaseConnection.get(); + let projectData = undefined; + + if (projectId && currentState.stats?.projects) { + projectData = currentState.stats.projects.find((project) => project.id === projectId); + } + + updateSupabaseConnection({ + selectedProjectId: projectId, + project: projectData, + }); + + toast.success('Project selected successfully'); + setIsDropdownOpen(false); + }; + + const handleCreateProject = async () => { + window.open('https://app.supabase.com/new/new-project', '_blank'); + }; + + return { + connection, + connecting, + fetchingStats, + isProjectsExpanded, + setIsProjectsExpanded, + isDropdownOpen, + setIsDropdownOpen, + handleConnect, + handleDisconnect, + selectProject, + handleCreateProject, + updateToken: (token: string) => updateSupabaseConnection({ ...connection, token }), + isConnected: !!(connection.user && connection.token), + }; +} diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 013a9a7..a04a3de 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -1,7 +1,7 @@ import type { WebContainer } from '@webcontainer/api'; import { path as nodePath } from '~/utils/path'; import { atom, map, type MapStore } from 'nanostores'; -import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions'; +import type { ActionAlert, BoltAction, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; @@ -70,16 +70,19 @@ export class ActionRunner { runnerId = atom(`${Date.now()}`); actions: ActionsMap = map({}); onAlert?: (alert: ActionAlert) => void; + onSupabaseAlert?: (alert: SupabaseAlert) => void; buildOutput?: { path: string; exitCode: number; output: string }; constructor( webcontainerPromise: Promise, getShellTerminal: () => BoltShell, onAlert?: (alert: ActionAlert) => void, + onSupabaseAlert?: (alert: SupabaseAlert) => void, ) { this.#webcontainer = webcontainerPromise; this.#shellTerminal = getShellTerminal; this.onAlert = onAlert; + this.onSupabaseAlert = onSupabaseAlert; } addAction(data: ActionCallbackData) { @@ -157,6 +160,21 @@ export class ActionRunner { await this.#runFileAction(action); break; } + case 'supabase': { + try { + await this.handleSupabaseAction(action as SupabaseAction); + } catch (error: any) { + // Update action status + this.#updateAction(actionId, { + status: 'failed', + error: error instanceof Error ? error.message : 'Supabase action failed', + }); + + // Return early without re-throwing + return; + } + break; + } case 'build': { const buildOutput = await this.#runBuildAction(action); @@ -377,4 +395,50 @@ export class ActionRunner { output, }; } + async handleSupabaseAction(action: SupabaseAction) { + const { operation, content, filePath } = action; + logger.debug('[Supabase Action]:', { operation, filePath, content }); + + switch (operation) { + case 'migration': + if (!filePath) { + throw new Error('Migration requires a filePath'); + } + + // Show alert for migration action + this.onSupabaseAlert?.({ + type: 'info', + title: 'Supabase Migration', + description: `Create migration file: ${filePath}`, + content, + source: 'supabase', + }); + + // Only create the migration file + await this.#runFileAction({ + type: 'file', + filePath, + content, + changeSource: 'supabase', + } as any); + return { success: true }; + + case 'query': { + // Always show the alert and let the SupabaseAlert component handle connection state + this.onSupabaseAlert?.({ + type: 'info', + title: 'Supabase Query', + description: 'Execute database query', + content, + source: 'supabase', + }); + + // The actual execution will be triggered from SupabaseChatAlert + return { pending: true }; + } + + default: + throw new Error(`Unknown operation: ${operation}`); + } + } } diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index 3b41b6d..1737548 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -1,4 +1,4 @@ -import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } from '~/types/actions'; +import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction, SupabaseAction } from '~/types/actions'; import type { BoltArtifactData } from '~/types/artifact'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; @@ -293,7 +293,27 @@ export class StreamingMessageParser { content: '', }; - if (actionType === 'file') { + if (actionType === 'supabase') { + const operation = this.#extractAttribute(actionTag, 'operation'); + + if (!operation || !['migration', 'query'].includes(operation)) { + logger.warn(`Invalid or missing operation for Supabase action: ${operation}`); + throw new Error(`Invalid Supabase operation: ${operation}`); + } + + (actionAttributes as SupabaseAction).operation = operation as 'migration' | 'query'; + + if (operation === 'migration') { + const filePath = this.#extractAttribute(actionTag, 'filePath'); + + if (!filePath) { + logger.warn('Migration requires a filePath'); + throw new Error('Migration requires a filePath'); + } + + (actionAttributes as SupabaseAction).filePath = filePath; + } + } else if (actionType === 'file') { const filePath = this.#extractAttribute(actionTag, 'filePath') as string; if (!filePath) { diff --git a/app/lib/stores/supabase.ts b/app/lib/stores/supabase.ts new file mode 100644 index 0000000..9f624e5 --- /dev/null +++ b/app/lib/stores/supabase.ts @@ -0,0 +1,130 @@ +import { atom } from 'nanostores'; +import type { SupabaseUser, SupabaseStats } from '~/types/supabase'; + +export interface SupabaseProject { + id: string; + name: string; + region: string; + organization_id: string; + status: string; + database?: { + host: string; + version: string; + postgres_engine: string; + release_channel: string; + }; + created_at: string; +} + +export interface SupabaseConnectionState { + user: SupabaseUser | null; + token: string; + stats?: SupabaseStats; + selectedProjectId?: string; + isConnected?: boolean; + project?: SupabaseProject; // Add the selected project data +} + +// Init from localStorage if available +const savedConnection = typeof localStorage !== 'undefined' ? localStorage.getItem('supabase_connection') : null; + +const initialState: SupabaseConnectionState = savedConnection + ? JSON.parse(savedConnection) + : { + user: null, + token: '', + stats: undefined, + selectedProjectId: undefined, + isConnected: false, + project: undefined, // Initialize as undefined + }; + +export const supabaseConnection = atom(initialState); + +// After init, fetch stats if we have a token +if (initialState.token && !initialState.stats) { + fetchSupabaseStats(initialState.token).catch(console.error); +} + +export const isConnecting = atom(false); +export const isFetchingStats = atom(false); + +export function updateSupabaseConnection(connection: Partial) { + const currentState = supabaseConnection.get(); + + // Set isConnected based on user presence AND token + if (connection.user !== undefined || connection.token !== undefined) { + const newUser = connection.user !== undefined ? connection.user : currentState.user; + const newToken = connection.token !== undefined ? connection.token : currentState.token; + connection.isConnected = !!(newUser && newToken); + } + + // Update the project data when selectedProjectId changes + if (connection.selectedProjectId !== undefined) { + if (connection.selectedProjectId && currentState.stats?.projects) { + const selectedProject = currentState.stats.projects.find( + (project) => project.id === connection.selectedProjectId, + ); + + if (selectedProject) { + connection.project = selectedProject; + } else { + // If project not found in stats but ID is provided, set a minimal project object + connection.project = { + id: connection.selectedProjectId, + name: `Project ${connection.selectedProjectId.substring(0, 8)}...`, + region: 'unknown', + organization_id: '', + status: 'active', + created_at: new Date().toISOString(), + }; + } + } else if (connection.selectedProjectId === '') { + // Clear the project when selectedProjectId is empty + connection.project = undefined; + } + } + + const newState = { ...currentState, ...connection }; + supabaseConnection.set(newState); + + /* + * Always save the connection state to localStorage to persist across chats + * Always save the connection state to localStorage to persist across chats + */ + if (connection.user || connection.token || connection.selectedProjectId !== undefined) { + localStorage.setItem('supabase_connection', JSON.stringify(newState)); + } else { + localStorage.removeItem('supabase_connection'); + } +} + +export async function fetchSupabaseStats(token: string) { + isFetchingStats.set(true); + + try { + const response = await fetch('https://api.supabase.com/v1/projects', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + + const projects = (await response.json()) as any; + + updateSupabaseConnection({ + stats: { + projects, + totalProjects: projects.length, + }, + }); + } catch (error) { + console.error('Failed to fetch Supabase stats:', error); + throw error; + } finally { + isFetchingStats.set(false); + } +} diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 870a9d6..051d45a 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -17,7 +17,7 @@ import { extractRelativePath } from '~/utils/diff'; import { description } from '~/lib/persistence'; import Cookies from 'js-cookie'; import { createSampler } from '~/utils/sampler'; -import type { ActionAlert } from '~/types/actions'; +import type { ActionAlert, SupabaseAlert } from '~/types/actions'; const { saveAs } = fileSaver; @@ -50,6 +50,8 @@ export class WorkbenchStore { unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); actionAlert: WritableAtom = import.meta.hot?.data.unsavedFiles ?? atom(undefined); + supabaseAlert: WritableAtom = + import.meta.hot?.data.unsavedFiles ?? atom(undefined); modifiedFiles = new Set(); artifactIdList: string[] = []; #globalExecutionQueue = Promise.resolve(); @@ -60,6 +62,7 @@ export class WorkbenchStore { import.meta.hot.data.showWorkbench = this.showWorkbench; import.meta.hot.data.currentView = this.currentView; import.meta.hot.data.actionAlert = this.actionAlert; + import.meta.hot.data.supabaseAlert = this.supabaseAlert; // Ensure binary files are properly preserved across hot reloads const filesMap = this.files.get(); @@ -114,6 +117,14 @@ export class WorkbenchStore { this.actionAlert.set(undefined); } + get SupabaseAlert() { + return this.supabaseAlert; + } + + clearSupabaseAlert() { + this.supabaseAlert.set(undefined); + } + toggleTerminal(value?: boolean) { this.#terminalStore.toggleTerminal(value); } @@ -405,6 +416,13 @@ export class WorkbenchStore { this.actionAlert.set(alert); }, + (alert) => { + if (this.#reloadedMessages.has(messageId)) { + return; + } + + this.supabaseAlert.set(alert); + }, ), }); } diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index bbecdae..6861d15 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -37,11 +37,15 @@ function parseCookies(cookieHeader: string): Record { } async function chatAction({ context, request }: ActionFunctionArgs) { - const { messages, files, promptId, contextOptimization } = await request.json<{ + const { messages, files, promptId, contextOptimization, supabase } = await request.json<{ messages: Messages; files: any; promptId?: string; contextOptimization: boolean; + supabase?: { + isConnected: boolean; + hasSelectedProject: boolean; + }; }>(); const cookieHeader = request.headers.get('Cookie'); @@ -181,6 +185,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) { // Stream the text const options: StreamingOptions = { + supabaseConnection: supabase, toolChoice: 'none', onFinish: async ({ text: content, finishReason, usage }) => { logger.debug('usage', JSON.stringify(usage)); diff --git a/app/routes/api.supabase.query.ts b/app/routes/api.supabase.query.ts new file mode 100644 index 0000000..0b81075 --- /dev/null +++ b/app/routes/api.supabase.query.ts @@ -0,0 +1,92 @@ +import { type ActionFunctionArgs } from '@remix-run/cloudflare'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('api.supabase.query'); + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + const authHeader = request.headers.get('Authorization'); + + if (!authHeader) { + return new Response('No authorization token provided', { status: 401 }); + } + + try { + const { projectId, query } = (await request.json()) as any; + logger.debug('Executing query:', { projectId, query }); + + const response = await fetch(`https://api.supabase.com/v1/projects/${projectId}/database/query`, { + method: 'POST', + headers: { + Authorization: authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorData; + + try { + errorData = JSON.parse(errorText); + } catch (e) { + console.log(e); + errorData = { message: errorText }; + } + + logger.error( + 'Supabase API error:', + JSON.stringify({ + status: response.status, + statusText: response.statusText, + error: errorData, + }), + ); + + return new Response( + JSON.stringify({ + error: { + status: response.status, + statusText: response.statusText, + message: errorData.message || errorData.error || errorText, + details: errorData, + }, + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } + + const result = await response.json(); + + return new Response(JSON.stringify(result), { + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + logger.error('Query execution error:', error); + return new Response( + JSON.stringify({ + error: { + message: error instanceof Error ? error.message : 'Query execution failed', + stack: error instanceof Error ? error.stack : undefined, + }, + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } +} diff --git a/app/routes/api.supabase.ts b/app/routes/api.supabase.ts new file mode 100644 index 0000000..56d9f81 --- /dev/null +++ b/app/routes/api.supabase.ts @@ -0,0 +1,67 @@ +import { json } from '@remix-run/node'; +import type { ActionFunction } from '@remix-run/node'; +import type { SupabaseProject } from '~/types/supabase'; + +export const action: ActionFunction = async ({ request }) => { + if (request.method !== 'POST') { + return json({ error: 'Method not allowed' }, { status: 405 }); + } + + // Inside the action function + try { + const { token } = (await request.json()) as any; + + const projectsResponse = await fetch('https://api.supabase.com/v1/projects', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!projectsResponse.ok) { + const errorText = await projectsResponse.text(); + console.error('Projects fetch failed:', errorText); + + return json({ error: 'Failed to fetch projects' }, { status: 401 }); + } + + const projects = (await projectsResponse.json()) as SupabaseProject[]; + + // Create a Map to store unique projects by ID + const uniqueProjectsMap = new Map(); + + // Only keep the latest version of each project + for (const project of projects) { + if (!uniqueProjectsMap.has(project.id)) { + uniqueProjectsMap.set(project.id, project); + } + } + + // Debug log to see unique projects + console.log( + 'Unique projects:', + Array.from(uniqueProjectsMap.values()).map((p) => ({ id: p.id, name: p.name })), + ); + + const uniqueProjects = Array.from(uniqueProjectsMap.values()); + + // Sort projects by creation date (newest first) + uniqueProjects.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + return json({ + user: { email: 'Connected', role: 'Admin' }, + stats: { + projects: uniqueProjects, + totalProjects: uniqueProjects.length, + }, + }); + } catch (error) { + console.error('Supabase API error:', error); + return json( + { + error: error instanceof Error ? error.message : 'Authentication failed', + }, + { status: 401 }, + ); + } +}; diff --git a/app/types/actions.ts b/app/types/actions.ts index 623c497..63b8426 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -1,6 +1,6 @@ import type { Change } from 'diff'; -export type ActionType = 'file' | 'shell'; +export type ActionType = 'file' | 'shell' | 'supabase'; export interface BaseAction { content: string; @@ -23,7 +23,14 @@ export interface BuildAction extends BaseAction { type: 'build'; } -export type BoltAction = FileAction | ShellAction | StartAction | BuildAction; +export interface SupabaseAction extends BaseAction { + type: 'supabase'; + operation: 'migration' | 'query'; + filePath?: string; + projectId?: string; +} + +export type BoltAction = FileAction | ShellAction | StartAction | BuildAction | SupabaseAction; export type BoltActionData = BoltAction | BaseAction; @@ -35,6 +42,14 @@ export interface ActionAlert { source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors } +export interface SupabaseAlert { + type: string; + title: string; + description: string; + content: string; + source?: 'supabase'; +} + export interface FileHistory { originalContent: string; lastModified: number; diff --git a/app/types/supabase.ts b/app/types/supabase.ts new file mode 100644 index 0000000..d16d50c --- /dev/null +++ b/app/types/supabase.ts @@ -0,0 +1,21 @@ +export interface SupabaseUser { + id: string; + email: string; + role: string; + created_at: string; + last_sign_in_at: string; +} + +export interface SupabaseProject { + id: string; + name: string; + organization_id: string; + region: string; + created_at: string; + status: string; +} + +export interface SupabaseStats { + projects: SupabaseProject[]; + totalProjects: number; +} From bc7e2c5821fd46dd0e477f61f02f002023808510 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 20 Mar 2025 11:17:27 +0000 Subject: [PATCH 06/12] feat(supabase): add credentials handling for Supabase API keys and URL This commit introduces the ability to fetch and store Supabase API keys and URL credentials when a project is selected. This enables the application to dynamically configure the Supabase connection environment variables, improving the integration with Supabase services. The changes include updates to the Supabase connection logic, new API endpoints, and modifications to the chat and prompt components to utilize the new credentials. --- app/components/chat/Chat.client.tsx | 4 + app/components/chat/SupabaseConnection.tsx | 9 ++- app/lib/.server/llm/stream-text.ts | 5 ++ app/lib/common/prompt-library.ts | 4 + app/lib/common/prompts/prompts.ts | 18 ++++- app/lib/hooks/useSupabaseConnection.ts | 44 ++++++++++- app/lib/stores/supabase.ts | 88 +++++++++++++++++----- app/routes/api.chat.ts | 5 +- app/routes/api.supabase.ts | 5 -- app/routes/api.supabase.variables.ts | 33 ++++++++ app/types/supabase.ts | 10 +++ 11 files changed, 192 insertions(+), 33 deletions(-) create mode 100644 app/routes/api.supabase.variables.ts diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index af5f2f4..4cf8c8c 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -169,6 +169,10 @@ export const ChatImpl = memo( supabase: { isConnected: supabaseConn.isConnected, hasSelectedProject: !!selectedProject, + credentials: { + supabaseUrl: supabaseConn?.credentials?.supabaseUrl, + anonKey: supabaseConn?.credentials?.anonKey, + }, }, }, sendExtraMessageFields: true, diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx index 2b03f44..6b2e34a 100644 --- a/app/components/chat/SupabaseConnection.tsx +++ b/app/components/chat/SupabaseConnection.tsx @@ -21,11 +21,11 @@ export function SupabaseConnection() { handleCreateProject, updateToken, isConnected, + fetchProjectApiKeys, } = useSupabaseConnection(); const currentChatId = useStore(chatId); - // Add event listener for opening the connection dialog useEffect(() => { const handleOpenConnectionDialog = () => { setIsDialogOpen(true); @@ -38,7 +38,6 @@ export function SupabaseConnection() { }; }, [setIsDialogOpen]); - // Load the selected project from localStorage when connected or chat changes useEffect(() => { if (isConnected && currentChatId) { const savedProjectId = localStorage.getItem(`supabase-project-${currentChatId}`); @@ -70,6 +69,12 @@ export function SupabaseConnection() { } }, [isConnected, supabaseConn.token]); + useEffect(() => { + if (isConnected && supabaseConn.selectedProjectId && supabaseConn.token) { + fetchProjectApiKeys(supabaseConn.selectedProjectId).catch(console.error); + } + }, [isConnected, supabaseConn.selectedProjectId, supabaseConn.token]); + return (
diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 1958dfa..7f5b485 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -16,6 +16,10 @@ export interface StreamingOptions extends Omit[0] supabaseConnection?: { isConnected: boolean; hasSelectedProject: boolean; + credentials?: { + anonKey?: string; + supabaseUrl?: string; + }; }; } @@ -105,6 +109,7 @@ export async function streamText(props: { supabase: { isConnected: options?.supabaseConnection?.isConnected || false, hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false, + credentials: options?.supabaseConnection?.credentials || undefined, }, }) ?? getSystemPrompt(); diff --git a/app/lib/common/prompt-library.ts b/app/lib/common/prompt-library.ts index 084925b..f4747d7 100644 --- a/app/lib/common/prompt-library.ts +++ b/app/lib/common/prompt-library.ts @@ -8,6 +8,10 @@ export interface PromptOptions { supabase?: { isConnected: boolean; hasSelectedProject: boolean; + credentials?: { + anonKey?: string; + supabaseUrl?: string; + }; }; } diff --git a/app/lib/common/prompts/prompts.ts b/app/lib/common/prompts/prompts.ts index 149f1b5..eb7ec1a 100644 --- a/app/lib/common/prompts/prompts.ts +++ b/app/lib/common/prompts/prompts.ts @@ -4,7 +4,11 @@ import { stripIndents } from '~/utils/stripIndent'; export const getSystemPrompt = ( cwd: string = WORK_DIR, - supabase?: { isConnected: boolean; hasSelectedProject: boolean }, + supabase?: { + isConnected: boolean; + hasSelectedProject: boolean; + credentials?: { anonKey?: string; supabaseUrl?: string }; + }, ) => ` You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. @@ -76,8 +80,16 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w : '' : '' } - The environment variables for Supabase connection will be available in the project's \`.env\` file. - IMPORTANT: Create a .env file if it doesnt exist. + IMPORTANT: Create a .env file if it doesnt exist and include the following variables: + ${ + supabase?.isConnected && + supabase?.hasSelectedProject && + supabase?.credentials?.supabaseUrl && + supabase?.credentials?.anonKey + ? `VITE_SUPABASE_URL=${supabase.credentials.supabaseUrl} + VITE_SUPABASE_ANON_KEY=${supabase.credentials.anonKey}` + : 'SUPABASE_URL=your_supabase_url\nSUPABASE_ANON_KEY=your_supabase_anon_key' + } NEVER modify any Supabase configuration or \`.env\` files. CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS: diff --git a/app/lib/hooks/useSupabaseConnection.ts b/app/lib/hooks/useSupabaseConnection.ts index 5bbe12e..8a2f811 100644 --- a/app/lib/hooks/useSupabaseConnection.ts +++ b/app/lib/hooks/useSupabaseConnection.ts @@ -2,21 +2,39 @@ import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useStore } from '@nanostores/react'; import { logStore } from '~/lib/stores/logs'; -import { supabaseConnection, isConnecting, isFetchingStats, updateSupabaseConnection } from '~/lib/stores/supabase'; +import { + supabaseConnection, + isConnecting, + isFetchingStats, + isFetchingApiKeys, + updateSupabaseConnection, + fetchProjectApiKeys, +} from '~/lib/stores/supabase'; export function useSupabaseConnection() { const connection = useStore(supabaseConnection); const connecting = useStore(isConnecting); const fetchingStats = useStore(isFetchingStats); + const fetchingApiKeys = useStore(isFetchingApiKeys); const [isProjectsExpanded, setIsProjectsExpanded] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); useEffect(() => { const savedConnection = localStorage.getItem('supabase_connection'); + const savedCredentials = localStorage.getItem('supabaseCredentials'); if (savedConnection) { const parsed = JSON.parse(savedConnection); + + if (savedCredentials && !parsed.credentials) { + parsed.credentials = JSON.parse(savedCredentials); + } + updateSupabaseConnection(parsed); + + if (parsed.token && parsed.selectedProjectId && !parsed.credentials) { + fetchProjectApiKeys(parsed.selectedProjectId, parsed.token).catch(console.error); + } } }, []); @@ -50,7 +68,6 @@ export function useSupabaseConnection() { toast.success('Successfully connected to Supabase'); - // Keep the dialog open and expand the projects section setIsProjectsExpanded(true); return true; @@ -72,7 +89,7 @@ export function useSupabaseConnection() { setIsDropdownOpen(false); }; - const selectProject = (projectId: string) => { + const selectProject = async (projectId: string) => { const currentState = supabaseConnection.get(); let projectData = undefined; @@ -85,7 +102,18 @@ export function useSupabaseConnection() { project: projectData, }); - toast.success('Project selected successfully'); + if (projectId && currentState.token) { + try { + await fetchProjectApiKeys(projectId, currentState.token); + toast.success('Project selected successfully'); + } catch (error) { + console.error('Failed to fetch API keys:', error); + toast.error('Selected project but failed to fetch API keys'); + } + } else { + toast.success('Project selected successfully'); + } + setIsDropdownOpen(false); }; @@ -97,6 +125,7 @@ export function useSupabaseConnection() { connection, connecting, fetchingStats, + fetchingApiKeys, isProjectsExpanded, setIsProjectsExpanded, isDropdownOpen, @@ -107,5 +136,12 @@ export function useSupabaseConnection() { handleCreateProject, updateToken: (token: string) => updateSupabaseConnection({ ...connection, token }), isConnected: !!(connection.user && connection.token), + fetchProjectApiKeys: (projectId: string) => { + if (connection.token) { + return fetchProjectApiKeys(projectId, connection.token); + } + + return Promise.reject(new Error('No token available')); + }, }; } diff --git a/app/lib/stores/supabase.ts b/app/lib/stores/supabase.ts index 9f624e5..3b8a581 100644 --- a/app/lib/stores/supabase.ts +++ b/app/lib/stores/supabase.ts @@ -1,5 +1,5 @@ import { atom } from 'nanostores'; -import type { SupabaseUser, SupabaseStats } from '~/types/supabase'; +import type { SupabaseUser, SupabaseStats, SupabaseApiKey, SupabaseCredentials } from '~/types/supabase'; export interface SupabaseProject { id: string; @@ -22,10 +22,10 @@ export interface SupabaseConnectionState { stats?: SupabaseStats; selectedProjectId?: string; isConnected?: boolean; - project?: SupabaseProject; // Add the selected project data + project?: SupabaseProject; + credentials?: SupabaseCredentials; } -// Init from localStorage if available const savedConnection = typeof localStorage !== 'undefined' ? localStorage.getItem('supabase_connection') : null; const initialState: SupabaseConnectionState = savedConnection @@ -36,30 +36,28 @@ const initialState: SupabaseConnectionState = savedConnection stats: undefined, selectedProjectId: undefined, isConnected: false, - project: undefined, // Initialize as undefined + project: undefined, }; export const supabaseConnection = atom(initialState); -// After init, fetch stats if we have a token if (initialState.token && !initialState.stats) { fetchSupabaseStats(initialState.token).catch(console.error); } export const isConnecting = atom(false); export const isFetchingStats = atom(false); +export const isFetchingApiKeys = atom(false); export function updateSupabaseConnection(connection: Partial) { const currentState = supabaseConnection.get(); - // Set isConnected based on user presence AND token if (connection.user !== undefined || connection.token !== undefined) { const newUser = connection.user !== undefined ? connection.user : currentState.user; const newToken = connection.token !== undefined ? connection.token : currentState.token; connection.isConnected = !!(newUser && newToken); } - // Update the project data when selectedProjectId changes if (connection.selectedProjectId !== undefined) { if (connection.selectedProjectId && currentState.stats?.projects) { const selectedProject = currentState.stats.projects.find( @@ -69,7 +67,6 @@ export function updateSupabaseConnection(connection: Partial key.name === 'anon' || key.name === 'public'); + + if (anonKey) { + const supabaseUrl = `https://${projectId}.supabase.co`; + + updateSupabaseConnection({ + credentials: { + anonKey: anonKey.api_key, + supabaseUrl, + }, + }); + + return { anonKey: anonKey.api_key, supabaseUrl }; + } + + return null; + } catch (error) { + console.error('Failed to fetch project API keys:', error); + throw error; + } finally { + isFetchingApiKeys.set(false); + } +} diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 6861d15..5917dfc 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -45,6 +45,10 @@ async function chatAction({ context, request }: ActionFunctionArgs) { supabase?: { isConnected: boolean; hasSelectedProject: boolean; + credentials?: { + anonKey?: string; + supabaseUrl?: string; + }; }; }>(); @@ -183,7 +187,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) { // logger.debug('Code Files Selected'); } - // Stream the text const options: StreamingOptions = { supabaseConnection: supabase, toolChoice: 'none', diff --git a/app/routes/api.supabase.ts b/app/routes/api.supabase.ts index 56d9f81..955ae44 100644 --- a/app/routes/api.supabase.ts +++ b/app/routes/api.supabase.ts @@ -7,7 +7,6 @@ export const action: ActionFunction = async ({ request }) => { return json({ error: 'Method not allowed' }, { status: 405 }); } - // Inside the action function try { const { token } = (await request.json()) as any; @@ -27,17 +26,14 @@ export const action: ActionFunction = async ({ request }) => { const projects = (await projectsResponse.json()) as SupabaseProject[]; - // Create a Map to store unique projects by ID const uniqueProjectsMap = new Map(); - // Only keep the latest version of each project for (const project of projects) { if (!uniqueProjectsMap.has(project.id)) { uniqueProjectsMap.set(project.id, project); } } - // Debug log to see unique projects console.log( 'Unique projects:', Array.from(uniqueProjectsMap.values()).map((p) => ({ id: p.id, name: p.name })), @@ -45,7 +41,6 @@ export const action: ActionFunction = async ({ request }) => { const uniqueProjects = Array.from(uniqueProjectsMap.values()); - // Sort projects by creation date (newest first) uniqueProjects.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); return json({ diff --git a/app/routes/api.supabase.variables.ts b/app/routes/api.supabase.variables.ts new file mode 100644 index 0000000..fd2d028 --- /dev/null +++ b/app/routes/api.supabase.variables.ts @@ -0,0 +1,33 @@ +import { json } from '@remix-run/node'; +import type { ActionFunctionArgs } from '@remix-run/node'; + +export async function action({ request }: ActionFunctionArgs) { + try { + // Add proper type assertion for the request body + const body = (await request.json()) as { projectId?: string; token?: string }; + const { projectId, token } = body; + + if (!projectId || !token) { + return json({ error: 'Project ID and token are required' }, { status: 400 }); + } + + const response = await fetch(`https://api.supabase.com/v1/projects/${projectId}/api-keys`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return json({ error: `Failed to fetch API keys: ${response.statusText}` }, { status: response.status }); + } + + const apiKeys = await response.json(); + + return json({ apiKeys }); + } catch (error) { + console.error('Error fetching project API keys:', error); + return json({ error: error instanceof Error ? error.message : 'Unknown error occurred' }, { status: 500 }); + } +} diff --git a/app/types/supabase.ts b/app/types/supabase.ts index d16d50c..f99bbaf 100644 --- a/app/types/supabase.ts +++ b/app/types/supabase.ts @@ -19,3 +19,13 @@ export interface SupabaseStats { projects: SupabaseProject[]; totalProjects: number; } + +export interface SupabaseApiKey { + name: string; + api_key: string; +} + +export interface SupabaseCredentials { + anonKey?: string; + supabaseUrl?: string; +} From c9c6f4e265d911697c680c78685e34c2cc221fdf Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 20 Mar 2025 11:21:18 +0000 Subject: [PATCH 07/12] fix: add Supabase database instructions to optimized prompts Include detailed guidelines for handling database operations using Supabase, covering setup, migrations, security, and best practices. This ensures consistent and secure database interactions in projects. --- app/lib/common/prompts/optimized.ts | 199 +++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/app/lib/common/prompts/optimized.ts b/app/lib/common/prompts/optimized.ts index 3387208..4de3232 100644 --- a/app/lib/common/prompts/optimized.ts +++ b/app/lib/common/prompts/optimized.ts @@ -1,7 +1,7 @@ import type { PromptOptions } from '~/lib/common/prompt-library'; export default (options: PromptOptions) => { - const { cwd, allowedHtmlElements } = options; + const { cwd, allowedHtmlElements, supabase } = options; return ` You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. @@ -18,6 +18,203 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w Available shell commands: cat, cp, ls, mkdir, mv, rm, rmdir, touch, hostname, ps, pwd, uptime, env, node, python3, code, jq, curl, head, sort, tail, clear, which, export, chmod, scho, kill, ln, xxd, alias, getconf, loadenv, wasm, xdg-open, command, exit, source + + The following instructions guide how you should handle database operations in projects. + + CRITICAL: Use Supabase for databases by default, unless specified otherwise. + + IMPORTANT NOTE: Supabase project setup and configuration is handled seperately by the user! ${ + supabase + ? !supabase.isConnected + ? 'You are not connected to Supabase. Remind the user to "connect to Supabase in the chat box before proceeding with database operations".' + : !supabase.hasSelectedProject + ? 'Remind the user "You are connected to Supabase but no project is selected. Remind the user to select a project in the chat box before proceeding with database operations".' + : '' + : '' + } + IMPORTANT: Create a .env file if it doesnt exist and include the following variables: + ${ + supabase?.isConnected && + supabase?.hasSelectedProject && + supabase?.credentials?.supabaseUrl && + supabase?.credentials?.anonKey + ? `VITE_SUPABASE_URL=${supabase.credentials.supabaseUrl} + VITE_SUPABASE_ANON_KEY=${supabase.credentials.anonKey}` + : 'SUPABASE_URL=your_supabase_url\nSUPABASE_ANON_KEY=your_supabase_anon_key' + } + NEVER modify any Supabase configuration or \`.env\` files. + + CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS: + - DATA INTEGRITY IS THE HIGHEST PRIORITY, users must NEVER lose their data + - FORBIDDEN: Any destructive operations like \`DROP\` or \`DELETE\` that could result in data loss (e.g., when dropping columns, changing column types, renaming tables, etc.) + - FORBIDDEN: Any transaction control statements (e.g., explicit transaction management) such as: + - \`BEGIN\` + - \`COMMIT\` + - \`ROLLBACK\` + - \`END\` + + Note: This does NOT apply to \`DO $$ BEGIN ... END $$\` blocks, which are PL/pgSQL anonymous blocks! + + Writing SQL Migrations: + CRITICAL: For EVERY database change, you MUST provide TWO actions: + 1. Migration File Creation: + + /* SQL migration content */ + + + 2. Immediate Query Execution: + + /* Same SQL content as migration */ + + + Example: + + + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL + ); + + + + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL + ); + + + + - IMPORTANT: The SQL content must be identical in both actions to ensure consistency between the migration file and the executed query. + - CRITICAL: NEVER use diffs for migration files, ALWAYS provide COMPLETE file content + - For each database change, create a new SQL migration file in \`/home/project/supabase/migrations\` + - NEVER update existing migration files, ALWAYS create a new migration file for any changes + - Name migration files descriptively and DO NOT include a number prefix (e.g., \`create_users.sql\`, \`add_posts_table.sql\`). + + - DO NOT worry about ordering as the files will be renamed correctly! + + - ALWAYS enable row level security (RLS) for new tables: + + + alter table users enable row level security; + + + - Add appropriate RLS policies for CRUD operations for each table + + - Use default values for columns: + - Set default values for columns where appropriate to ensure data consistency and reduce null handling + - Common default values include: + - Booleans: \`DEFAULT false\` or \`DEFAULT true\` + - Numbers: \`DEFAULT 0\` + - Strings: \`DEFAULT ''\` or meaningful defaults like \`'user'\` + - Dates/Timestamps: \`DEFAULT now()\` or \`DEFAULT CURRENT_TIMESTAMP\` + - Be cautious not to set default values that might mask problems; sometimes it's better to allow an error than to proceed with incorrect data + + - CRITICAL: Each migration file MUST follow these rules: + - ALWAYS Start with a markdown summary block (in a multi-line comment) that: + - Include a short, descriptive title (using a headline) that summarizes the changes (e.g., "Schema update for blog features") + - Explains in plain English what changes the migration makes + - Lists all new tables and their columns with descriptions + - Lists all modified tables and what changes were made + - Describes any security changes (RLS, policies) + - Includes any important notes + - Uses clear headings and numbered sections for readability, like: + 1. New Tables + 2. Security + 3. Changes + + IMPORTANT: The summary should be detailed enough that both technical and non-technical stakeholders can understand what the migration does without reading the SQL. + + - Include all necessary operations (e.g., table creation and updates, RLS, policies) + + Here is an example of a migration file: + + + /* + # Create users table + + 1. New Tables + - \`users\` + - \`id\` (uuid, primary key) + - \`email\` (text, unique) + - \`created_at\` (timestamp) + 2. Security + - Enable RLS on \`users\` table + - Add policy for authenticated users to read their own data + */ + + CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + created_at timestamptz DEFAULT now() + ); + + ALTER TABLE users ENABLE ROW LEVEL SECURITY; + + CREATE POLICY "Users can read own data" + ON users + FOR SELECT + TO authenticated + USING (auth.uid() = id); + + + - Ensure SQL statements are safe and robust: + - Use \`IF EXISTS\` or \`IF NOT EXISTS\` to prevent errors when creating or altering database objects. Here are examples: + + + CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + created_at timestamptz DEFAULT now() + ); + + + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'last_login' + ) THEN + ALTER TABLE users ADD COLUMN last_login timestamptz; + END IF; + END $$; + + + Client Setup: + - Use \`@supabase/supabase-js\` + - Create a singleton client instance + - Use the environment variables from the project's \`.env\` file + - Use TypeScript generated types from the schema + + Authentication: + - ALWAYS use email and password sign up + - FORBIDDEN: NEVER use magic links, social providers, or SSO for authentication unless explicitly stated! + - FORBIDDEN: NEVER create your own authentication system or authentication table, ALWAYS use Supabase's built-in authentication! + - Email confirmation is ALWAYS disabled unless explicitly stated! + + Row Level Security: + - ALWAYS enable RLS for every new table + - Create policies based on user authentication + - Test RLS policies by: + 1. Verifying authenticated users can only access their allowed data + 2. Confirming unauthenticated users cannot access protected data + 3. Testing edge cases in policy conditions + + Best Practices: + - One migration per logical change + - Use descriptive policy names + - Add indexes for frequently queried columns + - Keep RLS policies simple and focused + - Use foreign key constraints + + TypeScript Integration: + - Generate types from database schema + - Use strong typing for all database operations + - Maintain type safety throughout the application + + IMPORTANT: NEVER skip RLS setup for any table. Security is non-negotiable! + + Use 2 spaces for indentation From 6a79bc6e5bf312fb37367b3adb61132179ae0386 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 20 Mar 2025 12:32:10 +0000 Subject: [PATCH 08/12] fix: supabase button color to default --- app/components/chat/SupabaseConnection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx index 6b2e34a..7844fab 100644 --- a/app/components/chat/SupabaseConnection.tsx +++ b/app/components/chat/SupabaseConnection.tsx @@ -315,7 +315,7 @@ function Button({ active = false, disabled = false, children, onClick, className { 'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !active, - 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled, + 'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentAccent': active && !disabled, 'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed': disabled, }, From a109fc127f3829727045a7c6c74a4e97b9906cd2 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 20 Mar 2025 14:22:35 +0000 Subject: [PATCH 09/12] fix: ensure supabase credentials are persistent on reloads --- app/components/chat/SupabaseConnection.tsx | 4 ++-- app/lib/common/prompts/prompts.ts | 19 ++++++++----------- app/lib/stores/supabase.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx index 7844fab..a9261f4 100644 --- a/app/components/chat/SupabaseConnection.tsx +++ b/app/components/chat/SupabaseConnection.tsx @@ -70,10 +70,10 @@ export function SupabaseConnection() { }, [isConnected, supabaseConn.token]); useEffect(() => { - if (isConnected && supabaseConn.selectedProjectId && supabaseConn.token) { + if (isConnected && supabaseConn.selectedProjectId && supabaseConn.token && !supabaseConn.credentials) { fetchProjectApiKeys(supabaseConn.selectedProjectId).catch(console.error); } - }, [isConnected, supabaseConn.selectedProjectId, supabaseConn.token]); + }, [isConnected, supabaseConn.selectedProjectId, supabaseConn.token, supabaseConn.credentials]); return (
diff --git a/app/lib/common/prompts/prompts.ts b/app/lib/common/prompts/prompts.ts index eb7ec1a..7d24547 100644 --- a/app/lib/common/prompts/prompts.ts +++ b/app/lib/common/prompts/prompts.ts @@ -80,17 +80,14 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w : '' : '' } - IMPORTANT: Create a .env file if it doesnt exist and include the following variables: - ${ - supabase?.isConnected && - supabase?.hasSelectedProject && - supabase?.credentials?.supabaseUrl && - supabase?.credentials?.anonKey - ? `VITE_SUPABASE_URL=${supabase.credentials.supabaseUrl} - VITE_SUPABASE_ANON_KEY=${supabase.credentials.anonKey}` - : 'SUPABASE_URL=your_supabase_url\nSUPABASE_ANON_KEY=your_supabase_anon_key' - } - NEVER modify any Supabase configuration or \`.env\` files. + IMPORTANT: Create a .env file and ALWAYS populate the variables${ + supabase?.credentials?.supabaseUrl && supabase?.credentials?.anonKey + ? ` and include the following variables: + VITE_SUPABASE_URL=${supabase.credentials.supabaseUrl} + VITE_SUPABASE_ANON_KEY=${supabase.credentials.anonKey}` + : '.' + } + NEVER modify any Supabase configuration or \`.env\` files apart from creating the \`.env\`. CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS: - DATA INTEGRITY IS THE HIGHEST PRIORITY, users must NEVER lose their data diff --git a/app/lib/stores/supabase.ts b/app/lib/stores/supabase.ts index 3b8a581..7254091 100644 --- a/app/lib/stores/supabase.ts +++ b/app/lib/stores/supabase.ts @@ -27,6 +27,7 @@ export interface SupabaseConnectionState { } const savedConnection = typeof localStorage !== 'undefined' ? localStorage.getItem('supabase_connection') : null; +const savedCredentials = typeof localStorage !== 'undefined' ? localStorage.getItem('supabaseCredentials') : null; const initialState: SupabaseConnectionState = savedConnection ? JSON.parse(savedConnection) @@ -39,6 +40,14 @@ const initialState: SupabaseConnectionState = savedConnection project: undefined, }; +if (savedCredentials && !initialState.credentials) { + try { + initialState.credentials = JSON.parse(savedCredentials); + } catch (e) { + console.error('Failed to parse saved credentials:', e); + } +} + export const supabaseConnection = atom(initialState); if (initialState.token && !initialState.stats) { From d53acdadda5ebde25407ac78d7c6df273cc5841f Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 20 Mar 2025 14:25:31 +0000 Subject: [PATCH 10/12] fix: ensure supabase credentials are populating the env file by default --- app/lib/common/prompts/prompts.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/lib/common/prompts/prompts.ts b/app/lib/common/prompts/prompts.ts index 7d24547..26610b7 100644 --- a/app/lib/common/prompts/prompts.ts +++ b/app/lib/common/prompts/prompts.ts @@ -80,8 +80,11 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w : '' : '' } - IMPORTANT: Create a .env file and ALWAYS populate the variables${ - supabase?.credentials?.supabaseUrl && supabase?.credentials?.anonKey + IMPORTANT: Create a .env file if it doesnt exist${ + supabase?.isConnected && + supabase?.hasSelectedProject && + supabase?.credentials?.supabaseUrl && + supabase?.credentials?.anonKey ? ` and include the following variables: VITE_SUPABASE_URL=${supabase.credentials.supabaseUrl} VITE_SUPABASE_ANON_KEY=${supabase.credentials.anonKey}` From bc9948062efeacb619af875045ff1fa7cde15d89 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Fri, 21 Mar 2025 13:20:14 +0000 Subject: [PATCH 11/12] fix: add instruction to avoid generating types for supabase --- app/lib/common/prompts/prompts.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/lib/common/prompts/prompts.ts b/app/lib/common/prompts/prompts.ts index 26610b7..8fcacfd 100644 --- a/app/lib/common/prompts/prompts.ts +++ b/app/lib/common/prompts/prompts.ts @@ -92,6 +92,8 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w } NEVER modify any Supabase configuration or \`.env\` files apart from creating the \`.env\`. + Do not try to generate types for supabase. + CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS: - DATA INTEGRITY IS THE HIGHEST PRIORITY, users must NEVER lose their data - FORBIDDEN: Any destructive operations like \`DROP\` or \`DELETE\` that could result in data loss (e.g., when dropping columns, changing column types, renaming tables, etc.) From 418fbf13e02d19dc0bc5eec37eb54bfe7a97253e Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Tue, 25 Mar 2025 13:54:15 +0000 Subject: [PATCH 12/12] refactor: remove debug log and improve button layout in SupabaseConnection Remove console.log statement for debugging purposes in the API route and enhance the layout of buttons in the SupabaseConnection component by grouping them and adding a refresh button --- app/components/chat/SupabaseConnection.tsx | 24 +++++++++++++++------- app/routes/api.supabase.ts | 5 ----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx index a9261f4..dc73973 100644 --- a/app/components/chat/SupabaseConnection.tsx +++ b/app/components/chat/SupabaseConnection.tsx @@ -214,13 +214,23 @@ export function SupabaseConnection() { )} /> - +
+ + +
{isProjectsExpanded && ( diff --git a/app/routes/api.supabase.ts b/app/routes/api.supabase.ts index 955ae44..f21da60 100644 --- a/app/routes/api.supabase.ts +++ b/app/routes/api.supabase.ts @@ -34,11 +34,6 @@ export const action: ActionFunction = async ({ request }) => { } } - console.log( - 'Unique projects:', - Array.from(uniqueProjectsMap.values()).map((p) => ({ id: p.id, name: p.name })), - ); - const uniqueProjects = Array.from(uniqueProjectsMap.values()); uniqueProjects.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());