|
import { useStore } from '@nanostores/react'; |
|
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion'; |
|
import { computed } from 'nanostores'; |
|
import { memo, useCallback, useEffect, useState } from 'react'; |
|
import { toast } from 'react-toastify'; |
|
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 Cookies from 'js-cookie'; |
|
import { chatMetadata, useChatHistory } from '~/lib/persistence'; |
|
|
|
interface WorkspaceProps { |
|
chatStarted?: boolean; |
|
isStreaming?: boolean; |
|
} |
|
|
|
const viewTransition = { ease: cubicEasingFn }; |
|
|
|
const sliderOptions: SliderOptions<WorkbenchViewType> = { |
|
left: { |
|
value: 'code', |
|
text: 'Code', |
|
}, |
|
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; |
|
|
|
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => { |
|
renderLogger.trace('Workbench'); |
|
|
|
const [isSyncing, setIsSyncing] = useState(false); |
|
|
|
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 metadata = useStore(chatMetadata); |
|
const { updateChatMestaData } = useChatHistory(); |
|
|
|
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); |
|
} |
|
}, []); |
|
|
|
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={() => { |
|
let repoName = metadata?.gitUrl?.split('/').slice(-1)[0]?.replace('.git', '') || null; |
|
let repoConfirmed: boolean = true; |
|
|
|
if (repoName) { |
|
repoConfirmed = confirm(`Do you want to push to the repository ${repoName}?`); |
|
} |
|
|
|
if (!repoName || !repoConfirmed) { |
|
repoName = prompt( |
|
'Please enter a name for your new GitHub repository:', |
|
'bolt-generated-project', |
|
); |
|
} else { |
|
} |
|
|
|
if (!repoName) { |
|
alert('Repository name is required. Push to GitHub cancelled.'); |
|
return; |
|
} |
|
|
|
let githubUsername = Cookies.get('githubUsername'); |
|
let githubToken = Cookies.get('githubToken'); |
|
|
|
if (!githubUsername || !githubToken) { |
|
const usernameInput = prompt('Please enter your GitHub username:'); |
|
const tokenInput = prompt('Please enter your GitHub personal access token:'); |
|
|
|
if (!usernameInput || !tokenInput) { |
|
alert('GitHub username and token are required. Push to GitHub cancelled.'); |
|
return; |
|
} |
|
|
|
githubUsername = usernameInput; |
|
githubToken = tokenInput; |
|
|
|
Cookies.set('githubUsername', usernameInput); |
|
Cookies.set('githubToken', tokenInput); |
|
Cookies.set( |
|
'git:github.com', |
|
JSON.stringify({ username: tokenInput, password: 'x-oauth-basic' }), |
|
); |
|
} |
|
|
|
const commitMessage = |
|
prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit'; |
|
workbenchStore.pushToGitHub(repoName, commitMessage, githubUsername, githubToken); |
|
|
|
if (!metadata?.gitUrl) { |
|
updateChatMestaData({ |
|
...(metadata || {}), |
|
gitUrl: `https://github.com/${githubUsername}/${repoName}.git`, |
|
}); |
|
} |
|
}} |
|
> |
|
<div className="i-ph:github-logo" /> |
|
Push to GitHub |
|
</PanelHeaderButton> |
|
</div> |
|
)} |
|
<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: selectedView === 'code' ? 0 : '-100%' }} |
|
animate={{ x: selectedView === 'code' ? 0 : '-100%' }} |
|
> |
|
<EditorPanel |
|
editorDocument={currentDocument} |
|
isStreaming={isStreaming} |
|
selectedFile={selectedFile} |
|
files={files} |
|
unsavedFiles={unsavedFiles} |
|
onFileSelect={onFileSelect} |
|
onEditorScroll={onEditorScroll} |
|
onEditorChange={onEditorChange} |
|
onFileSave={onFileSave} |
|
onFileReset={onFileReset} |
|
/> |
|
</View> |
|
<View |
|
initial={{ x: selectedView === 'preview' ? 0 : '100%' }} |
|
animate={{ x: selectedView === 'preview' ? 0 : '100%' }} |
|
> |
|
<Preview /> |
|
</View> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</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> |
|
); |
|
}); |
|
|