mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
237 lines
8.8 KiB
TypeScript
237 lines
8.8 KiB
TypeScript
import { useStore } from '@nanostores/react';
|
|
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
|
import { computed } from 'nanostores';
|
|
import { memo, useCallback, useEffect } from 'react';
|
|
import { toast } from 'react-toastify';
|
|
import JSZip from 'jszip';
|
|
import type { FileSystemAPI } from '@webcontainer/api';
|
|
import {
|
|
type OnChangeCallback as OnEditorChange,
|
|
type OnScrollCallback as OnEditorScroll,
|
|
type ScrollPosition,
|
|
} 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 { webcontainer } from '~/lib/webcontainer';
|
|
import { classNames } from '~/utils/classNames';
|
|
import { cubicEasingFn } from '~/utils/easings';
|
|
import { renderLogger } from '~/utils/logger';
|
|
import { EditorPanel } from './EditorPanel';
|
|
import { Preview } from './Preview';
|
|
import type { PreviewInfo } from '~/lib/stores/previews';
|
|
|
|
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 hasPreview = useStore(computed(workbenchStore.previews, (previews: PreviewInfo[]) => 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 setSelectedView = (view: WorkbenchViewType) => {
|
|
workbenchStore.currentView.set(view);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (hasPreview) {
|
|
setSelectedView('preview');
|
|
}
|
|
}, [hasPreview]);
|
|
|
|
useEffect(() => {
|
|
workbenchStore.setDocuments(files);
|
|
}, [files]);
|
|
|
|
const onEditorChange = useCallback<OnEditorChange>((update: { content: string }) => {
|
|
workbenchStore.setCurrentDocumentContent(update.content);
|
|
}, []);
|
|
|
|
const onEditorScroll = useCallback<OnEditorScroll>((position: ScrollPosition) => {
|
|
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}
|
|
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',
|
|
{
|
|
'left-[var(--workbench-left)]': showWorkbench,
|
|
'left-[100%]': !showWorkbench,
|
|
},
|
|
)}
|
|
>
|
|
<div className="absolute inset-0 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' && (
|
|
<>
|
|
<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={async () => {
|
|
try {
|
|
const webcontainerInstance = await webcontainer;
|
|
const files = await webcontainerInstance.fs.readdir('/', { withFileTypes: true });
|
|
const zip = new JSZip();
|
|
|
|
const processDirectory = async (dirPath: string, entries: Awaited<ReturnType<FileSystemAPI['readdir']>>) => {
|
|
for (const entry of entries) {
|
|
const fullPath = `${dirPath}/${entry.name}`;
|
|
if (entry.isFile()) {
|
|
const content = await webcontainerInstance.fs.readFile(fullPath);
|
|
zip.file(fullPath.slice(1), content); // Remove leading slash
|
|
} else if (entry.isDirectory() && entry.name !== 'node_modules') {
|
|
const subEntries = await webcontainerInstance.fs.readdir(fullPath, { withFileTypes: true });
|
|
await processDirectory(fullPath, subEntries);
|
|
}
|
|
}
|
|
};
|
|
|
|
await processDirectory('', files);
|
|
|
|
const blob = await zip.generateAsync({ type: 'blob' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'project.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
toast.success('Project downloaded successfully');
|
|
} catch (error) {
|
|
console.error('Failed to download project:', error);
|
|
toast.error('Failed to download project');
|
|
}
|
|
}}
|
|
>
|
|
<div className="i-ph:download" />
|
|
Download Project
|
|
</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>
|
|
</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>
|
|
);
|
|
});
|