diff --git a/.github/actions/setup-and-build/action.yaml b/.github/actions/setup-and-build/action.yaml index b27bc6f..b4f27b7 100644 --- a/.github/actions/setup-and-build/action.yaml +++ b/.github/actions/setup-and-build/action.yaml @@ -30,3 +30,7 @@ runs: run: | pnpm install pnpm run build + + - name: Create history directory + shell: bash + run: mkdir -p .history diff --git a/.tool-versions b/.tool-versions index 427253d..74c88f6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ nodejs 20.15.1 -pnpm 9.4.0 +pnpm 9.4.0 \ No newline at end of file diff --git a/README.md b/README.md index 0a4e77e..3071fce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index ff1f418..f38a5cc 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -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( @@ -102,6 +104,7 @@ export const BaseChat = React.forwardRef( actionAlert, clearAlert, data, + actionRunner, }, ref, ) => { @@ -304,7 +307,7 @@ export const BaseChat = React.forwardRef( data-chat-visible={showChat} > {() => } -
+
{!chatStarted && (
@@ -318,40 +321,39 @@ export const BaseChat = React.forwardRef( )}
{() => { return chatStarted ? ( -
- -
+ ) : null; }}
- {actionAlert && ( - clearAlert?.()} - postMessage={(message) => { - sendMessage?.({} as any, message); - clearAlert?.(); - }} - /> - )} +
+ {actionAlert && ( + clearAlert?.()} + postMessage={(message) => { + sendMessage?.({} as any, message); + clearAlert?.(); + }} + /> + )} +
{progressAnnotations && }
(
- {!chatStarted && ( -
+
+ {!chatStarted && (
-
- {ImportButtons(importChat)} - -
+ {ImportButtons(importChat)} +
- - {ExamplePrompts((event, messageInput) => { + )} + {!chatStarted && + ExamplePrompts((event, messageInput) => { if (isStreaming) { handleStop?.(); return; @@ -602,11 +603,18 @@ export const BaseChat = React.forwardRef( handleSendMessage?.(event, messageInput); })} - -
- )} + {!chatStarted && } +
- {() => } + + {() => ( + + )} +
); diff --git a/app/components/ui/Slider.tsx b/app/components/ui/Slider.tsx index f332a6a..d5e9b00 100644 --- a/app/components/ui/Slider.tsx +++ b/app/components/ui/Slider.tsx @@ -9,10 +9,11 @@ interface SliderOption { text: string; } -export interface SliderOptions { - left: SliderOption; - right: SliderOption; -} +export type SliderOptions = { + left: { value: T; text: string }; + middle?: { value: T; text: string }; + right: { value: T; text: string }; +}; interface SliderProps { selected: T; @@ -21,14 +22,23 @@ interface SliderProps { } export const Slider = genericMemo(({ selected, options, setSelected }: SliderProps) => { - 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 (
setSelected?.(options.left.value)}> {options.left.text} - setSelected?.(options.right.value)}> + + {options.middle && ( + setSelected?.(options.middle!.value)}> + {options.middle.text} + + )} + + setSelected?.(options.right.value)}> {options.right.text}
diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx new file mode 100644 index 0000000..26b7e26 --- /dev/null +++ b/app/components/workbench/DiffView.tsx @@ -0,0 +1,647 @@ +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) => ( + +)); + +const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => { + if (!isFullscreen) return <>{children}; + + return ( +
+
+ {children} +
+
+ ); +}); + +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'); + const normalizedAfter = afterCode.replace(/\r\n/g, '\n'); + + // Dividir em linhas preservando linhas vazias + const beforeLines = normalizedBefore.split('\n'); + const afterLines = normalizedAfter.split('\n'); + + // Se os conteúdos são idênticos após normalização, não há mudanças + if (normalizedBefore === normalizedAfter) { + return { + beforeLines, + afterLines, + hasChanges: false, + lineChanges: { before: new Set(), after: new Set() }, + unifiedBlocks: [] + }; + } + + // Processar as diferenças com configurações otimizadas para detecção por linha + const changes = diffLines(normalizedBefore, normalizedAfter, { + newlineIsToken: false, // Não tratar quebras de linha como tokens separados + ignoreWhitespace: true, // Ignorar diferenças de espaços em branco + ignoreCase: false // Manter sensibilidade a maiúsculas/minúsculas + }); + + const lineChanges = { + before: new Set(), + after: new Set() + }; + + let beforeLineNumber = 0; + let afterLineNumber = 0; + + const unifiedBlocks = changes.reduce((blocks: DiffBlock[], change) => { + // Dividir o conteúdo em linhas preservando linhas vazias + const lines = change.value.split('\n'); + + if (change.added) { + // Processar linhas adicionadas + const addedBlocks = lines.map((line, i) => { + lineChanges.after.add(afterLineNumber + i); + return { + lineNumber: afterLineNumber + i, + content: line, + type: 'added' as const + }; + }); + afterLineNumber += lines.length; + return [...blocks, ...addedBlocks]; + } + + if (change.removed) { + // Processar linhas removidas + const removedBlocks = lines.map((line, i) => { + lineChanges.before.add(beforeLineNumber + i); + return { + lineNumber: beforeLineNumber + i, + content: line, + type: 'removed' as const + }; + }); + beforeLineNumber += lines.length; + return [...blocks, ...removedBlocks]; + } + + // Processar linhas não modificadas + const unchangedBlocks = lines.map((line, i) => { + const block = { + lineNumber: afterLineNumber + i, + content: line, + type: 'unchanged' as const, + correspondingLine: beforeLineNumber + i + }; + return block; + }); + beforeLineNumber += lines.length; + afterLineNumber += lines.length; + return [...blocks, ...unchangedBlocks]; + }, []); + + 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') => ( +
+
+
+

+ {type === 'binary' ? 'Binary file detected' : 'Error processing file'} +

+

+ {type === 'binary' + ? 'Diff view is not available for binary files' + : 'Could not generate diff preview'} +

+
+
+); + +const NoChangesView = memo(({ beforeCode, language, highlighter }: { + beforeCode: string; + language: string; + highlighter: any; +}) => ( +
+
+
+

Files are identical

+

Both versions match exactly

+
+
+
+ Current Content +
+
+ {beforeCode.split('\n').map((line, index) => ( +
+
{index + 1}
+
+ + ]*>/g, '') + .replace(/<\/?code[^>]*>/g, '') + : line + }} /> +
+
+ ))} +
+
+
+)); + +// Otimização do processamento de diferenças com memoização +const useProcessChanges = (beforeCode: string, afterCode: string) => { + return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]); +}; + +// Componente otimizado para renderização de linhas de código +const CodeLine = memo(({ + lineNumber, + content, + type, + highlighter, + language +}: { + lineNumber: number; + content: string; + type: 'added' | 'removed' | 'unchanged'; + highlighter: any; + language: string; +}) => { + 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: '' + }[type]; + + const highlightedCode = useMemo(() => { + if (!highlighter) return content; + return highlighter.codeToHtml(content, { + lang: language, + theme: 'github-dark' + }).replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, ''); + }, [content, highlighter, language]); + + return ( +
+
{lineNumber + 1}
+
+ + {type === 'added' && '+'} + {type === 'removed' && '-'} + {type === 'unchanged' && ' '} + + +
+
+ ); +}); + +// Componente para exibir informações sobre o arquivo +const FileInfo = memo(({ + filename, + hasChanges, + onToggleFullscreen, + isFullscreen, + beforeCode, + afterCode +}: { + filename: string; + hasChanges: boolean; + onToggleFullscreen: () => void; + isFullscreen: boolean; + beforeCode: string; + afterCode: string; +}) => { + // Calculate additions and deletions from the current document + const { additions, deletions } = useMemo(() => { + if (!hasChanges) return { additions: 0, deletions: 0 }; + + const changes = diffLines(beforeCode, afterCode, { + 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 }); + }, [hasChanges, beforeCode, afterCode]); + + const showStats = additions > 0 || deletions > 0; + + return ( +
+
+ {filename} + + {hasChanges ? ( + <> + {showStats && ( +
+ {additions > 0 && ( + +{additions} + )} + {deletions > 0 && ( + -{deletions} + )} +
+ )} + Modified + + {new Date().toLocaleTimeString()} + + + ) : ( + No Changes + )} + +
+
+ ); +}); + +const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language, lightTheme, darkTheme }: CodeComparisonProps) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [highlighter, setHighlighter] = useState(null); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen(prev => !prev); + }, []); + + const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(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'); + + return ( + +
+ +
+ {hasChanges ? ( +
+ {unifiedBlocks.map((block, index) => ( + + ))} +
+ ) : ( + + )} +
+
+
+ ); +}); + +const SideBySideComparison = memo(({ + beforeCode, + afterCode, + language, + filename, + lightTheme, + darkTheme, +}: CodeComparisonProps) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [highlighter, setHighlighter] = useState(null); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen(prev => !prev); + }, []); + + const { beforeLines, afterLines, hasChanges, lineChanges, isBinary, error } = useProcessChanges(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 ( + +
+ +
+ {hasChanges ? ( +
+
+ {beforeLines.map((line, index) => ( +
+
{index + 1}
+
+ + {lineChanges.before.has(index) ? '-' : ' '} + + +
+
+ ))} +
+
+ {afterLines.map((line, index) => ( +
+
{index + 1}
+
+ + {lineChanges.after.has(index) ? '+' : ' '} + + +
+
+ ))} +
+
+ ) : ( + + )} +
+
+
+ ); +}); + +interface DiffViewProps { + fileHistory: Record; + setFileHistory: React.Dispatch>>; + 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; + + // Normalizar o conteúdo para comparação + const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim(); + const normalizedOriginalContent = (existingHistory?.originalContent || file.content).replace(/\r\n/g, '\n').trim(); + + // Se não há histórico existente, criar um novo apenas se houver diferenças + if (!existingHistory) { + if (normalizedCurrentContent !== normalizedOriginalContent) { + const newChanges = diffLines(file.content, currentContent); + setFileHistory(prev => ({ + ...prev, + [selectedFile]: { + originalContent: file.content, + lastModified: Date.now(), + changes: newChanges, + versions: [{ + timestamp: Date.now(), + content: currentContent + }], + changeSource: 'auto-save' + } + })); + } + return; + } + + // Se já existe histórico, verificar se há mudanças reais desde a última versão + const lastVersion = existingHistory.versions[existingHistory.versions.length - 1]; + const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim(); + + if (normalizedCurrentContent === normalizedLastContent) { + return; // Não criar novo histórico se o conteúdo é o mesmo + } + + // Verificar se há mudanças significativas usando diffFiles + const relativePath = extractRelativePath(selectedFile); + const unifiedDiff = diffFiles( + relativePath, + existingHistory.originalContent, + currentContent + ); + + if (unifiedDiff) { + const newChanges = diffLines( + existingHistory.originalContent, + currentContent + ); + + // Verificar se as mudanças são significativas + const hasSignificantChanges = newChanges.some(change => + (change.added || change.removed) && change.value.trim().length > 0 + ); + + if (hasSignificantChanges) { + const newHistory: FileHistory = { + originalContent: existingHistory.originalContent, + 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 ( +
+ Select a file to view differences +
+ ); + } + + 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 ( +
+ {diffViewMode === 'inline' ? ( + + ) : ( + + )} +
+ ); + } catch (error) { + console.error('DiffView render error:', error); + return ( +
+
+
+

Failed to render diff view

+
+
+ ); + } +}); diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index f68e030..ef9e018 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -12,6 +12,7 @@ import { import { PanelHeader } from '~/components/ui/PanelHeader'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; import type { FileMap } from '~/lib/stores/files'; +import type { FileHistory } from '~/types/actions'; import { themeStore } from '~/lib/stores/theme'; import { WORK_DIR } from '~/utils/constants'; import { renderLogger } from '~/utils/logger'; @@ -27,6 +28,7 @@ interface EditorPanelProps { editorDocument?: EditorDocument; selectedFile?: string | undefined; isStreaming?: boolean; + fileHistory?: Record; onEditorChange?: OnEditorChange; onEditorScroll?: OnEditorScroll; onFileSelect?: (value?: string) => void; @@ -45,6 +47,7 @@ export const EditorPanel = memo( editorDocument, selectedFile, isStreaming, + fileHistory, onFileSelect, onEditorChange, onEditorScroll, @@ -83,6 +86,7 @@ export const EditorPanel = memo( files={files} hideRoot unsavedFiles={unsavedFiles} + fileHistory={fileHistory} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx index eb185cb..08d323e 100644 --- a/app/components/workbench/FileTree.tsx +++ b/app/components/workbench/FileTree.tsx @@ -3,6 +3,8 @@ import type { FileMap } from '~/lib/stores/files'; import { classNames } from '~/utils/classNames'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import * as ContextMenu from '@radix-ui/react-context-menu'; +import type { FileHistory } from '~/types/actions'; +import { diffLines, type Change } from 'diff'; const logger = createScopedLogger('FileTree'); @@ -19,6 +21,7 @@ interface Props { allowFolderSelection?: boolean; hiddenFiles?: Array; unsavedFiles?: Set; + fileHistory?: Record; className?: string; } @@ -34,6 +37,7 @@ export const FileTree = memo( hiddenFiles, className, unsavedFiles, + fileHistory = {}, }: Props) => { renderLogger.trace('FileTree'); @@ -138,6 +142,7 @@ export const FileTree = memo( selected={selectedFile === fileOrFolder.fullPath} file={fileOrFolder} unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)} + fileHistory={fileHistory} onCopyPath={() => { onCopyPath(fileOrFolder); }} @@ -253,19 +258,55 @@ interface FileProps { file: FileNode; selected: boolean; unsavedChanges?: boolean; + fileHistory?: Record; onCopyPath: () => void; onCopyRelativePath: () => void; onClick: () => void; } function File({ - file: { depth, name }, + file: { depth, name, fullPath }, onClick, onCopyPath, onCopyRelativePath, selected, unsavedChanges = false, + fileHistory = {}, }: FileProps) { + const fileModifications = fileHistory[fullPath]; + const hasModifications = fileModifications !== undefined; + + // Calculate added and removed lines from the most recent changes + const { additions, deletions } = useMemo(() => { + if (!fileModifications?.originalContent) return { additions: 0, deletions: 0 }; + + // Usar a mesma lógica do DiffView para processar as mudanças + const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n'); + const normalizedCurrent = fileModifications.versions[fileModifications.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 }); + }, [fileModifications]); + + const showStats = additions > 0 || deletions > 0; + return (
{name}
- {unsavedChanges && } +
+ {showStats && ( +
+ {additions > 0 && ( + +{additions} + )} + {deletions > 0 && ( + -{deletions} + )} +
+ )} + {unsavedChanges && ( + + )} +
diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 5fdcda7..39d10b9 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -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 = { 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, + 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 ( +
+ + {({ open }: { open: boolean }) => ( + <> + + File Changes + {hasChanges && ( + + {modifiedFiles.length} + + )} + + + +
+
+ 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" + /> +
+
+
+
+ +
+ {filteredFiles.length > 0 ? ( + filteredFiles.map(([filePath, history]) => { + const extension = filePath.split('.').pop() || ''; + const language = getLanguageFromExtension(extension); + + return ( + + ); + }) + ) : ( +
+
+
+
+

+ {searchQuery ? 'No matching files' : 'No modified files'} +

+

+ {searchQuery ? 'Try another search' : 'Changes will appear here as you edit'} +

+
+ )} +
+
+ + {hasChanges && ( +
+ +
+ )} + + + + )} + + +
+ ); +}); + +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>({}); + + 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 && (
)} + {selectedView === 'diff' && ( + + )}
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) => /> + + + @@ -215,14 +461,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => 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; } }} /> diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 250dff7..d615028 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -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 { + 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); + } } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e410e76..84d38ef 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -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; type Artifacts = MapStore>; -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 diff --git a/app/styles/diff-view.css b/app/styles/diff-view.css new file mode 100644 index 0000000..e99e8be --- /dev/null +++ b/app/styles/diff-view.css @@ -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; +} \ No newline at end of file diff --git a/app/types/actions.ts b/app/types/actions.ts index 3543ac7..cf13abd 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -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'; +} diff --git a/app/utils/getLanguageFromExtension.ts b/app/utils/getLanguageFromExtension.ts new file mode 100644 index 0000000..1dd5d0f --- /dev/null +++ b/app/utils/getLanguageFromExtension.ts @@ -0,0 +1,24 @@ +export const getLanguageFromExtension = (ext: string): string => { + const map: Record = { + 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"; +}; \ No newline at end of file diff --git a/package.json b/package.json index 6bbc999..f797859 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/vite.config.ts b/vite.config.ts index 01fb3b2..59ac01c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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', diff --git a/wrangler.toml b/wrangler.toml index 93c4160..addd100 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,4 +3,4 @@ name = "bolt" compatibility_flags = ["nodejs_compat"] compatibility_date = "2024-07-01" pages_build_output_dir = "./build/client" -send_metrics = false +send_metrics = false \ No newline at end of file