bolt.new/packages/bolt/app/components/workbench/Workbench.client.tsx
2024-08-08 15:56:36 +02:00

168 lines
5.4 KiB
TypeScript

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 } from '~/lib/stores/workbench';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
}
type ViewType = 'code' | 'preview';
const viewTransition = { ease: cubicEasingFn };
const sliderOptions: SliderOptions<ViewType> = {
left: {
value: 'code',
text: 'Code',
},
right: {
value: 'preview',
text: 'Preview',
},
};
const workbenchVariants = {
closed: {
width: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
width: '100%',
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');
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, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
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();
}, []);
return (
chatStarted && (
<motion.div initial="closed" animate={showWorkbench ? 'open' : 'closed'} variants={workbenchVariants}>
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
<div className="flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<PanelHeaderButton
className="ml-auto mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<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>
</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>
);
});