feat: Add Diff View and File History Tracking
Browse files- Implemented a new Diff View in the Workbench to visualize file changes
- Added file history tracking with detailed change information
- Enhanced FileTree and FileModifiedDropdown to show line additions and deletions
- Integrated file history saving and retrieval in ActionRunner
- Updated Workbench view types to include 'diff' option
- Added support for inline and side-by-side diff view modes
- .github/actions/setup-and-build/action.yaml +4 -0
- .tool-versions +1 -1
- README.md +3 -4
- app/components/chat/BaseChat.tsx +42 -34
- app/components/ui/Slider.tsx +16 -6
- app/components/workbench/DiffView.tsx +647 -0
- app/components/workbench/EditorPanel.tsx +4 -0
- app/components/workbench/FileTree.tsx +57 -2
- app/components/workbench/Workbench.client.tsx +265 -9
- app/lib/runtime/action-runner.ts +31 -4
- app/lib/stores/workbench.ts +8 -16
- app/styles/diff-view.css +72 -0
- app/types/actions.ts +14 -0
- app/utils/getLanguageFromExtension.ts +24 -0
- package.json +2 -0
- vite.config.ts +1 -0
- wrangler.toml +1 -1
.github/actions/setup-and-build/action.yaml
CHANGED
@@ -30,3 +30,7 @@ runs:
|
|
30 |
run: |
|
31 |
pnpm install
|
32 |
pnpm run build
|
|
|
|
|
|
|
|
|
|
30 |
run: |
|
31 |
pnpm install
|
32 |
pnpm run build
|
33 |
+
|
34 |
+
- name: Create history directory
|
35 |
+
shell: bash
|
36 |
+
run: mkdir -p .history
|
.tool-versions
CHANGED
@@ -1,2 +1,2 @@
|
|
1 |
nodejs 20.15.1
|
2 |
-
pnpm 9.4.0
|
|
|
1 |
nodejs 20.15.1
|
2 |
+
pnpm 9.4.0
|
README.md
CHANGED
@@ -4,12 +4,10 @@
|
|
4 |
|
5 |
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.
|
6 |
|
7 |
-
|
8 |
-
|
9 |
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
|
10 |
|
11 |
-
|
12 |
-
|
13 |
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!
|
14 |
|
15 |
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
|
|
81 |
- ✅ Add Starter Template Options (@thecodacus)
|
82 |
- ✅ Perplexity Integration (@meetpateltech)
|
83 |
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
|
|
84 |
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
|
85 |
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
86 |
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
|
|
4 |
|
5 |
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.
|
6 |
|
7 |
+
-----
|
|
|
8 |
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
|
9 |
|
10 |
+
-----
|
|
|
11 |
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!
|
12 |
|
13 |
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/).
|
|
|
79 |
- ✅ Add Starter Template Options (@thecodacus)
|
80 |
- ✅ Perplexity Integration (@meetpateltech)
|
81 |
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
82 |
+
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
|
83 |
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
|
84 |
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
85 |
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
app/components/chat/BaseChat.tsx
CHANGED
@@ -34,6 +34,7 @@ import ChatAlert from './ChatAlert';
|
|
34 |
import type { ModelInfo } from '~/lib/modules/llm/types';
|
35 |
import ProgressCompilation from './ProgressCompilation';
|
36 |
import type { ProgressAnnotation } from '~/types/context';
|
|
|
37 |
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
38 |
|
39 |
const TEXTAREA_MIN_HEIGHT = 76;
|
@@ -68,6 +69,7 @@ interface BaseChatProps {
|
|
68 |
actionAlert?: ActionAlert;
|
69 |
clearAlert?: () => void;
|
70 |
data?: JSONValue[] | undefined;
|
|
|
71 |
}
|
72 |
|
73 |
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
@@ -102,6 +104,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
102 |
actionAlert,
|
103 |
clearAlert,
|
104 |
data,
|
|
|
105 |
},
|
106 |
ref,
|
107 |
) => {
|
@@ -304,7 +307,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
304 |
data-chat-visible={showChat}
|
305 |
>
|
306 |
<ClientOnly>{() => <Menu />}</ClientOnly>
|
307 |
-
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
308 |
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
309 |
{!chatStarted && (
|
310 |
<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>(
|
|
318 |
)}
|
319 |
<div
|
320 |
className={classNames('pt-6 px-2 sm:px-6', {
|
321 |
-
'h-full flex flex-col
|
322 |
})}
|
323 |
ref={scrollRef}
|
324 |
>
|
325 |
<ClientOnly>
|
326 |
{() => {
|
327 |
return chatStarted ? (
|
328 |
-
<
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
/>
|
335 |
-
</div>
|
336 |
) : null;
|
337 |
}}
|
338 |
</ClientOnly>
|
339 |
<div
|
340 |
-
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
|
341 |
'sticky bottom-2': chatStarted,
|
342 |
-
'position-absolute': chatStarted,
|
343 |
})}
|
344 |
>
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
|
|
|
|
355 |
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
356 |
<div
|
357 |
className={classNames(
|
@@ -585,16 +587,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
585 |
</div>
|
586 |
</div>
|
587 |
</div>
|
588 |
-
|
589 |
-
|
590 |
<div className="flex justify-center gap-2">
|
591 |
-
|
592 |
-
|
593 |
-
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
594 |
-
</div>
|
595 |
</div>
|
596 |
-
|
597 |
-
|
|
|
598 |
if (isStreaming) {
|
599 |
handleStop?.();
|
600 |
return;
|
@@ -602,11 +603,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
602 |
|
603 |
handleSendMessage?.(event, messageInput);
|
604 |
})}
|
605 |
-
|
606 |
-
|
607 |
-
)}
|
608 |
</div>
|
609 |
-
<ClientOnly>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
610 |
</div>
|
611 |
</div>
|
612 |
);
|
|
|
34 |
import type { ModelInfo } from '~/lib/modules/llm/types';
|
35 |
import ProgressCompilation from './ProgressCompilation';
|
36 |
import type { ProgressAnnotation } from '~/types/context';
|
37 |
+
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
38 |
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
39 |
|
40 |
const TEXTAREA_MIN_HEIGHT = 76;
|
|
|
69 |
actionAlert?: ActionAlert;
|
70 |
clearAlert?: () => void;
|
71 |
data?: JSONValue[] | undefined;
|
72 |
+
actionRunner?: ActionRunner;
|
73 |
}
|
74 |
|
75 |
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
|
104 |
actionAlert,
|
105 |
clearAlert,
|
106 |
data,
|
107 |
+
actionRunner,
|
108 |
},
|
109 |
ref,
|
110 |
) => {
|
|
|
307 |
data-chat-visible={showChat}
|
308 |
>
|
309 |
<ClientOnly>{() => <Menu />}</ClientOnly>
|
310 |
+
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
311 |
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
312 |
{!chatStarted && (
|
313 |
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
|
|
321 |
)}
|
322 |
<div
|
323 |
className={classNames('pt-6 px-2 sm:px-6', {
|
324 |
+
'h-full flex flex-col': chatStarted,
|
325 |
})}
|
326 |
ref={scrollRef}
|
327 |
>
|
328 |
<ClientOnly>
|
329 |
{() => {
|
330 |
return chatStarted ? (
|
331 |
+
<Messages
|
332 |
+
ref={messageRef}
|
333 |
+
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
334 |
+
messages={messages}
|
335 |
+
isStreaming={isStreaming}
|
336 |
+
/>
|
|
|
|
|
337 |
) : null;
|
338 |
}}
|
339 |
</ClientOnly>
|
340 |
<div
|
341 |
+
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
|
342 |
'sticky bottom-2': chatStarted,
|
|
|
343 |
})}
|
344 |
>
|
345 |
+
<div className="bg-bolt-elements-background-depth-2">
|
346 |
+
{actionAlert && (
|
347 |
+
<ChatAlert
|
348 |
+
alert={actionAlert}
|
349 |
+
clearAlert={() => clearAlert?.()}
|
350 |
+
postMessage={(message) => {
|
351 |
+
sendMessage?.({} as any, message);
|
352 |
+
clearAlert?.();
|
353 |
+
}}
|
354 |
+
/>
|
355 |
+
)}
|
356 |
+
</div>
|
357 |
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
358 |
<div
|
359 |
className={classNames(
|
|
|
587 |
</div>
|
588 |
</div>
|
589 |
</div>
|
590 |
+
<div className="flex flex-col justify-center gap-5">
|
591 |
+
{!chatStarted && (
|
592 |
<div className="flex justify-center gap-2">
|
593 |
+
{ImportButtons(importChat)}
|
594 |
+
<GitCloneButton importChat={importChat} />
|
|
|
|
|
595 |
</div>
|
596 |
+
)}
|
597 |
+
{!chatStarted &&
|
598 |
+
ExamplePrompts((event, messageInput) => {
|
599 |
if (isStreaming) {
|
600 |
handleStop?.();
|
601 |
return;
|
|
|
603 |
|
604 |
handleSendMessage?.(event, messageInput);
|
605 |
})}
|
606 |
+
{!chatStarted && <StarterTemplates />}
|
607 |
+
</div>
|
|
|
608 |
</div>
|
609 |
+
<ClientOnly>
|
610 |
+
{() => (
|
611 |
+
<Workbench
|
612 |
+
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
613 |
+
chatStarted={chatStarted}
|
614 |
+
isStreaming={isStreaming}
|
615 |
+
/>
|
616 |
+
)}
|
617 |
+
</ClientOnly>
|
618 |
</div>
|
619 |
</div>
|
620 |
);
|
app/components/ui/Slider.tsx
CHANGED
@@ -9,10 +9,11 @@ interface SliderOption<T> {
|
|
9 |
text: string;
|
10 |
}
|
11 |
|
12 |
-
export
|
13 |
-
left:
|
14 |
-
|
15 |
-
}
|
|
|
16 |
|
17 |
interface SliderProps<T> {
|
18 |
selected: T;
|
@@ -21,14 +22,23 @@ interface SliderProps<T> {
|
|
21 |
}
|
22 |
|
23 |
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
24 |
-
const
|
|
|
|
|
25 |
|
26 |
return (
|
27 |
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
28 |
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
29 |
{options.left.text}
|
30 |
</SliderButton>
|
31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
{options.right.text}
|
33 |
</SliderButton>
|
34 |
</div>
|
|
|
9 |
text: string;
|
10 |
}
|
11 |
|
12 |
+
export type SliderOptions<T> = {
|
13 |
+
left: { value: T; text: string };
|
14 |
+
middle?: { value: T; text: string };
|
15 |
+
right: { value: T; text: string };
|
16 |
+
};
|
17 |
|
18 |
interface SliderProps<T> {
|
19 |
selected: T;
|
|
|
22 |
}
|
23 |
|
24 |
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
25 |
+
const hasMiddle = !!options.middle;
|
26 |
+
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
|
27 |
+
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
|
28 |
|
29 |
return (
|
30 |
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
31 |
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
32 |
{options.left.text}
|
33 |
</SliderButton>
|
34 |
+
|
35 |
+
{options.middle && (
|
36 |
+
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
|
37 |
+
{options.middle.text}
|
38 |
+
</SliderButton>
|
39 |
+
)}
|
40 |
+
|
41 |
+
<SliderButton selected={!isLeftSelected && !isMiddleSelected} setSelected={() => setSelected?.(options.right.value)}>
|
42 |
{options.right.text}
|
43 |
</SliderButton>
|
44 |
</div>
|
app/components/workbench/DiffView.tsx
ADDED
@@ -0,0 +1,647 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
|
2 |
+
import { useStore } from '@nanostores/react';
|
3 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
4 |
+
import type { FileMap } from '~/lib/stores/files';
|
5 |
+
import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
|
6 |
+
import { diffLines, type Change } from 'diff';
|
7 |
+
import { getHighlighter } from 'shiki';
|
8 |
+
import '~/styles/diff-view.css';
|
9 |
+
import { diffFiles, extractRelativePath } from '~/utils/diff';
|
10 |
+
import { ActionRunner } from '~/lib/runtime/action-runner';
|
11 |
+
import type { FileHistory } from '~/types/actions';
|
12 |
+
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
13 |
+
|
14 |
+
interface CodeComparisonProps {
|
15 |
+
beforeCode: string;
|
16 |
+
afterCode: string;
|
17 |
+
language: string;
|
18 |
+
filename: string;
|
19 |
+
lightTheme: string;
|
20 |
+
darkTheme: string;
|
21 |
+
}
|
22 |
+
|
23 |
+
interface DiffBlock {
|
24 |
+
lineNumber: number;
|
25 |
+
content: string;
|
26 |
+
type: 'added' | 'removed' | 'unchanged';
|
27 |
+
correspondingLine?: number;
|
28 |
+
}
|
29 |
+
|
30 |
+
interface FullscreenButtonProps {
|
31 |
+
onClick: () => void;
|
32 |
+
isFullscreen: boolean;
|
33 |
+
}
|
34 |
+
|
35 |
+
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
|
36 |
+
<button
|
37 |
+
onClick={onClick}
|
38 |
+
className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
|
39 |
+
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
40 |
+
>
|
41 |
+
<div className={isFullscreen ? "i-ph:corners-in" : "i-ph:corners-out"} />
|
42 |
+
</button>
|
43 |
+
));
|
44 |
+
|
45 |
+
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
|
46 |
+
if (!isFullscreen) return <>{children}</>;
|
47 |
+
|
48 |
+
return (
|
49 |
+
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
|
50 |
+
<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">
|
51 |
+
{children}
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
);
|
55 |
+
});
|
56 |
+
|
57 |
+
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
58 |
+
const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
|
59 |
+
|
60 |
+
const isBinaryFile = (content: string) => {
|
61 |
+
return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
|
62 |
+
};
|
63 |
+
|
64 |
+
const processChanges = (beforeCode: string, afterCode: string) => {
|
65 |
+
try {
|
66 |
+
if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
|
67 |
+
return {
|
68 |
+
beforeLines: [],
|
69 |
+
afterLines: [],
|
70 |
+
hasChanges: false,
|
71 |
+
lineChanges: { before: new Set(), after: new Set() },
|
72 |
+
unifiedBlocks: [],
|
73 |
+
isBinary: true
|
74 |
+
};
|
75 |
+
}
|
76 |
+
|
77 |
+
// Normalizar quebras de linha para evitar falsos positivos
|
78 |
+
const normalizedBefore = beforeCode.replace(/\r\n/g, '\n');
|
79 |
+
const normalizedAfter = afterCode.replace(/\r\n/g, '\n');
|
80 |
+
|
81 |
+
// Dividir em linhas preservando linhas vazias
|
82 |
+
const beforeLines = normalizedBefore.split('\n');
|
83 |
+
const afterLines = normalizedAfter.split('\n');
|
84 |
+
|
85 |
+
// Se os conteúdos são idênticos após normalização, não há mudanças
|
86 |
+
if (normalizedBefore === normalizedAfter) {
|
87 |
+
return {
|
88 |
+
beforeLines,
|
89 |
+
afterLines,
|
90 |
+
hasChanges: false,
|
91 |
+
lineChanges: { before: new Set(), after: new Set() },
|
92 |
+
unifiedBlocks: []
|
93 |
+
};
|
94 |
+
}
|
95 |
+
|
96 |
+
// Processar as diferenças com configurações otimizadas para detecção por linha
|
97 |
+
const changes = diffLines(normalizedBefore, normalizedAfter, {
|
98 |
+
newlineIsToken: false, // Não tratar quebras de linha como tokens separados
|
99 |
+
ignoreWhitespace: true, // Ignorar diferenças de espaços em branco
|
100 |
+
ignoreCase: false // Manter sensibilidade a maiúsculas/minúsculas
|
101 |
+
});
|
102 |
+
|
103 |
+
const lineChanges = {
|
104 |
+
before: new Set<number>(),
|
105 |
+
after: new Set<number>()
|
106 |
+
};
|
107 |
+
|
108 |
+
let beforeLineNumber = 0;
|
109 |
+
let afterLineNumber = 0;
|
110 |
+
|
111 |
+
const unifiedBlocks = changes.reduce((blocks: DiffBlock[], change) => {
|
112 |
+
// Dividir o conteúdo em linhas preservando linhas vazias
|
113 |
+
const lines = change.value.split('\n');
|
114 |
+
|
115 |
+
if (change.added) {
|
116 |
+
// Processar linhas adicionadas
|
117 |
+
const addedBlocks = lines.map((line, i) => {
|
118 |
+
lineChanges.after.add(afterLineNumber + i);
|
119 |
+
return {
|
120 |
+
lineNumber: afterLineNumber + i,
|
121 |
+
content: line,
|
122 |
+
type: 'added' as const
|
123 |
+
};
|
124 |
+
});
|
125 |
+
afterLineNumber += lines.length;
|
126 |
+
return [...blocks, ...addedBlocks];
|
127 |
+
}
|
128 |
+
|
129 |
+
if (change.removed) {
|
130 |
+
// Processar linhas removidas
|
131 |
+
const removedBlocks = lines.map((line, i) => {
|
132 |
+
lineChanges.before.add(beforeLineNumber + i);
|
133 |
+
return {
|
134 |
+
lineNumber: beforeLineNumber + i,
|
135 |
+
content: line,
|
136 |
+
type: 'removed' as const
|
137 |
+
};
|
138 |
+
});
|
139 |
+
beforeLineNumber += lines.length;
|
140 |
+
return [...blocks, ...removedBlocks];
|
141 |
+
}
|
142 |
+
|
143 |
+
// Processar linhas não modificadas
|
144 |
+
const unchangedBlocks = lines.map((line, i) => {
|
145 |
+
const block = {
|
146 |
+
lineNumber: afterLineNumber + i,
|
147 |
+
content: line,
|
148 |
+
type: 'unchanged' as const,
|
149 |
+
correspondingLine: beforeLineNumber + i
|
150 |
+
};
|
151 |
+
return block;
|
152 |
+
});
|
153 |
+
beforeLineNumber += lines.length;
|
154 |
+
afterLineNumber += lines.length;
|
155 |
+
return [...blocks, ...unchangedBlocks];
|
156 |
+
}, []);
|
157 |
+
|
158 |
+
return {
|
159 |
+
beforeLines,
|
160 |
+
afterLines,
|
161 |
+
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
162 |
+
lineChanges,
|
163 |
+
unifiedBlocks,
|
164 |
+
isBinary: false
|
165 |
+
};
|
166 |
+
} catch (error) {
|
167 |
+
console.error('Error processing changes:', error);
|
168 |
+
return {
|
169 |
+
beforeLines: [],
|
170 |
+
afterLines: [],
|
171 |
+
hasChanges: false,
|
172 |
+
lineChanges: { before: new Set(), after: new Set() },
|
173 |
+
unifiedBlocks: [],
|
174 |
+
error: true,
|
175 |
+
isBinary: false
|
176 |
+
};
|
177 |
+
}
|
178 |
+
};
|
179 |
+
|
180 |
+
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";
|
181 |
+
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";
|
182 |
+
|
183 |
+
const renderContentWarning = (type: 'binary' | 'error') => (
|
184 |
+
<div className="h-full flex items-center justify-center p-4">
|
185 |
+
<div className="text-center text-bolt-elements-textTertiary">
|
186 |
+
<div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
|
187 |
+
<p className="font-medium text-bolt-elements-textPrimary">
|
188 |
+
{type === 'binary' ? 'Binary file detected' : 'Error processing file'}
|
189 |
+
</p>
|
190 |
+
<p className="text-sm mt-1">
|
191 |
+
{type === 'binary'
|
192 |
+
? 'Diff view is not available for binary files'
|
193 |
+
: 'Could not generate diff preview'}
|
194 |
+
</p>
|
195 |
+
</div>
|
196 |
+
</div>
|
197 |
+
);
|
198 |
+
|
199 |
+
const NoChangesView = memo(({ beforeCode, language, highlighter }: {
|
200 |
+
beforeCode: string;
|
201 |
+
language: string;
|
202 |
+
highlighter: any;
|
203 |
+
}) => (
|
204 |
+
<div className="h-full flex flex-col items-center justify-center p-4">
|
205 |
+
<div className="text-center text-bolt-elements-textTertiary">
|
206 |
+
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
|
207 |
+
<p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
|
208 |
+
<p className="text-sm mt-1">Both versions match exactly</p>
|
209 |
+
</div>
|
210 |
+
<div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
|
211 |
+
<div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
|
212 |
+
Current Content
|
213 |
+
</div>
|
214 |
+
<div className="overflow-auto max-h-96">
|
215 |
+
{beforeCode.split('\n').map((line, index) => (
|
216 |
+
<div key={index} className="flex group min-w-fit">
|
217 |
+
<div className={lineNumberStyles}>{index + 1}</div>
|
218 |
+
<div className={lineContentStyles}>
|
219 |
+
<span className="mr-2"> </span>
|
220 |
+
<span dangerouslySetInnerHTML={{
|
221 |
+
__html: highlighter ?
|
222 |
+
highlighter.codeToHtml(line, { lang: language, theme: 'github-dark' })
|
223 |
+
.replace(/<\/?pre[^>]*>/g, '')
|
224 |
+
.replace(/<\/?code[^>]*>/g, '')
|
225 |
+
: line
|
226 |
+
}} />
|
227 |
+
</div>
|
228 |
+
</div>
|
229 |
+
))}
|
230 |
+
</div>
|
231 |
+
</div>
|
232 |
+
</div>
|
233 |
+
));
|
234 |
+
|
235 |
+
// Otimização do processamento de diferenças com memoização
|
236 |
+
const useProcessChanges = (beforeCode: string, afterCode: string) => {
|
237 |
+
return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
|
238 |
+
};
|
239 |
+
|
240 |
+
// Componente otimizado para renderização de linhas de código
|
241 |
+
const CodeLine = memo(({
|
242 |
+
lineNumber,
|
243 |
+
content,
|
244 |
+
type,
|
245 |
+
highlighter,
|
246 |
+
language
|
247 |
+
}: {
|
248 |
+
lineNumber: number;
|
249 |
+
content: string;
|
250 |
+
type: 'added' | 'removed' | 'unchanged';
|
251 |
+
highlighter: any;
|
252 |
+
language: string;
|
253 |
+
}) => {
|
254 |
+
const bgColor = {
|
255 |
+
added: 'bg-green-500/20 border-l-4 border-green-500',
|
256 |
+
removed: 'bg-red-500/20 border-l-4 border-red-500',
|
257 |
+
unchanged: ''
|
258 |
+
}[type];
|
259 |
+
|
260 |
+
const highlightedCode = useMemo(() => {
|
261 |
+
if (!highlighter) return content;
|
262 |
+
return highlighter.codeToHtml(content, {
|
263 |
+
lang: language,
|
264 |
+
theme: 'github-dark'
|
265 |
+
}).replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
|
266 |
+
}, [content, highlighter, language]);
|
267 |
+
|
268 |
+
return (
|
269 |
+
<div className="flex group min-w-fit">
|
270 |
+
<div className={lineNumberStyles}>{lineNumber + 1}</div>
|
271 |
+
<div className={`${lineContentStyles} ${bgColor}`}>
|
272 |
+
<span className="mr-2 text-bolt-elements-textTertiary">
|
273 |
+
{type === 'added' && '+'}
|
274 |
+
{type === 'removed' && '-'}
|
275 |
+
{type === 'unchanged' && ' '}
|
276 |
+
</span>
|
277 |
+
<span dangerouslySetInnerHTML={{ __html: highlightedCode }} />
|
278 |
+
</div>
|
279 |
+
</div>
|
280 |
+
);
|
281 |
+
});
|
282 |
+
|
283 |
+
// Componente para exibir informações sobre o arquivo
|
284 |
+
const FileInfo = memo(({
|
285 |
+
filename,
|
286 |
+
hasChanges,
|
287 |
+
onToggleFullscreen,
|
288 |
+
isFullscreen,
|
289 |
+
beforeCode,
|
290 |
+
afterCode
|
291 |
+
}: {
|
292 |
+
filename: string;
|
293 |
+
hasChanges: boolean;
|
294 |
+
onToggleFullscreen: () => void;
|
295 |
+
isFullscreen: boolean;
|
296 |
+
beforeCode: string;
|
297 |
+
afterCode: string;
|
298 |
+
}) => {
|
299 |
+
// Calculate additions and deletions from the current document
|
300 |
+
const { additions, deletions } = useMemo(() => {
|
301 |
+
if (!hasChanges) return { additions: 0, deletions: 0 };
|
302 |
+
|
303 |
+
const changes = diffLines(beforeCode, afterCode, {
|
304 |
+
newlineIsToken: false,
|
305 |
+
ignoreWhitespace: true,
|
306 |
+
ignoreCase: false
|
307 |
+
});
|
308 |
+
|
309 |
+
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
310 |
+
if (change.added) {
|
311 |
+
acc.additions += change.value.split('\n').length;
|
312 |
+
}
|
313 |
+
if (change.removed) {
|
314 |
+
acc.deletions += change.value.split('\n').length;
|
315 |
+
}
|
316 |
+
return acc;
|
317 |
+
}, { additions: 0, deletions: 0 });
|
318 |
+
}, [hasChanges, beforeCode, afterCode]);
|
319 |
+
|
320 |
+
const showStats = additions > 0 || deletions > 0;
|
321 |
+
|
322 |
+
return (
|
323 |
+
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
|
324 |
+
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
|
325 |
+
<span className="truncate">{filename}</span>
|
326 |
+
<span className="ml-auto shrink-0 flex items-center gap-2">
|
327 |
+
{hasChanges ? (
|
328 |
+
<>
|
329 |
+
{showStats && (
|
330 |
+
<div className="flex items-center gap-1 text-xs">
|
331 |
+
{additions > 0 && (
|
332 |
+
<span className="text-green-500">+{additions}</span>
|
333 |
+
)}
|
334 |
+
{deletions > 0 && (
|
335 |
+
<span className="text-red-500">-{deletions}</span>
|
336 |
+
)}
|
337 |
+
</div>
|
338 |
+
)}
|
339 |
+
<span className="text-yellow-400">Modified</span>
|
340 |
+
<span className="text-bolt-elements-textTertiary text-xs">
|
341 |
+
{new Date().toLocaleTimeString()}
|
342 |
+
</span>
|
343 |
+
</>
|
344 |
+
) : (
|
345 |
+
<span className="text-green-400">No Changes</span>
|
346 |
+
)}
|
347 |
+
<FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
|
348 |
+
</span>
|
349 |
+
</div>
|
350 |
+
);
|
351 |
+
});
|
352 |
+
|
353 |
+
const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language, lightTheme, darkTheme }: CodeComparisonProps) => {
|
354 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
355 |
+
const [highlighter, setHighlighter] = useState<any>(null);
|
356 |
+
|
357 |
+
const toggleFullscreen = useCallback(() => {
|
358 |
+
setIsFullscreen(prev => !prev);
|
359 |
+
}, []);
|
360 |
+
|
361 |
+
const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
|
362 |
+
|
363 |
+
useEffect(() => {
|
364 |
+
getHighlighter({
|
365 |
+
themes: ['github-dark'],
|
366 |
+
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
|
367 |
+
}).then(setHighlighter);
|
368 |
+
}, []);
|
369 |
+
|
370 |
+
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
|
371 |
+
|
372 |
+
return (
|
373 |
+
<FullscreenOverlay isFullscreen={isFullscreen}>
|
374 |
+
<div className="w-full h-full flex flex-col">
|
375 |
+
<FileInfo
|
376 |
+
filename={filename}
|
377 |
+
hasChanges={hasChanges}
|
378 |
+
onToggleFullscreen={toggleFullscreen}
|
379 |
+
isFullscreen={isFullscreen}
|
380 |
+
beforeCode={beforeCode}
|
381 |
+
afterCode={afterCode}
|
382 |
+
/>
|
383 |
+
<div className="flex-1 overflow-auto diff-panel-content">
|
384 |
+
{hasChanges ? (
|
385 |
+
<div className="overflow-x-auto">
|
386 |
+
{unifiedBlocks.map((block, index) => (
|
387 |
+
<CodeLine
|
388 |
+
key={`${block.lineNumber}-${index}`}
|
389 |
+
lineNumber={block.lineNumber}
|
390 |
+
content={block.content}
|
391 |
+
type={block.type}
|
392 |
+
highlighter={highlighter}
|
393 |
+
language={language}
|
394 |
+
/>
|
395 |
+
))}
|
396 |
+
</div>
|
397 |
+
) : (
|
398 |
+
<NoChangesView
|
399 |
+
beforeCode={beforeCode}
|
400 |
+
language={language}
|
401 |
+
highlighter={highlighter}
|
402 |
+
/>
|
403 |
+
)}
|
404 |
+
</div>
|
405 |
+
</div>
|
406 |
+
</FullscreenOverlay>
|
407 |
+
);
|
408 |
+
});
|
409 |
+
|
410 |
+
const SideBySideComparison = memo(({
|
411 |
+
beforeCode,
|
412 |
+
afterCode,
|
413 |
+
language,
|
414 |
+
filename,
|
415 |
+
lightTheme,
|
416 |
+
darkTheme,
|
417 |
+
}: CodeComparisonProps) => {
|
418 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
419 |
+
const [highlighter, setHighlighter] = useState<any>(null);
|
420 |
+
|
421 |
+
const toggleFullscreen = useCallback(() => {
|
422 |
+
setIsFullscreen(prev => !prev);
|
423 |
+
}, []);
|
424 |
+
|
425 |
+
const { beforeLines, afterLines, hasChanges, lineChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
|
426 |
+
|
427 |
+
useEffect(() => {
|
428 |
+
getHighlighter({
|
429 |
+
themes: ['github-dark'],
|
430 |
+
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
|
431 |
+
}).then(setHighlighter);
|
432 |
+
}, []);
|
433 |
+
|
434 |
+
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
|
435 |
+
|
436 |
+
const renderCode = (code: string) => {
|
437 |
+
if (!highlighter) return code;
|
438 |
+
const highlightedCode = highlighter.codeToHtml(code, {
|
439 |
+
lang: language,
|
440 |
+
theme: 'github-dark'
|
441 |
+
});
|
442 |
+
return highlightedCode.replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
|
443 |
+
};
|
444 |
+
|
445 |
+
return (
|
446 |
+
<FullscreenOverlay isFullscreen={isFullscreen}>
|
447 |
+
<div className="w-full h-full flex flex-col">
|
448 |
+
<FileInfo
|
449 |
+
filename={filename}
|
450 |
+
hasChanges={hasChanges}
|
451 |
+
onToggleFullscreen={toggleFullscreen}
|
452 |
+
isFullscreen={isFullscreen}
|
453 |
+
beforeCode={beforeCode}
|
454 |
+
afterCode={afterCode}
|
455 |
+
/>
|
456 |
+
<div className="flex-1 overflow-auto diff-panel-content">
|
457 |
+
{hasChanges ? (
|
458 |
+
<div className="grid md:grid-cols-2 divide-x divide-bolt-elements-borderColor relative h-full">
|
459 |
+
<div className="overflow-auto">
|
460 |
+
{beforeLines.map((line, index) => (
|
461 |
+
<div key={`before-${index}`} className="flex group min-w-fit">
|
462 |
+
<div className={lineNumberStyles}>{index + 1}</div>
|
463 |
+
<div className={`${lineContentStyles} ${lineChanges.before.has(index) ? 'bg-red-500/20 border-l-4 border-red-500' : ''}`}>
|
464 |
+
<span className="mr-2 text-bolt-elements-textTertiary">
|
465 |
+
{lineChanges.before.has(index) ? '-' : ' '}
|
466 |
+
</span>
|
467 |
+
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
|
468 |
+
</div>
|
469 |
+
</div>
|
470 |
+
))}
|
471 |
+
</div>
|
472 |
+
<div className="overflow-auto">
|
473 |
+
{afterLines.map((line, index) => (
|
474 |
+
<div key={`after-${index}`} className="flex group min-w-fit">
|
475 |
+
<div className={lineNumberStyles}>{index + 1}</div>
|
476 |
+
<div className={`${lineContentStyles} ${lineChanges.after.has(index) ? 'bg-green-500/20 border-l-4 border-green-500' : ''}`}>
|
477 |
+
<span className="mr-2 text-bolt-elements-textTertiary">
|
478 |
+
{lineChanges.after.has(index) ? '+' : ' '}
|
479 |
+
</span>
|
480 |
+
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
|
481 |
+
</div>
|
482 |
+
</div>
|
483 |
+
))}
|
484 |
+
</div>
|
485 |
+
</div>
|
486 |
+
) : (
|
487 |
+
<NoChangesView
|
488 |
+
beforeCode={beforeCode}
|
489 |
+
language={language}
|
490 |
+
highlighter={highlighter}
|
491 |
+
/>
|
492 |
+
)}
|
493 |
+
</div>
|
494 |
+
</div>
|
495 |
+
</FullscreenOverlay>
|
496 |
+
);
|
497 |
+
});
|
498 |
+
|
499 |
+
interface DiffViewProps {
|
500 |
+
fileHistory: Record<string, FileHistory>;
|
501 |
+
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
|
502 |
+
diffViewMode: 'inline' | 'side';
|
503 |
+
actionRunner: ActionRunner;
|
504 |
+
}
|
505 |
+
|
506 |
+
export const DiffView = memo(({ fileHistory, setFileHistory, diffViewMode, actionRunner }: DiffViewProps) => {
|
507 |
+
const files = useStore(workbenchStore.files) as FileMap;
|
508 |
+
const selectedFile = useStore(workbenchStore.selectedFile);
|
509 |
+
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
510 |
+
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
511 |
+
|
512 |
+
useEffect(() => {
|
513 |
+
if (selectedFile && currentDocument) {
|
514 |
+
const file = files[selectedFile];
|
515 |
+
if (!file || !('content' in file)) return;
|
516 |
+
|
517 |
+
const existingHistory = fileHistory[selectedFile];
|
518 |
+
const currentContent = currentDocument.value;
|
519 |
+
|
520 |
+
// Normalizar o conteúdo para comparação
|
521 |
+
const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
|
522 |
+
const normalizedOriginalContent = (existingHistory?.originalContent || file.content).replace(/\r\n/g, '\n').trim();
|
523 |
+
|
524 |
+
// Se não há histórico existente, criar um novo apenas se houver diferenças
|
525 |
+
if (!existingHistory) {
|
526 |
+
if (normalizedCurrentContent !== normalizedOriginalContent) {
|
527 |
+
const newChanges = diffLines(file.content, currentContent);
|
528 |
+
setFileHistory(prev => ({
|
529 |
+
...prev,
|
530 |
+
[selectedFile]: {
|
531 |
+
originalContent: file.content,
|
532 |
+
lastModified: Date.now(),
|
533 |
+
changes: newChanges,
|
534 |
+
versions: [{
|
535 |
+
timestamp: Date.now(),
|
536 |
+
content: currentContent
|
537 |
+
}],
|
538 |
+
changeSource: 'auto-save'
|
539 |
+
}
|
540 |
+
}));
|
541 |
+
}
|
542 |
+
return;
|
543 |
+
}
|
544 |
+
|
545 |
+
// Se já existe histórico, verificar se há mudanças reais desde a última versão
|
546 |
+
const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
|
547 |
+
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
|
548 |
+
|
549 |
+
if (normalizedCurrentContent === normalizedLastContent) {
|
550 |
+
return; // Não criar novo histórico se o conteúdo é o mesmo
|
551 |
+
}
|
552 |
+
|
553 |
+
// Verificar se há mudanças significativas usando diffFiles
|
554 |
+
const relativePath = extractRelativePath(selectedFile);
|
555 |
+
const unifiedDiff = diffFiles(
|
556 |
+
relativePath,
|
557 |
+
existingHistory.originalContent,
|
558 |
+
currentContent
|
559 |
+
);
|
560 |
+
|
561 |
+
if (unifiedDiff) {
|
562 |
+
const newChanges = diffLines(
|
563 |
+
existingHistory.originalContent,
|
564 |
+
currentContent
|
565 |
+
);
|
566 |
+
|
567 |
+
// Verificar se as mudanças são significativas
|
568 |
+
const hasSignificantChanges = newChanges.some(change =>
|
569 |
+
(change.added || change.removed) && change.value.trim().length > 0
|
570 |
+
);
|
571 |
+
|
572 |
+
if (hasSignificantChanges) {
|
573 |
+
const newHistory: FileHistory = {
|
574 |
+
originalContent: existingHistory.originalContent,
|
575 |
+
lastModified: Date.now(),
|
576 |
+
changes: [
|
577 |
+
...existingHistory.changes,
|
578 |
+
...newChanges
|
579 |
+
].slice(-100), // Limitar histórico de mudanças
|
580 |
+
versions: [
|
581 |
+
...existingHistory.versions,
|
582 |
+
{
|
583 |
+
timestamp: Date.now(),
|
584 |
+
content: currentContent
|
585 |
+
}
|
586 |
+
].slice(-10), // Manter apenas as 10 últimas versões
|
587 |
+
changeSource: 'auto-save'
|
588 |
+
};
|
589 |
+
|
590 |
+
setFileHistory(prev => ({ ...prev, [selectedFile]: newHistory }));
|
591 |
+
}
|
592 |
+
}
|
593 |
+
}
|
594 |
+
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
|
595 |
+
|
596 |
+
if (!selectedFile || !currentDocument) {
|
597 |
+
return (
|
598 |
+
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
|
599 |
+
Select a file to view differences
|
600 |
+
</div>
|
601 |
+
);
|
602 |
+
}
|
603 |
+
|
604 |
+
const file = files[selectedFile];
|
605 |
+
const originalContent = file && 'content' in file ? file.content : '';
|
606 |
+
const currentContent = currentDocument.value;
|
607 |
+
|
608 |
+
const history = fileHistory[selectedFile];
|
609 |
+
const effectiveOriginalContent = history?.originalContent || originalContent;
|
610 |
+
const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
|
611 |
+
|
612 |
+
try {
|
613 |
+
return (
|
614 |
+
<div className="h-full overflow-hidden">
|
615 |
+
{diffViewMode === 'inline' ? (
|
616 |
+
<InlineDiffComparison
|
617 |
+
beforeCode={effectiveOriginalContent}
|
618 |
+
afterCode={currentContent}
|
619 |
+
language={language}
|
620 |
+
filename={selectedFile}
|
621 |
+
lightTheme="github-light"
|
622 |
+
darkTheme="github-dark"
|
623 |
+
/>
|
624 |
+
) : (
|
625 |
+
<SideBySideComparison
|
626 |
+
beforeCode={effectiveOriginalContent}
|
627 |
+
afterCode={currentContent}
|
628 |
+
language={language}
|
629 |
+
filename={selectedFile}
|
630 |
+
lightTheme="github-light"
|
631 |
+
darkTheme="github-dark"
|
632 |
+
/>
|
633 |
+
)}
|
634 |
+
</div>
|
635 |
+
);
|
636 |
+
} catch (error) {
|
637 |
+
console.error('DiffView render error:', error);
|
638 |
+
return (
|
639 |
+
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
|
640 |
+
<div className="text-center">
|
641 |
+
<div className="i-ph:warning-circle text-4xl mb-2" />
|
642 |
+
<p>Failed to render diff view</p>
|
643 |
+
</div>
|
644 |
+
</div>
|
645 |
+
);
|
646 |
+
}
|
647 |
+
});
|
app/components/workbench/EditorPanel.tsx
CHANGED
@@ -12,6 +12,7 @@ import {
|
|
12 |
import { PanelHeader } from '~/components/ui/PanelHeader';
|
13 |
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
14 |
import type { FileMap } from '~/lib/stores/files';
|
|
|
15 |
import { themeStore } from '~/lib/stores/theme';
|
16 |
import { WORK_DIR } from '~/utils/constants';
|
17 |
import { renderLogger } from '~/utils/logger';
|
@@ -27,6 +28,7 @@ interface EditorPanelProps {
|
|
27 |
editorDocument?: EditorDocument;
|
28 |
selectedFile?: string | undefined;
|
29 |
isStreaming?: boolean;
|
|
|
30 |
onEditorChange?: OnEditorChange;
|
31 |
onEditorScroll?: OnEditorScroll;
|
32 |
onFileSelect?: (value?: string) => void;
|
@@ -45,6 +47,7 @@ export const EditorPanel = memo(
|
|
45 |
editorDocument,
|
46 |
selectedFile,
|
47 |
isStreaming,
|
|
|
48 |
onFileSelect,
|
49 |
onEditorChange,
|
50 |
onEditorScroll,
|
@@ -83,6 +86,7 @@ export const EditorPanel = memo(
|
|
83 |
files={files}
|
84 |
hideRoot
|
85 |
unsavedFiles={unsavedFiles}
|
|
|
86 |
rootFolder={WORK_DIR}
|
87 |
selectedFile={selectedFile}
|
88 |
onFileSelect={onFileSelect}
|
|
|
12 |
import { PanelHeader } from '~/components/ui/PanelHeader';
|
13 |
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
14 |
import type { FileMap } from '~/lib/stores/files';
|
15 |
+
import type { FileHistory } from '~/types/actions';
|
16 |
import { themeStore } from '~/lib/stores/theme';
|
17 |
import { WORK_DIR } from '~/utils/constants';
|
18 |
import { renderLogger } from '~/utils/logger';
|
|
|
28 |
editorDocument?: EditorDocument;
|
29 |
selectedFile?: string | undefined;
|
30 |
isStreaming?: boolean;
|
31 |
+
fileHistory?: Record<string, FileHistory>;
|
32 |
onEditorChange?: OnEditorChange;
|
33 |
onEditorScroll?: OnEditorScroll;
|
34 |
onFileSelect?: (value?: string) => void;
|
|
|
47 |
editorDocument,
|
48 |
selectedFile,
|
49 |
isStreaming,
|
50 |
+
fileHistory,
|
51 |
onFileSelect,
|
52 |
onEditorChange,
|
53 |
onEditorScroll,
|
|
|
86 |
files={files}
|
87 |
hideRoot
|
88 |
unsavedFiles={unsavedFiles}
|
89 |
+
fileHistory={fileHistory}
|
90 |
rootFolder={WORK_DIR}
|
91 |
selectedFile={selectedFile}
|
92 |
onFileSelect={onFileSelect}
|
app/components/workbench/FileTree.tsx
CHANGED
@@ -3,6 +3,8 @@ import type { FileMap } from '~/lib/stores/files';
|
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
5 |
import * as ContextMenu from '@radix-ui/react-context-menu';
|
|
|
|
|
6 |
|
7 |
const logger = createScopedLogger('FileTree');
|
8 |
|
@@ -19,6 +21,7 @@ interface Props {
|
|
19 |
allowFolderSelection?: boolean;
|
20 |
hiddenFiles?: Array<string | RegExp>;
|
21 |
unsavedFiles?: Set<string>;
|
|
|
22 |
className?: string;
|
23 |
}
|
24 |
|
@@ -34,6 +37,7 @@ export const FileTree = memo(
|
|
34 |
hiddenFiles,
|
35 |
className,
|
36 |
unsavedFiles,
|
|
|
37 |
}: Props) => {
|
38 |
renderLogger.trace('FileTree');
|
39 |
|
@@ -138,6 +142,7 @@ export const FileTree = memo(
|
|
138 |
selected={selectedFile === fileOrFolder.fullPath}
|
139 |
file={fileOrFolder}
|
140 |
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
|
|
141 |
onCopyPath={() => {
|
142 |
onCopyPath(fileOrFolder);
|
143 |
}}
|
@@ -253,19 +258,55 @@ interface FileProps {
|
|
253 |
file: FileNode;
|
254 |
selected: boolean;
|
255 |
unsavedChanges?: boolean;
|
|
|
256 |
onCopyPath: () => void;
|
257 |
onCopyRelativePath: () => void;
|
258 |
onClick: () => void;
|
259 |
}
|
260 |
|
261 |
function File({
|
262 |
-
file: { depth, name },
|
263 |
onClick,
|
264 |
onCopyPath,
|
265 |
onCopyRelativePath,
|
266 |
selected,
|
267 |
unsavedChanges = false,
|
|
|
268 |
}: FileProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
269 |
return (
|
270 |
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
271 |
<NodeButton
|
@@ -286,7 +327,21 @@ function File({
|
|
286 |
})}
|
287 |
>
|
288 |
<div className="flex-1 truncate pr-2">{name}</div>
|
289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
290 |
</div>
|
291 |
</NodeButton>
|
292 |
</FileContextMenu>
|
|
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
5 |
import * as ContextMenu from '@radix-ui/react-context-menu';
|
6 |
+
import type { FileHistory } from '~/types/actions';
|
7 |
+
import { diffLines, type Change } from 'diff';
|
8 |
|
9 |
const logger = createScopedLogger('FileTree');
|
10 |
|
|
|
21 |
allowFolderSelection?: boolean;
|
22 |
hiddenFiles?: Array<string | RegExp>;
|
23 |
unsavedFiles?: Set<string>;
|
24 |
+
fileHistory?: Record<string, FileHistory>;
|
25 |
className?: string;
|
26 |
}
|
27 |
|
|
|
37 |
hiddenFiles,
|
38 |
className,
|
39 |
unsavedFiles,
|
40 |
+
fileHistory = {},
|
41 |
}: Props) => {
|
42 |
renderLogger.trace('FileTree');
|
43 |
|
|
|
142 |
selected={selectedFile === fileOrFolder.fullPath}
|
143 |
file={fileOrFolder}
|
144 |
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
145 |
+
fileHistory={fileHistory}
|
146 |
onCopyPath={() => {
|
147 |
onCopyPath(fileOrFolder);
|
148 |
}}
|
|
|
258 |
file: FileNode;
|
259 |
selected: boolean;
|
260 |
unsavedChanges?: boolean;
|
261 |
+
fileHistory?: Record<string, FileHistory>;
|
262 |
onCopyPath: () => void;
|
263 |
onCopyRelativePath: () => void;
|
264 |
onClick: () => void;
|
265 |
}
|
266 |
|
267 |
function File({
|
268 |
+
file: { depth, name, fullPath },
|
269 |
onClick,
|
270 |
onCopyPath,
|
271 |
onCopyRelativePath,
|
272 |
selected,
|
273 |
unsavedChanges = false,
|
274 |
+
fileHistory = {},
|
275 |
}: FileProps) {
|
276 |
+
const fileModifications = fileHistory[fullPath];
|
277 |
+
const hasModifications = fileModifications !== undefined;
|
278 |
+
|
279 |
+
// Calculate added and removed lines from the most recent changes
|
280 |
+
const { additions, deletions } = useMemo(() => {
|
281 |
+
if (!fileModifications?.originalContent) return { additions: 0, deletions: 0 };
|
282 |
+
|
283 |
+
// Usar a mesma lógica do DiffView para processar as mudanças
|
284 |
+
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
|
285 |
+
const normalizedCurrent = fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
286 |
+
|
287 |
+
if (normalizedOriginal === normalizedCurrent) {
|
288 |
+
return { additions: 0, deletions: 0 };
|
289 |
+
}
|
290 |
+
|
291 |
+
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
292 |
+
newlineIsToken: false,
|
293 |
+
ignoreWhitespace: true,
|
294 |
+
ignoreCase: false
|
295 |
+
});
|
296 |
+
|
297 |
+
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
298 |
+
if (change.added) {
|
299 |
+
acc.additions += change.value.split('\n').length;
|
300 |
+
}
|
301 |
+
if (change.removed) {
|
302 |
+
acc.deletions += change.value.split('\n').length;
|
303 |
+
}
|
304 |
+
return acc;
|
305 |
+
}, { additions: 0, deletions: 0 });
|
306 |
+
}, [fileModifications]);
|
307 |
+
|
308 |
+
const showStats = additions > 0 || deletions > 0;
|
309 |
+
|
310 |
return (
|
311 |
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
312 |
<NodeButton
|
|
|
327 |
})}
|
328 |
>
|
329 |
<div className="flex-1 truncate pr-2">{name}</div>
|
330 |
+
<div className="flex items-center gap-1">
|
331 |
+
{showStats && (
|
332 |
+
<div className="flex items-center gap-1 text-xs">
|
333 |
+
{additions > 0 && (
|
334 |
+
<span className="text-green-500">+{additions}</span>
|
335 |
+
)}
|
336 |
+
{deletions > 0 && (
|
337 |
+
<span className="text-red-500">-{deletions}</span>
|
338 |
+
)}
|
339 |
+
</div>
|
340 |
+
)}
|
341 |
+
{unsavedChanges && (
|
342 |
+
<span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />
|
343 |
+
)}
|
344 |
+
</div>
|
345 |
</div>
|
346 |
</NodeButton>
|
347 |
</FileContextMenu>
|
app/components/workbench/Workbench.client.tsx
CHANGED
@@ -1,8 +1,15 @@
|
|
1 |
import { useStore } from '@nanostores/react';
|
2 |
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
3 |
import { computed } from 'nanostores';
|
4 |
-
import { memo, useCallback, useEffect, useState } from 'react';
|
5 |
import { toast } from 'react-toastify';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
import {
|
7 |
type OnChangeCallback as OnEditorChange,
|
8 |
type OnScrollCallback as OnEditorScroll,
|
@@ -18,10 +25,16 @@ import { EditorPanel } from './EditorPanel';
|
|
18 |
import { Preview } from './Preview';
|
19 |
import useViewport from '~/lib/hooks';
|
20 |
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
|
|
|
21 |
|
22 |
interface WorkspaceProps {
|
23 |
chatStarted?: boolean;
|
24 |
isStreaming?: boolean;
|
|
|
|
|
|
|
|
|
|
|
25 |
}
|
26 |
|
27 |
const viewTransition = { ease: cubicEasingFn };
|
@@ -31,6 +44,10 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
|
31 |
value: 'code',
|
32 |
text: 'Code',
|
33 |
},
|
|
|
|
|
|
|
|
|
34 |
right: {
|
35 |
value: 'preview',
|
36 |
text: 'Preview',
|
@@ -54,11 +71,211 @@ const workbenchVariants = {
|
|
54 |
},
|
55 |
} satisfies Variants;
|
56 |
|
57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
renderLogger.trace('Workbench');
|
59 |
|
60 |
const [isSyncing, setIsSyncing] = useState(false);
|
61 |
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
|
|
|
|
|
|
|
|
62 |
|
63 |
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
64 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
@@ -121,6 +338,15 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
121 |
}
|
122 |
}, []);
|
123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
return (
|
125 |
chatStarted && (
|
126 |
<motion.div
|
@@ -175,6 +401,14 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
175 |
</PanelHeaderButton>
|
176 |
</div>
|
177 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
<IconButton
|
179 |
icon="i-ph:x-circle"
|
180 |
className="-mr-1"
|
@@ -186,8 +420,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
186 |
</div>
|
187 |
<div className="relative flex-1 overflow-hidden">
|
188 |
<View
|
189 |
-
initial={{ x:
|
190 |
-
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
191 |
>
|
192 |
<EditorPanel
|
193 |
editorDocument={currentDocument}
|
@@ -195,6 +429,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
195 |
selectedFile={selectedFile}
|
196 |
files={files}
|
197 |
unsavedFiles={unsavedFiles}
|
|
|
198 |
onFileSelect={onFileSelect}
|
199 |
onEditorScroll={onEditorScroll}
|
200 |
onEditorChange={onEditorChange}
|
@@ -203,8 +438,19 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
203 |
/>
|
204 |
</View>
|
205 |
<View
|
206 |
-
initial={{ x:
|
207 |
-
animate={{ x: selectedView === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
208 |
>
|
209 |
<Preview />
|
210 |
</View>
|
@@ -215,14 +461,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
215 |
<PushToGitHubDialog
|
216 |
isOpen={isPushDialogOpen}
|
217 |
onClose={() => setIsPushDialogOpen(false)}
|
218 |
-
onPush={async (repoName, username, token
|
219 |
try {
|
220 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
return repoUrl;
|
222 |
} catch (error) {
|
223 |
console.error('Error pushing to GitHub:', error);
|
224 |
toast.error('Failed to push to GitHub');
|
225 |
-
throw error;
|
226 |
}
|
227 |
}}
|
228 |
/>
|
|
|
1 |
import { useStore } from '@nanostores/react';
|
2 |
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
3 |
import { computed } from 'nanostores';
|
4 |
+
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
|
5 |
import { toast } from 'react-toastify';
|
6 |
+
import { Popover, Transition } from '@headlessui/react';
|
7 |
+
import { diffLines, type Change } from 'diff';
|
8 |
+
import { formatDistanceToNow as formatDistance } from 'date-fns';
|
9 |
+
import { ActionRunner } from '~/lib/runtime/action-runner';
|
10 |
+
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
11 |
+
import type { FileHistory } from '~/types/actions';
|
12 |
+
import { DiffView } from './DiffView';
|
13 |
import {
|
14 |
type OnChangeCallback as OnEditorChange,
|
15 |
type OnScrollCallback as OnEditorScroll,
|
|
|
25 |
import { Preview } from './Preview';
|
26 |
import useViewport from '~/lib/hooks';
|
27 |
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
|
28 |
+
import Cookies from 'js-cookie';
|
29 |
|
30 |
interface WorkspaceProps {
|
31 |
chatStarted?: boolean;
|
32 |
isStreaming?: boolean;
|
33 |
+
actionRunner: ActionRunner;
|
34 |
+
metadata?: {
|
35 |
+
gitUrl?: string;
|
36 |
+
};
|
37 |
+
updateChatMestaData?: (metadata: any) => void;
|
38 |
}
|
39 |
|
40 |
const viewTransition = { ease: cubicEasingFn };
|
|
|
44 |
value: 'code',
|
45 |
text: 'Code',
|
46 |
},
|
47 |
+
middle: {
|
48 |
+
value: 'diff',
|
49 |
+
text: 'Diff',
|
50 |
+
},
|
51 |
right: {
|
52 |
value: 'preview',
|
53 |
text: 'Preview',
|
|
|
71 |
},
|
72 |
} satisfies Variants;
|
73 |
|
74 |
+
const FileModifiedDropdown = memo(({
|
75 |
+
fileHistory,
|
76 |
+
onSelectFile,
|
77 |
+
diffViewMode,
|
78 |
+
toggleDiffViewMode,
|
79 |
+
}: {
|
80 |
+
fileHistory: Record<string, FileHistory>,
|
81 |
+
onSelectFile: (filePath: string) => void,
|
82 |
+
diffViewMode: 'inline' | 'side',
|
83 |
+
toggleDiffViewMode: () => void,
|
84 |
+
}) => {
|
85 |
+
const modifiedFiles = Object.entries(fileHistory);
|
86 |
+
const hasChanges = modifiedFiles.length > 0;
|
87 |
+
const [searchQuery, setSearchQuery] = useState('');
|
88 |
+
|
89 |
+
const filteredFiles = useMemo(() => {
|
90 |
+
return modifiedFiles.filter(([filePath]) =>
|
91 |
+
filePath.toLowerCase().includes(searchQuery.toLowerCase())
|
92 |
+
);
|
93 |
+
}, [modifiedFiles, searchQuery]);
|
94 |
+
|
95 |
+
return (
|
96 |
+
<div className="flex items-center gap-2">
|
97 |
+
<Popover className="relative">
|
98 |
+
{({ open }: { open: boolean }) => (
|
99 |
+
<>
|
100 |
+
<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">
|
101 |
+
<span className="font-medium">File Changes</span>
|
102 |
+
{hasChanges && (
|
103 |
+
<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">
|
104 |
+
{modifiedFiles.length}
|
105 |
+
</span>
|
106 |
+
)}
|
107 |
+
</Popover.Button>
|
108 |
+
<Transition
|
109 |
+
show={open}
|
110 |
+
enter="transition duration-100 ease-out"
|
111 |
+
enterFrom="transform scale-95 opacity-0"
|
112 |
+
enterTo="transform scale-100 opacity-100"
|
113 |
+
leave="transition duration-75 ease-out"
|
114 |
+
leaveFrom="transform scale-100 opacity-100"
|
115 |
+
leaveTo="transform scale-95 opacity-0"
|
116 |
+
>
|
117 |
+
<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">
|
118 |
+
<div className="p-2">
|
119 |
+
<div className="relative mx-2 mb-2">
|
120 |
+
<input
|
121 |
+
type="text"
|
122 |
+
placeholder="Search files..."
|
123 |
+
value={searchQuery}
|
124 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
125 |
+
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"
|
126 |
+
/>
|
127 |
+
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
|
128 |
+
<div className="i-ph:magnifying-glass" />
|
129 |
+
</div>
|
130 |
+
</div>
|
131 |
+
|
132 |
+
<div className="max-h-60 overflow-y-auto">
|
133 |
+
{filteredFiles.length > 0 ? (
|
134 |
+
filteredFiles.map(([filePath, history]) => {
|
135 |
+
const extension = filePath.split('.').pop() || '';
|
136 |
+
const language = getLanguageFromExtension(extension);
|
137 |
+
|
138 |
+
return (
|
139 |
+
<button
|
140 |
+
key={filePath}
|
141 |
+
onClick={() => onSelectFile(filePath)}
|
142 |
+
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
|
143 |
+
>
|
144 |
+
<div className="flex items-center gap-2">
|
145 |
+
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
|
146 |
+
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && <div className="i-ph:file-js" />}
|
147 |
+
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
|
148 |
+
{language === 'html' && <div className="i-ph:code" />}
|
149 |
+
{language === 'json' && <div className="i-ph:brackets-curly" />}
|
150 |
+
{language === 'python' && <div className="i-ph:file-text" />}
|
151 |
+
{language === 'markdown' && <div className="i-ph:article" />}
|
152 |
+
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
|
153 |
+
{language === 'sql' && <div className="i-ph:database" />}
|
154 |
+
{language === 'dockerfile' && <div className="i-ph:cube" />}
|
155 |
+
{language === 'shell' && <div className="i-ph:terminal" />}
|
156 |
+
{!['typescript', 'javascript', 'css', 'html', 'json', 'python', 'markdown', 'yaml', 'yml', 'sql', 'dockerfile', 'shell', 'jsx', 'tsx', 'scss', 'less'].includes(language) && <div className="i-ph:file-text" />}
|
157 |
+
</div>
|
158 |
+
<div className="flex-1 min-w-0">
|
159 |
+
<div className="flex items-center justify-between gap-2">
|
160 |
+
<div className="flex flex-col min-w-0">
|
161 |
+
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
|
162 |
+
{filePath.split('/').pop()}
|
163 |
+
</span>
|
164 |
+
<span className="truncate text-xs text-bolt-elements-textTertiary">
|
165 |
+
{filePath}
|
166 |
+
</span>
|
167 |
+
</div>
|
168 |
+
{(() => {
|
169 |
+
// Calculate diff stats
|
170 |
+
const { additions, deletions } = (() => {
|
171 |
+
if (!history.originalContent) return { additions: 0, deletions: 0 };
|
172 |
+
|
173 |
+
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
174 |
+
const normalizedCurrent = history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
175 |
+
|
176 |
+
if (normalizedOriginal === normalizedCurrent) {
|
177 |
+
return { additions: 0, deletions: 0 };
|
178 |
+
}
|
179 |
+
|
180 |
+
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
181 |
+
newlineIsToken: false,
|
182 |
+
ignoreWhitespace: true,
|
183 |
+
ignoreCase: false
|
184 |
+
});
|
185 |
+
|
186 |
+
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
187 |
+
if (change.added) {
|
188 |
+
acc.additions += change.value.split('\n').length;
|
189 |
+
}
|
190 |
+
if (change.removed) {
|
191 |
+
acc.deletions += change.value.split('\n').length;
|
192 |
+
}
|
193 |
+
return acc;
|
194 |
+
}, { additions: 0, deletions: 0 });
|
195 |
+
})();
|
196 |
+
|
197 |
+
const showStats = additions > 0 || deletions > 0;
|
198 |
+
|
199 |
+
return showStats && (
|
200 |
+
<div className="flex items-center gap-1 text-xs shrink-0">
|
201 |
+
{additions > 0 && (
|
202 |
+
<span className="text-green-500">+{additions}</span>
|
203 |
+
)}
|
204 |
+
{deletions > 0 && (
|
205 |
+
<span className="text-red-500">-{deletions}</span>
|
206 |
+
)}
|
207 |
+
</div>
|
208 |
+
);
|
209 |
+
})()}
|
210 |
+
</div>
|
211 |
+
</div>
|
212 |
+
</div>
|
213 |
+
</button>
|
214 |
+
);
|
215 |
+
})
|
216 |
+
) : (
|
217 |
+
<div className="flex flex-col items-center justify-center p-4 text-center">
|
218 |
+
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
|
219 |
+
<div className="i-ph:file-dashed" />
|
220 |
+
</div>
|
221 |
+
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
222 |
+
{searchQuery ? 'No matching files' : 'No modified files'}
|
223 |
+
</p>
|
224 |
+
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
225 |
+
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
|
226 |
+
</p>
|
227 |
+
</div>
|
228 |
+
)}
|
229 |
+
</div>
|
230 |
+
</div>
|
231 |
+
|
232 |
+
{hasChanges && (
|
233 |
+
<div className="border-t border-bolt-elements-borderColor p-2">
|
234 |
+
<button
|
235 |
+
onClick={() => {
|
236 |
+
navigator.clipboard.writeText(
|
237 |
+
filteredFiles.map(([filePath]) => filePath).join('\n')
|
238 |
+
);
|
239 |
+
toast('File list copied to clipboard', {
|
240 |
+
icon: <div className="i-ph:check-circle text-accent-500" />
|
241 |
+
});
|
242 |
+
}}
|
243 |
+
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"
|
244 |
+
>
|
245 |
+
Copy File List
|
246 |
+
</button>
|
247 |
+
</div>
|
248 |
+
)}
|
249 |
+
</Popover.Panel>
|
250 |
+
</Transition>
|
251 |
+
</>
|
252 |
+
)}
|
253 |
+
</Popover>
|
254 |
+
<button
|
255 |
+
onClick={(e) => { e.stopPropagation(); toggleDiffViewMode(); }}
|
256 |
+
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"
|
257 |
+
>
|
258 |
+
<span className="font-medium">{diffViewMode === 'inline' ? 'Inline' : 'Side by Side'}</span>
|
259 |
+
</button>
|
260 |
+
</div>
|
261 |
+
);
|
262 |
+
});
|
263 |
+
|
264 |
+
export const Workbench = memo(({
|
265 |
+
chatStarted,
|
266 |
+
isStreaming,
|
267 |
+
actionRunner,
|
268 |
+
metadata,
|
269 |
+
updateChatMestaData
|
270 |
+
}: WorkspaceProps) => {
|
271 |
renderLogger.trace('Workbench');
|
272 |
|
273 |
const [isSyncing, setIsSyncing] = useState(false);
|
274 |
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
275 |
+
const [diffViewMode, setDiffViewMode] = useState<'inline' | 'side'>('inline');
|
276 |
+
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
277 |
+
|
278 |
+
const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
279 |
|
280 |
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
281 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
|
|
338 |
}
|
339 |
}, []);
|
340 |
|
341 |
+
const handleSelectFile = useCallback((filePath: string) => {
|
342 |
+
workbenchStore.setSelectedFile(filePath);
|
343 |
+
workbenchStore.currentView.set('diff');
|
344 |
+
}, []);
|
345 |
+
|
346 |
+
const toggleDiffViewMode = useCallback(() => {
|
347 |
+
setDiffViewMode(prev => prev === 'inline' ? 'side' : 'inline');
|
348 |
+
}, []);
|
349 |
+
|
350 |
return (
|
351 |
chatStarted && (
|
352 |
<motion.div
|
|
|
401 |
</PanelHeaderButton>
|
402 |
</div>
|
403 |
)}
|
404 |
+
{selectedView === 'diff' && (
|
405 |
+
<FileModifiedDropdown
|
406 |
+
fileHistory={fileHistory}
|
407 |
+
onSelectFile={handleSelectFile}
|
408 |
+
diffViewMode={diffViewMode}
|
409 |
+
toggleDiffViewMode={toggleDiffViewMode}
|
410 |
+
/>
|
411 |
+
)}
|
412 |
<IconButton
|
413 |
icon="i-ph:x-circle"
|
414 |
className="-mr-1"
|
|
|
420 |
</div>
|
421 |
<div className="relative flex-1 overflow-hidden">
|
422 |
<View
|
423 |
+
initial={{ x: '0%' }}
|
424 |
+
animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}
|
425 |
>
|
426 |
<EditorPanel
|
427 |
editorDocument={currentDocument}
|
|
|
429 |
selectedFile={selectedFile}
|
430 |
files={files}
|
431 |
unsavedFiles={unsavedFiles}
|
432 |
+
fileHistory={fileHistory}
|
433 |
onFileSelect={onFileSelect}
|
434 |
onEditorScroll={onEditorScroll}
|
435 |
onEditorChange={onEditorChange}
|
|
|
438 |
/>
|
439 |
</View>
|
440 |
<View
|
441 |
+
initial={{ x: '100%' }}
|
442 |
+
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
|
443 |
+
>
|
444 |
+
<DiffView
|
445 |
+
fileHistory={fileHistory}
|
446 |
+
setFileHistory={setFileHistory}
|
447 |
+
diffViewMode={diffViewMode}
|
448 |
+
actionRunner={actionRunner}
|
449 |
+
/>
|
450 |
+
</View>
|
451 |
+
<View
|
452 |
+
initial={{ x: '100%' }}
|
453 |
+
animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}
|
454 |
>
|
455 |
<Preview />
|
456 |
</View>
|
|
|
461 |
<PushToGitHubDialog
|
462 |
isOpen={isPushDialogOpen}
|
463 |
onClose={() => setIsPushDialogOpen(false)}
|
464 |
+
onPush={async (repoName, username, token) => {
|
465 |
try {
|
466 |
+
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
467 |
+
await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
|
468 |
+
const repoUrl = `https://github.com/${username}/${repoName}`;
|
469 |
+
|
470 |
+
if (updateChatMestaData && !metadata?.gitUrl) {
|
471 |
+
updateChatMestaData({
|
472 |
+
...(metadata || {}),
|
473 |
+
gitUrl: repoUrl,
|
474 |
+
});
|
475 |
+
}
|
476 |
+
|
477 |
return repoUrl;
|
478 |
} catch (error) {
|
479 |
console.error('Error pushing to GitHub:', error);
|
480 |
toast.error('Failed to push to GitHub');
|
481 |
+
throw error;
|
482 |
}
|
483 |
}}
|
484 |
/>
|
app/lib/runtime/action-runner.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
import type { WebContainer } from '@webcontainer/api';
|
2 |
-
import { path } from '~/utils/path';
|
3 |
import { atom, map, type MapStore } from 'nanostores';
|
4 |
-
import type { ActionAlert, BoltAction } from '~/types/actions';
|
5 |
import { createScopedLogger } from '~/utils/logger';
|
6 |
import { unreachable } from '~/utils/unreachable';
|
7 |
import type { ActionCallbackData } from './message-parser';
|
@@ -276,9 +276,9 @@ export class ActionRunner {
|
|
276 |
}
|
277 |
|
278 |
const webcontainer = await this.#webcontainer;
|
279 |
-
const relativePath =
|
280 |
|
281 |
-
let folder =
|
282 |
|
283 |
// remove trailing slashes
|
284 |
folder = folder.replace(/\/+$/g, '');
|
@@ -304,4 +304,31 @@ export class ActionRunner {
|
|
304 |
|
305 |
this.actions.setKey(id, { ...actions[id], ...newState });
|
306 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
307 |
}
|
|
|
1 |
import type { WebContainer } from '@webcontainer/api';
|
2 |
+
import { path as nodePath } from '~/utils/path';
|
3 |
import { atom, map, type MapStore } from 'nanostores';
|
4 |
+
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
|
5 |
import { createScopedLogger } from '~/utils/logger';
|
6 |
import { unreachable } from '~/utils/unreachable';
|
7 |
import type { ActionCallbackData } from './message-parser';
|
|
|
276 |
}
|
277 |
|
278 |
const webcontainer = await this.#webcontainer;
|
279 |
+
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
|
280 |
|
281 |
+
let folder = nodePath.dirname(relativePath);
|
282 |
|
283 |
// remove trailing slashes
|
284 |
folder = folder.replace(/\/+$/g, '');
|
|
|
304 |
|
305 |
this.actions.setKey(id, { ...actions[id], ...newState });
|
306 |
}
|
307 |
+
|
308 |
+
async getFileHistory(filePath: string): Promise<FileHistory | null> {
|
309 |
+
try {
|
310 |
+
const webcontainer = await this.#webcontainer;
|
311 |
+
const historyPath = this.#getHistoryPath(filePath);
|
312 |
+
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
|
313 |
+
return JSON.parse(content);
|
314 |
+
} catch (error) {
|
315 |
+
return null;
|
316 |
+
}
|
317 |
+
}
|
318 |
+
|
319 |
+
async saveFileHistory(filePath: string, history: FileHistory) {
|
320 |
+
const webcontainer = await this.#webcontainer;
|
321 |
+
const historyPath = this.#getHistoryPath(filePath);
|
322 |
+
|
323 |
+
await this.#runFileAction({
|
324 |
+
type: 'file',
|
325 |
+
filePath: historyPath,
|
326 |
+
content: JSON.stringify(history),
|
327 |
+
changeSource: 'auto-save'
|
328 |
+
} as any);
|
329 |
+
}
|
330 |
+
|
331 |
+
#getHistoryPath(filePath: string) {
|
332 |
+
return nodePath.join('.history', filePath);
|
333 |
+
}
|
334 |
}
|
app/lib/stores/workbench.ts
CHANGED
@@ -10,18 +10,16 @@ import { FilesStore, type FileMap } from './files';
|
|
10 |
import { PreviewsStore } from './previews';
|
11 |
import { TerminalStore } from './terminal';
|
12 |
import JSZip from 'jszip';
|
13 |
-
import
|
|
|
14 |
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
15 |
-
import
|
16 |
import { extractRelativePath } from '~/utils/diff';
|
17 |
import { description } from '~/lib/persistence';
|
18 |
import Cookies from 'js-cookie';
|
19 |
import { createSampler } from '~/utils/sampler';
|
20 |
import type { ActionAlert } from '~/types/actions';
|
21 |
|
22 |
-
// Destructure saveAs from the CommonJS module
|
23 |
-
const { saveAs } = fileSaver;
|
24 |
-
|
25 |
export interface ArtifactState {
|
26 |
id: string;
|
27 |
title: string;
|
@@ -34,7 +32,7 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
|
34 |
|
35 |
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
36 |
|
37 |
-
export type WorkbenchViewType = 'code' | 'preview';
|
38 |
|
39 |
export class WorkbenchStore {
|
40 |
#previewsStore = new PreviewsStore(webcontainer);
|
@@ -332,7 +330,7 @@ export class WorkbenchStore {
|
|
332 |
|
333 |
if (data.action.type === 'file') {
|
334 |
const wc = await webcontainer;
|
335 |
-
const fullPath =
|
336 |
|
337 |
if (this.selectedFile.value !== fullPath) {
|
338 |
this.setSelectedFile(fullPath);
|
@@ -437,13 +435,7 @@ export class WorkbenchStore {
|
|
437 |
return syncedFiles;
|
438 |
}
|
439 |
|
440 |
-
async pushToGitHub(
|
441 |
-
repoName: string,
|
442 |
-
commitMessage?: string,
|
443 |
-
githubUsername?: string,
|
444 |
-
ghToken?: string,
|
445 |
-
isPrivate: boolean = false,
|
446 |
-
) {
|
447 |
try {
|
448 |
// Use cookies if username and token are not provided
|
449 |
const githubToken = ghToken || Cookies.get('githubToken');
|
@@ -467,7 +459,7 @@ export class WorkbenchStore {
|
|
467 |
// Repository doesn't exist, so create a new one
|
468 |
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
469 |
name: repoName,
|
470 |
-
private:
|
471 |
auto_init: true,
|
472 |
});
|
473 |
repo = newRepo;
|
@@ -545,7 +537,7 @@ export class WorkbenchStore {
|
|
545 |
sha: newCommit.sha,
|
546 |
});
|
547 |
|
548 |
-
|
549 |
} catch (error) {
|
550 |
console.error('Error pushing to GitHub:', error);
|
551 |
throw error; // Rethrow the error for further handling
|
|
|
10 |
import { PreviewsStore } from './previews';
|
11 |
import { TerminalStore } from './terminal';
|
12 |
import JSZip from 'jszip';
|
13 |
+
import pkg from 'file-saver';
|
14 |
+
const { saveAs } = pkg;
|
15 |
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
16 |
+
import * as nodePath from 'node:path';
|
17 |
import { extractRelativePath } from '~/utils/diff';
|
18 |
import { description } from '~/lib/persistence';
|
19 |
import Cookies from 'js-cookie';
|
20 |
import { createSampler } from '~/utils/sampler';
|
21 |
import type { ActionAlert } from '~/types/actions';
|
22 |
|
|
|
|
|
|
|
23 |
export interface ArtifactState {
|
24 |
id: string;
|
25 |
title: string;
|
|
|
32 |
|
33 |
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
34 |
|
35 |
+
export type WorkbenchViewType = 'code' | 'diff' | 'preview';
|
36 |
|
37 |
export class WorkbenchStore {
|
38 |
#previewsStore = new PreviewsStore(webcontainer);
|
|
|
330 |
|
331 |
if (data.action.type === 'file') {
|
332 |
const wc = await webcontainer;
|
333 |
+
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
|
334 |
|
335 |
if (this.selectedFile.value !== fullPath) {
|
336 |
this.setSelectedFile(fullPath);
|
|
|
435 |
return syncedFiles;
|
436 |
}
|
437 |
|
438 |
+
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
|
|
|
|
|
|
|
|
|
|
|
|
|
439 |
try {
|
440 |
// Use cookies if username and token are not provided
|
441 |
const githubToken = ghToken || Cookies.get('githubToken');
|
|
|
459 |
// Repository doesn't exist, so create a new one
|
460 |
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
461 |
name: repoName,
|
462 |
+
private: false,
|
463 |
auto_init: true,
|
464 |
});
|
465 |
repo = newRepo;
|
|
|
537 |
sha: newCommit.sha,
|
538 |
});
|
539 |
|
540 |
+
alert(`Repository created and code pushed: ${repo.html_url}`);
|
541 |
} catch (error) {
|
542 |
console.error('Error pushing to GitHub:', error);
|
543 |
throw error; // Rethrow the error for further handling
|
app/styles/diff-view.css
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.diff-panel-content {
|
2 |
+
scrollbar-width: thin;
|
3 |
+
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
|
4 |
+
}
|
5 |
+
|
6 |
+
.diff-panel-content::-webkit-scrollbar {
|
7 |
+
width: 8px;
|
8 |
+
height: 8px;
|
9 |
+
}
|
10 |
+
|
11 |
+
.diff-panel-content::-webkit-scrollbar-track {
|
12 |
+
background: transparent;
|
13 |
+
}
|
14 |
+
|
15 |
+
.diff-panel-content::-webkit-scrollbar-thumb {
|
16 |
+
background-color: rgba(155, 155, 155, 0.5);
|
17 |
+
border-radius: 4px;
|
18 |
+
border: 2px solid transparent;
|
19 |
+
}
|
20 |
+
|
21 |
+
.diff-panel-content::-webkit-scrollbar-thumb:hover {
|
22 |
+
background-color: rgba(155, 155, 155, 0.7);
|
23 |
+
}
|
24 |
+
|
25 |
+
/* Hide scrollbar for the left panel when not hovered */
|
26 |
+
.diff-panel:not(:hover) .diff-panel-content::-webkit-scrollbar {
|
27 |
+
display: none;
|
28 |
+
}
|
29 |
+
|
30 |
+
.diff-panel:not(:hover) .diff-panel-content {
|
31 |
+
scrollbar-width: none;
|
32 |
+
}
|
33 |
+
|
34 |
+
/* Estilos para as linhas de diff */
|
35 |
+
.diff-block-added {
|
36 |
+
@apply bg-green-500/20 border-l-4 border-green-500;
|
37 |
+
}
|
38 |
+
|
39 |
+
.diff-block-removed {
|
40 |
+
@apply bg-red-500/20 border-l-4 border-red-500;
|
41 |
+
}
|
42 |
+
|
43 |
+
/* Melhorar contraste para mudanças */
|
44 |
+
.diff-panel-content .group:hover .diff-block-added {
|
45 |
+
@apply bg-green-500/30;
|
46 |
+
}
|
47 |
+
|
48 |
+
.diff-panel-content .group:hover .diff-block-removed {
|
49 |
+
@apply bg-red-500/30;
|
50 |
+
}
|
51 |
+
|
52 |
+
/* Estilos unificados para ambas as visualizações */
|
53 |
+
.diff-line {
|
54 |
+
@apply flex group min-w-fit transition-colors duration-150;
|
55 |
+
}
|
56 |
+
|
57 |
+
.diff-line-number {
|
58 |
+
@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;
|
59 |
+
}
|
60 |
+
|
61 |
+
.diff-line-content {
|
62 |
+
@apply px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary;
|
63 |
+
}
|
64 |
+
|
65 |
+
/* Cores específicas para adições/remoções */
|
66 |
+
.diff-added {
|
67 |
+
@apply bg-green-500/20 border-l-4 border-green-500;
|
68 |
+
}
|
69 |
+
|
70 |
+
.diff-removed {
|
71 |
+
@apply bg-red-500/20 border-l-4 border-red-500;
|
72 |
+
}
|
app/types/actions.ts
CHANGED
@@ -1,3 +1,5 @@
|
|
|
|
|
|
1 |
export type ActionType = 'file' | 'shell';
|
2 |
|
3 |
export interface BaseAction {
|
@@ -28,3 +30,15 @@ export interface ActionAlert {
|
|
28 |
content: string;
|
29 |
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
30 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { Change } from 'diff';
|
2 |
+
|
3 |
export type ActionType = 'file' | 'shell';
|
4 |
|
5 |
export interface BaseAction {
|
|
|
30 |
content: string;
|
31 |
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
32 |
}
|
33 |
+
|
34 |
+
export interface FileHistory {
|
35 |
+
originalContent: string;
|
36 |
+
lastModified: number;
|
37 |
+
changes: Change[];
|
38 |
+
versions: {
|
39 |
+
timestamp: number;
|
40 |
+
content: string;
|
41 |
+
}[];
|
42 |
+
// Novo campo para rastrear a origem das mudanças
|
43 |
+
changeSource?: 'user' | 'auto-save' | 'external';
|
44 |
+
}
|
app/utils/getLanguageFromExtension.ts
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const getLanguageFromExtension = (ext: string): string => {
|
2 |
+
const map: Record<string, string> = {
|
3 |
+
js: "javascript",
|
4 |
+
jsx: "jsx",
|
5 |
+
ts: "typescript",
|
6 |
+
tsx: "tsx",
|
7 |
+
json: "json",
|
8 |
+
html: "html",
|
9 |
+
css: "css",
|
10 |
+
py: "python",
|
11 |
+
java: "java",
|
12 |
+
rb: "ruby",
|
13 |
+
cpp: "cpp",
|
14 |
+
c: "c",
|
15 |
+
cs: "csharp",
|
16 |
+
go: "go",
|
17 |
+
rs: "rust",
|
18 |
+
php: "php",
|
19 |
+
swift: "swift",
|
20 |
+
md: "plaintext",
|
21 |
+
sh: "bash",
|
22 |
+
};
|
23 |
+
return map[ext] || "typescript";
|
24 |
+
};
|
package.json
CHANGED
@@ -74,6 +74,8 @@
|
|
74 |
"@radix-ui/react-switch": "^1.1.1",
|
75 |
"@radix-ui/react-tabs": "^1.1.2",
|
76 |
"@radix-ui/react-tooltip": "^1.1.4",
|
|
|
|
|
77 |
"@remix-run/cloudflare": "^2.15.2",
|
78 |
"@remix-run/cloudflare-pages": "^2.15.2",
|
79 |
"@remix-run/node": "^2.15.2",
|
|
|
74 |
"@radix-ui/react-switch": "^1.1.1",
|
75 |
"@radix-ui/react-tabs": "^1.1.2",
|
76 |
"@radix-ui/react-tooltip": "^1.1.4",
|
77 |
+
"lucide-react": "^0.474.0",
|
78 |
+
"next-themes": "^0.4.4",
|
79 |
"@remix-run/cloudflare": "^2.15.2",
|
80 |
"@remix-run/cloudflare-pages": "^2.15.2",
|
81 |
"@remix-run/node": "^2.15.2",
|
vite.config.ts
CHANGED
@@ -89,6 +89,7 @@ export default defineConfig((config) => {
|
|
89 |
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
|
90 |
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
|
91 |
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
|
|
|
92 |
},
|
93 |
build: {
|
94 |
target: 'esnext',
|
|
|
89 |
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
|
90 |
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
|
91 |
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
|
92 |
+
module: {},
|
93 |
},
|
94 |
build: {
|
95 |
target: 'esnext',
|
wrangler.toml
CHANGED
@@ -3,4 +3,4 @@ name = "bolt"
|
|
3 |
compatibility_flags = ["nodejs_compat"]
|
4 |
compatibility_date = "2024-07-01"
|
5 |
pages_build_output_dir = "./build/client"
|
6 |
-
send_metrics = false
|
|
|
3 |
compatibility_flags = ["nodejs_compat"]
|
4 |
compatibility_date = "2024-07-01"
|
5 |
pages_build_output_dir = "./build/client"
|
6 |
+
send_metrics = false
|