feat: supabase integration #1542 from xKevIsDev/supabase

Merge pull request #1542 from xKevIsDev/supabase
This commit is contained in:
Leex
2025-03-26 19:58:55 +01:00
committed by GitHub
22 changed files with 2331 additions and 48 deletions

View File

@@ -35,7 +35,11 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
const actions = useStore( const actions = useStore(
computed(artifact.runner.actions, (actions) => { 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'));
});
}), }),
); );

View File

@@ -29,13 +29,15 @@ import type { ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager'; import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import StarterTemplates from './StarterTemplates'; import StarterTemplates from './StarterTemplates';
import type { ActionAlert } from '~/types/actions'; import type { ActionAlert, SupabaseAlert } from '~/types/actions';
import ChatAlert from './ChatAlert'; import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types'; import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation'; import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context'; import type { ProgressAnnotation } from '~/types/context';
import type { ActionRunner } from '~/lib/runtime/action-runner'; import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
import { SupabaseConnection } from './SupabaseConnection';
const TEXTAREA_MIN_HEIGHT = 76; const TEXTAREA_MIN_HEIGHT = 76;
@@ -69,6 +71,8 @@ interface BaseChatProps {
setImageDataList?: (dataList: string[]) => void; setImageDataList?: (dataList: string[]) => void;
actionAlert?: ActionAlert; actionAlert?: ActionAlert;
clearAlert?: () => void; clearAlert?: () => void;
supabaseAlert?: SupabaseAlert;
clearSupabaseAlert?: () => void;
data?: JSONValue[] | undefined; data?: JSONValue[] | undefined;
actionRunner?: ActionRunner; actionRunner?: ActionRunner;
} }
@@ -105,6 +109,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages, messages,
actionAlert, actionAlert,
clearAlert, clearAlert,
supabaseAlert,
clearSupabaseAlert,
data, data,
actionRunner, actionRunner,
}, },
@@ -343,6 +349,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
) : null; ) : null;
}} }}
</ClientOnly> </ClientOnly>
{supabaseAlert && (
<SupabaseChatAlert
alert={supabaseAlert}
clearAlert={() => clearSupabaseAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearSupabaseAlert?.();
}}
/>
)}
<div <div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', { className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted, 'sticky bottom-2': chatStarted,
@@ -590,6 +606,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
a new line a new line
</div> </div>
) : null} ) : null}
<SupabaseConnection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,6 +26,7 @@ import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTempla
import { logStore } from '~/lib/stores/logs'; import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming'; import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils'; import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@@ -123,6 +124,11 @@ export const ChatImpl = memo(
const [fakeLoading, setFakeLoading] = useState(false); const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files); const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert); 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 { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
@@ -160,6 +166,14 @@ export const ChatImpl = memo(
files, files,
promptId, promptId,
contextOptimization: contextOptimizationEnabled, contextOptimization: contextOptimizationEnabled,
supabase: {
isConnected: supabaseConn.isConnected,
hasSelectedProject: !!selectedProject,
credentials: {
supabaseUrl: supabaseConn?.credentials?.supabaseUrl,
anonKey: supabaseConn?.credentials?.anonKey,
},
},
}, },
sendExtraMessageFields: true, sendExtraMessageFields: true,
onError: (e) => { onError: (e) => {
@@ -544,6 +558,8 @@ export const ChatImpl = memo(
setImageDataList={setImageDataList} setImageDataList={setImageDataList}
actionAlert={actionAlert} actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()} clearAlert={() => workbenchStore.clearAlert()}
supabaseAlert={supabaseAlert}
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
data={chatData} data={chatData}
/> />
); );

View File

