KevIsDev commited on
Commit
5d1816b
·
unverified ·
2 Parent(s): 5d9bb00 a8d8b7b

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

Browse files
.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;
@@ -69,6 +70,7 @@ interface BaseChatProps {
69
  actionAlert?: ActionAlert;
70
  clearAlert?: () => void;
71
  data?: JSONValue[] | undefined;
 
72
  }
73
 
74
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -104,6 +106,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
104
  actionAlert,
105
  clearAlert,
106
  data,
 
107
  },
108
  ref,
109
  ) => {
@@ -310,7 +313,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
310
  data-chat-visible={showChat}
311
  >
312
  <ClientOnly>{() => <Menu />}</ClientOnly>
313
- <div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
314
  <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
315
  {!chatStarted && (
316
  <div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
@@ -324,40 +327,39 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
324
  )}
325
  <div
326
  className={classNames('pt-6 px-2 sm:px-6', {
327
- 'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
328
  })}
329
  ref={scrollRef}
330
  >
331
  <ClientOnly>
332
  {() => {
333
  return chatStarted ? (
334
- <div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
335
- <Messages
336
- ref={messageRef}
337
- className="flex flex-col "
338
- messages={messages}
339
- isStreaming={isStreaming}
340
- />
341
- </div>
342
  ) : null;
343
  }}
344
  </ClientOnly>
345
  <div
346
- className={classNames('flex flex-col w-full max-w-chat mx-auto z-prompt', {
347
  'sticky bottom-2': chatStarted,
348
- 'position-absolute': chatStarted,
349
  })}
350
  >
351
- {actionAlert && (
352
- <ChatAlert
353
- alert={actionAlert}
354
- clearAlert={() => clearAlert?.()}
355
- postMessage={(message) => {
356
- sendMessage?.({} as any, message);
357
- clearAlert?.();
358
- }}
359
- />
360
- )}
 
 
361
  {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
362
  <div
363
  className={classNames(
@@ -591,16 +593,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
591
  </div>
592
  </div>
593
  </div>
594
- {!chatStarted && (
595
- <div className="flex flex-col justify-center mt-6 gap-5">
596
  <div className="flex justify-center gap-2">
597
- <div className="flex items-center gap-2">
598
- {ImportButtons(importChat)}
599
- <GitCloneButton importChat={importChat} className="min-w-[120px]" />
600
- </div>
601
  </div>
602
-
603
- {ExamplePrompts((event, messageInput) => {
 
604
  if (isStreaming) {
605
  handleStop?.();
606
  return;
@@ -608,11 +609,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
608
 
609
  handleSendMessage?.(event, messageInput);
610
  })}
611
- <StarterTemplates />
612
- </div>
613
- )}
614
  </div>
615
- <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
 
 
 
 
 
 
 
 
616
  </div>
617
  </div>
618
  );
 
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;
 
70
  actionAlert?: ActionAlert;
71
  clearAlert?: () => void;
72
  data?: JSONValue[] | undefined;
73
+ actionRunner?: ActionRunner;
74
  }
75
 
76
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
 
106
  actionAlert,
107
  clearAlert,
108
  data,
109
+ actionRunner,
110
  },
111
  ref,
112
  ) => {
 
313
  data-chat-visible={showChat}
314
  >
315
  <ClientOnly>{() => <Menu />}</ClientOnly>
316
+ <div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
317
  <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
318
  {!chatStarted && (
319
  <div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
 
327
  )}
328
  <div
329
  className={classNames('pt-6 px-2 sm:px-6', {
330
+ 'h-full flex flex-col': chatStarted,
331
  })}
332
  ref={scrollRef}
333
  >
334
  <ClientOnly>
335
  {() => {
336
  return chatStarted ? (
337
+ <Messages
338
+ ref={messageRef}
339
+ className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
340
+ messages={messages}
341
+ isStreaming={isStreaming}
342
+ />
 
 
343
  ) : null;
344
  }}
345
  </ClientOnly>
346
  <div
347
+ className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
348
  'sticky bottom-2': chatStarted,
 
349
  })}
350
  >
