Merge pull request #1335 from Toddyclipsgg/diff-view-v2

feat: diff-view-v2-no-conflict
This commit is contained in:
KevIsDev 2025-02-21 14:39:55 +00:00 committed by GitHub
commit 871aefbe83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1356 additions and 383 deletions

View File

@ -30,3 +30,7 @@ runs:
run: |
pnpm install
pnpm run build
- name: Create history directory
shell: bash
run: mkdir -p .history

View File

@ -1,2 +0,0 @@
nodejs 20.15.1
pnpm 9.4.0

View File

@ -4,12 +4,10 @@
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
---
-----
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
---
-----
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).
@ -81,6 +79,7 @@ project, please check the [project management guide](./PROJECT.md) to get starte
- ✅ Add Starter Template Options (@thecodacus)
- ✅ Perplexity Integration (@meetpateltech)
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call

View File

@ -34,6 +34,7 @@ import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
const TEXTAREA_MIN_HEIGHT = 76;
@ -68,6 +69,7 @@ interface BaseChatProps {
actionAlert?: ActionAlert;
clearAlert?: () => void;
data?: JSONValue[] | undefined;
actionRunner?: ActionRunner;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@ -102,6 +104,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
actionAlert,
clearAlert,
data,
actionRunner,
},
ref,
) => {
@ -304,7 +307,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
data-chat-visible={showChat}
>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
@ -318,40 +321,39 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
)}
<div
className={classNames('pt-6 px-2 sm:px-6', {
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
'h-full flex flex-col': chatStarted,
})}
ref={scrollRef}
>
<ClientOnly>
{() => {
return chatStarted ? (
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
<Messages
ref={messageRef}
className="flex flex-col "
messages={messages}
isStreaming={isStreaming}
/>
</div>
<Messages
ref={messageRef}
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
'position-absolute': chatStarted,
})}
>
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
<div className="bg-bolt-elements-background-depth-2">
{actionAlert && (
<ChatAlert
alert={actionAlert}
clearAlert={() => clearAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearAlert?.();
}}
/>
)}
</div>
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<div
className={classNames(
@ -585,16 +587,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
</div>
</div>
{!chatStarted && (
<div className="flex flex-col justify-center mt-6 gap-5">
<div className="flex flex-col justify-center gap-5">
{!chatStarted && (
<div className="flex justify-center gap-2">
<div className="flex items-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
</div>
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
{ExamplePrompts((event, messageInput) => {
)}
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {
handleStop?.();
return;
@ -602,11 +603,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput);
})}
<StarterTemplates />
</div>
)}
{!chatStarted && <StarterTemplates />}
</div>
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
<ClientOnly>
{() => (
<Workbench
actionRunner={actionRunner ?? ({} as ActionRunner)}
chatStarted={chatStarted}
isStreaming={isStreaming}
/>
)}
</ClientOnly>
</div>
</div>
);

View File

@ -9,10 +9,11 @@ interface SliderOption<T> {
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
export type SliderOptions<T> = {
left: { value: T; text: string };
middle?: { value: T; text: string };
right: { value: T; text: string };
};
interface SliderProps<T> {
selected: T;
@ -21,14 +22,23 @@ interface SliderProps<T> {
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
const hasMiddle = !!options.middle;
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
{options.left.text}
</SliderButton>
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.middle && (
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
{options.middle.text}
</SliderButton>
)}
<SliderButton selected={!isLeftSelected && !isMiddleSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.right.text}
</SliderButton>
</div>

View File

@ -0,0 +1,510 @@
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import type { FileMap } from '~/lib/stores/files';
import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
import { diffLines, type Change } from 'diff';
import { getHighlighter } from 'shiki';
import '~/styles/diff-view.css';
import { diffFiles, extractRelativePath } from '~/utils/diff';
import { ActionRunner } from '~/lib/runtime/action-runner';
import type { FileHistory } from '~/types/actions';
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
interface CodeComparisonProps {
beforeCode: string;
afterCode: string;
language: string;
filename: string;
lightTheme: string;
darkTheme: string;
}
interface DiffBlock {
lineNumber: number;
content: string;
type: 'added' | 'removed' | 'unchanged';
correspondingLine?: number;
}
interface FullscreenButtonProps {
onClick: () => void;
isFullscreen: boolean;
}
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
<button
onClick={onClick}
className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
>
<div className={isFullscreen ? "i-ph:corners-in" : "i-ph:corners-out"} />
</button>
));
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
if (!isFullscreen) return <>{children}</>;
return (
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
<div className="w-full h-full max-w-[90vw] max-h-[90vh] bg-bolt-elements-background-depth-2 rounded-lg border border-bolt-elements-borderColor shadow-xl overflow-hidden">
{children}
</div>
</div>
);
});
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
const isBinaryFile = (content: string) => {
return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
};
const processChanges = (beforeCode: string, afterCode: string) => {
try {
if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
return {
beforeLines: [],
afterLines: [],
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
isBinary: true
};
}
// Normalizar quebras de linha para evitar falsos positivos
const normalizedBefore = beforeCode.replace(/\r\n/g, '\n').trim();
const normalizedAfter = afterCode.replace(/\r\n/g, '\n').trim();
// Se os conteúdos são idênticos após normalização, não há mudanças
if (normalizedBefore === normalizedAfter) {
return {
beforeLines: normalizedBefore.split('\n'),
afterLines: normalizedAfter.split('\n'),
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: []
};
}
// Processar as diferenças com configurações mais precisas
const changes = diffLines(normalizedBefore, normalizedAfter, {
newlineIsToken: true,
ignoreWhitespace: false,
ignoreCase: false
});
// Mapear as mudanças com mais precisão
const beforeLines = normalizedBefore.split('\n');
const afterLines = normalizedAfter.split('\n');
const lineChanges = {
before: new Set<number>(),
after: new Set<number>()
};
let beforeLineNumber = 0;
let afterLineNumber = 0;
const unifiedBlocks = changes.map(change => {
const lines = change.value.split('\n').filter(line => line.length > 0);
if (change.added) {
lines.forEach((_, i) => lineChanges.after.add(afterLineNumber + i));
const block = lines.map((line, i) => ({
lineNumber: afterLineNumber + i,
content: line,
type: 'added' as const
}));
afterLineNumber += lines.length;
return block;
}
if (change.removed) {
lines.forEach((_, i) => lineChanges.before.add(beforeLineNumber + i));
const block = lines.map((line, i) => ({
lineNumber: beforeLineNumber + i,
content: line,
type: 'removed' as const
}));
beforeLineNumber += lines.length;
return block;
}
const block = lines.map((line, i) => ({
lineNumber: afterLineNumber + i,
content: line,
type: 'unchanged' as const,
correspondingLine: beforeLineNumber + i
}));
beforeLineNumber += lines.length;
afterLineNumber += lines.length;
return block;
}).flat();
return {
beforeLines,
afterLines,
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
lineChanges,
unifiedBlocks,
isBinary: false
};
} catch (error) {
console.error('Error processing changes:', error);
return {
beforeLines: [],
afterLines: [],
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
error: true,
isBinary: false
};
}
};
const lineNumberStyles = "w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1";
const lineContentStyles = "px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
const renderContentWarning = (type: 'binary' | 'error') => (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-bolt-elements-textTertiary">
<div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
<p className="font-medium text-bolt-elements-textPrimary">
{type === 'binary' ? 'Binary file detected' : 'Error processing file'}
</p>
<p className="text-sm mt-1">
{type === 'binary'
? 'Diff view is not available for binary files'
: 'Could not generate diff preview'}
</p>
</div>
</div>
);
const NoChangesView = memo(({ beforeCode, language, highlighter }: {
beforeCode: string;
language: string;
highlighter: any;
}) => (
<div className="h-full flex flex-col items-center justify-center p-4">
<div className="text-center text-bolt-elements-textTertiary">
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
<p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
<p className="text-sm mt-1">Both versions match exactly</p>
</div>
<div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
<div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
Current Content
</div>
<div className="overflow-auto max-h-96">
{beforeCode.split('\n').map((line, index) => (
<div key={index} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={lineContentStyles}>
<span className="mr-2"> </span>
<span dangerouslySetInnerHTML={{
__html: highlighter ?
highlighter.codeToHtml(line, { lang: language, theme: 'github-dark' })
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: line
}} />
</div>
</div>
))}
</div>
</div>
</div>
));
const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language, lightTheme, darkTheme }: CodeComparisonProps) => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [highlighter, setHighlighter] = useState<any>(null);
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
const { unifiedBlocks, hasChanges, isBinary, error } = useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
useEffect(() => {
getHighlighter({
themes: ['github-dark'],
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
}).then(setHighlighter);
}, []);
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
const renderDiffBlock = (block: DiffBlock, index?: number) => {
const key = index !== undefined ? `${block.lineNumber}-${index}` : block.lineNumber;
const bgColor = {
added: 'bg-green-500/20 border-l-4 border-green-500',
removed: 'bg-red-500/20 border-l-4 border-red-500',
unchanged: ''
}[block.type];
const highlightedCode = highlighter ?
highlighter.codeToHtml(block.content, { lang: language, theme: 'github-dark' }) :
block.content;
return (
<div key={key} className="flex group min-w-fit">
<div className={lineNumberStyles}>
{block.lineNumber + 1}
</div>
<div className={`${lineContentStyles} ${bgColor}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{block.type === 'added' && '+'}
{block.type === 'removed' && '-'}
{block.type === 'unchanged' && ' '}
</span>
<span
dangerouslySetInnerHTML={{
__html: highlightedCode.replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '')
}}
/>
</div>
</div>
);
};
return (
<FullscreenOverlay isFullscreen={isFullscreen}>
<div className="w-full h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
<span className="truncate">{filename}</span>
<span className="ml-auto shrink-0 flex items-center">
{hasChanges ? (
<span className="text-yellow-400">Modified</span>
) : (
<span className="text-green-400">No Changes</span>
)}
<FullscreenButton onClick={toggleFullscreen} isFullscreen={isFullscreen} />
</span>
</div>
<div className="flex-1 overflow-auto diff-panel-content">
{hasChanges ? (
<div className="overflow-x-auto">
{unifiedBlocks.map((block, index) => renderDiffBlock(block, index))}
</div>
) : (
<NoChangesView
beforeCode={beforeCode}
language={language}
highlighter={highlighter}
/>
)}
</div>
</div>
</FullscreenOverlay>
);
});
const SideBySideComparison = memo(({
beforeCode,
afterCode,
language,
filename,
lightTheme,
darkTheme,
}: CodeComparisonProps) => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [highlighter, setHighlighter] = useState<any>(null);
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
const { beforeLines, afterLines, hasChanges, lineChanges, isBinary, error } = useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
useEffect(() => {
getHighlighter({
themes: ['github-dark'],
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
}).then(setHighlighter);
}, []);
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
const renderCode = (code: string) => {
if (!highlighter) return code;
const highlightedCode = highlighter.codeToHtml(code, {
lang: language,
theme: 'github-dark'
});
return highlightedCode.replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
};
return (
<FullscreenOverlay isFullscreen={isFullscreen}>
<div className="w-full h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
<span className="truncate">{filename}</span>
<span className="ml-auto shrink-0 flex items-center">
{hasChanges ? (
<span className="text-yellow-400">Modified</span>
) : (
<span className="text-green-400">No Changes</span>
)}
<FullscreenButton onClick={toggleFullscreen} isFullscreen={isFullscreen} />
</span>
</div>
<div className="flex-1 overflow-auto diff-panel-content">
{hasChanges ? (
<div className="grid md:grid-cols-2 divide-x divide-bolt-elements-borderColor relative h-full">
<div className="overflow-auto">
<div className="overflow-auto">
{beforeLines.map((line, index) => (
<div key={`before-${index}`} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={`${lineContentStyles} ${lineChanges.before.has(index) ? 'bg-red-500/20 border-l-4 border-red-500' : ''}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{lineChanges.before.has(index) ? '-' : ' '}
</span>
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
</div>
</div>
))}
</div>
</div>
<div className="overflow-auto">
{afterLines.map((line, index) => (
<div key={`after-${index}`} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={`${lineContentStyles} ${lineChanges.after.has(index) ? 'bg-green-500/20 border-l-4 border-green-500' : ''}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{lineChanges.after.has(index) ? '+' : ' '}
</span>
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
</div>
</div>
))}
</div>
</div>
) : (
<NoChangesView
beforeCode={beforeCode}
language={language}
highlighter={highlighter}
/>
)}
</div>
</div>
</FullscreenOverlay>
);
});
interface DiffViewProps {
fileHistory: Record<string, FileHistory>;
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
diffViewMode: 'inline' | 'side';
actionRunner: ActionRunner;
}
export const DiffView = memo(({ fileHistory, setFileHistory, diffViewMode, actionRunner }: DiffViewProps) => {
const files = useStore(workbenchStore.files) as FileMap;
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
useEffect(() => {
if (selectedFile && currentDocument) {
const file = files[selectedFile];
if (!file || !('content' in file)) return;
const existingHistory = fileHistory[selectedFile];
const currentContent = currentDocument.value;
const relativePath = extractRelativePath(selectedFile);
const unifiedDiff = diffFiles(
relativePath,
existingHistory?.originalContent || file.content,
currentContent
);
if (unifiedDiff) {
const newChanges = diffLines(
existingHistory?.originalContent || file.content,
currentContent
);
const newHistory: FileHistory = {
originalContent: existingHistory?.originalContent || file.content,
lastModified: Date.now(),
changes: [
...(existingHistory?.changes || []),
...newChanges
].slice(-100), // Limitar histórico de mudanças
versions: [
...(existingHistory?.versions || []),
{
timestamp: Date.now(),
content: currentContent
}
].slice(-10), // Manter apenas as 10 últimas versões
changeSource: 'auto-save'
};
setFileHistory(prev => ({ ...prev, [selectedFile]: newHistory }));
}
}
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
if (!selectedFile || !currentDocument) {
return (
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
Select a file to view differences
</div>
);
}
const file = files[selectedFile];
const originalContent = file && 'content' in file ? file.content : '';
const currentContent = currentDocument.value;
const history = fileHistory[selectedFile];
const effectiveOriginalContent = history?.originalContent || originalContent;
const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
try {
return (
<div className="h-full overflow-hidden">
{diffViewMode === 'inline' ? (
<InlineDiffComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
) : (
<SideBySideComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
)}
</div>
);
} catch (error) {
console.error('DiffView render error:', error);
return (
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
<div className="text-center">
<div className="i-ph:warning-circle text-4xl mb-2" />
<p>Failed to render diff view</p>
</div>
</div>
);
}
});

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 { 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,171 @@ 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">
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
{filePath.split('/').pop()}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textTertiary">
<span className="truncate">{filePath}</span>
<span className="shrink-0"></span>
<span className="shrink-0">
{formatDistance(history.lastModified)}
</span>
</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 +298,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 +361,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 +380,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}
@ -203,8 +397,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 +420,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;
}
}}
/>

View File

@ -1,7 +1,7 @@
import type { WebContainer } from '@webcontainer/api';
import { path } from '~/utils/path';
import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction } from '~/types/actions';
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
@ -276,9 +276,9 @@ export class ActionRunner {
}
const webcontainer = await this.#webcontainer;
const relativePath = path.relative(webcontainer.workdir, action.filePath);
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
let folder = path.dirname(relativePath);
let folder = nodePath.dirname(relativePath);
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
@ -304,4 +304,31 @@ export class ActionRunner {
this.actions.setKey(id, { ...actions[id], ...newState });
}
async getFileHistory(filePath: string): Promise<FileHistory | null> {
try {
const webcontainer = await this.#webcontainer;
const historyPath = this.#getHistoryPath(filePath);
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
return null;
}
}
async saveFileHistory(filePath: string, history: FileHistory) {
const webcontainer = await this.#webcontainer;
const historyPath = this.#getHistoryPath(filePath);
await this.#runFileAction({
type: 'file',
filePath: historyPath,
content: JSON.stringify(history),
changeSource: 'auto-save'
} as any);
}
#getHistoryPath(filePath: string) {
return nodePath.join('.history', filePath);
}
}

View File

@ -10,18 +10,16 @@ import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal';
import JSZip from 'jszip';
import fileSaver from 'file-saver';
import pkg from 'file-saver';
const { saveAs } = pkg;
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import { path } from '~/utils/path';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions';
// Destructure saveAs from the CommonJS module
const { saveAs } = fileSaver;
export interface ArtifactState {
id: string;
title: string;
@ -34,7 +32,7 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type Artifacts = MapStore<Record<string, ArtifactState>>;
export type WorkbenchViewType = 'code' | 'preview';
export type WorkbenchViewType = 'code' | 'diff' | 'preview';
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
@ -332,7 +330,7 @@ export class WorkbenchStore {
if (data.action.type === 'file') {
const wc = await webcontainer;
const fullPath = path.join(wc.workdir, data.action.filePath);
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
if (this.selectedFile.value !== fullPath) {
this.setSelectedFile(fullPath);
@ -437,13 +435,7 @@ export class WorkbenchStore {
return syncedFiles;
}
async pushToGitHub(
repoName: string,
commitMessage?: string,
githubUsername?: string,
ghToken?: string,
isPrivate: boolean = false,
) {
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
try {
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
@ -467,7 +459,7 @@ export class WorkbenchStore {
// Repository doesn't exist, so create a new one
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
name: repoName,
private: isPrivate,
private: false,
auto_init: true,
});
repo = newRepo;
@ -545,7 +537,7 @@ export class WorkbenchStore {
sha: newCommit.sha,
});
return repo.html_url; // Return the URL instead of showing alert
alert(`Repository created and code pushed: ${repo.html_url}`);
} catch (error) {
console.error('Error pushing to GitHub:', error);
throw error; // Rethrow the error for further handling

72
app/styles/diff-view.css Normal file
View File

@ -0,0 +1,72 @@
.diff-panel-content {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.diff-panel-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.diff-panel-content::-webkit-scrollbar-track {
background: transparent;
}
.diff-panel-content::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 4px;
border: 2px solid transparent;
}
.diff-panel-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(155, 155, 155, 0.7);
}
/* Hide scrollbar for the left panel when not hovered */
.diff-panel:not(:hover) .diff-panel-content::-webkit-scrollbar {
display: none;
}
.diff-panel:not(:hover) .diff-panel-content {
scrollbar-width: none;
}
/* Estilos para as linhas de diff */
.diff-block-added {
@apply bg-green-500/20 border-l-4 border-green-500;
}
.diff-block-removed {
@apply bg-red-500/20 border-l-4 border-red-500;
}
/* Melhorar contraste para mudanças */
.diff-panel-content .group:hover .diff-block-added {
@apply bg-green-500/30;
}
.diff-panel-content .group:hover .diff-block-removed {
@apply bg-red-500/30;
}
/* Estilos unificados para ambas as visualizações */
.diff-line {
@apply flex group min-w-fit transition-colors duration-150;
}
.diff-line-number {
@apply w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1;
}
.diff-line-content {
@apply px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary;
}
/* Cores específicas para adições/remoções */
.diff-added {
@apply bg-green-500/20 border-l-4 border-green-500;
}
.diff-removed {
@apply bg-red-500/20 border-l-4 border-red-500;
}

View File

@ -1,3 +1,5 @@
import type { Change } from 'diff';
export type ActionType = 'file' | 'shell';
export interface BaseAction {
@ -28,3 +30,15 @@ export interface ActionAlert {
content: string;
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
}
export interface FileHistory {
originalContent: string;
lastModified: number;
changes: Change[];
versions: {
timestamp: number;
content: string;
}[];
// Novo campo para rastrear a origem das mudanças
changeSource?: 'user' | 'auto-save' | 'external';
}

View File

@ -0,0 +1,24 @@
export const getLanguageFromExtension = (ext: string): string => {
const map: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
json: "json",
html: "html",
css: "css",
py: "python",
java: "java",
rb: "ruby",
cpp: "cpp",
c: "c",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
swift: "swift",
md: "plaintext",
sh: "bash",
};
return map[ext] || "typescript";
};

View File

@ -74,6 +74,8 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.4",
"lucide-react": "^0.474.0",
"next-themes": "^0.4.4",
"@remix-run/cloudflare": "^2.15.2",
"@remix-run/cloudflare-pages": "^2.15.2",
"@remix-run/node": "^2.15.2",

File diff suppressed because it is too large Load Diff

View File

@ -89,6 +89,7 @@ export default defineConfig((config) => {
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
'module': {},
},
build: {
target: 'esnext',

View File

@ -1,6 +0,0 @@
#:schema node_modules/wrangler/config-schema.json
name = "bolt"
compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-07-01"
pages_build_output_dir = "./build/client"
send_metrics = false