@@ -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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border-bolt-elements-borderColor bg-bolt-elements-background-depth-2"
>
{/* Header */}
<div className="p-4 pb-2">
<div className="flex items-center gap-2">
<img height="10" width="18" crossOrigin="anonymous" src="https://cdn.simpleicons.org/supabase" />
<h3 className="text-sm font-medium text-[#3DCB8F]">{title}</h3>
</div>
</div>
{/* SQL Content */}
<div className="px-4">
{!isConnected ? (
<div className="p-3 rounded-md bg-bolt-elements-background-depth-3">
<span className="text-sm text-bolt-elements-textPrimary">
You must first connect to Supabase and select a project.
</span>
</div>
) : (
<>
<div
className="flex items-center p-2 rounded-md bg-bolt-elements-background-depth-3 cursor-pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="i-ph:database text-bolt-elements-textPrimary mr-2"></div>
<span className="text-sm text-bolt-elements-textPrimary flex-grow">
{description || 'Create table and setup auth'}
</span>
<div
className={`i-ph:caret-up text-bolt-elements-textPrimary transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
></div>
</div>
{!isCollapsed && content && (
<div className="mt-2 p-3 bg-bolt-elements-background-depth-4 rounded-md overflow-auto max-h-60 font-mono text-xs text-bolt-elements-textSecondary">
<pre>{cleanSqlContent(content)}</pre>
</div>
)}
</>
)}
</div>
{/* Message and Actions */}
<div className="p-4">
<p className="text-sm text-bolt-elements-textSecondary mb-4">{message}</p>
<div className="flex gap-2">
{showConnectButton ? (
<button
onClick={handleConnectClick}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#098F5F]',
'hover:bg-[#0aa06c]',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
'text-white',
'flex items-center gap-1.5',
)}
>
Connect to Supabase
</button>
) : (
<button
onClick={() => executeSupabaseAction(content)}
disabled={isExecuting}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#098F5F]',
'hover:bg-[#0aa06c]',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
'text-white',
'flex items-center gap-1.5',
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
)}
>
{isExecuting ? 'Applying...' : 'Apply Changes'}
</button>
)}
<button
onClick={clearAlert}
disabled={isExecuting}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#503B26]',
'hover:bg-[#774f28]',
'focus:outline-none',
'text-[#F79007]',
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
)}
>
Dismiss
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,339 @@
import { useEffect } from 'react';
import { useSupabaseConnection } from '~/lib/hooks/useSupabaseConnection';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { chatId } from '~/lib/persistence/useChatHistory';
import { fetchSupabaseStats } from '~/lib/stores/supabase';
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
export function SupabaseConnection() {
const {
connection: supabaseConn,
connecting,
fetchingStats,
isProjectsExpanded,
setIsProjectsExpanded,
isDropdownOpen: isDialogOpen,
setIsDropdownOpen: setIsDialogOpen,
handleConnect,
handleDisconnect,
selectProject,
handleCreateProject,
updateToken,
isConnected,
fetchProjectApiKeys,
} = useSupabaseConnection();
const currentChatId = useStore(chatId);
useEffect(() => {
const handleOpenConnectionDialog = () => {
setIsDialogOpen(true);
};
document.addEventListener('open-supabase-connection', handleOpenConnectionDialog);
return () => {
document.removeEventListener('open-supabase-connection', handleOpenConnectionDialog);
};
}, [setIsDialogOpen]);
useEffect(() => {
if (isConnected && currentChatId) {
const savedProjectId = localStorage.getItem(`supabase-project-${currentChatId}`);
/*
* If there's no saved project for this chat but there is a global selected project,
* use the global one instead of clearing it
*/
if (!savedProjectId && supabaseConn.selectedProjectId) {
// Save the current global project to this chat
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
} else if (savedProjectId && savedProjectId !== supabaseConn.selectedProjectId) {
selectProject(savedProjectId);
}
}
}, [isConnected, currentChatId]);
useEffect(() => {
if (currentChatId && supabaseConn.selectedProjectId) {
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
} else if (currentChatId && !supabaseConn.selectedProjectId) {
localStorage.removeItem(`supabase-project-${currentChatId}`);
}
}, [currentChatId, supabaseConn.selectedProjectId]);
useEffect(() => {
if (isConnected && supabaseConn.token) {
fetchSupabaseStats(supabaseConn.token).catch(console.error);
}
}, [isConnected, supabaseConn.token]);
useEffect(() => {
if (isConnected && supabaseConn.selectedProjectId && supabaseConn.token && !supabaseConn.credentials) {
fetchProjectApiKeys(supabaseConn.selectedProjectId).catch(console.error);
}
}, [isConnected, supabaseConn.selectedProjectId, supabaseConn.token, supabaseConn.credentials]);
return (
<div className="relative">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<Button
active
disabled={connecting}
onClick={() => setIsDialogOpen(!isDialogOpen)}
className="hover:bg-bolt-elements-item-backgroundActive !text-white flex items-center gap-2"
>
<img
className="w-4 h-4"
height="20"
width="20"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
{isConnected && supabaseConn.project && (
<span className="ml-1 text-xs max-w-[100px] truncate">{supabaseConn.project.name}</span>
)}
</Button>
</div>
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
{isDialogOpen && (
<Dialog className="max-w-[520px] p-6">
{!isConnected ? (
<div className="space-y-4">
<DialogTitle>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
Connect to Supabase
</DialogTitle>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
<input
type="password"
value={supabaseConn.token}
onChange={(e) => updateToken(e.target.value)}
disabled={connecting}
placeholder="Enter your Supabase access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-[#3ECF8E]',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.supabase.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-[#3ECF8E] hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<DialogClose asChild>
<DialogButton type="secondary">Cancel</DialogButton>
</DialogClose>
<button
onClick={handleConnect}
disabled={connecting || !supabaseConn.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#3ECF8E] text-white',
'hover:bg-[#3BBF84]',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<DialogTitle>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
Supabase Connection
</DialogTitle>
</div>
<div className="flex items-center gap-4 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{supabaseConn.user?.email}</h4>
<p className="text-xs text-bolt-elements-textSecondary">Role: {supabaseConn.user?.role}</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching projects...
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"
>
<div className="i-ph:database w-4 h-4" />
Your Projects ({supabaseConn.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
<div className="flex items-center gap-2">
<button
onClick={() => fetchSupabaseStats(supabaseConn.token)}
className="px-2 py-1 rounded-md text-xs bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#333333] flex items-center gap-1"
title="Refresh projects list"
>
<div className="i-ph:arrows-clockwise w-3 h-3" />
Refresh
</button>
<button
onClick={() => handleCreateProject()}
className="px-2 py-1 rounded-md text-xs bg-[#3ECF8E] text-white hover:bg-[#3BBF84] flex items-center gap-1"
>
<div className="i-ph:plus w-3 h-3" />
New Project
</button>
</div>
</div>
{isProjectsExpanded && (
<>
{!supabaseConn.selectedProjectId && (
<div className="mb-2 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg text-sm text-bolt-elements-textSecondary">
Select a project or create a new one for this chat
</div>
)}
{supabaseConn.stats?.projects?.length ? (
<div className="grid gap-2 max-h-60 overflow-y-auto">
{supabaseConn.stats.projects.map((project) => (
<div
key={project.id}
className="block p-3 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#3ECF8E] dark:hover:border-[#3ECF8E] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className="i-ph:database w-3 h-3 text-[#3ECF8E]" />
{project.name}
</h5>
<div className="text-xs text-bolt-elements-textSecondary mt-1">
{project.region}
</div>
</div>
<button
onClick={() => selectProject(project.id)}
className={classNames(
'px-3 py-1 rounded-md text-xs',
supabaseConn.selectedProjectId === project.id
? 'bg-[#3ECF8E] text-white'
: 'bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#3ECF8E] hover:text-white',
)}
>
{supabaseConn.selectedProjectId === project.id ? (
<span className="flex items-center gap-1">
<div className="i-ph:check w-3 h-3" />
Selected
</span>
) : (
'Select'
)}
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No projects found
</div>
)}
</>
)}
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<DialogClose asChild>
<DialogButton type="secondary">Close</DialogButton>
</DialogClose>
<DialogButton type="danger" onClick={handleDisconnect}>
<div className="i-ph:plug-x w-4 h-4" />
Disconnect
</DialogButton>
</div>
</div>
)}
</Dialog>
)}
</DialogRoot>
</div>
);
}
interface ButtonProps {
active?: boolean;
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
className?: string;
}
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center p-1.5',
{
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentAccent': active && !disabled,
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
disabled,
},
className,
)}
onClick={onClick}
>
{children}
</button>
);
}

View File

@@ -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 type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger'; import { createScopedLogger, renderLogger } from '~/utils/logger';
import * as ContextMenu from '@radix-ui/react-context-menu'; import * as ContextMenu from '@radix-ui/react-context-menu';
import type { FileHistory } from '~/types/actions'; import type { FileHistory } from '~/types/actions';
import { diffLines, type Change } from 'diff'; 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'); const logger = createScopedLogger('FileTree');
@@ -25,6 +28,14 @@ interface Props {
className?: string; className?: string;
} }
interface InlineInputProps {
depth: number;
placeholder: string;
initialValue?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
export const FileTree = memo( export const FileTree = memo(
({ ({
files = {}, files = {},
@@ -213,28 +224,258 @@ function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; childr
); );
} }
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) { function InlineInput({ depth, placeholder, initialValue = '', onSubmit, onCancel }: InlineInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
if (initialValue) {
inputRef.current.value = initialValue;
inputRef.current.select();
}
}
}, 50);
return () => clearTimeout(timer);
}, [initialValue]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
const value = inputRef.current?.value.trim();
if (value) {
onSubmit(value);
}
} else if (e.key === 'Escape') {
onCancel();
}
};
return ( return (
<ContextMenu.Root> <div
<ContextMenu.Trigger>{children}</ContextMenu.Trigger> className="flex items-center w-full px-2 bg-bolt-elements-background-depth-4 border border-bolt-elements-item-contentAccent py-0.5 text-bolt-elements-textPrimary"
<ContextMenu.Portal> style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
<ContextMenu.Content >
style={{ zIndex: 998 }} <div className="scale-120 shrink-0 i-ph:file-plus text-bolt-elements-textTertiary" />
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56" <input
> ref={inputRef}
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor"> type="text"
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem> className="ml-2 flex-1 bg-transparent border-none outline-none py-0.5 text-sm text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary min-w-0"
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem> placeholder={placeholder}
</ContextMenu.Group> onKeyDown={handleKeyDown}
</ContextMenu.Content> onBlur={() => {
</ContextMenu.Portal> setTimeout(() => {
</ContextMenu.Root> if (document.activeElement !== inputRef.current) {
onCancel();
}
}, 100);
}}
/>
</div>
);
}
function FileContextMenu({
onCopyPath,
onCopyRelativePath,
fullPath,
children,
}: FolderContextMenuProps & { fullPath: string }) {
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const depth = useMemo(() => fullPath.split('/').length, [fullPath]);
const fileName = useMemo(() => path.basename(fullPath), [fullPath]);
const isFolder = useMemo(() => {
const files = workbenchStore.files.get();
const fileEntry = files[fullPath];
return !fileEntry || fileEntry.type === 'folder';
}, [fullPath]);
const targetPath = useMemo(() => {
return isFolder ? fullPath : path.dirname(fullPath);
}, [fullPath, isFolder]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const items = Array.from(e.dataTransfer.items);
const files = items.filter((item) => item.kind === 'file');
for (const item of files) {
const file = item.getAsFile();
if (file) {
try {
const filePath = path.join(fullPath, file.name);
// Convert file to binary data (Uint8Array)
const arrayBuffer = await file.arrayBuffer();
const binaryContent = new Uint8Array(arrayBuffer);
const success = await workbenchStore.createFile(filePath, binaryContent);
if (success) {
toast.success(`File ${file.name} uploaded successfully`);
} else {
toast.error(`Failed to upload file ${file.name}`);
}
} catch (error) {
toast.error(`Error uploading ${file.name}`);
logger.error(error);
}
}
}
setIsDragging(false);
},
[fullPath],
);
const handleCreateFile = async (fileName: string) => {
const newFilePath = path.join(targetPath, fileName);
const success = await workbenchStore.createFile(newFilePath, '');
if (success) {
toast.success('File created successfully');
} else {
toast.error('Failed to create file');
}
setIsCreatingFile(false);
};
const handleCreateFolder = async (folderName: string) => {
const newFolderPath = path.join(targetPath, folderName);
const success = await workbenchStore.createFolder(newFolderPath);
if (success) {
toast.success('Folder created successfully');
} else {
toast.error('Failed to create folder');
}
setIsCreatingFolder(false);
};
const handleDelete = async () => {
try {
if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) {
return;
}
let success;
if (isFolder) {
success = await workbenchStore.deleteFolder(fullPath);
} else {
success = await workbenchStore.deleteFile(fullPath);
}
if (success) {
toast.success(`${isFolder ? 'Folder' : 'File'} deleted successfully`);
} else {
toast.error(`Failed to delete ${isFolder ? 'folder' : 'file'}`);
}
} catch (error) {
toast.error(`Error deleting ${isFolder ? 'folder' : 'file'}`);
logger.error(error);
}
};
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={classNames('relative', {
'bg-bolt-elements-background-depth-2 border border-dashed border-bolt-elements-item-contentAccent rounded-md':
isDragging,
})}
>
{children}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
style={{ zIndex: 998 }}
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
>
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={() => setIsCreatingFile(true)}>
<div className="flex items-center gap-2">
<div className="i-ph:file-plus" />
New File
</div>
</ContextMenuItem>
<ContextMenuItem onSelect={() => setIsCreatingFolder(true)}>
<div className="flex items-center gap-2">
<div className="i-ph:folder-plus" />
New Folder
</div>
</ContextMenuItem>
</ContextMenu.Group>
<ContextMenu.Group className="p-1">
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
{/* Add delete option in a new group */}
<ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={handleDelete}>
<div className="flex items-center gap-2 text-red-500">
<div className="i-ph:trash" />
Delete {isFolder ? 'Folder' : 'File'}
</div>
</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
{isCreatingFile && (
<InlineInput
depth={depth}
placeholder="Enter file name..."
onSubmit={handleCreateFile}
onCancel={() => setIsCreatingFile(false)}
/>
)}
{isCreatingFolder && (
<InlineInput
depth={depth}
placeholder="Enter folder name..."
onSubmit={handleCreateFolder}
onCancel={() => setIsCreatingFolder(false)}
/>
)}
</>
); );
} }
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) { function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
return ( return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}> <FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={folder.fullPath}>
<NodeButton <NodeButton
className={classNames('group', { className={classNames('group', {
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive': 'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
@@ -265,7 +506,7 @@ interface FileProps {
} }
function File({ function File({
file: { depth, name, fullPath }, file,
onClick, onClick,
onCopyPath, onCopyPath,
onCopyRelativePath, onCopyRelativePath,
@@ -273,17 +514,15 @@ function File({
unsavedChanges = false, unsavedChanges = false,
fileHistory = {}, fileHistory = {},
}: FileProps) { }: FileProps) {
const { depth, name, fullPath } = file;
const fileModifications = fileHistory[fullPath]; const fileModifications = fileHistory[fullPath];
// const hasModifications = fileModifications !== undefined;
// Calculate added and removed lines from the most recent changes
const { additions, deletions } = useMemo(() => { const { additions, deletions } = useMemo(() => {
if (!fileModifications?.originalContent) { if (!fileModifications?.originalContent) {
return { additions: 0, deletions: 0 }; 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 normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent = const normalizedCurrent =
fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || ''; fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
@@ -317,7 +556,7 @@ function File({
const showStats = additions > 0 || deletions > 0; const showStats = additions > 0 || deletions > 0;
return ( return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}> <FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={fullPath}>
<NodeButton <NodeButton
className={classNames('group', { className={classNames('group', {
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': 'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':

View File

@@ -12,7 +12,16 @@ import { getFilePaths } from './select-context';
export type Messages = Message[]; export type Messages = Message[];
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>; export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
supabaseConnection?: {
isConnected: boolean;
hasSelectedProject: boolean;
credentials?: {
anonKey?: string;
supabaseUrl?: string;
};
};
}
const logger = createScopedLogger('stream-text'); const logger = createScopedLogger('stream-text');
@@ -97,6 +106,11 @@ export async function streamText(props: {
cwd: WORK_DIR, cwd: WORK_DIR,
allowedHtmlElements: allowedHTMLElements, allowedHtmlElements: allowedHTMLElements,
modificationTagName: MODIFICATIONS_TAG_NAME, modificationTagName: MODIFICATIONS_TAG_NAME,
supabase: {
isConnected: options?.supabaseConnection?.isConnected || false,
hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false,
credentials: options?.supabaseConnection?.credentials || undefined,
},
}) ?? getSystemPrompt(); }) ?? getSystemPrompt();
if (files && contextFiles && contextOptimization) { if (files && contextFiles && contextOptimization) {

View File

@@ -5,6 +5,14 @@ export interface PromptOptions {
cwd: string; cwd: string;
allowedHtmlElements: string[]; allowedHtmlElements: string[];
modificationTagName: string; modificationTagName: string;
supabase?: {
isConnected: boolean;
hasSelectedProject: boolean;
credentials?: {
anonKey?: string;
supabaseUrl?: string;
};
};
} }
export class PromptLibrary { export class PromptLibrary {
@@ -19,7 +27,7 @@ export class PromptLibrary {
default: { default: {
label: 'Default Prompt', label: 'Default Prompt',
description: 'This is the battle tested default system Prompt', description: 'This is the battle tested default system Prompt',
get: (options) => getSystemPrompt(options.cwd), get: (options) => getSystemPrompt(options.cwd, options.supabase),
}, },
optimized: { optimized: {
label: 'Optimized Prompt (experimental)', label: 'Optimized Prompt (experimental)',

View File

@@ -1,7 +1,7 @@
import type { PromptOptions } from '~/lib/common/prompt-library'; import type { PromptOptions } from '~/lib/common/prompt-library';
export default (options: PromptOptions) => { export default (options: PromptOptions) => {
const { cwd, allowedHtmlElements } = options; const { cwd, allowedHtmlElements, supabase } = options;
return ` return `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. 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 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
</system_constraints> </system_constraints>
<database_instructions>
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:
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/your_migration.sql">
/* SQL migration content */
</boltAction>
2. Immediate Query Execution:
<boltAction type="supabase" operation="query" projectId="\${projectId}">
/* Same SQL content as migration */
</boltAction>
Example:
<boltArtifact id="create-users-table" title="Create Users Table">
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/create_users.sql">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
<boltAction type="supabase" operation="query" projectId="\${projectId}">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
</boltArtifact>
- 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:
<example>
alter table users enable row level security;
</example>
- 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:
<example>
/*
# 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);
</example>
- 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:
<example>
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
</example>
<example>
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 $$;
</example>
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!
</database_instructions>
<code_formatting_info> <code_formatting_info>
Use 2 spaces for indentation Use 2 spaces for indentation
</code_formatting_info> </code_formatting_info>

View File

@@ -2,7 +2,14 @@ import { WORK_DIR } from '~/utils/constants';
import { allowedHTMLElements } from '~/utils/markdown'; import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/stripIndent'; import { stripIndents } from '~/utils/stripIndent';
export const getSystemPrompt = (cwd: string = WORK_DIR) => ` export const getSystemPrompt = (
cwd: string = WORK_DIR,
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. You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
<system_constraints> <system_constraints>
@@ -59,6 +66,205 @@ 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 - curl, head, sort, tail, clear, which, export, chmod, scho, hostname, kill, ln, xxd, alias, false, getconf, true, loadenv, wasm, xdg-open, command, exit, source
</system_constraints> </system_constraints>
<database_instructions>
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${
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}`
: '.'
}
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.)
- 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:
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/your_migration.sql">
/* SQL migration content */
</boltAction>
2. Immediate Query Execution:
<boltAction type="supabase" operation="query" projectId="\${projectId}">
/* Same SQL content as migration */
</boltAction>
Example:
<boltArtifact id="create-users-table" title="Create Users Table">
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/create_users.sql">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
<boltAction type="supabase" operation="query" projectId="\${projectId}">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
</boltArtifact>
- 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:
<example>
alter table users enable row level security;
</example>
- 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:
<example>
/*
# 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);
</example>
- 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:
<example>
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
</example>
<example>
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 $$;
</example>
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!
</database_instructions>
<code_formatting_info> <code_formatting_info>
Use 2 spaces for code indentation Use 2 spaces for code indentation
</code_formatting_info> </code_formatting_info>

View File

@@ -0,0 +1,147 @@
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,
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);
}
}
}, []);
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');
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 = async (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,
});
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);
};
const handleCreateProject = async () => {
window.open('https://app.supabase.com/new/new-project', '_blank');
};
return {
connection,
connecting,
fetchingStats,
fetchingApiKeys,
isProjectsExpanded,
setIsProjectsExpanded,
isDropdownOpen,
setIsDropdownOpen,
handleConnect,
handleDisconnect,
selectProject,
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'));
},
};
}

View File

@@ -1,7 +1,7 @@
import type { WebContainer } from '@webcontainer/api'; import type { WebContainer } from '@webcontainer/api';
import { path as nodePath } from '~/utils/path'; import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores'; import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions'; import type { ActionAlert, BoltAction, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser'; import type { ActionCallbackData } from './message-parser';
@@ -70,16 +70,19 @@ export class ActionRunner {
runnerId = atom<string>(`${Date.now()}`); runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({}); actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void; onAlert?: (alert: ActionAlert) => void;
onSupabaseAlert?: (alert: SupabaseAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string }; buildOutput?: { path: string; exitCode: number; output: string };
constructor( constructor(
webcontainerPromise: Promise<WebContainer>, webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell, getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void, onAlert?: (alert: ActionAlert) => void,
onSupabaseAlert?: (alert: SupabaseAlert) => void,
) { ) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal; this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert; this.onAlert = onAlert;
this.onSupabaseAlert = onSupabaseAlert;
} }
addAction(data: ActionCallbackData) { addAction(data: ActionCallbackData) {
@@ -157,6 +160,21 @@ export class ActionRunner {
await this.#runFileAction(action); await this.#runFileAction(action);
break; 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': { case 'build': {
const buildOutput = await this.#runBuildAction(action); const buildOutput = await this.#runBuildAction(action);
@@ -377,4 +395,50 @@ export class ActionRunner {
output, 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}`);
}
}
} }

