This commit is contained in:
Stijnus
2025-01-24 01:08:51 +01:00
parent 4e6f18ea1e
commit 27eab591a9
19 changed files with 1759 additions and 592 deletions

View File

@@ -35,6 +35,7 @@ interface GitHubStats {
interface GitHubConnection { interface GitHubConnection {
user: GitHubUserResponse | null; user: GitHubUserResponse | null;
token: string; token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats; stats?: GitHubStats;
} }
@@ -42,6 +43,7 @@ export default function ConnectionsTab() {
const [connection, setConnection] = useState<GitHubConnection>({ const [connection, setConnection] = useState<GitHubConnection>({
user: null, user: null,
token: '', token: '',
tokenType: 'classic',
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
@@ -53,7 +55,14 @@ export default function ConnectionsTab() {
if (savedConnection) { if (savedConnection) {
const parsed = JSON.parse(savedConnection); const parsed = JSON.parse(savedConnection);
// Ensure backward compatibility with existing connections
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
setConnection(parsed); setConnection(parsed);
if (parsed.user && parsed.token) { if (parsed.user && parsed.token) {
fetchGitHubStats(parsed.token); fetchGitHubStats(parsed.token);
} }
@@ -73,7 +82,9 @@ export default function ConnectionsTab() {
}, },
}); });
if (!reposResponse.ok) throw new Error('Failed to fetch repositories'); if (!reposResponse.ok) {
throw new Error('Failed to fetch repositories');
}
const repos = (await reposResponse.json()) as GitHubRepoInfo[]; const repos = (await reposResponse.json()) as GitHubRepoInfo[];
@@ -107,10 +118,16 @@ export default function ConnectionsTab() {
}, },
}); });
if (!response.ok) throw new Error('Invalid token or unauthorized'); if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const data = (await response.json()) as GitHubUserResponse; const data = (await response.json()) as GitHubUserResponse;
const newConnection = { user: data, token }; const newConnection: GitHubConnection = {
user: data,
token,
tokenType: connection.tokenType,
};
// Save connection // Save connection
localStorage.setItem('github_connection', JSON.stringify(newConnection)); localStorage.setItem('github_connection', JSON.stringify(newConnection));
@@ -123,7 +140,7 @@ export default function ConnectionsTab() {
} catch (error) { } catch (error) {
logStore.logError('Failed to authenticate with GitHub', { error }); logStore.logError('Failed to authenticate with GitHub', { error });
toast.error('Failed to connect to GitHub'); toast.error('Failed to connect to GitHub');
setConnection({ user: null, token: '' }); setConnection({ user: null, token: '', tokenType: 'classic' });
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -136,11 +153,13 @@ export default function ConnectionsTab() {
const handleDisconnect = () => { const handleDisconnect = () => {
localStorage.removeItem('github_connection'); localStorage.removeItem('github_connection');
setConnection({ user: null, token: '' }); setConnection({ user: null, token: '', tokenType: 'classic' });
toast.success('Disconnected from GitHub'); toast.success('Disconnected from GitHub');
}; };
if (isLoading) return <LoadingSpinner />; if (isLoading) {
return <LoadingSpinner />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -174,31 +193,37 @@ export default function ConnectionsTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitHub Username</label> <label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
<input <select
type="text" value={connection.tokenType}
value={connection.user?.login || ''} onChange={(e) =>
disabled={true} setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
placeholder="Not connected" }
disabled={isConnecting || !!connection.user}
className={classNames( className={classNames(
'w-full px-3 py-2 rounded-lg text-sm', 'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]', 'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', 'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500', 'focus:outline-none focus:ring-1 focus:ring-purple-500',
'disabled:opacity-50', 'disabled:opacity-50',
)} )}
/> >
<option value="classic">Personal Access Token (Classic)</option>
<option value="fine-grained">Fine-grained Token</option>
</select>
</div> </div>
<div> <div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label> <label className="block text-sm text-bolt-elements-textSecondary mb-2">
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input <input
type="password" type="password"
value={connection.token} value={connection.token}
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))} onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
disabled={isConnecting || !!connection.user} disabled={isConnecting || !!connection.user}
placeholder="Enter your GitHub token" placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`}
className={classNames( className={classNames(
'w-full px-3 py-2 rounded-lg text-sm', 'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
@@ -257,70 +282,50 @@ export default function ConnectionsTab() {
)} )}
</div> </div>
{connection.user && connection.stats && ( {connection.user && (
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6"> <div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4">
<img <img
src={connection.user.avatar_url} src={connection.user.avatar_url}
alt={connection.user.login} alt={connection.user.login}
className="w-16 h-16 rounded-full" className="w-12 h-12 rounded-full"
/> />
<div> <div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary"> <h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
{connection.user.name || connection.user.login} <p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
</h3>
{connection.user.bio && (
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
)}
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:users w-4 h-4" />
{connection.user.followers} followers
</span>
<span className="flex items-center gap-1">
<div className="i-ph:star w-4 h-4" />
{connection.stats.totalStars} stars
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-4 h-4" />
{connection.stats.totalForks} forks
</span>
</div>
</div> </div>
</div> </div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4> {isFetchingStats ? (
<div className="space-y-3"> <div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
{connection.stats.repos.map((repo) => ( <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
<a Fetching GitHub stats...
key={repo.full_name} </div>
href={repo.html_url} ) : (
target="_blank" connection.stats && (
rel="noopener noreferrer" <div className="mt-4 grid grid-cols-3 gap-4">
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
>
<div className="flex items-center justify-between">
<div> <div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{repo.name}</h5> <p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
{repo.description && ( <p className="text-lg font-medium text-bolt-elements-textPrimary">
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p> {connection.user.public_repos}
</p>
</div>
<div>
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
<p className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.totalStars}
</p>
</div>
<div>
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
<p className="text-lg font-medium text-bolt-elements-textPrimary">
{connection.stats.totalForks}
</p>
</div>
</div>
)
)} )}
</div> </div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1">
<div className="i-ph:star w-3 h-3" />
{repo.stargazers_count}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-3 h-3" />
{repo.forks_count}
</span>
</div>
</div>
</a>
))}
</div>
</div>
)} )}
</div> </div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,180 @@
import React, { useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import type { GitHubAuthState } from '~/components/settings/connections/types/GitHub';
import Cookies from 'js-cookie';
import { getLocalStorage } from '~/utils/localStorage';
const GITHUB_TOKEN_KEY = 'github_token';
interface ConnectionFormProps {
authState: GitHubAuthState;
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
onSave: (e: React.FormEvent) => void;
onDisconnect: () => void;
}
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount
useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: savedToken,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
}));
}
}, []);
return (
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
</div>
</div>
</div>
<form onSubmit={onSave} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
GitHub Username
</label>
<input
id="username"
type="text"
value={authState.username}
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="e.g., octocat"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
Personal Access Token
</label>
<a
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
target="_blank"
rel="noopener noreferrer"
className={classNames(
'inline-flex items-center gap-1.5 text-xs',
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
'transition-colors duration-200',
)}
>
<span>Generate new token</span>
<div className="i-ph:plus-circle" />
</a>
</div>
<input
id="token"
type="password"
value={authState.tokenInfo?.token || ''}
onChange={(e) =>
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: e.target.value,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
username: '',
isConnected: false,
isVerifying: false,
isLoadingRepos: false,
}))
}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="ghp_xxxxxxxxxxxx"
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-4">
{!authState.isConnected ? (
<button
type="submit"
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-purple-500 hover:bg-purple-600',
'text-white',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{authState.isVerifying ? (
<>
<div className="i-ph:spinner animate-spin" />
<span>Verifying...</span>
</>
) : (
<>
<div className="i-ph:plug-fill" />
<span>Connect</span>
</>
)}
</button>
) : (
<>
<button
onClick={onDisconnect}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
'text-bolt-elements-textPrimary',
)}
>
<div className="i-ph:plug-fill" />
<span>Disconnect</span>
</button>
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
<div className="i-ph:check-circle-fill" />
<span>Connected</span>
</span>
</>
)}
</div>
{authState.rateLimits && (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
<div className="i-ph:clock-countdown opacity-60" />
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
</div>
)}
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import type { GitHubRepoInfo } from '~/components/settings/connections/types/GitHub';
import { GitBranch } from '@phosphor-icons/react';
interface GitHubBranch {
name: string;
default?: boolean;
}
interface CreateBranchDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (branchName: string, sourceBranch: string) => void;
repository: GitHubRepoInfo;
branches?: GitHubBranch[];
}
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm(branchName, sourceBranch);
setBranchName('');
onClose();
};
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
<Dialog.Content
className={classNames(
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-full max-w-md p-6 rounded-xl shadow-lg',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
Create New Branch
</Dialog.Title>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
Branch Name
</label>
<input
id="branchName"
type="text"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
placeholder="feature/my-new-branch"
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
required
/>
</div>
<div>
<label
htmlFor="sourceBranch"
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
>
Source Branch
</label>
<select
id="sourceBranch"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
>
{branches?.map((branch) => (
<option key={branch.name} value={branch.name}>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
</div>
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
<li className="flex items-center gap-2">
<GitBranch className="text-lg" />
Repository: {repository.name}
</li>
{branchName && (
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
New branch will be created as: {branchName}
</li>
)}
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
Based on: {sourceBranch}
</li>
</ul>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-bolt-elements-textPrimary',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'transition-colors',
)}
>
Cancel
</button>
<button
type="submit"
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-white bg-purple-500',
'hover:bg-purple-600',
'transition-colors',
)}
>
Create Branch
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,59 @@
export interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
}
export interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
}
export interface GitHubStats {
repos: GitHubRepoInfo[];
totalStars: number;
totalForks: number;
}
export interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
}
export interface GitHubTokenInfo {
token: string;
scope: string[];
avatar_url: string;
name: string | null;
created_at: string;
followers: number;
}
export interface GitHubRateLimits {
limit: number;
remaining: number;
reset: Date;
used: number;
}
export interface GitHubAuthState {
username: string;
tokenInfo: GitHubTokenInfo | null;
isConnected: boolean;
isVerifying: boolean;
isLoadingRepos: boolean;
rateLimits?: GitHubRateLimits;
}

View File

@@ -305,9 +305,9 @@ export default function DataTab() {
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" /> <div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium">Chat History</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
</div> </div>
<p className="text-sm text-bolt-elements-textSecondary mb-4">Export or delete all your chat history.</p> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
<div className="flex gap-4"> <div className="flex gap-4">
<motion.button <motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
@@ -339,9 +339,9 @@ export default function DataTab() {
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" /> <div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium">Settings Backup</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
</div> </div>
<p className="text-sm text-bolt-elements-textSecondary mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Export your settings to a JSON file or import settings from a previously exported file. Export your settings to a JSON file or import settings from a previously exported file.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -364,7 +364,7 @@ export default function DataTab() {
Import Settings Import Settings
</motion.button> </motion.button>
<motion.button <motion.button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setShowResetInlineConfirm(true)} onClick={() => setShowResetInlineConfirm(true)}
@@ -384,9 +384,9 @@ export default function DataTab() {
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" /> <div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium">API Keys Management</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
</div> </div>
<p className="text-sm text-bolt-elements-textSecondary mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Import API keys from a JSON file or download a template to fill in your keys. Import API keys from a JSON file or download a template to fill in your keys.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -405,7 +405,7 @@ export default function DataTab() {
disabled={isDownloadingTemplate} disabled={isDownloadingTemplate}
> >
{isDownloadingTemplate ? ( {isDownloadingTemplate ? (
<div className="i-ph:spinner-gap-bold animate-spin" /> <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
) : ( ) : (
<div className="i-ph:download-simple w-4 h-4" /> <div className="i-ph:download-simple w-4 h-4" />
)} )}

View File

@@ -92,10 +92,12 @@ interface WebAppInfo {
nodeVersion: string; nodeVersion: string;
dependencies: { [key: string]: string }; dependencies: { [key: string]: string };
devDependencies: { [key: string]: string }; devDependencies: { [key: string]: string };
// Build Info // Build Info
buildTime?: string; buildTime?: string;
buildNumber?: string; buildNumber?: string;
environment?: string; environment?: string;
// Git Info // Git Info
gitInfo?: { gitInfo?: {
branch: string; branch: string;
@@ -104,6 +106,7 @@ interface WebAppInfo {
author: string; author: string;
remoteUrl: string; remoteUrl: string;
}; };
// GitHub Repository Info // GitHub Repository Info
repoInfo?: { repoInfo?: {
name: string; name: string;
@@ -121,6 +124,39 @@ interface WebAppInfo {
}; };
} }
interface GitInfo {
branch: string;
commit: string;
commitTime: string;
author: string;
remoteUrl: string;
}
interface RepoData {
name: string;
full_name: string;
description: string;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
default_branch: string;
updated_at: string;
owner: {
login: string;
avatar_url: string;
};
}
interface AppData {
name: string;
version: string;
description: string;
license: string;
nodeVersion: string;
dependencies: { [key: string]: string };
devDependencies: { [key: string]: string };
}
export default function DebugTab() { export default function DebugTab() {
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null); const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null); const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
@@ -328,23 +364,27 @@ export default function DebugTab() {
// Fetch local app info // Fetch local app info
const appInfoResponse = await fetch('/api/system/app-info'); const appInfoResponse = await fetch('/api/system/app-info');
if (!appInfoResponse.ok) { if (!appInfoResponse.ok) {
throw new Error('Failed to fetch webapp info'); throw new Error('Failed to fetch webapp info');
} }
const appData = await appInfoResponse.json();
const appData = (await appInfoResponse.json()) as AppData;
// Fetch git info // Fetch git info
const gitInfoResponse = await fetch('/api/system/git-info'); const gitInfoResponse = await fetch('/api/system/git-info');
let gitInfo = null; let gitInfo: GitInfo | undefined;
if (gitInfoResponse.ok) { if (gitInfoResponse.ok) {
gitInfo = await gitInfoResponse.json(); gitInfo = (await gitInfoResponse.json()) as GitInfo;
} }
// Fetch GitHub repository info // Fetch GitHub repository info
const repoInfoResponse = await fetch('https://api.github.com/repos/stackblitz-labs/bolt.diy'); const repoInfoResponse = await fetch('https://api.github.com/repos/stackblitz-labs/bolt.diy');
let repoInfo = null; let repoInfo: WebAppInfo['repoInfo'] | undefined;
if (repoInfoResponse.ok) { if (repoInfoResponse.ok) {
const repoData = await repoInfoResponse.json(); const repoData = (await repoInfoResponse.json()) as RepoData;
repoInfo = { repoInfo = {
name: repoData.name, name: repoData.name,
fullName: repoData.full_name, fullName: repoData.full_name,
@@ -396,21 +436,6 @@ export default function DebugTab() {
return `${Math.round(size)} ${units[unitIndex]}`; return `${Math.round(size)} ${units[unitIndex]}`;
}; };
const handleLogSystemInfo = () => {
if (!systemInfo) {
return;
}
logStore.logSystem('System Information', {
os: systemInfo.os,
arch: systemInfo.arch,
cpus: systemInfo.cpus,
memory: systemInfo.memory,
node: systemInfo.node,
});
toast.success('System information logged');
};
const handleLogPerformance = () => { const handleLogPerformance = () => {
try { try {
setLoading((prev) => ({ ...prev, performance: true })); setLoading((prev) => ({ ...prev, performance: true }));
@@ -625,6 +650,26 @@ export default function DebugTab() {
Check Errors Check Errors
</button> </button>
<button
onClick={getWebAppInfo}
disabled={loading.webAppInfo}
className={classNames(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
)}
>
{loading.webAppInfo ? (
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
) : (
<div className="i-ph:info w-4 h-4" />
)}
Fetch WebApp Info
</button>
<button <button
onClick={exportDebugInfo} onClick={exportDebugInfo}
className={classNames( className={classNames(
@@ -640,68 +685,12 @@ export default function DebugTab() {
</button> </button>
</div> </div>
{/* Error Log Display */}
{errorLog.errors.length > 0 && (
<div className="mt-4">
<h3 className="text-lg font-semibold mb-2">Error Log</h3>
<div className="bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">
{errorLog.errors.map((error, index) => (
<div key={index} className="mb-4 last:mb-0 p-3 bg-white rounded border border-red-200">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-medium">Type:</span> {error.type}
<span className="font-medium ml-4">Time:</span>
{new Date(error.timestamp).toLocaleString()}
</div>
<div className="mt-2 text-red-600">{error.message}</div>
{error.filename && (
<div className="mt-1 text-sm text-gray-500">
File: {error.filename} (Line: {error.lineNumber}, Column: {error.columnNumber})
</div>
)}
</div>
))}
</div>
</div>
)}
{/* System Information */} {/* System Information */}
<div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"> <div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="i-ph:cpu text-purple-500 w-5 h-5" /> <div className="i-ph:cpu text-purple-500 w-5 h-5" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3> <h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={handleLogSystemInfo}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'transition-colors duration-200',
)}
>
<div className="i-ph:note text-bolt-elements-textSecondary w-4 h-4" />
Log
</button>
<button
onClick={getSystemInfo}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'transition-colors duration-200',
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
)}
disabled={loading.systemInfo}
>
<div className={classNames('i-ph:arrows-clockwise w-4 h-4', loading.systemInfo ? 'animate-spin' : '')} />
Refresh
</button>
</div>
</div>
{systemInfo ? ( {systemInfo ? (
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -826,27 +815,10 @@ export default function DebugTab() {
{/* Performance Metrics */} {/* Performance Metrics */}
<div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"> <div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="i-ph:chart-line text-purple-500 w-5 h-5" /> <div className="i-ph:chart-line text-purple-500 w-5 h-5" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3> <h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3>
</div> </div>
<button
onClick={handleLogPerformance}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
{ 'opacity-50 cursor-not-allowed': loading.performance },
)}
disabled={loading.performance}
>
<div className={classNames('i-ph:note w-4 h-4', loading.performance ? 'animate-spin' : '')} />
Log Performance
</button>
</div>
{systemInfo && ( {systemInfo && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -914,27 +886,10 @@ export default function DebugTab() {
{/* WebApp Information */} {/* WebApp Information */}
<div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"> <div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="i-ph:info text-blue-500 w-5 h-5" /> <div className="i-ph:info text-blue-500 w-5 h-5" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3> <h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3>
</div> </div>
<button
onClick={getWebAppInfo}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
)}
disabled={loading.webAppInfo}
>
<div className={classNames('i-ph:arrows-clockwise w-4 h-4', loading.webAppInfo ? 'animate-spin' : '')} />
Refresh
</button>
</div>
{webAppInfo ? ( {webAppInfo ? (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -1008,18 +963,12 @@ export default function DebugTab() {
<span className="text-bolt-elements-textSecondary">Git Info:</span> <span className="text-bolt-elements-textSecondary">Git Info:</span>
</div> </div>
<div className="pl-6 space-y-1"> <div className="pl-6 space-y-1">
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">Branch: {webAppInfo.gitInfo.branch}</div>
Branch: {webAppInfo.gitInfo.branch} <div className="text-xs text-bolt-elements-textPrimary">Commit: {webAppInfo.gitInfo.commit}</div>
</div>
<div className="text-xs text-bolt-elements-textPrimary">
Commit: {webAppInfo.gitInfo.commit}
</div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">
Commit Time: {webAppInfo.gitInfo.commitTime} Commit Time: {webAppInfo.gitInfo.commitTime}
</div> </div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">Author: {webAppInfo.gitInfo.author}</div>
Author: {webAppInfo.gitInfo.author}
</div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">
Remote URL: {webAppInfo.gitInfo.remoteUrl} Remote URL: {webAppInfo.gitInfo.remoteUrl}
</div> </div>
@@ -1033,21 +982,15 @@ export default function DebugTab() {
<span className="text-bolt-elements-textSecondary">GitHub Repository:</span> <span className="text-bolt-elements-textSecondary">GitHub Repository:</span>
</div> </div>
<div className="pl-6 space-y-1"> <div className="pl-6 space-y-1">
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">Name: {webAppInfo.repoInfo.name}</div>
Name: {webAppInfo.repoInfo.name}
</div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">
Full Name: {webAppInfo.repoInfo.fullName} Full Name: {webAppInfo.repoInfo.fullName}
</div> </div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">
Description: {webAppInfo.repoInfo.description} Description: {webAppInfo.repoInfo.description}
</div> </div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">Stars: {webAppInfo.repoInfo.stars}</div>
Stars: {webAppInfo.repoInfo.stars} <div className="text-xs text-bolt-elements-textPrimary">Forks: {webAppInfo.repoInfo.forks}</div>
</div>
<div className="text-xs text-bolt-elements-textPrimary">
Forks: {webAppInfo.repoInfo.forks}
</div>
<div className="text-xs text-bolt-elements-textPrimary"> <div className="text-xs text-bolt-elements-textPrimary">
Open Issues: {webAppInfo.repoInfo.openIssues} Open Issues: {webAppInfo.repoInfo.openIssues}
</div> </div>
@@ -1077,27 +1020,10 @@ export default function DebugTab() {
{/* Error Check */} {/* Error Check */}
<div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"> <div className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="i-ph:warning text-purple-500 w-5 h-5" /> <div className="i-ph:warning text-purple-500 w-5 h-5" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3> <h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3>
</div> </div>
<button
onClick={checkErrors}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20',
'text-bolt-elements-textPrimary dark:hover:text-purple-500',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-[#0A0A0A]',
{ 'opacity-50 cursor-not-allowed': loading.errors },
)}
disabled={loading.errors}
>
<div className={classNames('i-ph:magnifying-glass w-4 h-4', loading.errors ? 'animate-spin' : '')} />
Check for Errors
</button>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm text-bolt-elements-textSecondary"> <div className="text-sm text-bolt-elements-textSecondary">
Checks for: Checks for:

View File

@@ -1,12 +1,18 @@
import * as RadixDialog from '@radix-ui/react-dialog'; import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { TabManagement } from './TabManagement'; import { TabManagement } from './TabManagement';
import { TabTile } from '~/components/settings/shared/TabTile'; import { TabTile } from '~/components/settings/shared/TabTile';
import { DialogTitle } from '~/components/ui/Dialog'; import { DialogTitle } from '~/components/ui/Dialog';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types'; import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
import { tabConfigurationStore, updateTabConfiguration } from '~/lib/stores/settings'; import {
tabConfigurationStore,
resetTabConfiguration,
updateTabConfiguration,
developerModeStore,
setDeveloperMode,
} from '~/lib/stores/settings';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
@@ -24,6 +30,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab'; import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab';
import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab'; import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab';
import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab'; import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab';
import { Switch } from '~/components/ui/Switch';
interface DraggableTabTileProps { interface DraggableTabTileProps {
tab: TabVisibilityConfig; tab: TabVisibilityConfig;
@@ -83,8 +90,14 @@ const DraggableTabTile = ({
}, },
}); });
const dragDropRef = (node: HTMLDivElement | null) => {
if (node) {
drag(drop(node));
}
};
return ( return (
<div ref={(node) => drag(drop(node))} style={{ opacity: isDragging ? 0.5 : 1 }}> <div ref={dragDropRef} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile <TabTile
tab={tab} tab={tab}
onClick={onClick} onClick={onClick}
@@ -104,15 +117,32 @@ interface DeveloperWindowProps {
} }
export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => { export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
const tabConfiguration = useStore(tabConfigurationStore);
const [activeTab, setActiveTab] = useState<TabType | null>(null); const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null); const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const tabConfiguration = useStore(tabConfigurationStore);
const [showTabManagement, setShowTabManagement] = useState(false);
const developerMode = useStore(developerModeStore);
const [profile, setProfile] = useState(() => { const [profile, setProfile] = useState(() => {
const saved = localStorage.getItem('bolt_user_profile'); const saved = localStorage.getItem('bolt_user_profile');
return saved ? JSON.parse(saved) : { avatar: null, notifications: true }; return saved ? JSON.parse(saved) : { avatar: null, notifications: true };
}); });
// Handle developer mode change
const handleDeveloperModeChange = (checked: boolean) => {
setDeveloperMode(checked);
if (!checked) {
onClose();
}
};
// Ensure developer mode is true when window is opened
useEffect(() => {
if (open) {
setDeveloperMode(true);
}
}, [open]);
// Listen for profile changes // Listen for profile changes
useEffect(() => { useEffect(() => {
const handleStorageChange = (e: StorageEvent) => { const handleStorageChange = (e: StorageEvent) => {
@@ -134,6 +164,38 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
// Ensure tab configuration is properly initialized
useEffect(() => {
if (!tabConfiguration || !tabConfiguration.userTabs || !tabConfiguration.developerTabs) {
console.warn('Tab configuration is invalid in DeveloperWindow, resetting to defaults');
resetTabConfiguration();
} else {
// Validate tab configuration structure
const isValid =
tabConfiguration.userTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
) &&
tabConfiguration.developerTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
);
if (!isValid) {
console.warn('Tab configuration is malformed in DeveloperWindow, resetting to defaults');
resetTabConfiguration();
}
}
}, [tabConfiguration]);
const handleBack = () => { const handleBack = () => {
if (showTabManagement) { if (showTabManagement) {
setShowTabManagement(false); setShowTabManagement(false);
@@ -143,21 +205,55 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
}; };
// Only show tabs that are assigned to the developer window AND are visible // Only show tabs that are assigned to the developer window AND are visible
const visibleDeveloperTabs = tabConfiguration.developerTabs const visibleDeveloperTabs = useMemo(() => {
console.log('Filtering developer tabs with configuration:', tabConfiguration);
if (!tabConfiguration?.developerTabs || !Array.isArray(tabConfiguration.developerTabs)) {
console.warn('Invalid tab configuration, using empty array');
return [];
}
return tabConfiguration.developerTabs
.filter((tab) => { .filter((tab) => {
// Hide notifications tab if notifications are disabled if (!tab || typeof tab.id !== 'string') {
if (tab.id === 'notifications' && !profile.notifications) { console.warn('Invalid tab entry:', tab);
return false; return false;
} }
return tab.visible; // Hide notifications tab if notifications are disabled
if (tab.id === 'notifications' && !profile.notifications) {
console.log('Hiding notifications tab due to disabled notifications');
return false;
}
// Ensure the tab has the required properties
if (typeof tab.visible !== 'boolean' || typeof tab.window !== 'string' || typeof tab.order !== 'number') {
console.warn('Tab missing required properties:', tab);
return false;
}
// Only show tabs that are explicitly visible and assigned to the developer window
const isVisible = tab.visible && tab.window === 'developer';
console.log(`Tab ${tab.id} visibility:`, isVisible);
return isVisible;
}) })
.sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => (a.order || 0) - (b.order || 0)); .sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => {
const orderA = typeof a.order === 'number' ? a.order : 0;
const orderB = typeof b.order === 'number' ? b.order : 0;
return orderA - orderB;
});
}, [tabConfiguration, profile.notifications]);
console.log('Filtered visible developer tabs:', visibleDeveloperTabs);
const moveTab = (dragIndex: number, hoverIndex: number) => { const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleDeveloperTabs[dragIndex]; const draggedTab = visibleDeveloperTabs[dragIndex];
const targetTab = visibleDeveloperTabs[hoverIndex]; const targetTab = visibleDeveloperTabs[hoverIndex];
console.log('Moving developer tab:', { draggedTab, targetTab });
// Update the order of the dragged and target tabs // Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order }; const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order }; const updatedTargetTab = { ...targetTab, order: draggedTab.order };
@@ -278,7 +374,10 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}> <RadixDialog.Root open={open}>
<RadixDialog.Portal> <RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[60]"> <div
className="fixed inset-0 flex items-center justify-center z-[60]"
style={{ opacity: developerMode ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
>
<RadixDialog.Overlay className="fixed inset-0"> <RadixDialog.Overlay className="fixed inset-0">
<motion.div <motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm" className="absolute inset-0 bg-black/50 backdrop-blur-sm"
@@ -299,7 +398,7 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
'flex flex-col overflow-hidden', 'flex flex-col overflow-hidden',
)} )}
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: developerMode ? 1 : 0, scale: developerMode ? 1 : 0.95, y: developerMode ? 0 : 20 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
@@ -346,6 +445,16 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
</motion.button> </motion.button>
)} )}
<div className="flex items-center gap-2">
<Switch
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className="data-[state=checked]:bg-purple-500"
aria-label="Toggle developer mode"
/>
<label className="text-sm text-gray-500 dark:text-gray-400">Switch to User Mode</label>
</div>
<div className="relative"> <div className="relative">
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>

View File

@@ -37,7 +37,7 @@ const TabGroup = ({ title, description, tabs, onVisibilityChange, targetWindow }
const hiddenTabs = tabs.filter((tab) => !tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0)); const hiddenTabs = tabs.filter((tab) => !tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0));
return ( return (
<div className="mb-8 rounded-xl bg-white/5 p-6 backdrop-blur-sm dark:bg-gray-800/30"> <div className="mb-8 rounded-xl bg-white/5 p-6 dark:bg-gray-800/30">
<div className="mb-6"> <div className="mb-6">
<h3 className="flex items-center gap-2 text-lg font-medium text-gray-900 dark:text-white"> <h3 className="flex items-center gap-2 text-lg font-medium text-gray-900 dark:text-white">
<span className="i-ph:layout-fill h-5 w-5 text-purple-500" /> <span className="i-ph:layout-fill h-5 w-5 text-purple-500" />

View File

@@ -10,8 +10,6 @@ import { settingsStyles } from '~/components/settings/settings.styles';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { BsBox, BsCodeSquare, BsRobot } from 'react-icons/bs'; import { BsBox, BsCodeSquare, BsRobot } from 'react-icons/bs';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
import OllamaModelUpdater from './OllamaModelUpdater';
import { DialogRoot, Dialog } from '~/components/ui/Dialog';
import { BiChip } from 'react-icons/bi'; import { BiChip } from 'react-icons/bi';
import { TbBrandOpenai } from 'react-icons/tb'; import { TbBrandOpenai } from 'react-icons/tb';
import { providerBaseUrlEnvKeys } from '~/utils/constants'; import { providerBaseUrlEnvKeys } from '~/utils/constants';
@@ -33,12 +31,33 @@ const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
OpenAILike: 'Connect to OpenAI-compatible API endpoints', OpenAILike: 'Connect to OpenAI-compatible API endpoints',
}; };
interface OllamaModel {
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
error?: string;
newDigest?: string;
progress?: {
current: number;
total: number;
status: string;
};
}
const LocalProvidersTab = () => { const LocalProvidersTab = () => {
const settings = useSettings(); const settings = useSettings();
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]); const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false); const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
const [showOllamaUpdater, setShowOllamaUpdater] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null); const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Effect to filter and sort providers // Effect to filter and sort providers
useEffect(() => { useEffect(() => {
@@ -46,9 +65,32 @@ const LocalProvidersTab = () => {
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key)) .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
.map(([key, value]) => { .map(([key, value]) => {
const provider = value as IProviderConfig; const provider = value as IProviderConfig;
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
// Get environment URL safely
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
console.log(`Checking env URL for ${key}:`, {
envKey,
envUrl,
currentBaseUrl: provider.settings.baseUrl,
});
// If there's an environment URL and no base URL set, update it
if (envUrl && !provider.settings.baseUrl) {
console.log(`Setting base URL for ${key} from env:`, envUrl);
settings.updateProviderSettings(key, {
...provider.settings,
baseUrl: envUrl,
});
}
return { return {
name: key, name: key,
settings: provider.settings, settings: {
...provider.settings,
baseUrl: provider.settings.baseUrl || envUrl,
},
staticModels: provider.staticModels || [], staticModels: provider.staticModels || [],
getDynamicModels: provider.getDynamicModels, getDynamicModels: provider.getDynamicModels,
getApiKeyLink: provider.getApiKeyLink, getApiKeyLink: provider.getApiKeyLink,
@@ -57,16 +99,135 @@ const LocalProvidersTab = () => {
} as IProviderConfig; } as IProviderConfig;
}); });
const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name)); // Custom sort function to ensure LMStudio appears before OpenAILike
const sorted = newFilteredProviders.sort((a, b) => {
if (a.name === 'LMStudio') {
return -1;
}
if (b.name === 'LMStudio') {
return 1;
}
if (a.name === 'OpenAILike') {
return 1;
}
if (b.name === 'OpenAILike') {
return -1;
}
return a.name.localeCompare(b.name);
});
setFilteredProviders(sorted); setFilteredProviders(sorted);
}, [settings.providers]); }, [settings.providers]);
// Helper function to safely get environment URL
const getEnvUrl = (provider: IProviderConfig): string | undefined => {
const envKey = providerBaseUrlEnvKeys[provider.name]?.baseUrlKey;
return envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
};
// Add effect to update category toggle state based on provider states // Add effect to update category toggle state based on provider states
useEffect(() => { useEffect(() => {
const newCategoryState = filteredProviders.every((p) => p.settings.enabled); const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
setCategoryEnabled(newCategoryState); setCategoryEnabled(newCategoryState);
}, [filteredProviders]); }, [filteredProviders]);
// Fetch Ollama models when enabled
useEffect(() => {
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
if (ollamaProvider?.settings.enabled) {
fetchOllamaModels();
}
}, [filteredProviders]);
const fetchOllamaModels = async () => {
try {
setIsLoadingModels(true);
const response = await fetch('http://127.0.0.1:11434/api/tags');
const data = (await response.json()) as { models: OllamaModel[] };
setOllamaModels(
data.models.map((model) => ({
...model,
status: 'idle' as const,
})),
);
} catch (error) {
console.error('Error fetching Ollama models:', error);
} finally {
setIsLoadingModels(false);
}
};
const updateOllamaModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => {
try {
const response = await fetch('http://127.0.0.1:11434/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to update ${modelName}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response reader available');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const text = new TextDecoder().decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const data = JSON.parse(line) as {
status: string;
completed?: number;
total?: number;
digest?: string;
};
setOllamaModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: data.completed || 0,
total: data.total || 0,
status: data.status,
},
newDigest: data.digest,
}
: m,
),
);
}
}
const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
const updatedModel = updatedData.models.find((m) => m.name === modelName);
return { success: true, newDigest: updatedModel?.digest };
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return { success: false };
}
};
const handleToggleCategory = useCallback( const handleToggleCategory = useCallback(
(enabled: boolean) => { (enabled: boolean) => {
setCategoryEnabled(enabled); setCategoryEnabled(enabled);
@@ -106,6 +267,31 @@ const LocalProvidersTab = () => {
setEditingProvider(null); setEditingProvider(null);
}; };
const handleUpdateOllamaModel = async (modelName: string) => {
setOllamaModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateOllamaModel(modelName);
setOllamaModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
if (success) {
toast.success(`Updated ${modelName}`);
} else {
toast.error(`Failed to update ${modelName}`);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<motion.div <motion.div
@@ -139,7 +325,7 @@ const LocalProvidersTab = () => {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{filteredProviders.map((provider, index) => ( {filteredProviders.map((provider, index) => (
<motion.div <motion.div
key={provider.name} key={provider.name}
@@ -150,6 +336,12 @@ const LocalProvidersTab = () => {
'transition-all duration-200', 'transition-all duration-200',
'relative overflow-hidden group', 'relative overflow-hidden group',
'flex flex-col', 'flex flex-col',
// Make Ollama span 2 rows
provider.name === 'Ollama' ? 'row-span-2' : '',
// Place Ollama in the second column
provider.name === 'Ollama' ? 'col-start-2' : 'col-start-1',
)} )}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -253,21 +445,109 @@ const LocalProvidersTab = () => {
</div> </div>
</div> </div>
)} )}
</div>
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && ( {providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
<div className="mt-2 text-xs text-green-500"> <div className="mt-2 text-xs">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="i-ph:info" /> <div
<span>Environment URL set in .env file</span> className={
getEnvUrl(provider)
? 'i-ph:check-circle text-green-500'
: 'i-ph:warning-circle text-yellow-500'
}
/>
<span className={getEnvUrl(provider) ? 'text-green-500' : 'text-yellow-500'}>
{getEnvUrl(provider)
? 'Environment URL set in .env.local'
: 'Environment URL not set in .env.local'}
</span>
</div> </div>
</div> </div>
)} )}
</div>
</motion.div> </motion.div>
)} )}
</div> </div>
</div> </div>
{provider.name === 'Ollama' && provider.settings.enabled && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:cube-duotone text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</span>
</div>
{isLoadingModels ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
Loading models...
</div>
) : (
<span className="text-sm text-bolt-elements-textSecondary">
{ollamaModels.length} models available
</span>
)}
</div>
<div className="space-y-2">
{ollamaModels.map((model) => (
<div
key={model.name}
className="flex items-center justify-between p-2 rounded-lg bg-bolt-elements-background-depth-3"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
{model.status === 'updating' && (
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 text-purple-500" />
)}
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
<span>Version: {model.digest.substring(0, 7)}</span>
{model.status === 'updated' && model.newDigest && (
<>
<div className="i-ph:arrow-right w-3 h-3" />
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
</>
)}
{model.progress && (
<span className="ml-2">
{model.progress.status}{' '}
{model.progress.total > 0 && (
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
)}
</span>
)}
{model.details && (
<span className="ml-2">
({model.details.parameter_size}, {model.details.quantization_level})
</span>
)}
</div>
</div>
<motion.button
onClick={() => handleUpdateOllamaModel(model.name)}
disabled={model.status === 'updating'}
className={classNames(
settingsStyles.button.base,
settingsStyles.button.secondary,
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20 dark:text-bolt-elements-textPrimary dark:hover:text-purple-500',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update
</motion.button>
</div>
))}
</div>
</div>
)}
<motion.div <motion.div
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none" className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
animate={{ animate={{
@@ -276,36 +556,10 @@ const LocalProvidersTab = () => {
}} }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
/> />
{provider.name === 'Ollama' && provider.settings.enabled && (
<motion.button
onClick={() => setShowOllamaUpdater(true)}
className={classNames(
settingsStyles.button.base,
settingsStyles.button.secondary,
'ml-2',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20 dark:text-bolt-elements-textPrimary dark:hover:text-purple-500',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update Models
</motion.button>
)}
</motion.div> </motion.div>
))} ))}
</div> </div>
</motion.div> </motion.div>
<DialogRoot open={showOllamaUpdater} onOpenChange={setShowOllamaUpdater}>
<Dialog>
<div className="p-6">
<OllamaModelUpdater />
</div>
</Dialog>
</DialogRoot>
</div> </div>
); );
}; };

View File

@@ -6,6 +6,8 @@ import { Switch } from '~/components/ui/Switch';
import { themeStore, kTheme } from '~/lib/stores/theme'; import { themeStore, kTheme } from '~/lib/stores/theme';
import type { UserProfile } from '~/components/settings/settings.types'; import type { UserProfile } from '~/components/settings/settings.types';
import { settingsStyles } from '~/components/settings/settings.styles'; import { settingsStyles } from '~/components/settings/settings.styles';
import { useStore } from '@nanostores/react';
import { shortcutsStore } from '~/lib/stores/settings';
export default function SettingsTab() { export default function SettingsTab() {
const [currentTimezone, setCurrentTimezone] = useState(''); const [currentTimezone, setCurrentTimezone] = useState('');
@@ -212,6 +214,39 @@ export default function SettingsTab() {
</select> </select>
</div> </div>
</motion.div> </motion.div>
{/* Keyboard Shortcuts */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:keyboard-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Keyboard Shortcuts</span>
</div>
<div className="space-y-2">
{Object.entries(useStore(shortcutsStore)).map(([name, shortcut]) => (
<div key={name} className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A]">
<span className="text-sm text-bolt-elements-textPrimary capitalize">
{name.replace(/([A-Z])/g, ' $1').toLowerCase()}
</span>
<div className="flex items-center gap-1">
{shortcut.ctrlOrMetaKey && (
<kbd className="kdb">{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}</kbd>
)}
{shortcut.ctrlKey && <kbd className="kdb">Ctrl</kbd>}
{shortcut.metaKey && <kbd className="kdb"></kbd>}
{shortcut.shiftKey && <kbd className="kdb"></kbd>}
{shortcut.altKey && <kbd className="kdb"></kbd>}
<kbd className="kdb">{shortcut.key.toUpperCase()}</kbd>
</div>
</div>
))}
</div>
</motion.div>
</div> </div>
); );
} }

View File

@@ -36,7 +36,7 @@ const DraggableTabItem = ({
onWindowChange, onWindowChange,
onVisibilityChange, onVisibilityChange,
}: DraggableTabItemProps) => { }: DraggableTabItemProps) => {
const [{ isDragging }, drag] = useDrag({ const [{ isDragging }, dragRef] = useDrag({
type: 'tab', type: 'tab',
item: { type: 'tab', index, id: tab.id }, item: { type: 'tab', index, id: tab.id },
collect: (monitor) => ({ collect: (monitor) => ({
@@ -44,7 +44,7 @@ const DraggableTabItem = ({
}), }),
}); });
const [, drop] = useDrop({ const [, dropRef] = useDrop({
accept: 'tab', accept: 'tab',
hover: (item: DragItem, monitor) => { hover: (item: DragItem, monitor) => {
if (!monitor.isOver({ shallow: true })) { if (!monitor.isOver({ shallow: true })) {
@@ -64,9 +64,14 @@ const DraggableTabItem = ({
}, },
}); });
const ref = (node: HTMLDivElement | null) => {
dragRef(node);
dropRef(node);
};
return ( return (
<motion.div <motion.div
ref={(node) => drag(drop(node))} ref={ref}
initial={false} initial={false}
animate={{ animate={{
scale: isDragging ? 1.02 : 1, scale: isDragging ? 1.02 : 1,

View File

@@ -55,7 +55,7 @@ export const TabTile = ({
'border border-[#E5E5E5]/50 dark:border-[#333333]/50', 'border border-[#E5E5E5]/50 dark:border-[#333333]/50',
// Shadow and glass effect // Shadow and glass effect
'shadow-sm backdrop-blur-sm', 'shadow-sm',
'dark:shadow-[0_0_15px_rgba(0,0,0,0.1)]', 'dark:shadow-[0_0_15px_rgba(0,0,0,0.1)]',
'dark:bg-opacity-50', 'dark:bg-opacity-50',

View File

@@ -1,7 +1,7 @@
import * as RadixDialog from '@radix-ui/react-dialog'; import * as RadixDialog from '@radix-ui/react-dialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { DialogTitle } from '~/components/ui/Dialog'; import { DialogTitle } from '~/components/ui/Dialog';
import { Switch } from '~/components/ui/Switch'; import { Switch } from '~/components/ui/Switch';
@@ -9,7 +9,6 @@ import type { TabType, TabVisibilityConfig } from '~/components/settings/setting
import { TAB_LABELS } from '~/components/settings/settings.types'; import { TAB_LABELS } from '~/components/settings/settings.types';
import { DeveloperWindow } from '~/components/settings/developer/DeveloperWindow'; import { DeveloperWindow } from '~/components/settings/developer/DeveloperWindow';
import { TabTile } from '~/components/settings/shared/TabTile'; import { TabTile } from '~/components/settings/shared/TabTile';
import { tabConfigurationStore, updateTabConfiguration } from '~/lib/stores/settings';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
@@ -30,6 +29,13 @@ import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab'; import CloudProvidersTab from '~/components/settings/providers/CloudProvidersTab';
import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab'; import LocalProvidersTab from '~/components/settings/providers/LocalProvidersTab';
import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab'; import TaskManagerTab from '~/components/settings/task-manager/TaskManagerTab';
import {
tabConfigurationStore,
resetTabConfiguration,
updateTabConfiguration,
developerModeStore,
setDeveloperMode,
} from '~/lib/stores/settings';
interface DraggableTabTileProps { interface DraggableTabTileProps {
tab: TabVisibilityConfig; tab: TabVisibilityConfig;
@@ -89,8 +95,14 @@ const DraggableTabTile = ({
}, },
}); });
const dragDropRef = (node: HTMLDivElement | null) => {
if (node) {
drag(drop(node));
}
};
return ( return (
<div ref={(node) => drag(drop(node))} style={{ opacity: isDragging ? 0.5 : 1 }}> <div ref={dragDropRef} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile <TabTile
tab={tab} tab={tab}
onClick={onClick} onClick={onClick}
@@ -110,10 +122,15 @@ interface UsersWindowProps {
} }
export const UsersWindow = ({ open, onClose }: UsersWindowProps) => { export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
const [developerMode, setDeveloperMode] = useState(false);
const [activeTab, setActiveTab] = useState<TabType | null>(null); const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null); const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const tabConfiguration = useStore(tabConfigurationStore); const tabConfiguration = useStore(tabConfigurationStore);
const developerMode = useStore(developerModeStore);
const [showDeveloperWindow, setShowDeveloperWindow] = useState(false);
const [profile, setProfile] = useState(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved ? JSON.parse(saved) : { avatar: null, notifications: true };
});
// Status hooks // Status hooks
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck(); const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
@@ -122,11 +139,7 @@ export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
const [profile, setProfile] = useState(() => { // Listen for profile changes
const saved = localStorage.getItem('bolt_user_profile');
return saved ? JSON.parse(saved) : { avatar: null, notifications: true };
});
useEffect(() => { useEffect(() => {
const handleStorageChange = (e: StorageEvent) => { const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'bolt_user_profile') { if (e.key === 'bolt_user_profile') {
@@ -140,8 +153,66 @@ export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
return () => window.removeEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange);
}, []); }, []);
// Listen for settings toggle event
useEffect(() => {
const handleToggleSettings = () => {
if (!open) {
// Open settings panel
setActiveTab('settings');
onClose(); // Close any other open panels
}
};
document.addEventListener('toggle-settings', handleToggleSettings);
return () => document.removeEventListener('toggle-settings', handleToggleSettings);
}, [open, onClose]);
// Ensure tab configuration is properly initialized
useEffect(() => {
if (!tabConfiguration || !tabConfiguration.userTabs || !tabConfiguration.developerTabs) {
console.warn('Tab configuration is invalid, resetting to defaults');
resetTabConfiguration();
} else {
// Validate tab configuration structure
const isValid =
tabConfiguration.userTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
) &&
tabConfiguration.developerTabs.every(
(tab) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
);
if (!isValid) {
console.warn('Tab configuration is malformed, resetting to defaults');
resetTabConfiguration();
}
}
}, [tabConfiguration]);
// Handle developer mode changes
const handleDeveloperModeChange = (checked: boolean) => { const handleDeveloperModeChange = (checked: boolean) => {
setDeveloperMode(checked); setDeveloperMode(checked);
if (checked) {
setShowDeveloperWindow(true);
}
};
// Handle developer window close
const handleDeveloperWindowClose = () => {
setShowDeveloperWindow(false);
setDeveloperMode(false);
}; };
const handleBack = () => { const handleBack = () => {
@@ -149,21 +220,55 @@ export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
}; };
// Only show tabs that are assigned to the user window AND are visible // Only show tabs that are assigned to the user window AND are visible
const visibleUserTabs = tabConfiguration.userTabs const visibleUserTabs = useMemo(() => {
console.log('Filtering user tabs with configuration:', tabConfiguration);
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
console.warn('Invalid tab configuration, using empty array');
return [];
}
return tabConfiguration.userTabs
.filter((tab) => { .filter((tab) => {
// Hide notifications tab if notifications are disabled if (!tab || typeof tab.id !== 'string') {
if (tab.id === 'notifications' && !profile.notifications) { console.warn('Invalid tab entry:', tab);
return false; return false;
} }
return tab.visible; // Hide notifications tab if notifications are disabled
if (tab.id === 'notifications' && !profile.notifications) {
console.log('Hiding notifications tab due to disabled notifications');
return false;
}
// Ensure the tab has the required properties
if (typeof tab.visible !== 'boolean' || typeof tab.window !== 'string' || typeof tab.order !== 'number') {
console.warn('Tab missing required properties:', tab);
return false;
}
// Only show tabs that are explicitly visible and assigned to the user window
const isVisible = tab.visible && tab.window === 'user';
console.log(`Tab ${tab.id} visibility:`, isVisible);
return isVisible;
}) })
.sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => (a.order || 0) - (b.order || 0)); .sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => {
const orderA = typeof a.order === 'number' ? a.order : 0;
const orderB = typeof b.order === 'number' ? b.order : 0;
return orderA - orderB;
});
}, [tabConfiguration, profile.notifications]);
console.log('Filtered visible user tabs:', visibleUserTabs);
const moveTab = (dragIndex: number, hoverIndex: number) => { const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleUserTabs[dragIndex]; const draggedTab = visibleUserTabs[dragIndex];
const targetTab = visibleUserTabs[hoverIndex]; const targetTab = visibleUserTabs[hoverIndex];
console.log('Moving tab:', { draggedTab, targetTab });
// Update the order of the dragged and target tabs // Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order }; const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order }; const updatedTargetTab = { ...targetTab, order: draggedTab.order };
@@ -310,7 +415,7 @@ export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
className="data-[state=checked]:bg-purple-500" className="data-[state=checked]:bg-purple-500"
aria-label="Toggle developer mode" aria-label="Toggle developer mode"
/> />
<label className="text-sm text-gray-500 dark:text-gray-400">Developer Mode</label> <label className="text-sm text-gray-500 dark:text-gray-400">Switch to Developer Mode</label>
</div> </div>
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -412,9 +517,9 @@ export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
return ( return (
<> <>
<DeveloperWindow open={developerMode} onClose={() => setDeveloperMode(false)} /> <DeveloperWindow open={showDeveloperWindow} onClose={handleDeveloperWindowClose} />
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}> <RadixDialog.Root open={open && !showDeveloperWindow}>
<RadixDialog.Portal> <RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[50]"> <div className="fixed inset-0 flex items-center justify-center z-[50]">
<RadixDialog.Overlay asChild> <RadixDialog.Overlay asChild>

View File

@@ -87,7 +87,9 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
<DropdownMenu.Root open={isActive} modal={false}> <DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<span <span
ref={(ref) => (segmentRefs.current[index] = ref)} ref={(ref) => {
segmentRefs.current[index] = ref;
}}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', { className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive, 'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive, 'text-bolt-elements-textPrimary underline': isActive,

View File

@@ -5,6 +5,8 @@ import type { IProviderConfig } from '~/types/model';
import type { TabVisibilityConfig, TabWindowConfig } from '~/components/settings/settings.types'; import type { TabVisibilityConfig, TabWindowConfig } from '~/components/settings/settings.types';
import { DEFAULT_TAB_CONFIG } from '~/components/settings/settings.types'; import { DEFAULT_TAB_CONFIG } from '~/components/settings/settings.types';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { toggleTheme } from './theme';
import { chatStore } from './chat';
export interface Shortcut { export interface Shortcut {
key: string; key: string;
@@ -18,6 +20,9 @@ export interface Shortcut {
export interface Shortcuts { export interface Shortcuts {
toggleTerminal: Shortcut; toggleTerminal: Shortcut;
toggleTheme: Shortcut;
toggleChat: Shortcut;
toggleSettings: Shortcut;
} }
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike']; export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
@@ -31,6 +36,25 @@ export const shortcutsStore = map<Shortcuts>({
ctrlOrMetaKey: true, ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(), action: () => workbenchStore.toggleTerminal(),
}, },
toggleTheme: {
key: 't',
ctrlOrMetaKey: true,
shiftKey: true,
action: () => toggleTheme(),
},
toggleChat: {
key: '/',
ctrlOrMetaKey: true,
action: () => chatStore.setKey('showChat', !chatStore.get().showChat),
},
toggleSettings: {
key: ',',
ctrlOrMetaKey: true,
action: () => {
// This will be connected to the settings panel toggle
document.dispatchEvent(new CustomEvent('toggle-settings'));
},
},
}); });
const initialProviderSettings: ProviderSetting = {}; const initialProviderSettings: ProviderSetting = {};
@@ -70,18 +94,69 @@ export const enableContextOptimizationStore = atom(false);
// Initialize tab configuration from cookie or default // Initialize tab configuration from cookie or default
const savedTabConfig = Cookies.get('tabConfiguration'); const savedTabConfig = Cookies.get('tabConfiguration');
const initialTabConfig: TabWindowConfig = savedTabConfig console.log('Saved tab configuration:', savedTabConfig);
? JSON.parse(savedTabConfig)
: { let initialTabConfig: TabWindowConfig;
try {
if (savedTabConfig) {
const parsedConfig = JSON.parse(savedTabConfig);
// Validate the parsed configuration
if (
parsedConfig &&
Array.isArray(parsedConfig.userTabs) &&
Array.isArray(parsedConfig.developerTabs) &&
parsedConfig.userTabs.every(
(tab: any) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
) &&
parsedConfig.developerTabs.every(
(tab: any) =>
tab &&
typeof tab.id === 'string' &&
typeof tab.visible === 'boolean' &&
typeof tab.window === 'string' &&
typeof tab.order === 'number',
)
) {
initialTabConfig = parsedConfig;
console.log('Using saved tab configuration');
} else {
console.warn('Invalid saved tab configuration, using defaults');
initialTabConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'), userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'), developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
}; };
}
} else {
console.log('No saved tab configuration found, using defaults');
initialTabConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
};
}
} catch (error) {
console.error('Error loading tab configuration:', error);
initialTabConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
};
}
console.log('Initial tab configuration:', initialTabConfig);
export const tabConfigurationStore = map<TabWindowConfig>(initialTabConfig); export const tabConfigurationStore = map<TabWindowConfig>(initialTabConfig);
// Helper function to update tab configuration // Helper function to update tab configuration
export const updateTabConfiguration = (config: TabVisibilityConfig) => { export const updateTabConfiguration = (config: TabVisibilityConfig) => {
const currentConfig = tabConfigurationStore.get(); const currentConfig = tabConfigurationStore.get();
console.log('Current tab configuration before update:', currentConfig);
const isUserTab = config.window === 'user'; const isUserTab = config.window === 'user';
const targetArray = isUserTab ? 'userTabs' : 'developerTabs'; const targetArray = isUserTab ? 'userTabs' : 'developerTabs';
@@ -99,16 +174,38 @@ export const updateTabConfiguration = (config: TabVisibilityConfig) => {
[targetArray]: updatedTabs, [targetArray]: updatedTabs,
}; };
console.log('New tab configuration after update:', newConfig);
tabConfigurationStore.set(newConfig); tabConfigurationStore.set(newConfig);
Cookies.set('tabConfiguration', JSON.stringify(newConfig)); Cookies.set('tabConfiguration', JSON.stringify(newConfig), {
expires: 365, // Set cookie to expire in 1 year
path: '/',
sameSite: 'strict',
});
}; };
// Helper function to reset tab configuration // Helper function to reset tab configuration
export const resetTabConfiguration = () => { export const resetTabConfiguration = () => {
console.log('Resetting tab configuration to defaults');
const defaultConfig: TabWindowConfig = { const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'), userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'), developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
}; };
console.log('Default tab configuration:', defaultConfig);
tabConfigurationStore.set(defaultConfig); tabConfigurationStore.set(defaultConfig);
Cookies.set('tabConfiguration', JSON.stringify(defaultConfig)); Cookies.set('tabConfiguration', JSON.stringify(defaultConfig), {
expires: 365, // Set cookie to expire in 1 year
path: '/',
sameSite: 'strict',
});
};
// Developer mode store
export const developerModeStore = atom<boolean>(false);
export const setDeveloperMode = (value: boolean) => {
developerModeStore.set(value);
}; };

View File

@@ -2,7 +2,7 @@ import { json } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node'; import type { LoaderFunctionArgs } from '@remix-run/node';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request: _request }: LoaderFunctionArgs) {
try { try {
const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
const commit = execSync('git rev-parse --short HEAD').toString().trim(); const commit = execSync('git rev-parse --short HEAD').toString().trim();

View File

@@ -59,16 +59,17 @@
"@octokit/rest": "^21.0.2", "@octokit/rest": "^21.0.2",
"@octokit/types": "^13.6.2", "@octokit/types": "^13.6.2",
"@openrouter/ai-sdk-provider": "^0.0.5", "@openrouter/ai-sdk-provider": "^0.0.5",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@remix-run/cloudflare": "^2.15.0", "@remix-run/cloudflare": "^2.15.2",
"@remix-run/cloudflare-pages": "^2.15.0", "@remix-run/cloudflare-pages": "^2.15.2",
"@remix-run/node": "^2.15.2", "@remix-run/node": "^2.15.2",
"@remix-run/react": "^2.15.0", "@remix-run/react": "^2.15.2",
"@uiw/codemirror-theme-vscode": "^4.23.6", "@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9", "@unocss/reset": "^0.61.9",
"@webcontainer/api": "1.3.0-internal.10", "@webcontainer/api": "1.3.0-internal.10",
@@ -118,7 +119,7 @@
"@cloudflare/workers-types": "^4.20241127.0", "@cloudflare/workers-types": "^4.20241127.0",
"@iconify-json/ph": "^1.2.1", "@iconify-json/ph": "^1.2.1",
"@iconify/types": "^2.0.0", "@iconify/types": "^2.0.0",
"@remix-run/dev": "^2.15.0", "@remix-run/dev": "^2.15.2",
"@types/diff": "^5.2.3", "@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4", "@types/dom-speech-recognition": "^0.0.4",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",

View File

@@ -1,4 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {

765
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff