Dominic Elm commited on
Commit
a7d8693
·
1 Parent(s): 012b5ba

feat(workbench): add file tree and hook up editor

Browse files
packages/bolt/app/components/chat/BaseChat.tsx CHANGED
@@ -167,7 +167,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
167
  </div>
168
  </div>
169
  </div>
170
- <ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
171
  </div>
172
  </div>
173
  );
 
167
  </div>
168
  </div>
169
  </div>
170
+ <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
171
  </div>
172
  </div>
173
  );
packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx CHANGED
@@ -13,11 +13,11 @@ import {
13
  lineNumbers,
14
  scrollPastEnd,
15
  } from '@codemirror/view';
16
- import { useEffect, useRef, useState, type MutableRefObject } from 'react';
17
  import type { Theme } from '../../../types/theme';
18
  import { classNames } from '../../../utils/classNames';
19
  import { debounce } from '../../../utils/debounce';
20
- import { createScopedLogger } from '../../../utils/logger';
21
  import { BinaryContent } from './BinaryContent';
22
  import { getTheme, reconfigureTheme } from './cm-theme';
23
  import { indentKeyBinding } from './indent';
@@ -27,7 +27,8 @@ const logger = createScopedLogger('CodeMirrorEditor');
27
 
28
  export interface EditorDocument {
29
  value: string | Uint8Array;
30
- loading: boolean;
 
31
  filePath: string;
32
  scroll?: ScrollPosition;
33
  }