View File

@@ -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 type { BoltArtifactData } from '~/types/artifact';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
@@ -293,7 +293,27 @@ export class StreamingMessageParser {
content: '', 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; const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
if (!filePath) { if (!filePath) {

View File

@@ -42,6 +42,11 @@ export class FilesStore {
*/ */
#modifiedFiles: Map<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map(); #modifiedFiles: Map<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map();
/**
* Keeps track of deleted files and folders to prevent them from reappearing on reload
*/
#deletedPaths: Set<string> = import.meta.hot?.data.deletedPaths ?? new Set();
/** /**
* Map of files that matches the state of WebContainer. * Map of files that matches the state of WebContainer.
*/ */
@@ -54,9 +59,28 @@ export class FilesStore {
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise; 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) { if (import.meta.hot) {
// Persist our state across hot reloads
import.meta.hot.data.files = this.files; import.meta.hot.data.files = this.files;
import.meta.hot.data.modifiedFiles = this.#modifiedFiles; import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
import.meta.hot.data.deletedPaths = this.#deletedPaths;
} }
this.#init(); this.#init();
@@ -139,18 +163,74 @@ export class FilesStore {
async #init() { async #init() {
const webcontainer = await this.#webcontainer; const webcontainer = await this.#webcontainer;
// Clean up any files that were previously deleted
this.#cleanupDeletedFiles();
webcontainer.internal.watchPaths( webcontainer.internal.watchPaths(
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true }, { include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
bufferWatchEvents(100, this.#processEventBuffer.bind(this)), 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();
for (const deletedPath of this.#deletedPaths) {
if (currentFiles[deletedPath]) {
this.files.setKey(deletedPath, undefined);
if (currentFiles[deletedPath]?.type === 'file') {
this.#size--;
}
}
for (const [path, dirent] of Object.entries(currentFiles)) {
if (path.startsWith(deletedPath + '/')) {
this.files.setKey(path, undefined);
if (dirent?.type === 'file') {
this.#size--;
}
if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
this.#modifiedFiles.delete(path);
}
}
}
}
}
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) { #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
const watchEvents = events.flat(2); const watchEvents = events.flat(2);
for (const { type, path, buffer } of watchEvents) { for (const { type, path: eventPath, buffer } of watchEvents) {
// remove any trailing slashes // 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;
}
let isInDeletedFolder = false;
for (const deletedPath of this.#deletedPaths) {
if (sanitizedPath.startsWith(deletedPath + '/')) {
isInDeletedFolder = true;
break;
}
}
if (isInDeletedFolder) {
continue;
}
switch (type) { switch (type) {
case 'add_dir': { case 'add_dir': {
@@ -176,21 +256,30 @@ export class FilesStore {
} }
let content = ''; 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); 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); 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 = '';
}
}
const existingFile = this.files.get()[sanitizedPath];
if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) {
content = existingFile.content;
} }
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
break; break;
} }
case 'remove_file': { case 'remove_file': {
@@ -218,6 +307,160 @@ export class FilesStore {
return ''; 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}'`);
}
const dirPath = path.dirname(relativePath);
if (dirPath !== '.') {
await webcontainer.fs.mkdir(dirPath, { recursive: true });
}
const isBinary = content instanceof Uint8Array;
if (isBinary) {
await webcontainer.fs.writeFile(relativePath, Buffer.from(content));
const base64Content = Buffer.from(content).toString('base64');
this.files.setKey(filePath, { type: 'file', content: base64Content, isBinary: true });
this.#modifiedFiles.set(filePath, base64Content);
} else {
const contentToWrite = (content as string).length === 0 ? ' ' : content;
await webcontainer.fs.writeFile(relativePath, contentToWrite);
this.files.setKey(filePath, { type: 'file', content: content as string, isBinary: false });
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 });
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);
this.#deletedPaths.add(filePath);
this.files.setKey(filePath, undefined);
this.#size--;
if (this.#modifiedFiles.has(filePath)) {
this.#modifiedFiles.delete(filePath);
}
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 });
this.#deletedPaths.add(folderPath);
this.files.setKey(folderPath, undefined);
const allFiles = this.files.get();
for (const [path, dirent] of Object.entries(allFiles)) {
if (path.startsWith(folderPath + '/')) {
this.files.setKey(path, undefined);
this.#deletedPaths.add(path);
if (dirent?.type === 'file') {
this.#size--;
}
if (dirent?.type === 'file' && this.#modifiedFiles.has(path)) {
this.#modifiedFiles.delete(path);
}
}
}
this.#persistDeletedPaths();
logger.info(`Folder deleted: ${folderPath}`);
return true;
} catch (error) {
logger.error('Failed to delete folder\n\n', error);
throw error;
}
}
// 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) { function isBinaryFile(buffer: Uint8Array | undefined) {

