pauloj commited on
Commit
ab6f532
·
1 Parent(s): 8c72ed7

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 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 pb-4 overflow-y-auto': chatStarted,
322
  })}
323
  ref={scrollRef}
324
  >
325
  <ClientOnly>
326
  {() => {
327
  return chatStarted ? (
328
- <div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
329
- <Messages
330
- ref={messageRef}
331
- className="flex flex-col "
332
- messages={messages}
333
- isStreaming={isStreaming}
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
- {actionAlert && (
346
- <ChatAlert
347
- alert={actionAlert}
348
- clearAlert={() => clearAlert?.()}
349
- postMessage={(message) => {
350
- sendMessage?.({} as any, message);
351
- clearAlert?.();
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
- {!chatStarted && (
589
- <div className="flex flex-col justify-center mt-6 gap-5">
590
  <div className="flex justify-center gap-2">
591
- <div className="flex items-center gap-2">
592
- {ImportButtons(importChat)}
593
- <GitCloneButton importChat={importChat} className="min-w-[120px]" />
594
- </div>
595
  </div>
596
-
597
- {ExamplePrompts((event, messageInput) => {
 
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
- <StarterTemplates />
606
- </div>
607
- )}
608
  </div>
609
- <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</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 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,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
- {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,211 @@ 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 +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: selectedView === 'code' ? 0 : '-100%' }}
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: selectedView === 'preview' ? 0 : '100%' }}
207
- animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
 
 
 
 
 
 
 
 
 
 
 
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, 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
+ 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 = path.relative(webcontainer.workdir, action.filePath);
280
 
281
- let folder = path.dirname(relativePath);
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 fileSaver from 'file-saver';
 
14
  import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
15
- import { path } from '~/utils/path';
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 = path.join(wc.workdir, data.action.filePath);
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: isPrivate,
471
  auto_init: true,
472
  });
473
  repo = newRepo;
@@ -545,7 +537,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
 
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