@@ -58,6 +59,7 @@ interface Props {
58
  theme: Theme;
59
  id?: unknown;
60
  doc?: EditorDocument;
 
61
  debounceChange?: number;
62
  debounceScroll?: number;
63
  autoFocusOnDocumentChange?: boolean;
@@ -69,138 +71,154 @@ interface Props {
69
 
70
  type EditorStates = Map<string, EditorState>;
71
 
72
- export function CodeMirrorEditor({
73
- id,
74
- doc,
75
- debounceScroll = 100,
76
- debounceChange = 150,
77
- autoFocusOnDocumentChange = false,
78
- onScroll,
79
- onChange,
80
- theme,
81
- settings,
82
- className = '',
83
- }: Props) {
84
- const [language] = useState(new Compartment());
85
- const [readOnly] = useState(new Compartment());
86
-
87
- const containerRef = useRef<HTMLDivElement | null>(null);
88
- const viewRef = useRef<EditorView>();
89
- const themeRef = useRef<Theme>();
90
- const docRef = useRef<EditorDocument>();
91
- const editorStatesRef = useRef<EditorStates>();
92
- const onScrollRef = useRef(onScroll);
93
- const onChangeRef = useRef(onChange);
94
-
95
- const isBinaryFile = doc?.value instanceof Uint8Array;
96
-
97
- onScrollRef.current = onScroll;
98
- onChangeRef.current = onChange;
99
-
100
- docRef.current = doc;
101
- themeRef.current = theme;
102
-
103
- useEffect(() => {
104
- const onUpdate = debounce((update: EditorUpdate) => {
105
- onChangeRef.current?.(update);
106
- }, debounceChange);
107
-
108
- const view = new EditorView({
109
- parent: containerRef.current!,
110
- dispatchTransactions(transactions) {
111
- const previousSelection = view.state.selection;
112
-
113
- view.update(transactions);
114
-
115
- const newSelection = view.state.selection;
116
-
117
- const selectionChanged =
118
- newSelection !== previousSelection &&
119
- (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
120
-
121
- if (
122
- docRef.current &&
123
- !docRef.current.loading &&
124
- (transactions.some((transaction) => transaction.docChanged) || selectionChanged)
125
- ) {
126
- onUpdate({
127
- selection: view.state.selection,
128
- content: view.state.doc.toString(),
129
- });
130
-
131
- editorStatesRef.current!.set(docRef.current.filePath, view.state);
132
- }
133
- },
134
- });
135
-
136
- viewRef.current = view;
137
 
138
- return () => {
139
- viewRef.current?.destroy();
140
- viewRef.current = undefined;
141
- };
142
- }, []);
143
 
144
- useEffect(() => {
145
- if (!viewRef.current) {
146
- return;
147
- }
 
148
 
149
- viewRef.current.dispatch({
150
- effects: [reconfigureTheme(theme)],
151
- });
152
- }, [theme]);
153
 
154
- useEffect(() => {
155
- editorStatesRef.current = new Map<string, EditorState>();
156
- }, [id]);
 
157
 
158
- useEffect(() => {
159
- const editorStates = editorStatesRef.current!;
160
- const view = viewRef.current!;
161
- const theme = themeRef.current!;
162
 
163
- if (!doc) {
164
- const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [language.of([])]);
 
 
165
 
166
- view.setState(state);
 
 
 
 
 
167
 
168
- setNoDocument(view);
169
 
170
- return;
171
- }
172
 
173
- if (doc.value instanceof Uint8Array) {
174
- return;
175
- }
176
 
177
- if (doc.filePath === '') {
178
- logger.warn('File path should not be empty');
179
- }
180
 
181
- let state = editorStates.get(doc.filePath);
 
 
182
 
183
- if (!state) {
184
- state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
185
- language.of([]),
186
- readOnly.of([EditorState.readOnly.of(doc.loading)]),
187
- ]);
188
 
189
- editorStates.set(doc.filePath, state);
190
- }
 
 
 
 
191
 
192
- view.setState(state);
 
193
 
194
- setEditorDocument(view, theme, language, readOnly, autoFocusOnDocumentChange, doc as TextEditorDocument);
195
- }, [doc?.value, doc?.filePath, doc?.loading, autoFocusOnDocumentChange]);
196
 
197
- return (
198
- <div className={classNames('relative h-full', className)}>
199
- {isBinaryFile && <BinaryContent />}
200
- <div className="h-full overflow-hidden" ref={containerRef} />
201
- </div>
202
- );
203
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  export default CodeMirrorEditor;
206
 
@@ -280,8 +298,10 @@ function setNoDocument(view: EditorView) {
280
  function setEditorDocument(
281
  view: EditorView,
282
  theme: Theme,
283
- language: Compartment,
284
- readOnly: Compartment,
 
 
285
  autoFocus: boolean,
286
  doc: TextEditorDocument,
287
  ) {
@@ -297,7 +317,10 @@ function setEditorDocument(
297
  }
298
 
299
  view.dispatch({
300
- effects: [readOnly.reconfigure([EditorState.readOnly.of(doc.loading)])],
 
 
 
301
  });
302
 
303
  getLanguage(doc.filePath).then((languageSupport) => {
@@ -306,7 +329,7 @@ function setEditorDocument(
306
  }
307
 
308
  view.dispatch({
309
- effects: [language.reconfigure([languageSupport]), reconfigureTheme(theme)],
310
  });
311
 
312
  requestAnimationFrame(() => {
 
13
  lineNumbers,
14
  scrollPastEnd,
15
  } from '@codemirror/view';
16
+ import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
17
  import type { Theme } from '../../../types/theme';
18
  import { classNames } from '../../../utils/classNames';
19
  import { debounce } from '../../../utils/debounce';
20
+ import { createScopedLogger, renderLogger } from '../../../utils/logger';
21
  import { BinaryContent } from './BinaryContent';
22
  import { getTheme, reconfigureTheme } from './cm-theme';
23
  import { indentKeyBinding } from './indent';
 
27
 
28
  export interface EditorDocument {
29
  value: string | Uint8Array;
30
+ previousValue?: string | Uint8Array;
31
+ commitPending: boolean;
32
  filePath: string;
33
  scroll?: ScrollPosition;
34
  }
 
59
  theme: Theme;
60
  id?: unknown;
61
  doc?: EditorDocument;
62
+ editable?: boolean;
63
  debounceChange?: number;
64
  debounceScroll?: number;
65
  autoFocusOnDocumentChange?: boolean;
 
71
 
72
  type EditorStates = Map<string, EditorState>;
73
 
74
+ export const CodeMirrorEditor = memo(
75
+ ({
76
+ id,
77
+ doc,
78
+ debounceScroll = 100,
79
+ debounceChange = 150,
80
+ autoFocusOnDocumentChange = false,
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>();
96
+ const themeRef = useRef<Theme>();
97
+ const docRef = useRef<EditorDocument>();
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) => {
112
+ onChangeRef.current?.(update);
113
+ }, debounceChange);
114
+
115
+ const view = new EditorView({
116
+ parent: containerRef.current!,
117
+ dispatchTransactions(transactions) {
118
+ const previousSelection = view.state.selection;
119
+
120
+ view.update(transactions);
121
+
122
+ const newSelection = view.state.selection;
123
+
124
+ const selectionChanged =
125
+ newSelection !== previousSelection &&
126
+ (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
127
+
128
+ if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
129
+ onUpdate({
130
+ selection: view.state.selection,
131
+ content: view.state.doc.toString(),
132
+ });
133
+
134
+ editorStatesRef.current!.set(docRef.current.filePath, view.state);
135
+ }
136
+ },
137
+ });
 
138
 
139
+ viewRef.current = view;
 
 
 
 
140
 
141
+ return () => {
142
+ viewRef.current?.destroy();
143
+ viewRef.current = undefined;
144
+ };
145
+ }, []);
146
 
147
+ useEffect(() => {
148
+ if (!viewRef.current) {
149
+ return;
150
+ }
151
 
152
+ viewRef.current.dispatch({
153
+ effects: [reconfigureTheme(theme)],
154
+ });
155
+ }, [theme]);
156
 
157
+ useEffect(() => {
158
+ editorStatesRef.current = new Map<string, EditorState>();
159
+ }, [id]);
 
160
 
161
+ useEffect(() => {
162
+ const editorStates = editorStatesRef.current!;
163
+ const view = viewRef.current!;
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);
174
 
175
+ setNoDocument(view);
 
176
 
177
+ return;
178
+ }
 
179
 
180
+ if (doc.value instanceof Uint8Array) {
181
+ return;
182
+ }
183
 
184
+ if (doc.filePath === '') {
185
+ logger.warn('File path should not be empty');
186
+ }
187
 
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);
198
+ }
199
 
200
+ view.setState(state);
 
201
 
202
+ setEditorDocument(
203
+ view,
204
+ theme,
205
+ editable,
206
+ languageCompartment,
207
+ readOnlyCompartment,
208
+ editableCompartment,
209
+ autoFocusOnDocumentChange,
210
+ doc as TextEditorDocument,
211
+ );
212
+ }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
213
+
214
+ return (
215
+ <div className={classNames('relative h-full', className)}>
216
+ {isBinaryFile && <BinaryContent />}
217
+ <div className="h-full overflow-hidden" ref={containerRef} />
218
+ </div>
219
+ );
220
+ },
221
+ );
222
 