191
app/lib/stores/supabase.ts Normal file
View File

@@ -0,0 +1,191 @@
import { atom } from 'nanostores';
import type { SupabaseUser, SupabaseStats, SupabaseApiKey, SupabaseCredentials } 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;
credentials?: SupabaseCredentials;
}
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)
: {
user: null,
token: '',
stats: undefined,
selectedProjectId: undefined,
isConnected: false,
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<SupabaseConnectionState>(initialState);
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<SupabaseConnectionState>) {
const currentState = supabaseConnection.get();
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);
}
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 {
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 === '') {
connection.project = undefined;
connection.credentials = undefined;
}
}
const newState = { ...currentState, ...connection };
supabaseConnection.set(newState);
/*
* Always save the connection state to localStorage to persist across chats
*/
if (connection.user || connection.token || connection.selectedProjectId !== undefined || connection.credentials) {
localStorage.setItem('supabase_connection', JSON.stringify(newState));
if (newState.credentials) {
localStorage.setItem('supabaseCredentials', JSON.stringify(newState.credentials));
} else {
localStorage.removeItem('supabaseCredentials');
}
} else {
localStorage.removeItem('supabase_connection');
localStorage.removeItem('supabaseCredentials');
}
}
export async function fetchSupabaseStats(token: string) {
isFetchingStats.set(true);
try {
// Use the internal API route instead of direct Supabase API call
const response = await fetch('/api/supabase', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
}),
});
if (!response.ok) {
throw new Error('Failed to fetch projects');
}
const data = (await response.json()) as any;
updateSupabaseConnection({
user: data.user,
stats: data.stats,
});
} catch (error) {
console.error('Failed to fetch Supabase stats:', error);
throw error;
} finally {
isFetchingStats.set(false);
}
}
export async function fetchProjectApiKeys(projectId: string, token: string) {
isFetchingApiKeys.set(true);
try {
const response = await fetch('/api/supabase/variables', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
projectId,
token,
}),
});
if (!response.ok) {
throw new Error('Failed to fetch API keys');
}
const data = (await response.json()) as any;
const apiKeys = data.apiKeys;
const anonKey = apiKeys.find((key: SupabaseApiKey) => 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);
}
}

