Spaces:
Sleeping
Sleeping
| import { useStore } from '@nanostores/react'; | |
| import { motion, type HTMLMotionProps, type Variants } from 'framer-motion'; | |
| import { computed } from 'nanostores'; | |
| import { memo, useCallback, useEffect, useState, useMemo } from 'react'; | |
| import { toast } from 'react-toastify'; | |
| import { Popover, Transition } from '@headlessui/react'; | |
| import { diffLines, type Change } from 'diff'; | |
| import { ActionRunner } from '~/lib/runtime/action-runner'; | |
| import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension'; | |
| import type { FileHistory } from '~/types/actions'; | |
| import { DiffView } from './DiffView'; | |
| import { | |
| type OnChangeCallback as OnEditorChange, | |
| type OnScrollCallback as OnEditorScroll, | |
| } from '~/components/editor/codemirror/CodeMirrorEditor'; | |
| import { IconButton } from '~/components/ui/IconButton'; | |
| import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; | |
| import { Slider, type SliderOptions } from '~/components/ui/Slider'; | |
| import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench'; | |
| import { classNames } from '~/utils/classNames'; | |
| import { cubicEasingFn } from '~/utils/easings'; | |
| 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'; | |
| interface WorkspaceProps { | |
| chatStarted?: boolean; | |
| isStreaming?: boolean; | |
| actionRunner: ActionRunner; | |
| metadata?: { | |
| gitUrl?: string; | |
| }; | |
| updateChatMestaData?: (metadata: any) => void; | |
| } | |
| const viewTransition = { ease: cubicEasingFn }; | |
| const sliderOptions: SliderOptions<WorkbenchViewType> = { | |
| left: { | |
| value: 'code', | |
| text: 'Code', | |
| }, | |
| middle: { | |
| value: 'diff', | |
| text: 'Diff', | |
| }, | |
| right: { | |
| value: 'preview', | |
| text: 'Preview', | |
| }, | |
| }; | |
| const workbenchVariants = { | |
| closed: { | |
| width: 0, | |
| transition: { | |
| duration: 0.2, | |
| ease: cubicEasingFn, | |
| }, | |
| }, | |
| open: { | |
| width: 'var(--workbench-width)', | |
| transition: { | |
| duration: 0.2, | |
| ease: cubicEasingFn, | |
| }, | |
| }, | |
| } satisfies Variants; | |
| const FileModifiedDropdown = memo( | |
| ({ | |
| fileHistory, | |
| onSelectFile, | |
| }: { | |
| fileHistory: Record<string, FileHistory>; | |
| onSelectFile: (filePath: string) => void; | |
| }) => { | |
| const modifiedFiles = Object.entries(fileHistory); | |
| const hasChanges = modifiedFiles.length > 0; | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const filteredFiles = useMemo(() => { | |
| return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase())); | |
| }, [modifiedFiles, searchQuery]); | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <Popover className="relative"> | |
| {({ open }: { open: boolean }) => ( | |
| <> | |
| <Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-item-contentDefault"> | |
| <span>File Changes</span> | |
| {hasChanges && ( | |
| <span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30"> | |
| {modifiedFiles.length} | |
| </span> | |
| )} | |
| </Popover.Button> | |
| <Transition | |
| show={open} | |
| enter="transition duration-100 ease-out" | |
| enterFrom="transform scale-95 opacity-0" | |
| enterTo="transform scale-100 opacity-100" | |
| leave="transition duration-75 ease-out" | |
| leaveFrom="transform scale-100 opacity-100" | |
| leaveTo="transform scale-95 opacity-0" | |
| > | |
| <Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor"> | |
| <div className="p-2"> | |
| <div className="relative mx-2 mb-2"> | |
| <input | |
| type="text" | |
| placeholder="Search files..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50" | |
| /> | |
| <div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary"> | |
| <div className="i-ph:magnifying-glass" /> | |
| </div> | |
| </div> | |
| <div className="max-h-60 overflow-y-auto"> | |
| {filteredFiles.length > 0 ? ( | |
| filteredFiles.map(([filePath, history]) => { | |
| const extension = filePath.split('.').pop() || ''; | |
| const language = getLanguageFromExtension(extension); | |
| return ( | |
| <button | |
| key={filePath} | |
| onClick={() => onSelectFile(filePath)} | |
| className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary"> | |
| {['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && ( | |
| <div className="i-ph:file-js" /> | |
| )} | |
| {['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />} | |
| {language === 'html' && <div className="i-ph:code" />} | |
| {language === 'json' && <div className="i-ph:brackets-curly" />} | |
| {language === 'python' && <div className="i-ph:file-text" />} | |
| {language === 'markdown' && <div className="i-ph:article" />} | |
| {['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />} | |
| {language === 'sql' && <div className="i-ph:database" />} | |
| {language === 'dockerfile' && <div className="i-ph:cube" />} | |
| {language === 'shell' && <div className="i-ph:terminal" />} | |
| {![ | |
| 'typescript', | |
| 'javascript', | |
| 'css', | |
| 'html', | |
| 'json', | |
| 'python', | |
| 'markdown', | |
| 'yaml', | |
| 'yml', | |
| 'sql', | |
| 'dockerfile', | |
| 'shell', | |
| 'jsx', | |
| 'tsx', | |
| 'scss', | |
| 'less', | |
| ].includes(language) && <div className="i-ph:file-text" />} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <div className="flex flex-col min-w-0"> | |
| <span className="truncate text-sm font-medium text-bolt-elements-textPrimary"> | |
| {filePath.split('/').pop()} | |
| </span> | |
| <span className="truncate text-xs text-bolt-elements-textTertiary"> | |
| {filePath} | |
| </span> | |
| </div> | |
| {(() => { | |
| // Calculate diff stats | |
| const { additions, deletions } = (() => { | |
| if (!history.originalContent) { | |
| return { additions: 0, deletions: 0 }; | |
| } | |
| const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n'); | |
| const normalizedCurrent = | |
| history.versions[history.versions.length - 1]?.content.replace( | |
| /\r\n/g, | |
| '\n', | |
| ) || ''; | |
| if (normalizedOriginal === normalizedCurrent) { | |
| return { additions: 0, deletions: 0 }; | |
| } | |
| const changes = diffLines(normalizedOriginal, normalizedCurrent, { | |
| newlineIsToken: false, | |
| ignoreWhitespace: true, | |
| ignoreCase: false, | |
| }); | |
| return changes.reduce( | |
| (acc: { additions: number; deletions: number }, change: Change) => { | |
| if (change.added) { | |
| acc.additions += change.value.split('\n').length; | |
| } | |
| if (change.removed) { | |
| acc.deletions += change.value.split('\n').length; | |
| } | |
| return acc; | |
| }, | |
| { additions: 0, deletions: 0 }, | |
| ); | |
| })(); | |
| const showStats = additions > 0 || deletions > 0; | |
| return ( | |
| showStats && ( | |
| <div className="flex items-center gap-1 text-xs shrink-0"> | |
| {additions > 0 && <span className="text-green-500">+{additions}</span>} | |
| {deletions > 0 && <span className="text-red-500">-{deletions}</span>} | |
| </div> | |
| ) | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| </div> | |
| </button> | |
| ); | |
| }) | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center p-4 text-center"> | |
| <div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary"> | |
| <div className="i-ph:file-dashed" /> | |
| </div> | |
| <p className="text-sm font-medium text-bolt-elements-textPrimary"> | |
| {searchQuery ? 'No matching files' : 'No modified files'} | |
| </p> | |
| <p className="text-xs text-bolt-elements-textTertiary mt-1"> | |
| {searchQuery ? 'Try another search' : 'Changes will appear here as you edit'} | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {hasChanges && ( | |
| <div className="border-t border-bolt-elements-borderColor p-2"> | |
| <button | |
| onClick={() => { | |
| navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n')); | |
| toast('File list copied to clipboard', { | |
| icon: <div className="i-ph:check-circle text-accent-500" />, | |
| }); | |
| }} | |
| className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary" | |
| > | |
| Copy File List | |
| </button> | |
| </div> | |
| )} | |
| </Popover.Panel> | |
| </Transition> | |
| </> | |
| )} | |
| </Popover> | |
| </div> | |
| ); | |
| }, | |
| ); | |
| export const Workbench = memo( | |
| ({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => { | |
| renderLogger.trace('Workbench'); | |
| const [isSyncing, setIsSyncing] = useState(false); | |
| const [isPushDialogOpen, setIsPushDialogOpen] = useState(false); | |
| const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({}); | |
| // const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys()); | |
| const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); | |
| const showWorkbench = useStore(workbenchStore.showWorkbench); | |
| const selectedFile = useStore(workbenchStore.selectedFile); | |
| const currentDocument = useStore(workbenchStore.currentDocument); | |
| const unsavedFiles = useStore(workbenchStore.unsavedFiles); | |
| const files = useStore(workbenchStore.files); | |
| const selectedView = useStore(workbenchStore.currentView); | |
| const isSmallViewport = useViewport(1024); | |
| const setSelectedView = (view: WorkbenchViewType) => { | |
| workbenchStore.currentView.set(view); | |
| }; | |
| useEffect(() => { | |
| if (hasPreview) { | |
| setSelectedView('preview'); | |
| } | |
| }, [hasPreview]); | |
| useEffect(() => { | |
| workbenchStore.setDocuments(files); | |
| }, [files]); | |
| const onEditorChange = useCallback<OnEditorChange>((update) => { | |
| workbenchStore.setCurrentDocumentContent(update.content); | |
| }, []); | |
| const onEditorScroll = useCallback<OnEditorScroll>((position) => { | |
| workbenchStore.setCurrentDocumentScrollPosition(position); | |
| }, []); | |
| const onFileSelect = useCallback((filePath: string | undefined) => { | |
| workbenchStore.setSelectedFile(filePath); | |
| }, []); | |
| const onFileSave = useCallback(() => { | |
| workbenchStore | |
| .saveCurrentDocument() | |
| .then(() => { | |
| // Explicitly refresh all previews after a file save | |
| const previewStore = usePreviewStore(); | |
| previewStore.refreshAllPreviews(); | |
| }) | |
| .catch(() => { | |
| toast.error('Failed to update file content'); | |
| }); | |
| }, []); | |
| const onFileReset = useCallback(() => { | |
| workbenchStore.resetCurrentDocument(); | |
| }, []); | |
| const handleSyncFiles = useCallback(async () => { | |
| setIsSyncing(true); | |
| try { | |
| const directoryHandle = await window.showDirectoryPicker(); | |
| await workbenchStore.syncFiles(directoryHandle); | |
| toast.success('Files synced successfully'); | |
| } catch (error) { | |
| console.error('Error syncing files:', error); | |
| toast.error('Failed to sync files'); | |
| } finally { | |
| setIsSyncing(false); | |
| } | |
| }, []); | |
| const handleSelectFile = useCallback((filePath: string) => { | |
| workbenchStore.setSelectedFile(filePath); | |
| workbenchStore.currentView.set('diff'); | |
| }, []); | |
| return ( | |
| chatStarted && ( | |
| <motion.div | |
| initial="closed" | |
| animate={showWorkbench ? 'open' : 'closed'} | |
| variants={workbenchVariants} | |
| className="z-workbench" | |
| > | |
| <div | |
| className={classNames( | |
| 'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier', | |
| { | |
| 'w-full': isSmallViewport, | |
| 'left-0': showWorkbench && isSmallViewport, | |
| 'left-[var(--workbench-left)]': showWorkbench, | |
| 'left-[100%]': !showWorkbench, | |
| }, | |
| )} | |
| > | |
| <div className="absolute inset-0 px-2 lg:px-6"> | |
| <div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden"> | |
| <div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1"> | |
| <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} /> | |
| <div className="ml-auto" /> | |
| {selectedView === 'code' && ( | |
| <div className="flex overflow-y-auto"> | |
| <PanelHeaderButton | |
| className="mr-1 text-sm" | |
| onClick={() => { | |
| workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get()); | |
| }} | |
| > | |
| <div className="i-ph:terminal" /> | |
| Toggle Terminal | |
| </PanelHeaderButton> | |
| <DropdownMenu.Root> | |
| <DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed"> | |
| <div className="i-ph:box-arrow-up" /> | |
| Sync & Export | |
| </DropdownMenu.Trigger> | |
| <DropdownMenu.Content | |
| className={classNames( | |
| 'min-w-[240px] z-[250]', | |
| 'bg-white dark:bg-[#141414]', | |
| 'rounded-lg shadow-lg', | |
| 'border border-gray-200/50 dark:border-gray-800/50', | |
| 'animate-in fade-in-0 zoom-in-95', | |
| 'py-1', | |
| )} | |
| sideOffset={5} | |
| align="end" | |
| > | |
| <DropdownMenu.Item | |
| className={classNames( | |
| 'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative', | |
| )} | |
| onClick={() => { | |
| workbenchStore.downloadZip(); | |
| }} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="i-ph:download-simple"></div> | |
| <span>Download Code</span> | |
| </div> | |
| </DropdownMenu.Item> | |
| <DropdownMenu.Item | |
| className={classNames( | |
| 'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative', | |
| )} | |
| onClick={handleSyncFiles} | |
| disabled={isSyncing} | |
| > | |
| <div className="flex items-center gap-2"> | |
| {isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />} | |
| <span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span> | |
| </div> | |
| </DropdownMenu.Item> | |
| <DropdownMenu.Item | |
| className={classNames( | |
| 'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative', | |
| )} | |
| onClick={() => setIsPushDialogOpen(true)} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="i-ph:git-branch" /> | |
| Push to GitHub | |
| </div> | |
| </DropdownMenu.Item> | |
| </DropdownMenu.Content> | |
| </DropdownMenu.Root> | |
| </div> | |
| )} | |
| {selectedView === 'diff' && ( | |
| <FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} /> | |
| )} | |
| <IconButton | |
| icon="i-ph:x-circle" | |
| className="-mr-1" | |
| size="xl" | |
| onClick={() => { | |
| workbenchStore.showWorkbench.set(false); | |
| }} | |
| /> | |
| </div> | |
| <div className="relative flex-1 overflow-hidden"> | |
| <View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}> | |
| <EditorPanel | |
| editorDocument={currentDocument} | |
| isStreaming={isStreaming} | |
| selectedFile={selectedFile} | |
| files={files} | |
| unsavedFiles={unsavedFiles} | |
| fileHistory={fileHistory} | |
| onFileSelect={onFileSelect} | |
| onEditorScroll={onEditorScroll} | |
| onEditorChange={onEditorChange} | |
| onFileSave={onFileSave} | |
| onFileReset={onFileReset} | |
| /> | |
| </View> | |
| <View | |
| initial={{ x: '100%' }} | |
| animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }} | |
| > | |
| <DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} /> | |
| </View> | |
| <View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}> | |
| <Preview /> | |
| </View> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <PushToGitHubDialog | |
| isOpen={isPushDialogOpen} | |
| onClose={() => 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 (error) { | |
| console.error('Error pushing to GitHub:', error); | |
| toast.error('Failed to push to GitHub'); | |
| throw error; | |
| } | |
| }} | |
| /> | |
| </motion.div> | |
| ) | |
| ); | |
| }, | |
| ); | |
| // View component for rendering content with motion transitions | |
| interface ViewProps extends HTMLMotionProps<'div'> { | |
| children: JSX.Element; | |
| } | |
| const View = memo(({ children, ...props }: ViewProps) => { | |
| return ( | |
| <motion.div className="absolute inset-0" transition={viewTransition} {...props}> | |
| {children} | |
| </motion.div> | |
| ); | |
| }); | |