351
+ <div className="bg-bolt-elements-background-depth-2">
352
+ {actionAlert && (
353
+ <ChatAlert
354
+ alert={actionAlert}
355
+ clearAlert={() => clearAlert?.()}
356
+ postMessage={(message) => {
357
+ sendMessage?.({} as any, message);
358
+ clearAlert?.();
359
+ }}
360
+ />
361
+ )}
362
+ </div>
363
  {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
364
  <div
365
  className={classNames(
 
593
  </div>
594
  </div>
595
  </div>
596
+ <div className="flex flex-col justify-center gap-5">
597
+ {!chatStarted && (
598
  <div className="flex justify-center gap-2">
599
+ {ImportButtons(importChat)}
600
+ <GitCloneButton importChat={importChat} />
 
 
601
  </div>
602
+ )}
603
+ {!chatStarted &&
604
+ ExamplePrompts((event, messageInput) => {
605
  if (isStreaming) {
606
  handleStop?.();
607
  return;
 
609
 
610
  handleSendMessage?.(event, messageInput);
611
  })}
612
+ {!chatStarted && <StarterTemplates />}
613
+ </div>
 
614
  </div>
615
+ <ClientOnly>
616
+ {() => (
617
+ <Workbench
618
+ actionRunner={actionRunner ?? ({} as ActionRunner)}
619
+ chatStarted={chatStarted}
620
+ isStreaming={isStreaming}
621
+ />
622
+ )}
623
+ </ClientOnly>
624
  </div>
625
  </div>
626
  );
app/components/ui/Slider.tsx CHANGED
@@ -9,10 +9,11 @@ interface SliderOption<T> {
9
  text: string;
10
  }
11
 
12
- export interface SliderOptions<T> {
13
- left: SliderOption<T>;
14
- right: SliderOption<T>;
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 isLeftSelected = selected === options.left.value;
 
 
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
- <SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
 
 
 
 
 
 
 
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,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import { themeStore } from '~/lib/stores/theme';
14
+
15
+ interface CodeComparisonProps {
16
+ beforeCode: string;
17
+ afterCode: string;
18
+ language: string;
19
+ filename: string;
20
+ lightTheme: string;
21
+ darkTheme: string;
22
+ }
23
+
24
+ interface DiffBlock {
25
+ lineNumber: number;
26
+ content: string;
27
+ type: 'added' | 'removed' | 'unchanged';
28
+ correspondingLine?: number;
29
+ charChanges?: Array<{
30
+ value: string;
31
+ type: 'added' | 'removed' | 'unchanged';
32
+ }>;
33
+ }
34
+
35
+ interface FullscreenButtonProps {
36
+ onClick: () => void;
37
+ isFullscreen: boolean;
38
+ }
39
+
40
+ const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
41
+ <button
42
+ onClick={onClick}
43
+ className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
44
+ title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
45
+ >
46
+ <div className={isFullscreen ? "i-ph:corners-in" : "i-ph:corners-out"} />
47
+ </button>
48
+ ));
49
+
50
+ const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
51
+ if (!isFullscreen) return <>{children}</>;
52
+
53
+ return (
54
+ <div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
55
+ <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">
56
+ {children}
57
+ </div>
58
+ </div>
59
+ );
60
+ });
61
+
62
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB
63
+ const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
64
+
65
+ const isBinaryFile = (content: string) => {
66
+ return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
67
+ };
68
+
69
+ const processChanges = (beforeCode: string, afterCode: string) => {
70
+ try {
71
+ if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
72
+ return {
73
+ beforeLines: [],
74
+ afterLines: [],
75
+ hasChanges: false,
76
+ lineChanges: { before: new Set(), after: new Set() },
77
+ unifiedBlocks: [],
78
+ isBinary: true
79
+ };
80
+ }
81
+
82
+ // Normalize line endings and content
83
+ const normalizeContent = (content: string): string[] => {
84
+ return content
85
+ .replace(/\r\n/g, '\n')
86
+ .split('\n')
87
+ .map(line => line.trimEnd());
88
+ };
89
+
90
+ const beforeLines = normalizeContent(beforeCode);
91
+ const afterLines = normalizeContent(afterCode);
92
+
93
+ // Early return if files are identical
94
+ if (beforeLines.join('\n') === afterLines.join('\n')) {
95
+ return {
96
+ beforeLines,
97
+ afterLines,
98
+ hasChanges: false,
99
+ lineChanges: { before: new Set(), after: new Set() },
100
+ unifiedBlocks: [],
101
+ isBinary: false
102
+ };
103
+ }
104
+
105
+ const lineChanges = {
106
+ before: new Set<number>(),
107
+ after: new Set<number>()
108
+ };
109
+
110
+ const unifiedBlocks: DiffBlock[] = [];
111
+
112
+ // Compare lines directly for more accurate diff
113
+ let i = 0, j = 0;
114
+ while (i < beforeLines.length || j < afterLines.length) {
115
+ if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
116
+ // Unchanged line
117
+ unifiedBlocks.push({
118
+ lineNumber: j,
119
+ content: afterLines[j],
120
+ type: 'unchanged',
121
+ correspondingLine: i
122
+ });
123
+ i++;
124
+ j++;
125
+ } else {
126
+ // Look ahead for potential matches
127
+ let matchFound = false;
128
+ const lookAhead = 3; // Number of lines to look ahead
129
+
130
+ // Try to find matching lines ahead
131
+ for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
132
+ if (beforeLines[i + k] === afterLines[j]) {
133
+ // Found match in after lines - mark lines as removed
134
+ for (let l = 0; l < k; l++) {
135
+ lineChanges.before.add(i + l);
136
+ unifiedBlocks.push({
137
+ lineNumber: i + l,
138
+ content: beforeLines[i + l],
139
+ type: 'removed',
140
+ correspondingLine: j,
141
+ charChanges: [{ value: beforeLines[i + l], type: 'removed' }]
142
+ });
143
+ }
144
+ i += k;
145
+ matchFound = true;
146
+ break;
147
+ } else if (beforeLines[i] === afterLines[j + k]) {
148
+ // Found match in before lines - mark lines as added
149
+ for (let l = 0; l < k; l++) {
150
+ lineChanges.after.add(j + l);
151
+ unifiedBlocks.push({
152
+ lineNumber: j + l,
153
+ content: afterLines[j + l],
154
+ type: 'added',
155
+ correspondingLine: i,
156
+ charChanges: [{ value: afterLines[j + l], type: 'added' }]
157
+ });
158
+ }
159
+ j += k;
160
+ matchFound = true;
161
+ break;
162
+ }
163
+ }
164
+
165
+ if (!matchFound) {
166
+ // No match found - try to find character-level changes
167
+ if (i < beforeLines.length && j < afterLines.length) {
168
+ const beforeLine = beforeLines[i];
169
+ const afterLine = afterLines[j];
170
+
171
+ // Find common prefix and suffix
172
+ let prefixLength = 0;
173
+ while (prefixLength < beforeLine.length &&
174
+ prefixLength < afterLine.length &&
175
+ beforeLine[prefixLength] === afterLine[prefixLength]) {
176
+ prefixLength++;
177
+ }
178
+
179
+ let suffixLength = 0;
180
+ while (suffixLength < beforeLine.length - prefixLength &&
181
+ suffixLength < afterLine.length - prefixLength &&
182
+ beforeLine[beforeLine.length - 1 - suffixLength] ===
183
+ afterLine[afterLine.length - 1 - suffixLength]) {
184
+ suffixLength++;
185
+ }
186
+
187
+ const prefix = beforeLine.slice(0, prefixLength);
188
+ const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
189
+ const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
190
+ const suffix = beforeLine.slice(beforeLine.length - suffixLength);
191
+
192
+ if (beforeMiddle || afterMiddle) {
193
+ // There are character-level changes
194
+ if (beforeMiddle) {
195
+ lineChanges.before.add(i);
196
+ unifiedBlocks.push({
197
+ lineNumber: i,
198
+ content: beforeLine,
199
+ type: 'removed',
200
+ correspondingLine: j,
201
+ charChanges: [
202
+ { value: prefix, type: 'unchanged' },
203
+ { value: beforeMiddle, type: 'removed' },
204
+ { value: suffix, type: 'unchanged' }
205
+ ]
206
+ });
207
+ i++;
208
+ }
209
+ if (afterMiddle) {
210
+ lineChanges.after.add(j);
211
+ unifiedBlocks.push({
212
+ lineNumber: j,
213
+ content: afterLine,
214
+ type: 'added',
215
+ correspondingLine: i - 1,
216
+ charChanges: [
217
+ { value: prefix, type: 'unchanged' },
218
+ { value: afterMiddle, type: 'added' },
219
+ { value: suffix, type: 'unchanged' }
220
+ ]
221
+ });
222
+ j++;
223
+ }
224
+ } else {
225
+ // No character-level changes found, treat as regular line changes
226
+ if (i < beforeLines.length) {
227
+ lineChanges.before.add(i);
228
+ unifiedBlocks.push({
229
+ lineNumber: i,
230
+ content: beforeLines[i],
231
+ type: 'removed',
232
+ correspondingLine: j,
233
+ charChanges: [{ value: beforeLines[i], type: 'removed' }]
234
+ });
235
+ i++;
236
+ }
237
+ if (j < afterLines.length) {
238
+ lineChanges.after.add(j);
239
+ unifiedBlocks.push({
240
+ lineNumber: j,
241
+ content: afterLines[j],
242
+ type: 'added',
243
+ correspondingLine: i - 1,
244
+ charChanges: [{ value: afterLines[j], type: 'added' }]
245
+ });
246
+ j++;
247
+ }
248
+ }
249
+ } else {
250
+ // Handle remaining lines
251
+ if (i < beforeLines.length) {
252
+ lineChanges.before.add(i);
253
+ unifiedBlocks.push({
254
+ lineNumber: i,
255
+ content: beforeLines[i],
256
+ type: 'removed',
257
+ correspondingLine: j,
258
+ charChanges: [{ value: beforeLines[i], type: 'removed' }]
259
+ });
260
+ i++;
261
+ }
262
+ if (j < afterLines.length) {
263
+ lineChanges.after.add(j);
264
+ unifiedBlocks.push({
265
+ lineNumber: j,
266
+ content: afterLines[j],
267
+ type: 'added',
268
+ correspondingLine: i - 1,
269
+ charChanges: [{ value: afterLines[j], type: 'added' }]
270
+ });
271
+ j++;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ // Sort blocks by line number
279
+ const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
280
+
281
+ return {
282
+ beforeLines,
283
+ afterLines,
284
+ hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
285
+ lineChanges,
286
+ unifiedBlocks: processedBlocks,
287
+ isBinary: false
288
+ };
289
+ } catch (error) {
290
+ console.error('Error processing changes:', error);
291
+ return {
292
+ beforeLines: [],
293
+ afterLines: [],
294
+ hasChanges: false,
295
+ lineChanges: { before: new Set(), after: new Set() },
296
+ unifiedBlocks: [],
297
+ error: true,
298
+ isBinary: false
299
+ };
300
+ }
301
+ };
302
+
303
+ const lineNumberStyles = "w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1";
304
+ const lineContentStyles = "px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
305
+ const diffPanelStyles = "h-full overflow-auto diff-panel-content";
306
+
307
+ // Updated color styles for better consistency
308
+ const diffLineStyles = {
309
+ added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
310
+ removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
311
+ unchanged: ''
312
+ };
313
+
314
+ const changeColorStyles = {
315
+ added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
316
+ removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
317
+ unchanged: 'text-bolt-elements-textPrimary'
318
+ };
319
+
320
+ const renderContentWarning = (type: 'binary' | 'error') => (
321
+ <div className="h-full flex items-center justify-center p-4">
322
+ <div className="text-center text-bolt-elements-textTertiary">
323
+ <div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
324
+ <p className="font-medium text-bolt-elements-textPrimary">
325
+ {type === 'binary' ? 'Binary file detected' : 'Error processing file'}
326
+ </p>
327
+ <p className="text-sm mt-1">
328
+ {type === 'binary'
329
+ ? 'Diff view is not available for binary files'
330
+ : 'Could not generate diff preview'}
331
+ </p>
332
+ </div>
333
+ </div>
334
+ );
335
+
336
+ const NoChangesView = memo(({ beforeCode, language, highlighter, theme }: {
337
+ beforeCode: string;
338
+ language: string;
339
+ highlighter: any;
340
+ theme: string;
341
+ }) => (
342
+ <div className="h-full flex flex-col items-center justify-center p-4">
343
+ <div className="text-center text-bolt-elements-textTertiary">
344
+ <div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
345
+ <p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
346
+ <p className="text-sm mt-1">Both versions match exactly</p>
347
+ </div>
348
+ <div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
349
+ <div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
350
+ Current Content
351
+ </div>
352
+ <div className="overflow-auto max-h-96">
353
+ {beforeCode.split('\n').map((line, index) => (
354
+ <div key={index} className="flex group min-w-fit">
355
+ <div className={lineNumberStyles}>{index + 1}</div>
356
+ <div className={lineContentStyles}>
357
+ <span className="mr-2"> </span>
358
+ <span dangerouslySetInnerHTML={{
359
+ __html: highlighter ?
360
+ highlighter.codeToHtml(line, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
361
+ .replace(/<\/?pre[^>]*>/g, '')
362
+ .replace(/<\/?code[^>]*>/g, '')
363
+ : line
364
+ }} />
365
+ </div>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ </div>
370
+ </div>
371
+ ));
372
+
373
+ // Otimização do processamento de diferenças com memoização
374
+ const useProcessChanges = (beforeCode: string, afterCode: string) => {
375
+ return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
376
+ };
377
+
378
+ // Componente otimizado para renderização de linhas de código
379
+ const CodeLine = memo(({
380
+ lineNumber,
381
+ content,
382
+ type,
383
+ highlighter,
384
+ language,
385
+ block,
386
+ theme
387
+ }: {
388
+ lineNumber: number;
389
+ content: string;
390
+ type: 'added' | 'removed' | 'unchanged';
391
+ highlighter: any;
392
+ language: string;
393
+ block: DiffBlock;
394
+ theme: string;
395
+ }) => {
396
+ const bgColor = diffLineStyles[type];
397
+
398
+ const renderContent = () => {
399
+ if (type === 'unchanged' || !block.charChanges) {
400
+ const highlightedCode = highlighter ?
401
+ highlighter.codeToHtml(content, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
402
+ .replace(/<\/?pre[^>]*>/g, '')
403
+ .replace(/<\/?code[^>]*>/g, '')
404
+ : content;
405
+ return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
406
+ }
407
+
408
+ return (
409
+ <>
410
+ {block.charChanges.map((change, index) => {
411
+ const changeClass = changeColorStyles[change.type];
412
+
413
+ const highlightedCode = highlighter ?
414
+ highlighter.codeToHtml(change.value, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
415
+ .replace(/<\/?pre[^>]*>/g, '')
416
+ .replace(/<\/?code[^>]*>/g, '')
417
+ : change.value;
418
+
419
+ return (
420
+ <span
421
+ key={index}
422
+ className={changeClass}
423
+ dangerouslySetInnerHTML={{ __html: highlightedCode }}
424
+ />
425
+ );
426
+ })}
427
+ </>
428
+ );
429
+ };
430
+
431
+ return (
432
+ <div className="flex group min-w-fit">
433
+ <div className={lineNumberStyles}>{lineNumber + 1}</div>
434
+ <div className={`${lineContentStyles} ${bgColor}`}>
435
+ <span className="mr-2 text-bolt-elements-textTertiary">
436
+ {type === 'added' && <span className="text-green-700 dark:text-green-500">+</span>}
437
+ {type === 'removed' && <span className="text-red-700 dark:text-red-500">-</span>}
438
+ {type === 'unchanged' && ' '}
439
+ </span>
440
+ {renderContent()}
441
+ </div>
442
+ </div>
443
+ );
444
+ });
445
+
446
+ // Componente para exibir informações sobre o arquivo
447
+ const FileInfo = memo(({
448
+ filename,
449
+ hasChanges,
450
+ onToggleFullscreen,
451
+ isFullscreen,
452
+ beforeCode,
453
+ afterCode
454
+ }: {
455
+ filename: string;
456
+ hasChanges: boolean;
457
+ onToggleFullscreen: () => void;
458
+ isFullscreen: boolean;
459
+ beforeCode: string;
460
+ afterCode: string;
461
+ }) => {
462
+ // Calculate additions and deletions from the current document
463
+ const { additions, deletions } = useMemo(() => {
464
+ if (!hasChanges) return { additions: 0, deletions: 0 };
465
+
466
+ const changes = diffLines(beforeCode, afterCode, {
467
+ newlineIsToken: false,
468
+ ignoreWhitespace: true,
469
+ ignoreCase: false
470
+ });
471
+
472
+ return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
473
+ if (change.added) {
474
+ acc.additions += change.value.split('\n').length;
475
+ }
476
+ if (change.removed) {
477
+ acc.deletions += change.value.split('\n').length;
478
+ }
479
+ return acc;
480
+ }, { additions: 0, deletions: 0 });
481
+ }, [hasChanges, beforeCode, afterCode]);
482
+
483
+ const showStats = additions > 0 || deletions > 0;
484
+
485
+ return (
486
+ <div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
487
+ <div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
488
+ <span className="truncate">{filename}</span>
489
+ <span className="ml-auto shrink-0 flex items-center gap-2">
490
+ {hasChanges ? (
491
+ <>
492
+ {showStats && (
493
+ <div className="flex items-center gap-1 text-xs">
494
+ {additions > 0 && (
495
+ <span className="text-green-700 dark:text-green-500">+{additions}</span>
496
+ )}
497
+ {deletions > 0 && (
498
+ <span className="text-red-700 dark:text-red-500">-{deletions}</span>
499
+ )}
500
+ </div>
501
+ )}
502
+ <span className="text-yellow-600 dark:text-yellow-400">Modified</span>
503
+ <span className="text-bolt-elements-textTertiary text-xs">
504
+ {new Date().toLocaleTimeString()}
505
+ </span>
506
+ </>
507
+ ) : (
508
+ <span className="text-green-700 dark:text-green-400">No Changes</span>
509
+ )}
510
+ <FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
511
+ </span>
512
+ </div>
513
+ );
514
+ });
515
+
516
+ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language, lightTheme, darkTheme }: CodeComparisonProps) => {
517
+ const [isFullscreen, setIsFullscreen] = useState(false);
518
+ const [highlighter, setHighlighter] = useState<any>(null);
519
+ const theme = useStore(themeStore);
520
+
521
+ const toggleFullscreen = useCallback(() => {
522
+ setIsFullscreen(prev => !prev);
523
+ }, []);
524
+
525
+ const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
526
+
527
+ useEffect(() => {
528
+ getHighlighter({
529
+ themes: ['github-dark', 'github-light'],
530
+ langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
531
+ }).then(setHighlighter);
532
+ }, []);
533
+
534
+ if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
535
+
536
+ return (
537
+ <FullscreenOverlay isFullscreen={isFullscreen}>
538
+ <div className="w-full h-full flex flex-col">
539
+ <FileInfo
540
+ filename={filename}
541
+ hasChanges={hasChanges}
542
+ onToggleFullscreen={toggleFullscreen}
543
+ isFullscreen={isFullscreen}
544
+ beforeCode={beforeCode}
545
+ afterCode={afterCode}
546
+ />
547
+ <div className={diffPanelStyles}>
548
+ {hasChanges ? (
549
+ <div className="overflow-x-auto min-w-full">
550
+ {unifiedBlocks.map((block, index) => (
551
+ <CodeLine
552
+ key={`${block.lineNumber}-${index}`}
553
+ lineNumber={block.lineNumber}
554
+ content={block.content}
555
+ type={block.type}
556
+ highlighter={highlighter}
557
+ language={language}
558
+ block={block}
559
+ theme={theme}
560
+ />
561
+ ))}
562
+ </div>
563
+ ) : (
564
+ <NoChangesView
565
+ beforeCode={beforeCode}
566
+ language={language}
567
+ highlighter={highlighter}
568
+ theme={theme}
569
+ />
570
+ )}
571
+ </div>
572
+ </div>
573
+ </FullscreenOverlay>
574
+ );
575
+ });
576
+
577
+ interface DiffViewProps {
578
+ fileHistory: Record<string, FileHistory>;
579
+ setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
580
+ actionRunner: ActionRunner;
581
+ }
582
+
583
+ export const DiffView = memo(({ fileHistory, setFileHistory, actionRunner }: DiffViewProps) => {
584
+ const files = useStore(workbenchStore.files) as FileMap;
585
+ const selectedFile = useStore(workbenchStore.selectedFile);
586
+ const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
587
+ const unsavedFiles = useStore(workbenchStore.unsavedFiles);
588
+
589
+ useEffect(() => {
590
+ if (selectedFile && currentDocument) {
591
+ const file = files[selectedFile];
592
+ if (!file || !('content' in file)) return;
593
+
594
+ const existingHistory = fileHistory[selectedFile];
595
+ const currentContent = currentDocument.value;
596
+
597
+ // Normalizar o conteúdo para comparação
598
+ const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
599
+ const normalizedOriginalContent = (existingHistory?.originalContent || file.content).replace(/\r\n/g, '\n').trim();
600
+
601
+ // Se não há histórico existente, criar um novo apenas se houver diferenças
602
+ if (!existingHistory) {
603
+ if (normalizedCurrentContent !== normalizedOriginalContent) {
604
+ const newChanges = diffLines(file.content, currentContent);
605
+ setFileHistory(prev => ({
606
+ ...prev,
607
+ [selectedFile]: {
608
+ originalContent: file.content,
609
+ lastModified: Date.now(),
610
+ changes: newChanges,
611
+ versions: [{
612
+ timestamp: Date.now(),
613
+ content: currentContent
614
+ }],
615
+ changeSource: 'auto-save'
616
+ }
617
+ }));
618
+ }
619
+ return;
620
+ }
621
+
622
+ // Se já existe histórico, verificar se há mudanças reais desde a última versão
623
+ const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
624
+ const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
625
+
626
+ if (normalizedCurrentContent === normalizedLastContent) {
627
+ return; // Não criar novo histórico se o conteúdo é o mesmo
628
+ }
629
+
630
+ // Verificar se há mudanças significativas usando diffFiles
631
+ const relativePath = extractRelativePath(selectedFile);
632
+ const unifiedDiff = diffFiles(
633
+ relativePath,
634
+ existingHistory.originalContent,
635
+ currentContent
636
+ );
637
+
638
+ if (unifiedDiff) {
639
+ const newChanges = diffLines(
640
+ existingHistory.originalContent,
641
+ currentContent
642
+ );
643
+
644
+ // Verificar se as mudanças são significativas
645
+ const hasSignificantChanges = newChanges.some(change =>
646
+ (change.added || change.removed) && change.value.trim().length > 0
647
+ );
648
+
649
+ if (hasSignificantChanges) {
650
+ const newHistory: FileHistory = {
651
+ originalContent: existingHistory.originalContent,
652
+ lastModified: Date.now(),
653
+ changes: [
654
+ ...existingHistory.changes,
655
+ ...newChanges
656
+ ].slice(-100), // Limitar histórico de mudanças
657
+ versions: [
658
+ ...existingHistory.versions,
659
+ {
660
+ timestamp: Date.now(),
661
+ content: currentContent
662
+ }
663
+ ].slice(-10), // Manter apenas as 10 últimas versões
664
+ changeSource: 'auto-save'
665
+ };
666
+
667
+ setFileHistory(prev => ({ ...prev, [selectedFile]: newHistory }));
668
+ }
669
+ }
670
+ }
671
+ }, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
672
+
673
+ if (!selectedFile || !currentDocument) {
674
+ return (
675
+ <div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
676
+ Select a file to view differences
677
+ </div>
678
+ );
679
+ }
680
+
681
+ const file = files[selectedFile];
682
+ const originalContent = file && 'content' in file ? file.content : '';
683
+ const currentContent = currentDocument.value;
684
+
685
+ const history = fileHistory[selectedFile];
686
+ const effectiveOriginalContent = history?.originalContent || originalContent;
687
+ const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
688
+
689
+ try {
690
+ return (
691
+ <div className="h-full overflow-hidden">
692
+ <InlineDiffComparison
693
+ beforeCode={effectiveOriginalContent}
694
+ afterCode={currentContent}
695
+ language={language}
696
+ filename={selectedFile}
697
+ lightTheme="github-light"
698
+ darkTheme="github-dark"
699
+ />
700
+ </div>
701
+ );
702
+ } catch (error) {
703
+ console.error('DiffView render error:', error);
704
+ return (
705
+ <div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
706
+ <div className="text-center">
707
+ <div className="i-ph:warning-circle text-4xl mb-2" />
708
+ <p>Failed to render diff view</p>
709
+ </div>
710
+ </div>
711
+ );
712
+ }
713
+ });
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
- {unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,200 @@ const workbenchVariants = {
54
  },
55
  } satisfies Variants;
56
 
57
- export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 +327,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
121
  }
122
  }, []);
123
 
 
 
 
 
 
124
  return (
125
  chatStarted && (
126
  <motion.div
@@ -175,6 +386,12 @@ 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 +403,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
186
  </div>
187
  <div className="relative flex-1 overflow-hidden">
188
  <View
189
- initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
190
- animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
191
  >
192
  <EditorPanel
193
  editorDocument={currentDocument}
@@ -195,6 +412,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 +421,18 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
203
  />
204
  </View>
205
  <View
206
- initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
207
- animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
 
 
 
 
 
 
 
 
 
 
208
  >
209
  <Preview />
210
  </View>
@@ -215,14 +443,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
215
  <PushToGitHubDialog
216
  isOpen={isPushDialogOpen}
217
  onClose={() => setIsPushDialogOpen(false)}
218
- onPush={async (repoName, username, token, isPrivate) => {
219
  try {
220
- const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
 
 
 
 
 
 
 
 
 
 
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; // Rethrow to let PushToGitHubDialog handle the error state
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
+ }: {
78
+ fileHistory: Record<string, FileHistory>,
79
+ onSelectFile: (filePath: string) => void,
80
+ }) => {
81
+ const modifiedFiles = Object.entries(fileHistory);
82
+ const hasChanges = modifiedFiles.length > 0;
83
+ const [searchQuery, setSearchQuery] = useState('');
84
+
85
+ const filteredFiles = useMemo(() => {
86
+ return modifiedFiles.filter(([filePath]) =>
87
+ filePath.toLowerCase().includes(searchQuery.toLowerCase())
88
+ );
89
+ }, [modifiedFiles, searchQuery]);
90
+
91
+ return (
92
+ <div className="flex items-center gap-2">
93
+ <Popover className="relative">
94
+ {({ open }: { open: boolean }) => (
95
+ <>
96
+ <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">
97
+ <span className="font-medium">File Changes</span>
98
+ {hasChanges && (
99
+ <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">
100
+ {modifiedFiles.length}
101
+ </span>
102
+ )}
103
+ </Popover.Button>
104
+ <Transition
105
+ show={open}
106
+ enter="transition duration-100 ease-out"
107
+ enterFrom="transform scale-95 opacity-0"
108
+ enterTo="transform scale-100 opacity-100"
109
+ leave="transition duration-75 ease-out"
110
+ leaveFrom="transform scale-100 opacity-100"
111
+ leaveTo="transform scale-95 opacity-0"
112
+ >
113
+ <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">
114
+ <div className="p-2">
115
+ <div className="relative mx-2 mb-2">
116
+ <input
117
+ type="text"
118
+ placeholder="Search files..."
119
+ value={searchQuery}
120
+ onChange={(e) => setSearchQuery(e.target.value)}
121
+ 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"
122
+ />
123
+ <div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
124
+ <div className="i-ph:magnifying-glass" />
125
+ </div>
126
+ </div>
127
+
128
+ <div className="max-h-60 overflow-y-auto">
129
+ {filteredFiles.length > 0 ? (
130
+ filteredFiles.map(([filePath, history]) => {
131
+ const extension = filePath.split('.').pop() || '';
132
+ const language = getLanguageFromExtension(extension);
133
+
134
+ return (
135
+ <button
136
+ key={filePath}
137
+ onClick={() => onSelectFile(filePath)}
138
+ className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
139
+ >
140
+ <div className="flex items-center gap-2">
141
+ <div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
142
+ {['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && <div className="i-ph:file-js" />}
143
+ {['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
144
+ {language === 'html' && <div className="i-ph:code" />}
145
+ {language === 'json' && <div className="i-ph:brackets-curly" />}
146
+ {language === 'python' && <div className="i-ph:file-text" />}
147
+ {language === 'markdown' && <div className="i-ph:article" />}
148
+ {['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
149
+ {language === 'sql' && <div className="i-ph:database" />}
150
+ {language === 'dockerfile' && <div className="i-ph:cube" />}
151
+ {language === 'shell' && <div className="i-ph:terminal" />}
152
+ {!['typescript', 'javascript', 'css', 'html', 'json', 'python', 'markdown', 'yaml', 'yml', 'sql', 'dockerfile', 'shell', 'jsx', 'tsx', 'scss', 'less'].includes(language) && <div className="i-ph:file-text" />}
153
+ </div>
154
+ <div className="flex-1 min-w-0">
155
+ <div className="flex items-center justify-between gap-2">
156
+ <div className="flex flex-col min-w-0">
157
+ <span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
158
+ {filePath.split('/').pop()}
159
+ </span>
160
+ <span className="truncate text-xs text-bolt-elements-textTertiary">
161
+ {filePath}
162
+ </span>
163
+ </div>
164
+ {(() => {
165
+ // Calculate diff stats
166
+ const { additions, deletions } = (() => {
167
+ if (!history.originalContent) return { additions: 0, deletions: 0 };
168
+
169
+ const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
170
+ const normalizedCurrent = history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
171
+
172
+ if (normalizedOriginal === normalizedCurrent) {
173
+ return { additions: 0, deletions: 0 };
174
+ }
175
+
176
+ const changes = diffLines(normalizedOriginal, normalizedCurrent, {
177
+ newlineIsToken: false,
178
+ ignoreWhitespace: true,
179
+ ignoreCase: false
180
+ });
181
+
182
+ return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
183
+ if (change.added) {
184
+ acc.additions += change.value.split('\n').length;
185
+ }
186
+ if (change.removed) {
187
+ acc.deletions += change.value.split('\n').length;
188
+ }
189
+ return acc;
190
+ }, { additions: 0, deletions: 0 });
191
+ })();
192
+
193
+ const showStats = additions > 0 || deletions > 0;
194
+
195
+ return showStats && (
196
+ <div className="flex items-center gap-1 text-xs shrink-0">
197
+ {additions > 0 && (
198
+ <span className="text-green-500">+{additions}</span>
199
+ )}
200
+ {deletions > 0 && (
201
+ <span className="text-red-500">-{deletions}</span>
202
+ )}
203
+ </div>
204
+ );
205
+ })()}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </button>
210
+ );
211
+ })
212
+ ) : (
213
+ <div className="flex flex-col items-center justify-center p-4 text-center">
214
+ <div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
215
+ <div className="i-ph:file-dashed" />
216
+ </div>
217
+ <p className="text-sm font-medium text-bolt-elements-textPrimary">
218
+ {searchQuery ? 'No matching files' : 'No modified files'}
219
+ </p>
220
+ <p className="text-xs text-bolt-elements-textTertiary mt-1">
221
+ {searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
222
+ </p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ </div>
227
+
228
+ {hasChanges && (
229
+ <div className="border-t border-bolt-elements-borderColor p-2">
230
+ <button
231
+ onClick={() => {
232
+ navigator.clipboard.writeText(
233
+ filteredFiles.map(([filePath]) => filePath).join('\n')
234
+ );
235
+ toast('File list copied to clipboard', {
236
+ icon: <div className="i-ph:check-circle text-accent-500" />
237
+ });
238
+ }}
239
+ 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"
240
+ >
241
+ Copy File List
242
+ </button>
243
+ </div>
244
+ )}
245
+ </Popover.Panel>
246
+ </Transition>
247
+ </>
248
+ )}
249
+ </Popover>
250
+ </div>
251
+ );
252
+ });
253
+
254
+ export const Workbench = memo(({
255
+ chatStarted,
256
+ isStreaming,
257
+ actionRunner,
258
+ metadata,
259
+ updateChatMestaData
260
+ }: WorkspaceProps) => {
261
  renderLogger.trace('Workbench');
262
 
263
  const [isSyncing, setIsSyncing] = useState(false);
264
  const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
265
+ const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
266
+
267
+ const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
268
 
269
  const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
270
  const showWorkbench = useStore(workbenchStore.showWorkbench);
 
327
  }
328
  }, []);
329
 
330
+ const handleSelectFile = useCallback((filePath: string) => {
331
+ workbenchStore.setSelectedFile(filePath);
332
+ workbenchStore.currentView.set('diff');
333
+ }, []);
334
+
335
  return (
336
  chatStarted && (
337
  <motion.div
 
386
  </PanelHeaderButton>
387
  </div>
388
  )}
389
+ {selectedView === 'diff' && (
390
+ <FileModifiedDropdown
391
+ fileHistory={fileHistory}
392
+ onSelectFile={handleSelectFile}
393
+ />
394
+ )}
395
  <IconButton
396
  icon="i-ph:x-circle"
397
  className="-mr-1"
 
403
  </div>
404
  <div className="relative flex-1 overflow-hidden">
405
  <View
406
+ initial={{ x: '0%' }}
407
+ animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}
408
  >
409
  <EditorPanel
410
  editorDocument={currentDocument}
 
412
  selectedFile={selectedFile}
413
  files={files}
414
  unsavedFiles={unsavedFiles}
415
+ fileHistory={fileHistory}
416
  onFileSelect={onFileSelect}
417
  onEditorScroll={onEditorScroll}
418
  onEditorChange={onEditorChange}
 
421
  />
422
  </View>
423
  <View
424
+ initial={{ x: '100%' }}
425
+ animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
426
+ >
427
+ <DiffView
428
+ fileHistory={fileHistory}
429
+ setFileHistory={setFileHistory}
430
+ actionRunner={actionRunner}
431
+ />
432
+ </View>
433
+ <View
434
+ initial={{ x: '100%' }}
435
+ animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}
436
  >
437
  <Preview />
438
  </View>
 
443
  <PushToGitHubDialog
444
  isOpen={isPushDialogOpen}
445
  onClose={() => setIsPushDialogOpen(false)}
446
+ onPush={async (repoName, username, token) => {
447
  try {
448
+ const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
449
+ await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
450
+ const repoUrl = `https://github.com/${username}/${repoName}`;
451
+
452
+ if (updateChatMestaData && !metadata?.gitUrl) {
453
+ updateChatMestaData({
454
+ ...(metadata || {}),
455
+ gitUrl: repoUrl,
456
+ });
457
+ }
458
+
459
  return repoUrl;
460
  } catch (error) {
461
  console.error('Error pushing to GitHub:', error);
462
  toast.error('Failed to push to GitHub');
463
+ throw error;
464
  }
465
  }}
466
  />
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';
@@ -284,9 +284,9 @@ export class ActionRunner {
284
  }
285
 
286
  const webcontainer = await this.#webcontainer;
287
- const relativePath = path.relative(webcontainer.workdir, action.filePath);
288
 
289
- let folder = path.dirname(relativePath);
290
 
291
  // remove trailing slashes
292
  folder = folder.replace(/\/+$/g, '');
@@ -307,12 +307,40 @@ export class ActionRunner {
307
  logger.error('Failed to write file\n\n', error);
308
  }
309
  }
 
310
  #updateAction(id: string, newState: ActionStateUpdate) {
311
  const actions = this.actions.get();
312
 
313
  this.actions.setKey(id, { ...actions[id], ...newState });
314
  }
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  async #runBuildAction(action: ActionState) {
317
  if (action.type !== 'build') {
318
  unreachable('Expected build action');
@@ -339,7 +367,7 @@ export class ActionRunner {
339
  }
340
 
341
  // Get the build output directory path
342
- const buildDir = path.join(webcontainer.workdir, 'dist');
343
 
344
  return {
345
  path: buildDir,
@@ -347,4 +375,4 @@ export class ActionRunner {
347
  output,
348
  };
349
  }
350
- }
 
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';
 
284
  }
285
 
286
  const webcontainer = await this.#webcontainer;
287
+ const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
288
 
289
+ let folder = nodePath.dirname(relativePath);
290
 
291
  // remove trailing slashes
292
  folder = folder.replace(/\/+$/g, '');
 
307
  logger.error('Failed to write file\n\n', error);
308
  }
309
  }
310
+
311
  #updateAction(id: string, newState: ActionStateUpdate) {
312
  const actions = this.actions.get();
313
 
314
  this.actions.setKey(id, { ...actions[id], ...newState });
315
  }
316
 
317
+ async getFileHistory(filePath: string): Promise<FileHistory | null> {
318
+ try {
319
+ const webcontainer = await this.#webcontainer;
320
+ const historyPath = this.#getHistoryPath(filePath);
321
+ const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
322
+ return JSON.parse(content);
323
+ } catch (error) {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ async saveFileHistory(filePath: string, history: FileHistory) {
329
+ const webcontainer = await this.#webcontainer;
330
+ const historyPath = this.#getHistoryPath(filePath);
331
+
332
+ await this.#runFileAction({
333
+ type: 'file',
334
+ filePath: historyPath,
335
+ content: JSON.stringify(history),
336
+ changeSource: 'auto-save'
337
+ } as any);
338
+ }
339
+
340
+ #getHistoryPath(filePath: string) {
341
+ return nodePath.join('.history', filePath);
342
+ }
343
+
344
  async #runBuildAction(action: ActionState) {