View File

@@ -17,7 +17,7 @@ import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence'; import { description } from '~/lib/persistence';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler'; import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions'; import type { ActionAlert, SupabaseAlert } from '~/types/actions';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
@@ -50,6 +50,8 @@ export class WorkbenchStore {
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>()); unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> = actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined); import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
supabaseAlert: WritableAtom<SupabaseAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
modifiedFiles = new Set<string>(); modifiedFiles = new Set<string>();
artifactIdList: string[] = []; artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve(); #globalExecutionQueue = Promise.resolve();
@@ -60,6 +62,17 @@ export class WorkbenchStore {
import.meta.hot.data.showWorkbench = this.showWorkbench; import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView; import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert; 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();
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 });
}
}
} }
} }
@@ -104,6 +117,14 @@ export class WorkbenchStore {
this.actionAlert.set(undefined); this.actionAlert.set(undefined);
} }
get SupabaseAlert() {
return this.supabaseAlert;
}
clearSupabaseAlert() {
this.supabaseAlert.set(undefined);
}
toggleTerminal(value?: boolean) { toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value); this.#terminalStore.toggleTerminal(value);
} }
@@ -238,6 +259,7 @@ export class WorkbenchStore {
getFileModifcations() { getFileModifcations() {
return this.#filesStore.getFileModifications(); return this.#filesStore.getFileModifications();
} }
getModifiedFiles() { getModifiedFiles() {
return this.#filesStore.getModifiedFiles(); return this.#filesStore.getModifiedFiles();
} }
@@ -246,6 +268,120 @@ export class WorkbenchStore {
this.#filesStore.resetFileModifications(); this.#filesStore.resetFileModifications();
} }
async createFile(filePath: string, content: string | Uint8Array = '') {
try {
const success = await this.#filesStore.createFile(filePath, content);
if (success) {
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 {
const currentDocument = this.currentDocument.get();
const isCurrentFile = currentDocument?.filePath === filePath;
const success = await this.#filesStore.deleteFile(filePath);
if (success) {
const newUnsavedFiles = new Set(this.unsavedFiles.get());
if (newUnsavedFiles.has(filePath)) {
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
}
if (isCurrentFile) {
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 {
const currentDocument = this.currentDocument.get();
const isInCurrentFolder = currentDocument?.filePath?.startsWith(folderPath + '/');
const success = await this.#filesStore.deleteFolder(folderPath);
if (success) {
const unsavedFiles = this.unsavedFiles.get();
const newUnsavedFiles = new Set<string>();
for (const file of unsavedFiles) {
if (!file.startsWith(folderPath + '/')) {
newUnsavedFiles.add(file);
}
}
if (newUnsavedFiles.size !== unsavedFiles.size) {
this.unsavedFiles.set(newUnsavedFiles);
}
if (isInCurrentFolder) {
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() { abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this? // TODO: what do we wanna do and how do we wanna recover from this?
} }
@@ -280,6 +416,13 @@ export class WorkbenchStore {
this.actionAlert.set(alert); this.actionAlert.set(alert);
}, },
(alert) => {
if (this.#reloadedMessages.has(messageId)) {
return;
}
this.supabaseAlert.set(alert);
},
), ),
}); });
} }

