diff --git a/.gitignore b/.gitignore index 4bc03e175d..37d2abfaf8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ docs/instructions/Roadmap.md .cursorrules *.md .qodo +CLAUDE.md diff --git a/app/components/workbench/AutoSaveSettings.tsx b/app/components/workbench/AutoSaveSettings.tsx new file mode 100644 index 0000000000..455d102316 --- /dev/null +++ b/app/components/workbench/AutoSaveSettings.tsx @@ -0,0 +1,299 @@ +import { memo, useState, useEffect } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import * as Switch from '@radix-ui/react-switch'; +import * as Slider from '@radix-ui/react-slider'; +import { classNames } from '~/utils/classNames'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface AutoSaveSettingsProps { + onSettingsChange?: (settings: AutoSaveConfig) => void; + trigger?: React.ReactNode; +} + +export interface AutoSaveConfig { + enabled: boolean; + interval: number; // in seconds + minChanges: number; + saveOnBlur: boolean; + saveBeforeRun: boolean; + showNotifications: boolean; +} + +const DEFAULT_CONFIG: AutoSaveConfig = { + enabled: false, + interval: 30, + minChanges: 1, + saveOnBlur: true, + saveBeforeRun: true, + showNotifications: true, +}; + +const PRESET_INTERVALS = [ + { label: '10s', value: 10 }, + { label: '30s', value: 30 }, + { label: '1m', value: 60 }, + { label: '2m', value: 120 }, + { label: '5m', value: 300 }, +]; + +export const AutoSaveSettings = memo(({ onSettingsChange, trigger }: AutoSaveSettingsProps) => { + const [isOpen, setIsOpen] = useState(false); + const [config, setConfig] = useState(() => { + // Load from localStorage if available + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('bolt-autosave-config'); + + if (saved) { + try { + return JSON.parse(saved); + } catch { + // Invalid JSON, use defaults + } + } + } + + return DEFAULT_CONFIG; + }); + + // Save to localStorage whenever config changes + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('bolt-autosave-config', JSON.stringify(config)); + } + + onSettingsChange?.(config); + }, [config, onSettingsChange]); + + const updateConfig = (key: K, value: AutoSaveConfig[K]) => { + setConfig((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + + {trigger || ( + + )} + + + + {isOpen && ( + + + + + + + +
+ {/* Header */} +
+ + Auto-save Settings + + +
+ +
+ + {/* Content */} +
+ {/* Enable/Disable Auto-save */} +
+
+ +

+ Automatically save files at regular intervals +

+
+ updateConfig('enabled', checked)} + className={classNames( + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', + config.enabled ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3', + )} + > + + +
+ + {/* Save Interval */} +
+
+ +

How often to save changes

+
+ + updateConfig('interval', value)} + min={5} + max={300} + step={5} + className="relative flex items-center select-none touch-none w-full h-5" + > + + + + + + + {/* Preset buttons */} +
+ {PRESET_INTERVALS.map((preset) => ( + + ))} +
+
+ + {/* Minimum Changes */} +
+
+ +

+ Minimum number of files to trigger auto-save +

+
+ + updateConfig('minChanges', value)} + min={1} + max={10} + step={1} + className="relative flex items-center select-none touch-none w-full h-5" + > + + + + + +
+ + {/* Additional Options */} +
+
+
+ +

+ Save when switching to another tab +

+
+ updateConfig('saveOnBlur', checked)} + className={classNames( + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', + config.saveOnBlur ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3', + )} + > + + +
+ +
+
+ +

+ Save all files before running commands +

+
+ updateConfig('saveBeforeRun', checked)} + className={classNames( + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', + config.saveBeforeRun ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3', + )} + > + + +
+ +
+
+ +

+ Display toast notifications on save +