345
  if (action.type !== 'build') {
346
  unreachable('Expected build action');
 
367
  }
368
 
369
  // Get the build output directory path
370
+ const buildDir = nodePath.join(webcontainer.workdir, 'dist');
371
 
372
  return {
373
  path: buildDir,
 
375
  output,
376
  };
377
  }
378
+ }
app/lib/stores/workbench.ts CHANGED
@@ -19,7 +19,6 @@ 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 {
@@ -34,7 +33,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);
@@ -437,13 +436,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 +460,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: isPrivate,
471
  auto_init: true,
472
  });
473
  repo = newRepo;
@@ -545,7 +538,7 @@ export class WorkbenchStore {
545
  sha: newCommit.sha,
546
  });
547
 
548
- return repo.html_url; // Return the URL instead of showing alert
549
  } catch (error) {
550
  console.error('Error pushing to GitHub:', error);
551
  throw error; // Rethrow the error for further handling
 
19
  import { createSampler } from '~/utils/sampler';
20
  import type { ActionAlert } from '~/types/actions';
21
 
 
22
  const { saveAs } = fileSaver;
23
 
24
  export interface ArtifactState {
 
33
 
34
  type Artifacts = MapStore<Record<string, ArtifactState>>;
35
 
36
+ export type WorkbenchViewType = 'code' | 'diff' | 'preview';
37
 
38
  export class WorkbenchStore {
39
  #previewsStore = new PreviewsStore(webcontainer);
 
436
  return syncedFiles;
437
  }
438
 
439
+ async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
 
 
 
 
 
 
440
  try {
441
  // Use cookies if username and token are not provided
442
  const githubToken = ghToken || Cookies.get('githubToken');
 
460
  // Repository doesn't exist, so create a new one
461
  const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
462
  name: repoName,
463
+ private: false,
464
  auto_init: true,
465
  });
466
  repo = newRepo;
 
538
  sha: newCommit.sha,
539
  });