View File

@@ -37,11 +37,19 @@ function parseCookies(cookieHeader: string): Record<string, string> {
} }
async function chatAction({ context, request }: ActionFunctionArgs) { 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; messages: Messages;
files: any; files: any;
promptId?: string; promptId?: string;
contextOptimization: boolean; contextOptimization: boolean;
supabase?: {
isConnected: boolean;
hasSelectedProject: boolean;
credentials?: {
anonKey?: string;
supabaseUrl?: string;
};
};
}>(); }>();
const cookieHeader = request.headers.get('Cookie'); const cookieHeader = request.headers.get('Cookie');
@@ -179,8 +187,8 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
// logger.debug('Code Files Selected'); // logger.debug('Code Files Selected');
} }
// Stream the text
const options: StreamingOptions = { const options: StreamingOptions = {
supabaseConnection: supabase,
toolChoice: 'none', toolChoice: 'none',
onFinish: async ({ text: content, finishReason, usage }) => { onFinish: async ({ text: content, finishReason, usage }) => {
logger.debug('usage', JSON.stringify(usage)); logger.debug('usage', JSON.stringify(usage));

View File

@@ -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',
},
},
);
}
}

View File

@@ -0,0 +1,57 @@
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 });
}
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[];
const uniqueProjectsMap = new Map<string, SupabaseProject>();
for (const project of projects) {
if (!uniqueProjectsMap.has(project.id)) {
uniqueProjectsMap.set(project.id, project);
}
}
const uniqueProjects = Array.from(uniqueProjectsMap.values());
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 },
);
}
};

View File

@@ -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 });
}
}

View File

@@ -1,6 +1,6 @@
import type { Change } from 'diff'; import type { Change } from 'diff';
export type ActionType = 'file' | 'shell'; export type ActionType = 'file' | 'shell' | 'supabase';
export interface BaseAction { export interface BaseAction {
content: string; content: string;
@@ -23,7 +23,14 @@ export interface BuildAction extends BaseAction {
type: 'build'; 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; export type BoltActionData = BoltAction | BaseAction;
@@ -35,6 +42,14 @@ export interface ActionAlert {
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
} }
export interface SupabaseAlert {
type: string;
title: string;
description: string;
content: string;
source?: 'supabase';
}
export interface FileHistory { export interface FileHistory {
originalContent: string; originalContent: string;
lastModified: number; lastModified: number;

31
app/types/supabase.ts Normal file
View File

@@ -0,0 +1,31 @@
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;
}
export interface SupabaseApiKey {
name: string;
api_key: string;
}
export interface SupabaseCredentials {
anonKey?: string;
supabaseUrl?: string;
}