+
+ updateConfig('showNotifications', checked)} + className={classNames( + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', + config.showNotifications ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3', + )} + > + + +
+
+
+ + {/* Footer */} +
+ + + Done + +
+
+ + + + )} + + + ); +}); + +AutoSaveSettings.displayName = 'AutoSaveSettings'; diff --git a/app/components/workbench/FileStatusIndicator.tsx b/app/components/workbench/FileStatusIndicator.tsx new file mode 100644 index 0000000000..7c1af5c5e5 --- /dev/null +++ b/app/components/workbench/FileStatusIndicator.tsx @@ -0,0 +1,145 @@ +import { useStore } from '@nanostores/react'; +import { memo, useMemo } from 'react'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { classNames } from '~/utils/classNames'; +import { motion } from 'framer-motion'; + +interface FileStatusIndicatorProps { + className?: string; + showDetails?: boolean; +} + +export const FileStatusIndicator = memo(({ className = '', showDetails = true }: FileStatusIndicatorProps) => { + const unsavedFiles = useStore(workbenchStore.unsavedFiles); + const files = useStore(workbenchStore.files); + + const stats = useMemo(() => { + let totalFiles = 0; + let totalFolders = 0; + let totalSize = 0; + + Object.entries(files).forEach(([_path, dirent]) => { + if (dirent?.type === 'file') { + totalFiles++; + totalSize += dirent.content?.length || 0; + } else if (dirent?.type === 'folder') { + totalFolders++; + } + }); + + return { + totalFiles, + totalFolders, + unsavedCount: unsavedFiles.size, + totalSize: formatFileSize(totalSize), + modifiedPercentage: totalFiles > 0 ? Math.round((unsavedFiles.size / totalFiles) * 100) : 0, + }; + }, [files, unsavedFiles]); + + function formatFileSize(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + } + + const getStatusColor = () => { + if (stats.unsavedCount === 0) { + return 'text-green-500'; + } + + if (stats.modifiedPercentage > 50) { + return 'text-red-500'; + } + + if (stats.modifiedPercentage > 20) { + return 'text-yellow-500'; + } + + return 'text-orange-500'; + }; + + return ( +
+ {/* Status dot with pulse animation */} +
+ 0 ? [1, 1.2, 1] : 1, + }} + transition={{ + duration: 2, + repeat: stats.unsavedCount > 0 ? Infinity : 0, + repeatType: 'loop', + }} + className={classNames( + 'w-2 h-2 rounded-full', + getStatusColor(), + stats.unsavedCount > 0 ? 'bg-current' : 'bg-green-500', + )} + /> + + {stats.unsavedCount === 0 ? 'All saved' : `${stats.unsavedCount} unsaved`} + +
+ + {showDetails && ( + <> + {/* File count */} +
+
+ {stats.totalFiles} files +
+ + {/* Folder count */} +
+
+ {stats.totalFolders} folders +
+ + {/* Total size */} +
+
+ {stats.totalSize} +
+ + {/* Progress bar for unsaved files */} + {stats.unsavedCount > 0 && ( +
+ {stats.modifiedPercentage}% modified +
+ 50 + ? 'bg-red-500' + : stats.modifiedPercentage > 20 + ? 'bg-yellow-500' + : 'bg-orange-500', + )} + /> +
+
+ )} + + )} +
+ ); +}); + +FileStatusIndicator.displayName = 'FileStatusIndicator'; diff --git a/app/components/workbench/KeyboardSaveAll.tsx b/app/components/workbench/KeyboardSaveAll.tsx new file mode 100644 index 0000000000..952755abd5 --- /dev/null +++ b/app/components/workbench/KeyboardSaveAll.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { workbenchStore } from '~/lib/stores/workbench'; + +export function useKeyboardSaveAll() { + useEffect(() => { + const handleKeyPress = async (e: KeyboardEvent) => { + // Ctrl+Shift+S or Cmd+Shift+S to save all + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') { + e.preventDefault(); + + const unsavedFiles = workbenchStore.unsavedFiles.get(); + + if (unsavedFiles.size === 0) { + toast.info('All files are already saved', { + position: 'bottom-right', + autoClose: 2000, + }); + return; + } + + try { + const count = unsavedFiles.size; + await workbenchStore.saveAllFiles(); + + toast.success(`Saved ${count} file${count > 1 ? 's' : ''}`, { + position: 'bottom-right', + autoClose: 2000, + }); + } catch { + toast.error('Failed to save some files', { + position: 'bottom-right', + autoClose: 3000, + }); + } + } + }; + + window.addEventListener('keydown', handleKeyPress); + + return () => window.removeEventListener('keydown', handleKeyPress); + }, []); +} diff --git a/app/components/workbench/SaveAllButton.tsx b/app/components/workbench/SaveAllButton.tsx new file mode 100644 index 0000000000..2e188cf45d --- /dev/null +++ b/app/components/workbench/SaveAllButton.tsx @@ -0,0 +1,305 @@ +import { useStore } from '@nanostores/react'; +import { memo, useCallback, useEffect, useState, useRef } from 'react'; +import { toast } from 'react-toastify'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { classNames } from '~/utils/classNames'; + +interface SaveAllButtonProps { + className?: string; + variant?: 'icon' | 'button'; + showCount?: boolean; + autoSave?: boolean; + autoSaveInterval?: number; +} + +export const SaveAllButton = memo( + ({ + className = '', + variant = 'icon', + showCount = true, + autoSave = false, + autoSaveInterval = 30000, + }: SaveAllButtonProps) => { + const unsavedFiles = useStore(workbenchStore.unsavedFiles); + const [isSaving, setIsSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [timeUntilAutoSave, setTimeUntilAutoSave] = useState(null); + const autoSaveTimerRef = useRef(null); + const countdownTimerRef = useRef(null); + + const unsavedCount = unsavedFiles.size; + const hasUnsavedFiles = unsavedCount > 0; + + // Log unsaved files state changes + useEffect(() => { + console.log('[SaveAllButton] Unsaved files changed:', { + count: unsavedCount, + files: Array.from(unsavedFiles), + hasUnsavedFiles, + }); + }, [unsavedFiles, unsavedCount, hasUnsavedFiles]); + + // Auto-save logic + useEffect(() => { + if (!autoSave || !hasUnsavedFiles) { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + + setTimeUntilAutoSave(null); + + return; + } + + // Set up auto-save timer + console.log('[SaveAllButton] Setting up auto-save timer for', autoSaveInterval, 'ms'); + autoSaveTimerRef.current = setTimeout(async () => { + if (hasUnsavedFiles && !isSaving) { + console.log('[SaveAllButton] Auto-save triggered'); + await handleSaveAll(true); + } + }, autoSaveInterval); + + // Set up countdown timer + const startTime = Date.now(); + setTimeUntilAutoSave(Math.ceil(autoSaveInterval / 1000)); + + countdownTimerRef.current = setInterval(() => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, autoSaveInterval - elapsed); + setTimeUntilAutoSave(Math.ceil(remaining / 1000)); + + if (remaining <= 0 && countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + }, 1000); + }, [autoSave, hasUnsavedFiles, autoSaveInterval, isSaving]); + + // Cleanup effect + useEffect(() => { + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + } + }; + }, []); + + const handleSaveAll = useCallback( + async (isAutoSave = false) => { + if (!hasUnsavedFiles || isSaving) { + console.log('[SaveAllButton] Skipping save:', { hasUnsavedFiles, isSaving }); + return; + } + + console.log('[SaveAllButton] Starting save:', { + unsavedCount, + isAutoSave, + files: Array.from(unsavedFiles), + }); + + setIsSaving(true); + + const startTime = performance.now(); + const savedFiles: string[] = []; + const failedFiles: string[] = []; + + try { + // Save each file individually with detailed logging + for (const filePath of unsavedFiles) { + try { + console.log(`[SaveAllButton] Saving file: ${filePath}`); + await workbenchStore.saveFile(filePath); + savedFiles.push(filePath); + console.log(`[SaveAllButton] Successfully saved: ${filePath}`); + } catch (error) { + console.error(`[SaveAllButton] Failed to save ${filePath}:`, error); + failedFiles.push(filePath); + } + } + + const endTime = performance.now(); + const duration = Math.round(endTime - startTime); + setLastSaved(new Date()); + + // Check final state + const remainingUnsaved = workbenchStore.unsavedFiles.get(); + console.log('[SaveAllButton] Save complete:', { + savedCount: savedFiles.length, + failedCount: failedFiles.length, + remainingUnsaved: Array.from(remainingUnsaved), + duration, + }); + + // Show appropriate feedback + if (failedFiles.length === 0) { + const message = isAutoSave + ? `Auto-saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}` + : `Saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`; + + toast.success(message, { + position: 'bottom-right', + autoClose: 2000, + }); + } else { + toast.warning(`Saved ${savedFiles.length} files, ${failedFiles.length} failed`, { + position: 'bottom-right', + autoClose: 3000, + }); + } + } catch (error) { + console.error('[SaveAllButton] Critical error during save:', error); + toast.error('Failed to save files', { + position: 'bottom-right', + autoClose: 3000, + }); + } finally { + setIsSaving(false); + } + }, + [hasUnsavedFiles, isSaving, unsavedCount, unsavedFiles], + ); + + // Keyboard shortcut + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') { + e.preventDefault(); + handleSaveAll(false); + } + }; + + window.addEventListener('keydown', handleKeyPress); + + return () => window.removeEventListener('keydown', handleKeyPress); + }, [handleSaveAll]); + + const formatLastSaved = () => { + if (!lastSaved) { + return null; + } + + const now = new Date(); + const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000); + + if (diff < 60) { + return `${diff}s ago`; + } + + if (diff < 3600) { + return `${Math.floor(diff / 60)}m ago`; + } + + return `${Math.floor(diff / 3600)}h ago`; + }; + + // Icon-only variant for header + if (variant === 'icon') { + return ( + + + + + + + +
+
+ {hasUnsavedFiles ? `${unsavedCount} unsaved file${unsavedCount > 1 ? 's' : ''}` : 'All files saved'} +
+ {lastSaved &&
Last saved: {formatLastSaved()}
} + {autoSave && hasUnsavedFiles && timeUntilAutoSave && ( +
Auto-save in: {timeUntilAutoSave}s
+ )} +
+ Ctrl+Shift+S to save all +
+
+ +
+
+
+
+ ); + } + + // Button variant + return ( + + + + + + + +
+ Ctrl+Shift+S to save all +
+ +
+
+
+
+ ); + }, +); + +SaveAllButton.displayName = 'SaveAllButton'; diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 315f48d1ae..147c922197 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -22,7 +22,7 @@ import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; import useViewport from '~/lib/hooks'; - +import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { usePreviewStore } from '~/lib/stores/previews'; import { chatStore } from '~/lib/stores/chat'; @@ -279,19 +279,47 @@ const FileModifiedDropdown = memo( ); export const Workbench = memo( - ({ - chatStarted, - isStreaming, - metadata: _metadata, - updateChatMestaData: _updateChatMestaData, - setSelectedElement, - }: WorkspaceProps) => { + ({ chatStarted, isStreaming, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => { renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); - + const [isPushDialogOpen, setIsPushDialogOpen] = useState(false); const [fileHistory, setFileHistory] = useState>({}); + // Keyboard shortcut for Save All (Ctrl+Shift+S) + useEffect(() => { + const handleKeyPress = async (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') { + e.preventDefault(); + + const unsavedFiles = workbenchStore.unsavedFiles.get(); + + if (unsavedFiles.size > 0) { + try { + await workbenchStore.saveAllFiles(); + toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, { + position: 'bottom-right', + autoClose: 2000, + }); + } catch { + toast.error('Failed to save some files', { + position: 'bottom-right', + autoClose: 3000, + }); + } + } else { + toast.info('All files are already saved', { + position: 'bottom-right', + autoClose: 2000, + }); + } + } + }; + window.addEventListener('keydown', handleKeyPress); + + return () => window.removeEventListener('keydown', handleKeyPress); + }, []); + // const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys()); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); @@ -356,8 +384,8 @@ export const Workbench = memo( const directoryHandle = await window.showDirectoryPicker(); await workbenchStore.syncFiles(directoryHandle); toast.success('Files synced successfully'); - } catch (error) { - console.error('Error syncing files:', error); + } catch { + console.error('Error syncing files'); toast.error('Failed to sync files'); } finally { setIsSyncing(false); @@ -404,6 +432,42 @@ export const Workbench = memo(
{selectedView === 'code' && (
+ { + console.log('[SaveAll] Button clicked'); + + const unsavedFiles = workbenchStore.unsavedFiles.get(); + console.log('[SaveAll] Unsaved files:', Array.from(unsavedFiles)); + + if (unsavedFiles.size > 0) { + try { + console.log('[SaveAll] Starting save...'); + await workbenchStore.saveAllFiles(); + toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, { + position: 'bottom-right', + autoClose: 2000, + }); + console.log('[SaveAll] Save successful'); + } catch { + console.error('[SaveAll] Save failed'); + toast.error('Failed to save files', { + position: 'bottom-right', + autoClose: 3000, + }); + } + } else { + console.log('[SaveAll] No unsaved files'); + toast.info('All files are already saved', { + position: 'bottom-right', + autoClose: 2000, + }); + } + }} + > +
+ Save All + { @@ -442,6 +506,17 @@ export const Workbench = memo( {isSyncing ? 'Syncing...' : 'Sync Files'}
+ setIsPushDialogOpen(true)} + > +
+
+ Push to GitHub +
+
@@ -488,6 +563,31 @@ export const Workbench = memo(
+ setIsPushDialogOpen(false)} + onPush={async (repoName, username, token, isPrivate) => { + try { + console.log('Dialog onPush called with isPrivate =', isPrivate); + + const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit'; + const repoUrl = await workbenchStore.pushToGitHub(repoName, commitMessage, username, token, isPrivate); + + if (updateChatMestaData && !metadata?.gitUrl) { + updateChatMestaData({ + ...(metadata || {}), + gitUrl: repoUrl, + }); + } + + return repoUrl; + } catch { + console.error('Error pushing to GitHub'); + toast.error('Failed to push to GitHub'); + throw new Error('Failed to push to GitHub'); + } + }} + /> ) ); diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index cfe65268a8..429c48e573 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -72,6 +72,7 @@ function cleanEscapedTags(content: string) { } export class StreamingMessageParser { #messages = new Map(); + #artifactCounter = 0; constructor(private _options: StreamingMessageParserOptions = {}) {} @@ -300,6 +301,69 @@ export class StreamingMessageParser { break; } } else { + // Check for code blocks outside of artifacts + if (!state.insideArtifact && input[i] === '`' && input[i + 1] === '`' && input[i + 2] === '`') { + // Find the end of the code block + const languageEnd = input.indexOf('\n', i + 3); + + if (languageEnd !== -1) { + const codeBlockEnd = input.indexOf('\n```', languageEnd + 1); + + if (codeBlockEnd !== -1) { + // Extract language and code content + const language = input.slice(i + 3, languageEnd).trim(); + const codeContent = input.slice(languageEnd + 1, codeBlockEnd); + + // Determine file extension based on language + const fileExtension = this.#getFileExtension(language); + const fileName = `code_${++this.#artifactCounter}${fileExtension}`; + + // Auto-generate artifact and action tags + const artifactId = `artifact_${Date.now()}_${this.#artifactCounter}`; + const autoArtifact = { + id: artifactId, + title: fileName, + type: 'code', + }; + + // Emit artifact open callback + this._options.callbacks?.onArtifactOpen?.({ messageId, ...autoArtifact }); + + // Add artifact element to output + const artifactFactory = this._options.artifactElement ?? createArtifactElement; + output += artifactFactory({ messageId }); + + // Emit action for file creation + const fileAction = { + type: 'file' as const, + filePath: fileName, + content: codeContent + '\n', + }; + + this._options.callbacks?.onActionOpen?.({ + artifactId, + messageId, + actionId: String(state.actionId++), + action: fileAction, + }); + + this._options.callbacks?.onActionClose?.({ + artifactId, + messageId, + actionId: String(state.actionId - 1), + action: fileAction, + }); + + // Emit artifact close callback + this._options.callbacks?.onArtifactClose?.({ messageId, ...autoArtifact }); + + // Move position past the code block + i = codeBlockEnd + 4; // +4 for \n``` + continue; + } + } + } + output += input[i]; i++; } @@ -367,6 +431,78 @@ export class StreamingMessageParser { const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i')); return match ? match[1] : undefined; } + + #getFileExtension(language: string): string { + const languageMap: Record = { + javascript: '.js', + js: '.js', + typescript: '.ts', + ts: '.ts', + jsx: '.jsx', + tsx: '.tsx', + python: '.py', + py: '.py', + java: '.java', + c: '.c', + cpp: '.cpp', + 'c++': '.cpp', + csharp: '.cs', + 'c#': '.cs', + php: '.php', + ruby: '.rb', + rb: '.rb', + go: '.go', + rust: '.rs', + rs: '.rs', + kotlin: '.kt', + kt: '.kt', + swift: '.swift', + html: '.html', + css: '.css', + scss: '.scss', + sass: '.sass', + less: '.less', + xml: '.xml', + json: '.json', + yaml: '.yaml', + yml: '.yml', + toml: '.toml', + markdown: '.md', + md: '.md', + sql: '.sql', + sh: '.sh', + bash: '.sh', + zsh: '.sh', + fish: '.fish', + powershell: '.ps1', + ps1: '.ps1', + dockerfile: '.dockerfile', + docker: '.dockerfile', + makefile: '.makefile', + make: '.makefile', + vim: '.vim', + lua: '.lua', + perl: '.pl', + r: '.r', + matlab: '.m', + julia: '.jl', + scala: '.scala', + clojure: '.clj', + haskell: '.hs', + erlang: '.erl', + elixir: '.ex', + nim: '.nim', + crystal: '.cr', + dart: '.dart', + vue: '.vue', + svelte: '.svelte', + astro: '.astro', + }; + + const normalized = language.toLowerCase(); + + return languageMap[normalized] || '.txt'; + } } const createArtifactElement: ElementFactory = (props) => { diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e7b69db17d..d712322fe7 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -223,10 +223,13 @@ export class WorkbenchStore { } async saveFile(filePath: string) { + console.log(`[WorkbenchStore] saveFile called for: ${filePath}`); + const documents = this.#editorStore.documents.get(); const document = documents[filePath]; if (document === undefined) { + console.warn(`[WorkbenchStore] No document found for: ${filePath}`); return; } @@ -236,21 +239,39 @@ export class WorkbenchStore { * This is a more complex feature that would be implemented in a future update */ - await this.#filesStore.saveFile(filePath, document.value); - - const newUnsavedFiles = new Set(this.unsavedFiles.get()); - newUnsavedFiles.delete(filePath); + try { + console.log(`[WorkbenchStore] Saving to file system: ${filePath}`); + await this.#filesStore.saveFile(filePath, document.value); + console.log(`[WorkbenchStore] File saved successfully: ${filePath}`); + + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + const wasUnsaved = newUnsavedFiles.has(filePath); + newUnsavedFiles.delete(filePath); + + console.log(`[WorkbenchStore] Updating unsaved files:`, { + filePath, + wasUnsaved, + previousCount: this.unsavedFiles.get().size, + newCount: newUnsavedFiles.size, + remainingFiles: Array.from(newUnsavedFiles), + }); - this.unsavedFiles.set(newUnsavedFiles); + this.unsavedFiles.set(newUnsavedFiles); + } catch (error) { + console.error(`[WorkbenchStore] Failed to save file ${filePath}:`, error); + throw error; + } } async saveCurrentDocument() { const currentDocument = this.currentDocument.get(); if (currentDocument === undefined) { + console.warn('[WorkbenchStore] No current document to save'); return; } + console.log(`[WorkbenchStore] Saving current document: ${currentDocument.filePath}`); await this.saveFile(currentDocument.filePath); } @@ -272,9 +293,14 @@ export class WorkbenchStore { } async saveAllFiles() { - for (const filePath of this.unsavedFiles.get()) { + const filesToSave = Array.from(this.unsavedFiles.get()); + console.log(`[WorkbenchStore] saveAllFiles called for ${filesToSave.length} files:`, filesToSave); + + for (const filePath of filesToSave) { await this.saveFile(filePath); } + + console.log('[WorkbenchStore] saveAllFiles complete. Remaining unsaved:', Array.from(this.unsavedFiles.get())); } getFileModifcations() { diff --git a/package.json b/package.json index cbf558f6fd..fcf4eec66d 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468c93caad..f272ec9565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@radix-ui/react-separator': specifier: ^1.1.0 version: 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.1 version: 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2297,6 +2300,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -2602,6 +2608,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -10270,6 +10289,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -10587,6 +10608,25 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) diff --git a/start-prod.sh b/start-prod.sh new file mode 100755 index 0000000000..6d019ad89c --- /dev/null +++ b/start-prod.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Set working directory +cd /root/bolt + +# Kill any existing processes on port 5173 +lsof -ti:5173 | xargs -r kill -9 2>/dev/null + +# Set environment variables +export NODE_OPTIONS="--max-old-space-size=3482" +export HOST=0.0.0.0 +export PORT=5173 +# Don't set NODE_ENV for dev server +unset NODE_ENV + +echo "Starting Bolt.gives Server..." +echo "=========================================" +echo "Version: 3.0.1" +echo "Port: 5173" +echo "Host: 0.0.0.0" +echo "URL: https://bolt.openweb.live" +echo "=========================================" + +# Use dev server with fixed dependencies +exec pnpm run dev --host 0.0.0.0 --port 5173 \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 65d4352fca..ddb3eafe06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,19 @@ dotenv.config(); export default defineConfig((config) => { return { + server: { + host: '0.0.0.0', + port: 5173, + hmr: { + host: 'bolt.openweb.live', + protocol: 'wss' + }, + headers: { + 'Cross-Origin-Embedder-Policy': 'credentialless', + 'Cross-Origin-Opener-Policy': 'same-origin', + }, + allowedHosts: ['bolt.openweb.live', 'localhost'] + }, define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), },