| 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'; |
|
|
| 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-textPrimary border border-bolt-elements-borderColor"> |
| <span className="font-medium">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 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().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"> |
| <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.downloadZip(); |
| }} |
| > |
| <div className="i-ph:code" /> |
| Download Code |
| </PanelHeaderButton> |
| <PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}> |
| {isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />} |
| {isSyncing ? 'Syncing...' : 'Sync Files'} |
| </PanelHeaderButton> |
| <PanelHeaderButton |
| className="mr-1 text-sm" |
| onClick={() => { |
| workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get()); |
| }} |
| > |
| <div className="i-ph:terminal" /> |
| Toggle Terminal |
| </PanelHeaderButton> |
| <PanelHeaderButton className="mr-1 text-sm" onClick={() => setIsPushDialogOpen(true)}> |
| <div className="i-ph:git-branch" /> |
| Push to GitHub |
| </PanelHeaderButton> |
| </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) => { |
| try { |
| const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit'; |
| await workbenchStore.pushToGitHub(repoName, commitMessage, username, token); |
| |
| const repoUrl = `https://github.com/${username}/${repoName}`; |
| |
| 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> |
| ) |
| ); |
| }, |
| ); |
|
|
| |
| 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> |
| ); |
| }); |
|
|