540
 
541
+ alert(`Repository created and code pushed: ${repo.html_url}`);
542
  } catch (error) {
543
  console.error('Error pushing to GitHub:', error);
544
  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 {
@@ -32,3 +34,15 @@ export interface ActionAlert {
32
  content: string;
33
  source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
34
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Change } from 'diff';
2
+
3
  export type ActionType = 'file' | 'shell';
4
 
5
  export interface BaseAction {
 
34
  content: string;
35
  source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
36
  }
37
+
38
+ export interface FileHistory {
39
+ originalContent: string;
40
+ lastModified: number;
41
+ changes: Change[];
42
+ versions: {
43
+ timestamp: number;
44
+ content: string;
45
+ }[];
46
+ // Novo campo para rastrear a origem das mudanças
47
+ changeSource?: 'user' | 'auto-save' | 'external';
48
+ }
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
@@ -78,6 +78,7 @@
78
  "@remix-run/cloudflare-pages": "^2.15.2",
79
  "@remix-run/node": "^2.15.2",
80
  "@remix-run/react": "^2.15.2",
 
81
  "@types/react-beautiful-dnd": "^13.1.8",
82
  "@uiw/codemirror-theme-vscode": "^4.23.6",
83
  "@unocss/reset": "^0.61.9",
@@ -133,6 +134,8 @@
133
  "@iconify-json/ph": "^1.2.1",
134
  "@iconify/types": "^2.0.0",
135
  "@remix-run/dev": "^2.15.2",
 
 
136
  "@types/diff": "^5.2.3",
137
  "@types/dom-speech-recognition": "^0.0.4",
138
  "@types/file-saver": "^2.0.7",
@@ -140,9 +143,11 @@
140
  "@types/path-browserify": "^1.0.3",
141
  "@types/react": "^18.3.12",
142
  "@types/react-dom": "^18.3.1",
 
143
  "fast-glob": "^3.3.2",
144
  "husky": "9.1.7",
145
  "is-ci": "^3.0.1",
 
146
  "node-fetch": "^3.3.2",
147
  "pnpm": "^9.14.4",
148
  "prettier": "^3.4.1",
 
78
  "@remix-run/cloudflare-pages": "^2.15.2",
79
  "@remix-run/node": "^2.15.2",
80
  "@remix-run/react": "^2.15.2",
81
+ "@tanstack/react-virtual": "^3.13.0",
82
  "@types/react-beautiful-dnd": "^13.1.8",
83
  "@uiw/codemirror-theme-vscode": "^4.23.6",
84
  "@unocss/reset": "^0.61.9",
 
134
  "@iconify-json/ph": "^1.2.1",
135
  "@iconify/types": "^2.0.0",
136
  "@remix-run/dev": "^2.15.2",
137
+ "@testing-library/jest-dom": "^6.6.3",
138
+ "@testing-library/react": "^16.2.0",
139
  "@types/diff": "^5.2.3",
140
  "@types/dom-speech-recognition": "^0.0.4",
141
  "@types/file-saver": "^2.0.7",
 
143
  "@types/path-browserify": "^1.0.3",
144
  "@types/react": "^18.3.12",
145
  "@types/react-dom": "^18.3.1",
146
+ "@vitejs/plugin-react": "^4.3.4",
147
  "fast-glob": "^3.3.2",
148
  "husky": "9.1.7",
149
  "is-ci": "^3.0.1",
150
+ "jsdom": "^26.0.0",
151
  "node-fetch": "^3.3.2",
152
  "pnpm": "^9.14.4",
153
  "prettier": "^3.4.1",
pnpm-lock.yaml CHANGED
The diff for this file is too large to render. See raw diff
 
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