mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-09 06:29:43 +00:00
Merge pull request #1335 from Toddyclipsgg/diff-view-v2
feat: diff-view-v2-no-conflict
This commit is contained in:
commit
871aefbe83
4
.github/actions/setup-and-build/action.yaml
vendored
4
.github/actions/setup-and-build/action.yaml
vendored
@ -30,3 +30,7 @@ runs:
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Create history directory
|
||||
shell: bash
|
||||
run: mkdir -p .history
|
||||
|
@ -1,2 +0,0 @@
|
||||
nodejs 20.15.1
|
||||
pnpm 9.4.0
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
510
app/components/workbench/DiffView.tsx
Normal file
510
app/components/workbench/DiffView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
});
|
@ -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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
72
app/styles/diff-view.css
Normal 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;
|
||||
}
|
@ -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';
|
||||
}
|
||||
|
24
app/utils/getLanguageFromExtension.ts
Normal file
24
app/utils/getLanguageFromExtension.ts
Normal 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";
|
||||
};
|
@ -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",
|
||||
|
707
pnpm-lock.yaml
707
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user