223
  export default CodeMirrorEditor;
224
 
 
298
  function setEditorDocument(
299
  view: EditorView,
300
  theme: Theme,
301
+ editable: boolean,
302
+ languageCompartment: Compartment,
303
+ readOnlyCompartment: Compartment,
304
+ editableCompartment: Compartment,
305
  autoFocus: boolean,
306
  doc: TextEditorDocument,
307
  ) {
 
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) => {
 
329
  }
330
 
331
  view.dispatch({
332
+ effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
333
  });
334
 
335
  requestAnimationFrame(() => {
packages/bolt/app/components/editor/codemirror/cm-theme.ts CHANGED
@@ -65,7 +65,7 @@ function getEditorTheme(settings: EditorSettings) {
65
  '&.cm-lineNumbers': {
66
  fontFamily: 'Roboto Mono, monospace',
67
  fontSize: '13px',
68
- minWidth: '28px',
69
  },
70
  '& .cm-activeLineGutter': {
71
  background: 'transparent',
 
65
  '&.cm-lineNumbers': {
66
  fontFamily: 'Roboto Mono, monospace',
67
  fontSize: '13px',
68
+ minWidth: '40px',
69
  },
70
  '& .cm-activeLineGutter': {
71
  background: 'transparent',
packages/bolt/app/components/workbench/EditorPanel.tsx CHANGED
@@ -1,21 +1,52 @@
1
  import { useStore } from '@nanostores/react';
 
2
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
 
3
  import { themeStore } from '../../lib/stores/theme';
4
- import CodeMirrorEditor from '../editor/codemirror/CodeMirrorEditor';
 
 
 
 
 
 
 
5
  import { FileTreePanel } from './FileTreePanel';
6
 
7
- export function EditorPanel() {
8
- const theme = useStore(themeStore);
9
-
10
- return (
11
- <PanelGroup direction="horizontal">
12
- <Panel defaultSize={30} minSize={20} collapsible={false}>
13
- <FileTreePanel />
14
- </Panel>
15
- <PanelResizeHandle />
16
- <Panel defaultSize={70} minSize={20}>
17
- <CodeMirrorEditor theme={theme} settings={{ tabSize: 2 }} />
18
- </Panel>
19
- </PanelGroup>
20
- );
21
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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';
6
+ import { renderLogger } from '../../utils/logger';
7
+ 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
+ );
51
+ },
52
+ );
packages/bolt/app/components/workbench/FileTree.tsx CHANGED
@@ -1,3 +1,281 @@
1
- export function FileTree() {
2
- return <div>File Tree</div>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
 
1
+ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+ import type { FileMap } from '../../lib/stores/files';
3
+ import { classNames } from '../../utils/classNames';
4
+ import { renderLogger } from '../../utils/logger';
5
+
6
+ const NODE_PADDING_LEFT = 12;
7
+ const DEFAULT_HIDDEN_FILES = [/\/node_modules\//];
8
+
9
+ interface Props {
10
+ files?: FileMap;
11
+ selectedFile?: string;
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
+
31
+ useEffect(() => {
32
+ setCollapsedFolders((prevCollapsed) => {
33
+ const newCollapsed = new Set<string>();
34
+
35
+ for (const folder of fileList) {
36
+ if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
37
+ newCollapsed.add(folder.fullPath);
38
+ }
39
+ }
40
+
41
+ return newCollapsed;
42
+ });
43
+ }, [fileList]);
44
+
45
+ const filteredFileList = useMemo(() => {
46
+ const list = [];
47
+
48
+ let lastDepth = Number.MAX_SAFE_INTEGER;
49
+
50
+ for (const fileOrFolder of fileList) {
51
+ const depth = fileOrFolder.depth;
52
+
53
+ // if the depth is equal we reached the end of the collaped group
54
+ if (lastDepth === depth) {
55
+ lastDepth = Number.MAX_SAFE_INTEGER;
56
+ }
57
+
58
+ // ignore collapsed folders
59
+ if (collapsedFolders.has(fileOrFolder.fullPath)) {
60
+ lastDepth = Math.min(lastDepth, depth);
61
+ }
62
+
63
+ // ignore files and folders below the last collapsed folder
64
+ if (lastDepth < depth) {
65
+ continue;
66
+ }
67
+
68
+ list.push(fileOrFolder);
69
+ }
70
+
71
+ return list;
72
+ }, [fileList, collapsedFolders]);
73
+
74
+ const toggleCollapseState = (fullPath: string) => {
75
+ setCollapsedFolders((prevSet) => {
76
+ const newSet = new Set(prevSet);
77
+
78
+ if (newSet.has(fullPath)) {
79
+ newSet.delete(fullPath);
80
+ } else {
81
+ newSet.add(fullPath);
82
+ }
83
+
84
+ return newSet;
85
+ });
86
+ };
87
+
88
+ return (
89
+ <div className={className}>
90
+ {filteredFileList.map((fileOrFolder) => {
91
+ switch (fileOrFolder.kind) {
92
+ case 'file': {
93
+ return (
94
+ <File
95
+ key={fileOrFolder.id}
96
+ selected={selectedFile === fileOrFolder.fullPath}
97
+ file={fileOrFolder}
98
+ onClick={() => {
99
+ onFileSelect?.(fileOrFolder.fullPath);
100
+ }}
101
+ />
102
+ );
103
+ }
104
+ case 'folder': {
105
+ return (
106
+ <Folder
107
+ key={fileOrFolder.id}
108
+ folder={fileOrFolder}
109
+ collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
110
+ onClick={() => {
111
+ toggleCollapseState(fileOrFolder.fullPath);
112
+ }}
113
+ />
114
+ );
115
+ }
116
+ default: {
117
+ return undefined;
118
+ }
119
+ }
120
+ })}
121
+ </div>
122
+ );
123
+ },
124
+ );
125
+
126
+ export default FileTree;
127
+
128
+ interface FolderProps {
129
+ folder: FolderNode;
130
+ collapsed: boolean;
131
+ onClick: () => void;
132
+ }
133
+
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,
141
+ 'i-ph:caret-down scale-98': !collapsed,
142
+ })}
143
+ onClick={onClick}
144
+ >
145
+ {name}
146
+ </NodeButton>
147
+ );
148
+ }
149
+
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', {
160
+ 'bg-white hover:bg-gray-50': !selected,
161
+ 'bg-gray-100': selected,
162
+ })}
163
+ depth={depth}
164
+ iconClasses={classNames('i-ph:file-duotone scale-98', {
165
+ 'text-gray-600': !selected,
166
+ })}
167
+ onClick={onClick}
168
+ >
169
+ {name}
170
+ </NodeButton>
171
+ );
172
+ }
173
+
174
+ interface ButtonProps {
175
+ depth: number;
176
+ iconClasses: string;
177
+ children: ReactNode;
178
+ className?: string;
179
+ onClick?: () => void;
180
+ }
181
+
182
+ function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
183
+ return (
184
+ <button
185
+ className={`flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded ${className ?? ''}`}
186
+ style={{ paddingLeft: `${12 + depth * NODE_PADDING_LEFT}px` }}
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
+ }
194
+
195
+ type Node = FileNode | FolderNode;
196
+
197
+ interface BaseNode {
198
+ id: number;
199
+ depth: number;
200
+ name: string;
201
+ fullPath: string;
202
+ }
203
+
204
+ interface FileNode extends BaseNode {
205
+ kind: 'file';
206
+ }
207
+
208
+ interface FolderNode extends BaseNode {
209
+ kind: 'folder';
210
+ }
211
+
212
+ function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array<string | RegExp>): Node[] {
213
+ const folderPaths = new Set<string>();
214
+ const fileList: Node[] = [];
215
+
216
+ let defaultDepth = 0;
217
+
218
+ if (rootFolder === '/') {
219
+ defaultDepth = 1;
220
+ fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
221
+ }
222
+
223
+ for (const [filePath, dirent] of Object.entries(files)) {
224
+ const segments = filePath.split('/').filter((segment) => segment);
225
+ const fileName = segments.at(-1);
226
+
227
+ if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
228
+ continue;
229
+ }
230
+
231
+ let currentPath = '';
232
+
233
+ let i = 0;
234
+ let depth = 0;
235
+
236
+ while (i < segments.length) {
237
+ const name = segments[i];
238
+ const fullPath = (currentPath += `/${name}`);
239
+
240
+ if (!fullPath.startsWith(rootFolder)) {
241
+ i++;
242
+ continue;
243
+ }
244
+
245
+ if (i === segments.length - 1 && dirent?.type === 'file') {
246
+ fileList.push({
247
+ kind: 'file',
248
+ id: fileList.length,
249
+ name,
250
+ fullPath,
251
+ depth: depth + defaultDepth,
252
+ });
253
+ } else if (!folderPaths.has(fullPath)) {
254
+ folderPaths.add(fullPath);
255
+
256
+ fileList.push({
257
+ kind: 'folder',
258
+ id: fileList.length,
259
+ name,
260
+ fullPath,
261
+ depth: depth + defaultDepth,
262
+ });
263
+ }
264
+
265
+ i++;
266
+ depth++;
267
+ }
268
+ }
269
+
270
+ return fileList;
271
+ }
272
+
273
+ function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
274
+ return hiddenFiles.some((pathOrRegex) => {
275
+ if (typeof pathOrRegex === 'string') {
276
+ return fileName === pathOrRegex;
277
+ }
278
+
279
+ return pathOrRegex.test(filePath);
280
+ });
281
  }
