Dominic Elm commited on
Commit
d45b95d
·
unverified ·
1 Parent(s): df25c67

feat(workbench): sync file changes back to webcontainer (#5)

Browse files
packages/bolt/app/components/chat/Chat.client.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useChat } from 'ai/react';
2
  import { useAnimate } from 'framer-motion';
3
  import { useEffect, useRef, useState } from 'react';
 
4
  import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks';
5
  import { chatStore } from '../../lib/stores/chat';
6
  import { workbenchStore } from '../../lib/stores/workbench';
@@ -8,6 +9,11 @@ import { cubicEasingFn } from '../../utils/easings';
8
  import { createScopedLogger } from '../../utils/logger';
9
  import { BaseChat } from './BaseChat';
10
 
 
 
 
 
 
11
  const logger = createScopedLogger('Chat');
12
 
13
  export function Chat() {
@@ -90,35 +96,38 @@ export function Chat() {
90
  const [messageRef, scrollRef] = useSnapScroll();
91
 
92
  return (
93
- <BaseChat
94
- ref={animationScope}
95
- textareaRef={textareaRef}
96
- input={input}
97
- chatStarted={chatStarted}
98
- isStreaming={isLoading}
99
- enhancingPrompt={enhancingPrompt}
100
- promptEnhanced={promptEnhanced}
101
- sendMessage={sendMessage}
102
- messageRef={messageRef}
103
- scrollRef={scrollRef}
104
- handleInputChange={handleInputChange}
105
- handleStop={abort}
106
- messages={messages.map((message, i) => {
107
- if (message.role === 'user') {
108
- return message;
109
- }
110
-
111
- return {
112
- ...message,
113
- content: parsedMessages[i] || '',
114
- };
115
- })}
116
- enhancePrompt={() => {
117
- enhancePrompt(input, (input) => {
118
- setInput(input);
119
- scrollTextArea();
120
- });
121
- }}
122
- ></BaseChat>
 
 
 
123
  );
124
  }
 
1
  import { useChat } from 'ai/react';
2
  import { useAnimate } from 'framer-motion';
3
  import { useEffect, useRef, useState } from 'react';
4
+ import { ToastContainer, cssTransition } from 'react-toastify';
5
  import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks';
6
  import { chatStore } from '../../lib/stores/chat';
7
  import { workbenchStore } from '../../lib/stores/workbench';
 
9
  import { createScopedLogger } from '../../utils/logger';
10
  import { BaseChat } from './BaseChat';
11
 
12
+ const toastAnimation = cssTransition({
13
+ enter: 'animated fadeInRight',
14
+ exit: 'animated fadeOutRight',
15
+ });
16
+
17
  const logger = createScopedLogger('Chat');
18
 
19
  export function Chat() {
 
96
  const [messageRef, scrollRef] = useSnapScroll();
97
 
98
  return (
99
+ <>
100
+ <BaseChat
101
+ ref={animationScope}
102
+ textareaRef={textareaRef}
103
+ input={input}
104
+ chatStarted={chatStarted}
105
+ isStreaming={isLoading}
106
+ enhancingPrompt={enhancingPrompt}
107
+ promptEnhanced={promptEnhanced}
108
+ sendMessage={sendMessage}
109
+ messageRef={messageRef}
110
+ scrollRef={scrollRef}
111
+ handleInputChange={handleInputChange}
112
+ handleStop={abort}
113
+ messages={messages.map((message, i) => {
114
+ if (message.role === 'user') {
115
+ return message;
116
+ }
117
+
118
+ return {
119
+ ...message,
120
+ content: parsedMessages[i] || '',
121
+ };
122
+ })}
123
+ enhancePrompt={() => {
124
+ enhancePrompt(input, (input) => {
125
+ setInput(input);
126
+ scrollTextArea();
127
+ });
128
+ }}
129
+ />
130
+ <ToastContainer position="bottom-right" stacked={true} pauseOnFocusLoss={true} transition={toastAnimation} />
131
+ </>
132
  );
133
  }
packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx CHANGED
@@ -2,7 +2,7 @@ import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/aut
2
  import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
3
  import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
4
  import { searchKeymap } from '@codemirror/search';
5
- import { Compartment, EditorSelection, EditorState, type Extension } from '@codemirror/state';
6
  import {
7
  EditorView,
8
  drawSelection,
@@ -27,8 +27,6 @@ const logger = createScopedLogger('CodeMirrorEditor');
27
 
28
  export interface EditorDocument {
29
  value: string | Uint8Array;
30
- previousValue?: string | Uint8Array;
31
- commitPending: boolean;
32
  filePath: string;
33
  scroll?: ScrollPosition;
34
  }
@@ -54,6 +52,7 @@ export interface EditorUpdate {
54
 
55
  export type OnChangeCallback = (update: EditorUpdate) => void;
56
  export type OnScrollCallback = (position: ScrollPosition) => void;
 
57
 
58
  interface Props {
59
  theme: Theme;
@@ -65,12 +64,30 @@ interface Props {
65
  autoFocusOnDocumentChange?: boolean;
66
  onChange?: OnChangeCallback;
67
  onScroll?: OnScrollCallback;
 
68
  className?: string;
69
  settings?: EditorSettings;
70
  }
71
 
72
  type EditorStates = Map<string, EditorState>;
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  export const CodeMirrorEditor = memo(
75
  ({
76
  id,
@@ -81,15 +98,14 @@ export const CodeMirrorEditor = memo(
81
  editable = true,
82
  onScroll,
83
  onChange,
 
84
  theme,
85
  settings,
86
  className = '',
87
  }: Props) => {
88
- renderLogger.debug('CodeMirrorEditor');
89
 
90
  const [languageCompartment] = useState(new Compartment());
91
- const [readOnlyCompartment] = useState(new Compartment());
92
- const [editableCompartment] = useState(new Compartment());
93
 
94
  const containerRef = useRef<HTMLDivElement | null>(null);
95
  const viewRef = useRef<EditorView>();
@@ -98,14 +114,21 @@ export const CodeMirrorEditor = memo(
98
  const editorStatesRef = useRef<EditorStates>();
99
  const onScrollRef = useRef(onScroll);
100
  const onChangeRef = useRef(onChange);
 
101
 
102
  const isBinaryFile = doc?.value instanceof Uint8Array;
103
 
104
- onScrollRef.current = onScroll;
105
- onChangeRef.current = onChange;
106
-
107
- docRef.current = doc;
108
- themeRef.current = theme;
 
 
 
 
 
 
109
 
110
  useEffect(() => {
111
  const onUpdate = debounce((update: EditorUpdate) => {
@@ -164,10 +187,8 @@ export const CodeMirrorEditor = memo(
164
  const theme = themeRef.current!;
165
 
166
  if (!doc) {
167
- const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [
168
  languageCompartment.of([]),
169
- readOnlyCompartment.of([]),
170
- editableCompartment.of([]),
171
  ]);
172
 
173
  view.setState(state);
@@ -188,10 +209,8 @@ export const CodeMirrorEditor = memo(
188
  let state = editorStates.get(doc.filePath);
189
 
190
  if (!state) {
191
- state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
192
  languageCompartment.of([]),
193
- readOnlyCompartment.of([EditorState.readOnly.of(!editable)]),
194
- editableCompartment.of([EditorView.editable.of(editable)]),
195
  ]);
196
 
197
  editorStates.set(doc.filePath, state);
@@ -204,8 +223,6 @@ export const CodeMirrorEditor = memo(
204
  theme,
205
  editable,
206
  languageCompartment,
207
- readOnlyCompartment,
208
- editableCompartment,
209
  autoFocusOnDocumentChange,
210
  doc as TextEditorDocument,
211
  );
@@ -230,20 +247,20 @@ function newEditorState(
230
  settings: EditorSettings | undefined,
231
  onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
232
  debounceScroll: number,
 
233
  extensions: Extension[],
234
  ) {
235
  return EditorState.create({
236
  doc: content,
237
  extensions: [
238
  EditorView.domEventHandlers({
239
- scroll: debounce((_event, view) => {
 
 
 
 
240
  onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
241
  }, debounceScroll),
242
- keydown: (event) => {
243
- if (event.code === 'KeyS' && (event.ctrlKey || event.metaKey)) {
244
- event.preventDefault();
245
- }
246
- },
247
  }),
248
  getTheme(theme, settings),
249
  history(),
@@ -252,6 +269,14 @@ function newEditorState(
252
  ...historyKeymap,
253
  ...searchKeymap,
254
  { key: 'Tab', run: acceptCompletion },
 
 
 
 
 
 
 
 
255
  indentKeyBinding,
256
  ]),
257
  indentUnit.of('\t'),
@@ -266,6 +291,9 @@ function newEditorState(
266
  bracketMatching(),
267
  EditorState.tabSize.of(settings?.tabSize ?? 2),
268
  indentOnInput(),
 
 
 
269
  highlightActiveLineGutter(),
270
  highlightActiveLine(),
271
  foldGutter({
@@ -300,8 +328,6 @@ function setEditorDocument(
300
  theme: Theme,
301
  editable: boolean,
302
  languageCompartment: Compartment,
303
- readOnlyCompartment: Compartment,
304
- editableCompartment: Compartment,
305
  autoFocus: boolean,
306
  doc: TextEditorDocument,
307
  ) {
@@ -317,10 +343,7 @@ function setEditorDocument(
317
  }
318
 
319
  view.dispatch({
320
- effects: [
321
- readOnlyCompartment.reconfigure([EditorState.readOnly.of(!editable)]),
322
- editableCompartment.reconfigure([EditorView.editable.of(editable)]),
323
- ],
324
  });
325
 
326
  getLanguage(doc.filePath).then((languageSupport) => {
@@ -340,7 +363,7 @@ function setEditorDocument(
340
 
341
  const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
342
 
343
- if (autoFocus) {
344
  if (needsScrolling) {
345
  // we have to wait until the scroll position was changed before we can set the focus
346
  view.scrollDOM.addEventListener(
 
2
  import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
3
  import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
4
  import { searchKeymap } from '@codemirror/search';
5
+ import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
6
  import {
7
  EditorView,
8
  drawSelection,
 
27
 
28
  export interface EditorDocument {
29
  value: string | Uint8Array;
 
 
30
  filePath: string;
31
  scroll?: ScrollPosition;
32
  }
 
52
 
53
  export type OnChangeCallback = (update: EditorUpdate) => void;
54
  export type OnScrollCallback = (position: ScrollPosition) => void;
55
+ export type OnSaveCallback = () => void;
56
 
57
  interface Props {
58
  theme: Theme;
 
64
  autoFocusOnDocumentChange?: boolean;
65
  onChange?: OnChangeCallback;
66
  onScroll?: OnScrollCallback;
67
+ onSave?: OnSaveCallback;
68
  className?: string;
69
  settings?: EditorSettings;
70
  }
71
 
72
  type EditorStates = Map<string, EditorState>;
73
 
74
+ const editableStateEffect = StateEffect.define<boolean>();
75
+
76
+ const editableStateField = StateField.define<boolean>({
77
+ create() {
78
+ return true;
79
+ },
80
+ update(value, transaction) {
81
+ for (const effect of transaction.effects) {
82
+ if (effect.is(editableStateEffect)) {
83
+ return effect.value;
84
+ }
85
+ }
86
+
87
+ return value;
88
+ },
89
+ });
90
+
91
  export const CodeMirrorEditor = memo(
92
  ({
93
  id,
 
98
  editable = true,
99
  onScroll,
100
  onChange,
101
+ onSave,
102
  theme,
103
  settings,
104
  className = '',
105
  }: Props) => {
106
+ renderLogger.trace('CodeMirrorEditor');
107
 
108
  const [languageCompartment] = useState(new Compartment());
 
 
109
 
110
  const containerRef = useRef<HTMLDivElement | null>(null);
111
  const viewRef = useRef<EditorView>();
 
114
  const editorStatesRef = useRef<EditorStates>();
115
  const onScrollRef = useRef(onScroll);
116
  const onChangeRef = useRef(onChange);
117
+ const onSaveRef = useRef(onSave);
118
 
119
  const isBinaryFile = doc?.value instanceof Uint8Array;
120
 
121
+ /**
122
+ * This effect is used to avoid side effects directly in the render function
123
+ * and instead the refs are updated after each render.
124
+ */
125
+ useEffect(() => {
126
+ onScrollRef.current = onScroll;
127
+ onChangeRef.current = onChange;
128
+ onSaveRef.current = onSave;
129
+ docRef.current = doc;
130
+ themeRef.current = theme;
131
+ });
132
 
133
  useEffect(() => {
134
  const onUpdate = debounce((update: EditorUpdate) => {
 
187
  const theme = themeRef.current!;
188
 
189
  if (!doc) {
190
+ const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
191
  languageCompartment.of([]),
 
 
192
  ]);
193
 
194
  view.setState(state);
 
209
  let state = editorStates.get(doc.filePath);
210
 
211
  if (!state) {
212
+ state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
213
  languageCompartment.of([]),
 
 
214
  ]);
215
 
216
  editorStates.set(doc.filePath, state);
 
223
  theme,
224
  editable,
225
  languageCompartment,
 
 
226
  autoFocusOnDocumentChange,
227
  doc as TextEditorDocument,
228
  );
 
247
  settings: EditorSettings | undefined,
248
  onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
249
  debounceScroll: number,
250
+ onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
251
  extensions: Extension[],
252
  ) {
253
  return EditorState.create({
254
  doc: content,
255
  extensions: [
256
  EditorView.domEventHandlers({
257
+ scroll: debounce((event, view) => {
258
+ if (event.target !== view.scrollDOM) {
259
+ return;
260
+ }
261
+
262
  onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
263
  }, debounceScroll),
 
 
 
 
 
264
  }),
265
  getTheme(theme, settings),
266
  history(),
 
269
  ...historyKeymap,
270
  ...searchKeymap,
271
  { key: 'Tab', run: acceptCompletion },
272
+ {
273
+ key: 'Mod-s',
274
+ preventDefault: true,
275
+ run: () => {
276
+ onFileSaveRef.current?.();
277
+ return true;
278
+ },
279
+ },
280
  indentKeyBinding,
281
  ]),
282
  indentUnit.of('\t'),
 
291
  bracketMatching(),
292
  EditorState.tabSize.of(settings?.tabSize ?? 2),
293
  indentOnInput(),
294
+ editableStateField,
295
+ EditorState.readOnly.from(editableStateField, (editable) => !editable),
296
+ EditorView.editable.from(editableStateField, (editable) => editable),
297
  highlightActiveLineGutter(),
298
  highlightActiveLine(),
299
  foldGutter({
 
328
  theme: Theme,
329
  editable: boolean,
330
  languageCompartment: Compartment,
 
 
331
  autoFocus: boolean,
332
  doc: TextEditorDocument,
333
  ) {
 
343
  }
344
 
345
  view.dispatch({
346
+ effects: [editableStateEffect.of(editable)],
 
 
 
347
  });
348
 
349
  getLanguage(doc.filePath).then((languageSupport) => {
 
363
 
364
  const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
365
 
366
+ if (autoFocus && editable) {
367
  if (needsScrolling) {
368
  // we have to wait until the scroll position was changed before we can set the focus
369
  view.scrollDOM.addEventListener(
packages/bolt/app/components/editor/codemirror/cm-theme.ts CHANGED
@@ -38,6 +38,9 @@ function getEditorTheme(settings: EditorSettings) {
38
  },
39
  '.cm-scroller': {
40
  lineHeight: '1.5',
 
 
 
41
  },
42
  '.cm-line': {
43
  padding: '0 0 0 4px',
 
38
  },
39
  '.cm-scroller': {
40
  lineHeight: '1.5',
41
+ '&:focus-visible': {
42
+ outline: 'none',
43
+ },
44
  },
45
  '.cm-line': {
46
  padding: '0 0 0 4px',
packages/bolt/app/components/ui/PanelHeaderButton.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { classNames } from '../../utils/classNames';
3
+
4
+ interface PanelHeaderButtonProps {
5
+ className?: string;
6
+ disabledClassName?: string;
7
+ disabled?: boolean;
8
+ children: string | JSX.Element | Array<JSX.Element | string>;
9
+ onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
10
+ }
11
+
12
+ export const PanelHeaderButton = memo(
13
+ ({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
14
+ return (
15
+ <button
16
+ className={classNames(
17
+ 'flex items-center gap-1.5 px-1.5 rounded-lg py-0.5 bg-transparent hover:bg-white disabled:cursor-not-allowed',
18
+ {
19
+ [classNames('opacity-30', disabledClassName)]: disabled,
20
+ },
21
+ className,
22
+ )}
23
+ disabled={disabled}
24
+ onClick={(event) => {
25
+ if (disabled) {
26
+ return;
27
+ }
28
+
29
+ onClick?.(event);
30
+ }}
31
+ >
32
+ {children}
33
+ </button>
34
+ );
35
+ },
36
+ );
packages/bolt/app/components/workbench/EditorPanel.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useStore } from '@nanostores/react';
2
- import { memo } from 'react';
3
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
4
  import type { FileMap } from '../../lib/stores/files';
5
  import { themeStore } from '../../lib/stores/theme';
@@ -8,43 +8,107 @@ import { isMobile } from '../../utils/mobile';
8
  import {
9
  CodeMirrorEditor,
10
  type EditorDocument,
 
11
  type OnChangeCallback as OnEditorChange,
 
12
  type OnScrollCallback as OnEditorScroll,
13
  } from '../editor/codemirror/CodeMirrorEditor';
 
14
  import { FileTreePanel } from './FileTreePanel';
15
 
16
  interface EditorPanelProps {
17
  files?: FileMap;
 
18
  editorDocument?: EditorDocument;
19
  selectedFile?: string | undefined;
20
  isStreaming?: boolean;
21
  onEditorChange?: OnEditorChange;
22
  onEditorScroll?: OnEditorScroll;
23
  onFileSelect?: (value?: string) => void;
 
 
24
  }
25
 
 
 
26
  export const EditorPanel = memo(
27
- ({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => {
 
 
 
 
 
 
 
 
 
 
 
28
  renderLogger.trace('EditorPanel');
29
 
30
  const theme = useStore(themeStore);
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  return (
33
  <PanelGroup direction="horizontal">
34
- <Panel defaultSize={25} minSize={10} collapsible={true}>
35
- <FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} />
 
 
 
 
 
 
 
 
 
 
 
36
  </Panel>
37
  <PanelResizeHandle />
38
- <Panel defaultSize={75} minSize={20}>
39
- <CodeMirrorEditor
40
- theme={theme}
41
- editable={true}
42
- settings={{ tabSize: 2 }}
43
- doc={editorDocument}
44
- autoFocusOnDocumentChange={!isMobile()}
45
- onScroll={onEditorScroll}
46
- onChange={onEditorChange}
47
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </Panel>
49
  </PanelGroup>
50
  );
 
1
  import { useStore } from '@nanostores/react';
2
+ import { memo, useMemo } from 'react';
3
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
4
  import type { FileMap } from '../../lib/stores/files';
5
  import { themeStore } from '../../lib/stores/theme';
 
8
  import {
9
  CodeMirrorEditor,
10
  type EditorDocument,
11
+ type EditorSettings,
12
  type OnChangeCallback as OnEditorChange,
13
+ type OnSaveCallback as OnEditorSave,
14
  type OnScrollCallback as OnEditorScroll,
15
  } from '../editor/codemirror/CodeMirrorEditor';
16
+ import { PanelHeaderButton } from '../ui/PanelHeaderButton';
17
  import { FileTreePanel } from './FileTreePanel';
18
 
19
  interface EditorPanelProps {
20
  files?: FileMap;
21
+ unsavedFiles?: Set<string>;
22
  editorDocument?: EditorDocument;
23
  selectedFile?: string | undefined;
24
  isStreaming?: boolean;
25
  onEditorChange?: OnEditorChange;
26
  onEditorScroll?: OnEditorScroll;
27
  onFileSelect?: (value?: string) => void;
28
+ onFileSave?: OnEditorSave;
29
+ onFileReset?: () => void;
30
  }
31
 
32
+ const editorSettings: EditorSettings = { tabSize: 2 };
33
+
34
  export const EditorPanel = memo(
35
+ ({
36
+ files,
37
+ unsavedFiles,
38
+ editorDocument,
39
+ selectedFile,
40
+ isStreaming,
41
+ onFileSelect,
42
+ onEditorChange,
43
+ onEditorScroll,
44
+ onFileSave,
45
+ onFileReset,
46
+ }: EditorPanelProps) => {
47
  renderLogger.trace('EditorPanel');
48
 
49
  const theme = useStore(themeStore);
50
 
51
+ const activeFile = useMemo(() => {
52
+ if (!editorDocument) {
53
+ return '';
54
+ }
55
+
56
+ return editorDocument.filePath.split('/').at(-1);
57
+ }, [editorDocument]);
58
+
59
+ const activeFileUnsaved = useMemo(() => {
60
+ return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
61
+ }, [editorDocument, unsavedFiles]);
62
+
63
  return (
64
  <PanelGroup direction="horizontal">
65
+ <Panel className="flex flex-col" defaultSize={25} minSize={10} collapsible={true}>
66
+ <div className="border-r h-full">
67
+ <div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]">
68
+ <div className="i-ph:tree-structure-duotone shrink-0" />
69
+ Files
70
+ </div>
71
+ <FileTreePanel
72
+ files={files}
73
+ unsavedFiles={unsavedFiles}
74
+ selectedFile={selectedFile}
75
+ onFileSelect={onFileSelect}
76
+ />
77
+ </div>
78
  </Panel>
79
  <PanelResizeHandle />
80
+ <Panel className="flex flex-col" defaultSize={75} minSize={20}>
81
+ <div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px] text-sm">
82
+ {activeFile && (
83
+ <div className="flex items-center flex-1">
84
+ {activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
85
+ {activeFileUnsaved && (
86
+ <div className="flex gap-1 ml-auto -mr-1.5">
87
+ <PanelHeaderButton onClick={onFileSave}>
88
+ <div className="i-ph:floppy-disk-duotone" />
89
+ Save
90
+ </PanelHeaderButton>
91
+ <PanelHeaderButton onClick={onFileReset}>
92
+ <div className="i-ph:clock-counter-clockwise-duotone" />
93
+ Reset
94
+ </PanelHeaderButton>
95
+ </div>
96
+ )}
97
+ </div>
98
+ )}
99
+ </div>
100
+ <div className="h-full flex-1 overflow-hidden">
101
+ <CodeMirrorEditor
102
+ theme={theme}
103
+ editable={!isStreaming && editorDocument !== undefined}
104
+ settings={editorSettings}
105
+ doc={editorDocument}
106
+ autoFocusOnDocumentChange={!isMobile()}
107
+ onScroll={onEditorScroll}
108
+ onChange={onEditorChange}
109
+ onSave={onFileSave}
110
+ />
111
+ </div>
112
  </Panel>
113
  </PanelGroup>
114
  );
packages/bolt/app/components/workbench/FileTree.tsx CHANGED
@@ -12,19 +12,19 @@ interface Props {
12
  onFileSelect?: (filePath: string) => void;
13
  rootFolder?: string;
14
  hiddenFiles?: Array<string | RegExp>;
 
15
  className?: string;
16
  }
17
 
18
  export const FileTree = memo(
19
- ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => {
20
  renderLogger.trace('FileTree');
21
 
22
  const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
23
 
24
- const fileList = useMemo(
25
- () => buildFileList(files, rootFolder, computedHiddenFiles),
26
- [files, rootFolder, computedHiddenFiles],
27
- );
28
 
29
  const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
30
 
@@ -95,6 +95,7 @@ export const FileTree = memo(
95
  key={fileOrFolder.id}
96
  selected={selectedFile === fileOrFolder.fullPath}
97
  file={fileOrFolder}
 
98
  onClick={() => {
99
  onFileSelect?.(fileOrFolder.fullPath);
100
  }}
@@ -134,7 +135,7 @@ interface FolderProps {
134
  function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
135
  return (
136
  <NodeButton
137
- className="group bg-white hover:bg-gray-100 text-md"
138
  depth={depth}
139
  iconClasses={classNames({
140
  'i-ph:caret-right scale-98': collapsed,
@@ -150,10 +151,11 @@ function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
150
  interface FileProps {
151
  file: FileNode;
152
  selected: boolean;
 
153
  onClick: () => void;
154
  }
155
 
156
- function File({ file: { depth, name }, onClick, selected }: FileProps) {
157
  return (
158
  <NodeButton
159
  className={classNames('group', {
@@ -166,7 +168,10 @@ function File({ file: { depth, name }, onClick, selected }: FileProps) {
166
  })}
167
  onClick={onClick}
168
  >
169
- {name}
 
 
 
170
  </NodeButton>
171
  );
172
  }
@@ -187,7 +192,7 @@ function NodeButton({ depth, iconClasses, onClick, className, children }: Button
187
  onClick={() => onClick?.()}
188
  >
189
  <div className={classNames('scale-120 shrink-0', iconClasses)}></div>
190
- <span className="whitespace-nowrap">{children}</span>
191
  </button>
192
  );
193
  }
 
12
  onFileSelect?: (filePath: string) => void;
13
  rootFolder?: string;
14
  hiddenFiles?: Array<string | RegExp>;
15
+ unsavedFiles?: Set<string>;
16
  className?: string;
17
  }
18
 
19
  export const FileTree = memo(
20
+ ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => {
21
  renderLogger.trace('FileTree');
22
 
23
  const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
24
 
25
+ const fileList = useMemo(() => {
26
+ return buildFileList(files, rootFolder, computedHiddenFiles);
27
+ }, [files, rootFolder, computedHiddenFiles]);
 
28
 
29
  const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
30
 
 
95
  key={fileOrFolder.id}
96
  selected={selectedFile === fileOrFolder.fullPath}
97
  file={fileOrFolder}
98
+ unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
99
  onClick={() => {
100
  onFileSelect?.(fileOrFolder.fullPath);
101
  }}
 
135
  function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
136
  return (
137
  <NodeButton
138
+ className="group bg-white hover:bg-gray-50 text-md"
139
  depth={depth}
140
  iconClasses={classNames({
141
  'i-ph:caret-right scale-98': collapsed,
 
151
  interface FileProps {
152
  file: FileNode;
153
  selected: boolean;
154
+ unsavedChanges?: boolean;
155
  onClick: () => void;
156
  }
157
 
158
+ function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
159
  return (
160
  <NodeButton
161
  className={classNames('group', {
 
168
  })}
169
  onClick={onClick}
170
  >
171
+ <div className="flex items-center">
172
+ <div className="flex-1 truncate pr-2">{name}</div>
173
+ {unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-warning-400" />}
174
+ </div>
175
  </NodeButton>
176
  );
177
  }
 
192
  onClick={() => onClick?.()}
193
  >
194
  <div className={classNames('scale-120 shrink-0', iconClasses)}></div>
195
+ <div className="truncate w-full text-left">{children}</div>
196
  </button>
197
  );
198
  }
packages/bolt/app/components/workbench/FileTreePanel.tsx CHANGED
@@ -7,15 +7,22 @@ import { FileTree } from './FileTree';
7
  interface FileTreePanelProps {
8
  files?: FileMap;
9
  selectedFile?: string;
 
10
  onFileSelect?: (value?: string) => void;
11
  }
12
 
13
- export const FileTreePanel = memo(({ files, selectedFile, onFileSelect }: FileTreePanelProps) => {
14
  renderLogger.trace('FileTreePanel');
15
 
16
  return (
17
- <div className="border-r h-full">
18
- <FileTree files={files} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} />
 
 
 
 
 
 
19
  </div>
20
  );
21
  });
 
7
  interface FileTreePanelProps {
8
  files?: FileMap;
9
  selectedFile?: string;
10
+ unsavedFiles?: Set<string>;
11
  onFileSelect?: (value?: string) => void;
12
  }
13
 
14
+ export const FileTreePanel = memo(({ files, unsavedFiles, selectedFile, onFileSelect }: FileTreePanelProps) => {
15
  renderLogger.trace('FileTreePanel');
16
 
17
  return (
18
+ <div className="h-full">
19
+ <FileTree
20
+ files={files}
21
+ unsavedFiles={unsavedFiles}
22
+ rootFolder={WORK_DIR}
23
+ selectedFile={selectedFile}
24
+ onFileSelect={onFileSelect}
25
+ />
26
  </div>
27
  );
28
  });
packages/bolt/app/components/workbench/Workbench.client.tsx CHANGED
@@ -2,12 +2,13 @@ import { useStore } from '@nanostores/react';
2
  import { AnimatePresence, motion, type Variants } from 'framer-motion';
3
  import { memo, useCallback, useEffect } from 'react';
4
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
 
5
  import { workbenchStore } from '../../lib/stores/workbench';
6
  import { cubicEasingFn } from '../../utils/easings';
7
  import { renderLogger } from '../../utils/logger';
8
- import type {
9
- OnChangeCallback as OnEditorChange,
10
- OnScrollCallback as OnEditorScroll,
11
  } from '../editor/codemirror/CodeMirrorEditor';
12
  import { IconButton } from '../ui/IconButton';
13
  import { EditorPanel } from './EditorPanel';
@@ -41,6 +42,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
41
  const showWorkbench = useStore(workbenchStore.showWorkbench);
42
  const selectedFile = useStore(workbenchStore.selectedFile);
43
  const currentDocument = useStore(workbenchStore.currentDocument);
 
44
 
45
  const files = useStore(workbenchStore.files);
46
 
@@ -60,6 +62,16 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
60
  workbenchStore.setSelectedFile(filePath);
61
  }, []);
62
 
 
 
 
 
 
 
 
 
 
 
63
  return (
64
  chatStarted && (
65
  <AnimatePresence>
@@ -70,7 +82,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
70
  <div className="px-3 py-2 border-b border-gray-200">
71
  <IconButton
72
  icon="i-ph:x-circle"
73
- className="ml-auto"
74
  size="xxl"
75
  onClick={() => {
76
  workbenchStore.showWorkbench.set(false);
@@ -85,9 +97,12 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
85
  isStreaming={isStreaming}
86
  selectedFile={selectedFile}
87
  files={files}
 
88
  onFileSelect={onFileSelect}
89
  onEditorScroll={onEditorScroll}
90
  onEditorChange={onEditorChange}
 
 
91
  />
92
  </Panel>
93
  <PanelResizeHandle />
 
2
  import { AnimatePresence, motion, type Variants } from 'framer-motion';
3
  import { memo, useCallback, useEffect } from 'react';
4
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
5
+ import { toast } from 'react-toastify';
6
  import { workbenchStore } from '../../lib/stores/workbench';
7
  import { cubicEasingFn } from '../../utils/easings';
8
  import { renderLogger } from '../../utils/logger';
9
+ import {
10
+ type OnChangeCallback as OnEditorChange,
11
+ type OnScrollCallback as OnEditorScroll,
12
  } from '../editor/codemirror/CodeMirrorEditor';
13
  import { IconButton } from '../ui/IconButton';
14
  import { EditorPanel } from './EditorPanel';
 
42
  const showWorkbench = useStore(workbenchStore.showWorkbench);
43
  const selectedFile = useStore(workbenchStore.selectedFile);
44
  const currentDocument = useStore(workbenchStore.currentDocument);
45
+ const unsavedFiles = useStore(workbenchStore.unsavedFiles);
46
 
47
  const files = useStore(workbenchStore.files);
48
 
 
62
  workbenchStore.setSelectedFile(filePath);
63
  }, []);
64
 
65
+ const onFileSave = useCallback(() => {
66
+ workbenchStore.saveCurrentDocument().catch(() => {
67
+ toast.error('Failed to update file content');
68
+ });
69
+ }, []);
70
+
71
+ const onFileReset = useCallback(() => {
72
+ workbenchStore.resetCurrentDocument();
73
+ }, []);
74
+
75
  return (
76
  chatStarted && (
77
  <AnimatePresence>
 
82
  <div className="px-3 py-2 border-b border-gray-200">
83
  <IconButton
84
  icon="i-ph:x-circle"
85
+ className="ml-auto -mr-1"
86
  size="xxl"
87
  onClick={() => {
88
  workbenchStore.showWorkbench.set(false);
 
97
  isStreaming={isStreaming}
98
  selectedFile={selectedFile}
99
  files={files}
100
+ unsavedFiles={unsavedFiles}
101
  onFileSelect={onFileSelect}
102
  onEditorScroll={onEditorScroll}
103
  onEditorChange={onEditorChange}
104
+ onFileSave={onFileSave}
105
+ onFileReset={onFileReset}
106
  />
107
  </Panel>
108
  <PanelResizeHandle />
packages/bolt/app/lib/runtime/action-runner.ts CHANGED
@@ -167,7 +167,7 @@ export class ActionRunner {
167
  await webcontainer.fs.mkdir(folder, { recursive: true });
168
  logger.debug('Created folder', folder);
169
  } catch (error) {
170
- logger.error('Failed to create folder\n', error);
171
  }
172
  }
173
 
@@ -175,7 +175,7 @@ export class ActionRunner {
175
  await webcontainer.fs.writeFile(action.filePath, action.content);
176
  logger.debug(`File written ${action.filePath}`);
177
  } catch (error) {
178
- logger.error('Failed to write file\n', error);
179
  }
180
  }
181
 
 
167
  await webcontainer.fs.mkdir(folder, { recursive: true });
168
  logger.debug('Created folder', folder);
169
  } catch (error) {
170
+ logger.error('Failed to create folder\n\n', error);
171
  }
172
  }
173
 
 
175
  await webcontainer.fs.writeFile(action.filePath, action.content);
176
  logger.debug(`File written ${action.filePath}`);
177
  } catch (error) {
178
+ logger.error('Failed to write file\n\n', error);
179
  }
180
  }
181
 
packages/bolt/app/lib/stores/editor.ts CHANGED
@@ -1,15 +1,16 @@
1
- import type { WebContainer } from '@webcontainer/api';
2
- import { atom, computed, map } from 'nanostores';
3
  import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
4
- import type { FileMap } from './files';
5
 
6
  export type EditorDocuments = Record<string, EditorDocument>;
7
 
 
 
8
  export class EditorStore {
9
- #webcontainer: Promise<WebContainer>;
10
 
11
- selectedFile = atom<string | undefined>();
12
- documents = map<EditorDocuments>({});
13
 
14
  currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
15
  if (!selectedFile) {
@@ -19,12 +20,13 @@ export class EditorStore {
19
  return documents[selectedFile];
20
  });
21
 
22
- constructor(webcontainerPromise: Promise<WebContainer>) {
23
- this.#webcontainer = webcontainerPromise;
24
- }
25
 
26
- commitFileContent(_filePath: string) {
27
- // TODO
 
 
28
  }
29
 
30
  setDocuments(files: FileMap) {
@@ -38,13 +40,14 @@ export class EditorStore {
38
  return undefined;
39
  }
40
 
 
 
41
  return [
42
  filePath,
43
  {
44
  value: dirent.content,
45
- commitPending: false,
46
  filePath,
47
- scroll: previousDocuments?.[filePath]?.scroll,
48
  },
49
  ] as [string, EditorDocument];
50
  })
@@ -71,26 +74,22 @@ export class EditorStore {
71
  });
72
  }
73
 
74
- updateFile(filePath: string, content: string): boolean {
75
  const documents = this.documents.get();
76
  const documentState = documents[filePath];
77
 
78
  if (!documentState) {
79
- return false;
80
  }
81
 
82
  const currentContent = documentState.value;
83
- const contentChanged = currentContent !== content;
84
 
85
  if (contentChanged) {
86
  this.documents.setKey(filePath, {
87
  ...documentState,
88
- previousValue: !documentState.commitPending ? currentContent : documentState.previousValue,
89
- commitPending: documentState.previousValue ? documentState.previousValue !== content : true,
90
- value: content,
91
  });
92
  }
93
-
94
- return contentChanged;
95
  }
96
  }
 
1
+ import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
 
2
  import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
3
+ import type { FileMap, FilesStore } from './files';
4
 
5
  export type EditorDocuments = Record<string, EditorDocument>;
6
 
7
+ type SelectedFile = WritableAtom<string | undefined>;
8
+
9
  export class EditorStore {
10
+ #filesStore: FilesStore;
11
 
12
+ selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
13
+ documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map<EditorDocuments>({});
14
 
15
  currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
16
  if (!selectedFile) {
 
20
  return documents[selectedFile];
21
  });
22
 
23
+ constructor(filesStore: FilesStore) {
24
+ this.#filesStore = filesStore;
 
25
 
26
+ if (import.meta.hot) {
27
+ import.meta.hot.data.documents = this.documents;
28
+ import.meta.hot.data.selectedFile = this.selectedFile;
29
+ }
30
  }
31
 
32
  setDocuments(files: FileMap) {
 
40
  return undefined;
41
  }
42
 
43
+ const previousDocument = previousDocuments?.[filePath];
44
+
45
  return [
46
  filePath,
47
  {
48
  value: dirent.content,
 
49
  filePath,
50
+ scroll: previousDocument?.scroll,
51
  },
52
  ] as [string, EditorDocument];
53
  })
 
74
  });
75
  }
76
 
77
+ updateFile(filePath: string, newContent: string | Uint8Array) {
78
  const documents = this.documents.get();
79
  const documentState = documents[filePath];
80
 
81
  if (!documentState) {
82
+ return;
83
  }
84
 
85
  const currentContent = documentState.value;
86
+ const contentChanged = currentContent !== newContent;
87
 
88
  if (contentChanged) {
89
  this.documents.setKey(filePath, {
90
  ...documentState,
91
+ value: newContent,
 
 
92
  });
93
  }
 
 
94
  }
95
  }
packages/bolt/app/lib/stores/files.ts CHANGED
@@ -1,16 +1,20 @@
1
  import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
2
- import { map } from 'nanostores';
 
3
  import { bufferWatchEvents } from '../../utils/buffer';
4
  import { WORK_DIR } from '../../utils/constants';
 
 
 
5
 
6
  const textDecoder = new TextDecoder('utf8', { fatal: true });
7
 
8
- interface File {
9
  type: 'file';
10
- content: string;
11
  }
12
 
13
- interface Folder {
14
  type: 'folder';
15
  }
16
 
@@ -21,14 +25,59 @@ export type FileMap = Record<string, Dirent | undefined>;
21
  export class FilesStore {
22
  #webcontainer: Promise<WebContainer>;
23
 
24
- files = map<FileMap>({});
 
 
 
 
 
 
 
 
 
25
 
26
  constructor(webcontainerPromise: Promise<WebContainer>) {
27
  this.#webcontainer = webcontainerPromise;
28
 
 
 
 
 
29
  this.#init();
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  async #init() {
33
  const webcontainer = await this.#webcontainer;
34
 
@@ -64,10 +113,16 @@ export class FilesStore {
64
  }
65
  case 'add_file':
66
  case 'change': {
 
 
 
 
67
  this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
 
68
  break;
69
  }
70
  case 'remove_file': {
 
71
  this.files.setKey(sanitizedPath, undefined);
72
  break;
73
  }
 
1
  import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
2
+ import { map, type MapStore } from 'nanostores';
3
+ import * as nodePath from 'node:path';
4
  import { bufferWatchEvents } from '../../utils/buffer';
5
  import { WORK_DIR } from '../../utils/constants';
6
+ import { createScopedLogger } from '../../utils/logger';
7
+
8
+ const logger = createScopedLogger('FilesStore');
9
 
10
  const textDecoder = new TextDecoder('utf8', { fatal: true });
11
 
12
+ export interface File {
13
  type: 'file';
14
+ content: string | Uint8Array;
15
  }
16
 
17
+ export interface Folder {
18
  type: 'folder';
19
  }
20
 
 
25
  export class FilesStore {
26
  #webcontainer: Promise<WebContainer>;
27
 
28
+ /**
29
+ * Tracks the number of files without folders.
30
+ */
31
+ #size = 0;
32
+
33
+ files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
34
+
35
+ get filesCount() {
36
+ return this.#size;
37
+ }
38
 
39
  constructor(webcontainerPromise: Promise<WebContainer>) {
40
  this.#webcontainer = webcontainerPromise;
41
 
42
+ if (import.meta.hot) {
43
+ import.meta.hot.data.files = this.files;
44
+ }
45
+
46
  this.#init();
47
  }
48
 
49
+ getFile(filePath: string) {
50
+ const dirent = this.files.get()[filePath];
51
+
52
+ if (dirent?.type !== 'file') {
53
+ return undefined;
54
+ }
55
+
56
+ return dirent;
57
+ }
58
+
59
+ async saveFile(filePath: string, content: string | Uint8Array) {
60
+ const webcontainer = await this.#webcontainer;
61
+
62
+ try {
63
+ const relativePath = nodePath.relative(webcontainer.workdir, filePath);
64
+
65
+ if (!relativePath) {
66
+ throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
67
+ }
68
+
69
+ await webcontainer.fs.writeFile(relativePath, content);
70
+
71
+ this.files.setKey(filePath, { type: 'file', content });
72
+
73
+ logger.info('File updated');
74
+ } catch (error) {
75
+ logger.error('Failed to update file content\n\n', error);
76
+
77
+ throw error;
78
+ }
79
+ }
80
+
81
  async #init() {
82
  const webcontainer = await this.#webcontainer;
83
 
 
113
  }
114
  case 'add_file':
115
  case 'change': {
116
+ if (type === 'add_file') {
117
+ this.#size++;
118
+ }
119
+
120
  this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
121
+
122
  break;
123
  }
124
  case 'remove_file': {
125
+ this.#size--;
126
  this.files.setKey(sanitizedPath, undefined);
127
  break;
128
  }
packages/bolt/app/lib/stores/workbench.ts CHANGED
@@ -21,11 +21,20 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
21
  export class WorkbenchStore {
22
  #previewsStore = new PreviewsStore(webcontainer);
23
  #filesStore = new FilesStore(webcontainer);
24
- #editorStore = new EditorStore(webcontainer);
25
 
26
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
27
-
28
  showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
 
 
 
 
 
 
 
 
 
 
29
 
30
  get previews() {
31
  return this.#previewsStore.previews;
@@ -45,20 +54,53 @@ export class WorkbenchStore {
45
 
46
  setDocuments(files: FileMap) {
47
  this.#editorStore.setDocuments(files);
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  setShowWorkbench(show: boolean) {
51
  this.showWorkbench.set(show);
52
  }
53
 
54
- setCurrentDocumentContent(newContent: string) {
55
  const filePath = this.currentDocument.get()?.filePath;
56
 
57
  if (!filePath) {
58
  return;
59
  }
60
 
 
 
 
61
  this.#editorStore.updateFile(filePath, newContent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
 
64
  setCurrentDocumentScrollPosition(position: ScrollPosition) {
@@ -77,6 +119,40 @@ export class WorkbenchStore {
77
  this.#editorStore.setSelectedFile(filePath);
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  abortAllActions() {
81
  // TODO: what do we wanna do and how do we wanna recover from this?
82
  }
@@ -136,8 +212,3 @@ export class WorkbenchStore {
136
  }
137
 
138
  export const workbenchStore = new WorkbenchStore();
139
-
140
- if (import.meta.hot) {
141
- import.meta.hot.data.artifacts = workbenchStore.artifacts;
142
- import.meta.hot.data.showWorkbench = workbenchStore.showWorkbench;
143
- }
 
21
  export class WorkbenchStore {
22
  #previewsStore = new PreviewsStore(webcontainer);
23
  #filesStore = new FilesStore(webcontainer);
24
+ #editorStore = new EditorStore(this.#filesStore);
25
 
26
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
 
27
  showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
28
+ unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
29
+ modifiedFiles = new Set<string>();
30
+
31
+ constructor() {
32
+ if (import.meta.hot) {
33
+ import.meta.hot.data.artifacts = this.artifacts;
34
+ import.meta.hot.data.unsavedFiles = this.unsavedFiles;
35
+ import.meta.hot.data.showWorkbench = this.showWorkbench;
36
+ }
37
+ }
38
 
39
  get previews() {
40
  return this.#previewsStore.previews;
 
54
 
55
  setDocuments(files: FileMap) {
56
  this.#editorStore.setDocuments(files);
57
+
58
+ if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
59
+ // we find the first file and select it
60
+ for (const [filePath, dirent] of Object.entries(files)) {
61
+ if (dirent?.type === 'file') {
62
+ this.setSelectedFile(filePath);
63
+ break;
64
+ }
65
+ }
66
+ }
67
  }
68
 
69
  setShowWorkbench(show: boolean) {
70
  this.showWorkbench.set(show);
71
  }
72
 
73
+ setCurrentDocumentContent(newContent: string | Uint8Array) {
74
  const filePath = this.currentDocument.get()?.filePath;
75
 
76
  if (!filePath) {
77
  return;
78
  }
79
 
80
+ const originalContent = this.#filesStore.getFile(filePath)?.content;
81
+ const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
82
+
83
  this.#editorStore.updateFile(filePath, newContent);
84
+
85
+ const currentDocument = this.currentDocument.get();
86
+
87
+ if (currentDocument) {
88
+ const previousUnsavedFiles = this.unsavedFiles.get();
89
+
90
+ if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) {
91
+ return;
92
+ }
93
+
94
+ const newUnsavedFiles = new Set(previousUnsavedFiles);
95
+
96
+ if (unsavedChanges) {
97
+ newUnsavedFiles.add(currentDocument.filePath);
98
+ } else {
99
+ newUnsavedFiles.delete(currentDocument.filePath);
100
+ }
101
+
102
+ this.unsavedFiles.set(newUnsavedFiles);
103
+ }
104
  }
105
 
106
  setCurrentDocumentScrollPosition(position: ScrollPosition) {
 
119
  this.#editorStore.setSelectedFile(filePath);
120
  }
121
 
122
+ async saveCurrentDocument() {
123
+ const currentDocument = this.currentDocument.get();
124
+
125
+ if (currentDocument === undefined) {
126
+ return;
127
+ }
128
+
129
+ const { filePath } = currentDocument;
130
+
131
+ await this.#filesStore.saveFile(filePath, currentDocument.value);
132
+
133
+ const newUnsavedFiles = new Set(this.unsavedFiles.get());
134
+ newUnsavedFiles.delete(filePath);
135
+
136
+ this.unsavedFiles.set(newUnsavedFiles);
137
+ }
138
+
139
+ resetCurrentDocument() {
140
+ const currentDocument = this.currentDocument.get();
141
+
142
+ if (currentDocument === undefined) {
143
+ return;
144
+ }
145
+
146
+ const { filePath } = currentDocument;
147
+ const file = this.#filesStore.getFile(filePath);
148
+
149
+ if (!file) {
150
+ return;
151
+ }
152
+
153
+ this.setCurrentDocumentContent(file.content);
154
+ }
155
+
156
  abortAllActions() {
157
  // TODO: what do we wanna do and how do we wanna recover from this?
158
  }
 
212
  }
213
 
214
  export const workbenchStore = new WorkbenchStore();
 
 
 
 
 
packages/bolt/app/root.tsx CHANGED
@@ -5,6 +5,7 @@ import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
5
  import { themeStore } from './lib/stores/theme';
6
  import { stripIndents } from './utils/stripIndent';
7
 
 
8
  import globalStyles from './styles/index.scss?url';
9
 
10
  import 'virtual:uno.css';
@@ -17,6 +18,7 @@ export const links: LinksFunction = () => [
17
  },
18
  { rel: 'stylesheet', href: tailwindReset },
19
  { rel: 'stylesheet', href: globalStyles },
 
20
  {
21
  rel: 'preconnect',
22
  href: 'https://fonts.googleapis.com',
 
5
  import { themeStore } from './lib/stores/theme';
6
  import { stripIndents } from './utils/stripIndent';
7
 
8
+ import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
9
  import globalStyles from './styles/index.scss?url';
10
 
11
  import 'virtual:uno.css';
 
18
  },
19
  { rel: 'stylesheet', href: tailwindReset },
20
  { rel: 'stylesheet', href: globalStyles },
21
+ { rel: 'stylesheet', href: reactToastifyStyles },
22
  {
23
  rel: 'preconnect',
24
  href: 'https://fonts.googleapis.com',
packages/bolt/app/styles/animations.scss ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .animated {
2
+ animation-fill-mode: both;
3
+ animation-duration: var(--animate-duration, 0.2s);
4
+ animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
5
+
6
+ &.fadeInRight {
7
+ animation-name: fadeInRight;
8
+ }
9
+
10
+ &.fadeOutRight {
11
+ animation-name: fadeOutRight;
12
+ }
13
+ }
14
+
15
+ @keyframes fadeInRight {
16
+ from {
17
+ opacity: 0;
18
+ transform: translate3d(100%, 0, 0);
19
+ }
20
+
21
+ to {
22
+ opacity: 1;
23
+ transform: translate3d(0, 0, 0);
24
+ }
25
+ }
26
+
27
+ @keyframes fadeOutRight {
28
+ from {
29
+ opacity: 1;
30
+ }
31
+
32
+ to {
33
+ opacity: 0;
34
+ transform: translate3d(100%, 0, 0);
35
+ }
36
+ }
packages/bolt/app/styles/index.scss CHANGED
@@ -1,4 +1,5 @@
1
  @import './variables.scss';
 
2
 
3
  body {
4
  --at-apply: bg-bolt-elements-app-backgroundColor;
 
1
  @import './variables.scss';
2
+ @import './animations.scss';
3
 
4
  body {
5
  --at-apply: bg-bolt-elements-app-backgroundColor;
packages/bolt/app/utils/logger.ts CHANGED
@@ -57,7 +57,21 @@ function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
57
  styles.push('', scopeStyles);
58
  }
59
 
60
- console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, ...messages);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
  }
63
 
 
57
  styles.push('', scopeStyles);
58
  }
59
 
60
+ console.log(
61
+ `%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`,
62
+ ...styles,
63
+ messages.reduce((acc, current) => {
64
+ if (acc.endsWith('\n')) {
65
+ return acc + current;
66
+ }
67
+
68
+ if (!acc) {
69
+ return current;
70
+ }
71
+
72
+ return `${acc} ${current}`;
73
+ }, ''),
74
+ );
75
  }
76
  }
77
 
packages/bolt/package.json CHANGED
@@ -50,6 +50,7 @@
50
  "react-dom": "^18.2.0",
51
  "react-markdown": "^9.0.1",
52
  "react-resizable-panels": "^2.0.20",
 
53
  "rehype-raw": "^7.0.0",
54
  "remark-gfm": "^4.0.0",
55
  "remix-utils": "^7.6.0",
 
50
  "react-dom": "^18.2.0",
51
  "react-markdown": "^9.0.1",
52
  "react-resizable-panels": "^2.0.20",
53
+ "react-toastify": "^10.0.5",
54
  "rehype-raw": "^7.0.0",
55
  "remark-gfm": "^4.0.0",
56
  "remix-utils": "^7.6.0",
pnpm-lock.yaml CHANGED
@@ -137,6 +137,9 @@ importers:
137
  react-resizable-panels:
138
  specifier: ^2.0.20
139
 
 
 
140
  rehype-raw:
141
  specifier: ^7.0.0
142
  version: 7.0.0
@@ -2036,6 +2039,10 @@ packages:
2036
  resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
2037
  engines: {node: '>=0.8'}
2038
 
 
 
 
 
2039
2040
  resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
2041
 
@@ -4085,6 +4092,12 @@ packages:
4085
  peerDependencies:
4086
  react: '>=16.8'
4087
 
 
 
 
 
 
 
4088
4089
  resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
4090
  engines: {node: '>=0.10.0'}
@@ -7147,6 +7160,8 @@ snapshots:
7147
 
7148
7149
 
 
 
7150
7151
  dependencies:
7152
  '@jridgewell/sourcemap-codec': 1.4.15
@@ -9715,6 +9730,12 @@ snapshots:
9715
  '@remix-run/router': 1.17.1
9716
  react: 18.3.1
9717
 
 
 
 
 
 
 
9718
9719
  dependencies:
9720
  loose-envify: 1.4.0
 
137
  react-resizable-panels:
138
  specifier: ^2.0.20
139
140
+ react-toastify:
141
+ specifier: ^10.0.5
142
143
  rehype-raw:
144
  specifier: ^7.0.0
145
  version: 7.0.0
 
2039
  resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
2040
  engines: {node: '>=0.8'}
2041
 
2042
2043
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
2044
+ engines: {node: '>=6'}
2045
+
2046
2047
  resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
2048
 
 
4092
  peerDependencies:
4093
  react: '>=16.8'
4094
 
4095
4096
+ resolution: {integrity: sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==}
4097
+ peerDependencies:
4098
+ react: '>=18'
4099
+ react-dom: '>=18'
4100
+
4101
4102
  resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
4103
  engines: {node: '>=0.10.0'}
 
7160
 
7161
7162
 
7163
7164
+
7165
7166
  dependencies:
7167
  '@jridgewell/sourcemap-codec': 1.4.15
 
9730
  '@remix-run/router': 1.17.1
9731
  react: 18.3.1
9732
 
9733
9734
+ dependencies:
9735
+ clsx: 2.1.1
9736
+ react: 18.3.1
9737
+ react-dom: 18.3.1([email protected])
9738
+
9739
9740
  dependencies:
9741
  loose-envify: 1.4.0