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