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 +1 -1
- packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx +145 -122
- packages/bolt/app/components/editor/codemirror/cm-theme.ts +1 -1
- packages/bolt/app/components/workbench/EditorPanel.tsx +46 -15
- packages/bolt/app/components/workbench/FileTree.tsx +280 -2
- packages/bolt/app/components/workbench/FileTreePanel.tsx +16 -4
- packages/bolt/app/components/workbench/Workbench.client.tsx +40 -3
- packages/bolt/app/lib/.server/llm/prompts.ts +3 -1
- packages/bolt/app/lib/hooks/useMessageParser.ts +2 -2
- packages/bolt/app/lib/stores/editor.ts +96 -0
- packages/bolt/app/lib/stores/files.ts +94 -0
- packages/bolt/app/lib/stores/workbench.ts +48 -1
- packages/bolt/app/lib/webcontainer/index.ts +2 -1
- packages/bolt/app/utils/buffer.ts +29 -0
- packages/bolt/app/utils/constants.ts +2 -0
- packages/bolt/app/utils/logger.ts +2 -0
- packages/bolt/app/utils/mobile.ts +4 -0
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 |
-
|
|
|
|
| 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
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
viewRef.current = view;
|
| 137 |
|
| 138 |
-
|
| 139 |
-
viewRef.current?.destroy();
|
| 140 |
-
viewRef.current = undefined;
|
| 141 |
-
};
|
| 142 |
-
}, []);
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
const theme = themeRef.current!;
|
| 162 |
|
| 163 |
-
|
| 164 |
-
const
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
|
| 170 |
-
|
| 171 |
-
}
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
}
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
| 184 |
-
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
|
| 185 |
-
language.of([]),
|
| 186 |
-
readOnly.of([EditorState.readOnly.of(doc.loading)]),
|
| 187 |
-
]);
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
|
|
|
|
| 193 |
|
| 194 |
-
|
| 195 |
-
}, [doc?.value, doc?.filePath, doc?.loading, autoFocusOnDocumentChange]);
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
|
| 284 |
-
|
|
|
|
|
|
|
| 285 |
autoFocus: boolean,
|
| 286 |
doc: TextEditorDocument,
|
| 287 |
) {
|
|
@@ -297,7 +317,10 @@ function setEditorDocument(
|
|
| 297 |
}
|
| 298 |
|
| 299 |
view.dispatch({
|
| 300 |
-
effects: [
|
|
|
|
|
|
|
|
|
|
| 301 |
});
|
| 302 |
|
| 303 |
getLanguage(doc.filePath).then((languageSupport) => {
|
|
@@ -306,7 +329,7 @@ function setEditorDocument(
|
|
| 306 |
}
|
| 307 |
|
| 308 |
view.dispatch({
|
| 309 |
-
effects: [
|
| 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: '
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import { FileTreePanel } from './FileTreePanel';
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
return (
|
| 5 |
-
<div className="border-r h-full
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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:
|
| 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 |
+
}
|