packages/bolt/app/components/workbench/FileTreePanel.tsx CHANGED
@@ -1,9 +1,21 @@
 
 
 
 
1
  import { FileTree } from './FileTree';
2
 
3
- export function FileTreePanel() {
 
 
 
 
 
 
 
 
4
  return (
5
- <div className="border-r h-full p-4">
6
- <FileTree />
7
  </div>
8
  );
9
- }
 
1
+ import { memo } from 'react';
2
+ import type { FileMap } from '../../lib/stores/files';
3
+ import { WORK_DIR } from '../../utils/constants';
4
+ import { renderLogger } from '../../utils/logger';
5
  import { FileTree } from './FileTree';
6
 
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
+ });
packages/bolt/app/components/workbench/Workbench.client.tsx CHANGED
@@ -1,14 +1,21 @@
1
  import { useStore } from '@nanostores/react';
2
  import { AnimatePresence, motion, type Variants } from 'framer-motion';
 
3
  import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
4
  import { workbenchStore } from '../../lib/stores/workbench';
5
  import { cubicEasingFn } from '../../utils/easings';
 
 
 
 
 
6
  import { IconButton } from '../ui/IconButton';
7
  import { EditorPanel } from './EditorPanel';
8
  import { Preview } from './Preview';
9
 
10
  interface WorkspaceProps {
11
  chatStarted?: boolean;
 
12
  }
