Files
bolt.diy/app/workbench/components/Workbench.client.tsx
KevIsDev 335f7c6077 refactor: track additions and deletions in file history
- Add additions and deletions fields to FileHistory interface
- Calculate diff stats when creating/updating file history
- Use pre-calculated stats in UI instead of recalculating
- Add fallback for contextFiles to continue with the llm request in event of an error.
- Remove FileModifiedDropdown to its own component
- Add refreshAllPreviews class in previews store for refreshing previews on file change
- Add auto switch to preview once isStreaming goes from true to false
- Add actionType to files to now show whether a file is being created or updated and modified message-parser and artifact.tsx accordingly
2025-06-23 13:10:45 +01:00

335 lines
13 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 { FileHistory } from '~/shared/types/actions';
import { DiffView } from './ui/DiffView';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
} from '~/workbench/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/shared/components/ui/IconButton';
import { PanelHeaderButton } from '~/shared/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/shared/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/workbench/stores/workbench';
import { classNames } from '~/shared/utils/classNames';
import { cubicEasingFn } from '~/shared/utils/easings';
import { renderLogger } from '~/shared/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/shared/hooks';
import { PushToGitHubDialog } from '~/settings/tabs/connections/components/PushToGitHubDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { chatStore } from '~/chat/stores/chat';
import type { ElementInfo } from './ui/Inspector';
import { FileModifiedDropdown } from './ui/FileModifiedDropdown';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
metadata?: {
gitUrl?: string;
};
updateChatMestaData?: (metadata: any) => void;
setSelectedElement?: (element: ElementInfo | null) => 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;
export const Workbench = memo(
({ chatStarted, isStreaming, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
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 { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const [wasStreaming, setWasStreaming] = useState(false);
const isSmallViewport = useViewport(1024);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
// Track streaming state changes
useEffect(() => {
if (isStreaming) {
setWasStreaming(true);
} else if (wasStreaming && hasPreview) {
// Streaming just stopped and we have a preview
setSelectedView('preview');
setWasStreaming(false);
}
}, [isStreaming, wasStreaming, hasPreview]);
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.2rem)] bottom-6 w-[var(--workbench-inner-width)] 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-4">
<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 gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
<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.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
<div className="i-ph:box-arrow-up" />
Sync
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => setIsPushDialogOpen(true)}
>
<div className="flex items-center gap-2">
<div className="i-ph:git-branch" />
Push to GitHub
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</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} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
</div>
</div>
</div>
</div>
<PushToGitHubDialog
isOpen={isPushDialogOpen}
onClose={() => setIsPushDialogOpen(false)}
onPush={async (repoName, username, token, isPrivate) => {
try {
console.log('Dialog onPush called with isPrivate =', isPrivate);
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
const repoUrl = await workbenchStore.pushToGitHub(repoName, commitMessage, username, token, isPrivate);
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>
)
);
},
);
// View component for rendering content with motion transitions
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>
);
});