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