13
 
14
  const workbenchVariants = {
@@ -28,8 +35,30 @@ const workbenchVariants = {
28
  },
29
  } satisfies Variants;
30
 
31
- export function Workbench({ chatStarted }: WorkspaceProps) {
 
 
32
  const showWorkbench = useStore(workbenchStore.showWorkbench);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  return (
35
  chatStarted && (
@@ -51,7 +80,15 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
51
  <div className="flex-1 overflow-hidden">
52
  <PanelGroup direction="vertical">
53
  <Panel defaultSize={50} minSize={20}>
54
- <EditorPanel />
 
 
 
 
 
 
 
 
55
  </Panel>
56
  <PanelResizeHandle />
57
  <Panel defaultSize={50} minSize={20}>
@@ -66,4 +103,4 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
66
  </AnimatePresence>
67
  )
68
  );
69
- }
 
1
  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';
14
  import { Preview } from './Preview';
15
 
16
  interface WorkspaceProps {
17
  chatStarted?: boolean;
18
+ isStreaming?: boolean;
19
  }
20
 
21
  const workbenchVariants = {
 
35
  },
36
  } satisfies Variants;
37
 
38
+ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
39
+ renderLogger.trace('Workbench');
40
+
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
+
47
+ useEffect(() => {
48
+ workbenchStore.setDocuments(files);
49
+ }, [files]);
50
+
51
+ const onEditorChange = useCallback<OnEditorChange>((update) => {
52
+ workbenchStore.setCurrentDocumentContent(update.content);
53
+ }, []);
54
+
55
+ const onEditorScroll = useCallback<OnEditorScroll>((position) => {
56
+ workbenchStore.setCurrentDocumentScrollPosition(position);
57
+ }, []);
58
+
59
+ const onFileSelect = useCallback((filePath: string | undefined) => {
60
+ workbenchStore.setSelectedFile(filePath);
61
+ }, []);
62
 
