feat: Add Diff View and File History Tracking

- Implemented a new Diff View in the Workbench to visualize file changes
- Added file history tracking with detailed change information
- Enhanced FileTree and FileModifiedDropdown to show line additions and deletions
- Integrated file history saving and retrieval in ActionRunner
- Updated Workbench view types to include 'diff' option
- Added support for inline and side-by-side diff view modes
This commit is contained in:
Toddyclipsgg
2025-02-23 07:55:38 -03:00
parent 8c72ed76b3
commit ab6f5328b4
17 changed files with 1192 additions and 77 deletions

View File

@@ -1,8 +1,15 @@
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 { 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 { formatDistanceToNow as formatDistance } from 'date-fns';
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,
@@ -18,10 +25,16 @@ import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
import Cookies from 'js-cookie';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
actionRunner: ActionRunner;
metadata?: {
gitUrl?: string;
};
updateChatMestaData?: (metadata: any) => void;
}
const viewTransition = { ease: cubicEasingFn };
@@ -31,6 +44,10 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
value: 'code',
text: 'Code',
},
middle: {
value: 'diff',
text: 'Diff',
},
right: {
value: 'preview',
text: 'Preview',
@@ -54,11 +71,211 @@ const workbenchVariants = {
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
const FileModifiedDropdown = memo(({
fileHistory,
onSelectFile,
diffViewMode,
toggleDiffViewMode,
}: {
fileHistory: Record<string, FileHistory>,
onSelectFile: (filePath: string) => void,
diffViewMode: 'inline' | 'side',
toggleDiffViewMode: () => 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>
<button
onClick={(e) => { e.stopPropagation(); toggleDiffViewMode(); }}
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">{diffViewMode === 'inline' ? 'Inline' : 'Side by Side'}</span>
</button>
</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 [diffViewMode, setDiffViewMode] = useState<'inline' | 'side'>('inline');
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);
@@ -121,6 +338,15 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
}
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
const toggleDiffViewMode = useCallback(() => {
setDiffViewMode(prev => prev === 'inline' ? 'side' : 'inline');
}, []);
return (
chatStarted && (
<motion.div
@@ -175,6 +401,14 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
</PanelHeaderButton>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown
fileHistory={fileHistory}
onSelectFile={handleSelectFile}
diffViewMode={diffViewMode}
toggleDiffViewMode={toggleDiffViewMode}
/>
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
@@ -186,8 +420,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
</div>
<div className="relative flex-1 overflow-hidden">
<View
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
initial={{ x: '0%' }}
animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}
>
<EditorPanel
editorDocument={currentDocument}
@@ -195,6 +429,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
@@ -203,8 +438,19 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
/>
</View>
<View
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView
fileHistory={fileHistory}
setFileHistory={setFileHistory}
diffViewMode={diffViewMode}
actionRunner={actionRunner}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}
>
<Preview />
</View>
@@ -215,14 +461,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<PushToGitHubDialog
isOpen={isPushDialogOpen}
onClose={() => setIsPushDialogOpen(false)}
onPush={async (repoName, username, token, isPrivate) => {
onPush={async (repoName, username, token) => {
try {
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
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; // Rethrow to let PushToGitHubDialog handle the error state
throw error;
}
}}
/>