63
  return (
64
  chatStarted && (
 
80
  <div className="flex-1 overflow-hidden">
81
  <PanelGroup direction="vertical">
82
  <Panel defaultSize={50} minSize={20}>
83
+ <EditorPanel
84
+ editorDocument={currentDocument}
85
+ isStreaming={isStreaming}
86
+ selectedFile={selectedFile}
87
+ files={files}
88
+ onFileSelect={onFileSelect}
89
+ onEditorScroll={onEditorScroll}
90
+ onEditorChange={onEditorChange}
91
+ />
92
  </Panel>
93
  <PanelResizeHandle />
94
  <Panel defaultSize={50} minSize={20}>
 
103
  </AnimatePresence>
104
  )
105
  );
106
+ });
packages/bolt/app/lib/.server/llm/prompts.ts CHANGED
@@ -1,4 +1,6 @@
1
- export const getSystemPrompt = (cwd: string = '/home/project') => `
 
 
2
  You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
3
 
4
  <system_constraints>
 
1
+ import { WORK_DIR } from '../../../utils/constants';
2
+
3
+ export const getSystemPrompt = (cwd: string = WORK_DIR) => `
4
  You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
5
 
6
  <system_constraints>
packages/bolt/app/lib/hooks/useMessageParser.ts CHANGED
@@ -20,7 +20,7 @@ const messageParser = new StreamingMessageParser({
20
  workbenchStore.updateArtifact(data, { closed: true });
21
  },
22
  onActionOpen: (data) => {
23
- logger.debug('onActionOpen', data.action);
24
 
25
  // we only add shell actions when when the close tag got parsed because only then we have the content
26
  if (data.action.type !== 'shell') {
@@ -28,7 +28,7 @@ const messageParser = new StreamingMessageParser({
28
  }
29
  },
30
  onActionClose: (data) => {
31
- logger.debug('onActionClose', data.action);
32
 
33
  if (data.action.type === 'shell') {
34
  workbenchStore.addAction(data);
 
20
  workbenchStore.updateArtifact(data, { closed: true });
21
  },
22
  onActionOpen: (data) => {
23
+ logger.trace('onActionOpen', data.action);
24
 
25
  // we only add shell actions when when the close tag got parsed because only then we have the content
26
  if (data.action.type !== 'shell') {
 
28
  }
29
  },
30
  onActionClose: (data) => {
31
+ logger.trace('onActionClose', data.action);
32
 
33
  if (data.action.type === 'shell') {
34
  workbenchStore.addAction(data);
packages/bolt/app/lib/stores/editor.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {
16
+ return undefined;
17
+ }
18
+
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) {
31
+ const previousDocuments = this.documents.value;
32
+
33
+ this.documents.set(
34
+ Object.fromEntries<EditorDocument>(
35
+ Object.entries(files)
36
+ .map(([filePath, dirent]) => {
37
+ if (dirent === undefined || dirent.type === 'folder') {
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
+ })
51
+ .filter(Boolean) as Array<[string, EditorDocument]>,
52
+ ),
53
+ );
54
+ }
55
+
56
+ setSelectedFile(filePath: string | undefined) {
57
+ this.selectedFile.set(filePath);
58
+ }
59
+
60
+ updateScrollPosition(filePath: string, position: ScrollPosition) {
61
+ const documents = this.documents.get();
62
+ const documentState = documents[filePath];
63
+
64
+ if (!documentState) {
65
+ return;
66
+ }
67
+
68
+ this.documents.setKey(filePath, {
69
+ ...documentState,
70
+ scroll: position,
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
+ }
packages/bolt/app/lib/stores/files.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
17
+ type Dirent = File | Folder;
18
+
19
+ export type FileMap = Record<string, Dirent | undefined>;
20
+
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
+
35
+ webcontainer.watchPaths(
36
+ { include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
37
+ bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
38
+ );
39
+ }
40
+
41
+ #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
42
+ const watchEvents = events.flat(2);
43
+
44
+ for (const { type, path, buffer } of watchEvents) {
45
+ // remove any trailing slashes
46
+ const sanitizedPath = path.replace(/\/+$/g, '');
47
+
48
+ switch (type) {
49
+ case 'add_dir': {
50
+ // we intentionally add a trailing slash so we can distinguish files from folders in the file tree
51
+ this.files.setKey(sanitizedPath, { type: 'folder' });
52
+ break;
53
+ }
54
+ case 'remove_dir': {
55
+ this.files.setKey(sanitizedPath, undefined);
56
+
57
+ for (const [direntPath] of Object.entries(this.files)) {
58
+ if (direntPath.startsWith(sanitizedPath)) {
59
+ this.files.setKey(direntPath, undefined);
60
+ }
61
+ }
62
+
63
+ break;
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
+ }
74
+ case 'update_directory': {
75
+ // we don't care about these events
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ #decodeFileContent(buffer?: Uint8Array) {
83
+ if (!buffer || buffer.byteLength === 0) {
84
+ return '';
85
+ }
86
+
87
+ try {
88
+ return textDecoder.decode(buffer);
89
+ } catch (error) {
90
+ console.log(error);
91
+ return '';
92
+ }
93
+ }
94
+ }
packages/bolt/app/lib/stores/workbench.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { atom, map, type MapStore, type WritableAtom } from 'nanostores';
 
2
  import type { BoltAction } from '../../types/actions';
3
  import { unreachable } from '../../utils/unreachable';
4
  import { ActionRunner } from '../runtime/action-runner';
5
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
6
  import { webcontainer } from '../webcontainer';
7
  import { chatStore } from './chat';
 
 
8
  import { PreviewsStore } from './previews';
9
 
10
  const MIN_SPINNER_TIME = 200;
@@ -41,6 +44,8 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
41
  export class WorkbenchStore {
42
  #actionRunner = new ActionRunner(webcontainer);
43
  #previewsStore = new PreviewsStore(webcontainer);
 
 
44
 
45
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
46
 
@@ -50,10 +55,52 @@ export class WorkbenchStore {
50
  return this.#previewsStore.previews;
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  setShowWorkbench(show: boolean) {
54
  this.showWorkbench.set(show);
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  abortAllActions() {
58
  for (const [, artifact] of Object.entries(this.artifacts.get())) {
59
  for (const [, action] of Object.entries(artifact.actions.get())) {
 
1
+ import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
2
+ import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
3
  import type { BoltAction } from '../../types/actions';
4
  import { unreachable } from '../../utils/unreachable';
5
  import { ActionRunner } from '../runtime/action-runner';
6
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
7
  import { webcontainer } from '../webcontainer';
8
  import { chatStore } from './chat';
9
+ import { EditorStore } from './editor';
10
+ import { FilesStore, type FileMap } from './files';
11
  import { PreviewsStore } from './previews';
12
 
13
  const MIN_SPINNER_TIME = 200;
 
44
  export class WorkbenchStore {
45
  #actionRunner = new ActionRunner(webcontainer);
46
  #previewsStore = new PreviewsStore(webcontainer);
47
+ #filesStore = new FilesStore(webcontainer);
48
+ #editorStore = new EditorStore(webcontainer);
49
 
50
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
51
 
 
55
  return this.#previewsStore.previews;
56
  }
57
 
58
+ get files() {
59
+ return this.#filesStore.files;
60
+ }
61
+
62
+ get currentDocument(): ReadableAtom<EditorDocument | undefined> {
63
+ return this.#editorStore.currentDocument;
64
+ }
65
+
66
+ get selectedFile(): ReadableAtom<string | undefined> {
67
+ return this.#editorStore.selectedFile;
68
+ }
69
+
70
+ setDocuments(files: FileMap) {
71
+ this.#editorStore.setDocuments(files);
72
+ }
73
+
74
  setShowWorkbench(show: boolean) {
75
  this.showWorkbench.set(show);
76
  }
77
 
78
+ setCurrentDocumentContent(newContent: string) {
79
+ const filePath = this.currentDocument.get()?.filePath;
80
+
81
+ if (!filePath) {
82
+ return;
83
+ }
84
+
85
+ this.#editorStore.updateFile(filePath, newContent);
86
+ }
87
+
88
+ setCurrentDocumentScrollPosition(position: ScrollPosition) {
89
+ const editorDocument = this.currentDocument.get();
90
+
91
+ if (!editorDocument) {
92
+ return;
93
+ }
94
+
95
+ const { filePath } = editorDocument;
96
+
97
+ this.#editorStore.updateScrollPosition(filePath, position);
98
+ }
99
+
100
+ setSelectedFile(filePath: string | undefined) {
101
+ this.#editorStore.setSelectedFile(filePath);
102
+ }
103
+
104
  abortAllActions() {
105
  for (const [, artifact] of Object.entries(this.artifacts.get())) {
106
  for (const [, action] of Object.entries(artifact.actions.get())) {
packages/bolt/app/lib/webcontainer/index.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { WebContainer } from '@webcontainer/api';
 
2
 
3
  interface WebContainerContext {
4
  loaded: boolean;
@@ -20,7 +21,7 @@ if (!import.meta.env.SSR) {
20
  webcontainer =
21
  import.meta.hot?.data.webcontainer ??
22
  Promise.resolve()
23
- .then(() => WebContainer.boot({ workdirName: 'project' }))
24
  .then((webcontainer) => {
25
  webcontainerContext.loaded = true;
26
  return webcontainer;
 
1
  import { WebContainer } from '@webcontainer/api';
2
+ import { WORK_DIR_NAME } from '../../utils/constants';
3
 
4
  interface WebContainerContext {
5
  loaded: boolean;
 
21
  webcontainer =
22
  import.meta.hot?.data.webcontainer ??
23
  Promise.resolve()
24
+ .then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME }))
25
  .then((webcontainer) => {
26
  webcontainerContext.loaded = true;
27
  return webcontainer;
packages/bolt/app/utils/buffer.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function bufferWatchEvents<T extends unknown[]>(timeInMs: number, cb: (events: T[]) => unknown) {
2
+ let timeoutId: number | undefined;
3
+ let events: T[] = [];
4
+
5
+ // keep track of the processing of the previous batch so we can wait for it
6
+ let processing: Promise<unknown> = Promise.resolve();
7
+
8
+ const scheduleBufferTick = () => {
9
+ timeoutId = self.setTimeout(async () => {
10
+ // we wait until the previous batch is entirely processed so events are processed in order
11
+ await processing;
12
+
13
+ if (events.length > 0) {
14
+ processing = Promise.resolve(cb(events));
15
+ }
16
+
17
+ timeoutId = undefined;
18
+ events = [];
19
+ }, timeInMs);
20
+ };
21
+
22
+ return (...args: T) => {
23
+ events.push(args);
24
+
25
+ if (!timeoutId) {
26
+ scheduleBufferTick();
27
+ }
28
+ };
29
+ }
packages/bolt/app/utils/constants.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export const WORK_DIR_NAME = 'project';
2
+ export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
packages/bolt/app/utils/logger.ts CHANGED
@@ -85,3 +85,5 @@ function getColorForLevel(level: DebugLevel): string {
85
  }
86
  }
87
  }
 
 
 
85
  }
86
  }
87
  }
88
+
89
+ export const renderLogger = createScopedLogger('Render');
packages/bolt/app/utils/mobile.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export function isMobile() {
2
+ // we use sm: as the breakpoint for mobile. It's currently set to 640px
3
+ return globalThis.innerWidth < 640;
4
+ }