From d45b95dd11d173458cb02c26b23a8292e0e8d133 Mon Sep 17 00:00:00 2001 From: Dominic Elm Date: Wed, 24 Jul 2024 16:10:39 +0200 Subject: [PATCH] feat(workbench): sync file changes back to webcontainer (#5) --- .../bolt/app/components/chat/Chat.client.tsx | 67 ++++++++------ .../editor/codemirror/CodeMirrorEditor.tsx | 87 +++++++++++------- .../components/editor/codemirror/cm-theme.ts | 3 + .../app/components/ui/PanelHeaderButton.tsx | 36 ++++++++ .../app/components/workbench/EditorPanel.tsx | 92 ++++++++++++++++--- .../app/components/workbench/FileTree.tsx | 23 +++-- .../components/workbench/FileTreePanel.tsx | 13 ++- .../components/workbench/Workbench.client.tsx | 23 ++++- .../bolt/app/lib/runtime/action-runner.ts | 4 +- packages/bolt/app/lib/stores/editor.ts | 43 +++++---- packages/bolt/app/lib/stores/files.ts | 65 ++++++++++++- packages/bolt/app/lib/stores/workbench.ts | 87 ++++++++++++++++-- packages/bolt/app/root.tsx | 2 + packages/bolt/app/styles/animations.scss | 36 ++++++++ packages/bolt/app/styles/index.scss | 1 + packages/bolt/app/utils/logger.ts | 16 +++- packages/bolt/package.json | 1 + pnpm-lock.yaml | 21 +++++ 18 files changed, 491 insertions(+), 129 deletions(-) create mode 100644 packages/bolt/app/components/ui/PanelHeaderButton.tsx create mode 100644 packages/bolt/app/styles/animations.scss diff --git a/packages/bolt/app/components/chat/Chat.client.tsx b/packages/bolt/app/components/chat/Chat.client.tsx index 7aada17..161e3e2 100644 --- a/packages/bolt/app/components/chat/Chat.client.tsx +++ b/packages/bolt/app/components/chat/Chat.client.tsx @@ -1,6 +1,7 @@ import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { useEffect, useRef, useState } from 'react'; +import { ToastContainer, cssTransition } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks'; import { chatStore } from '../../lib/stores/chat'; import { workbenchStore } from '../../lib/stores/workbench'; @@ -8,6 +9,11 @@ import { cubicEasingFn } from '../../utils/easings'; import { createScopedLogger } from '../../utils/logger'; import { BaseChat } from './BaseChat'; +const toastAnimation = cssTransition({ + enter: 'animated fadeInRight', + exit: 'animated fadeOutRight', +}); + const logger = createScopedLogger('Chat'); export function Chat() { @@ -90,35 +96,38 @@ export function Chat() { const [messageRef, scrollRef] = useSnapScroll(); return ( - { - if (message.role === 'user') { - return message; - } + <> + { + if (message.role === 'user') { + return message; + } - return { - ...message, - content: parsedMessages[i] || '', - }; - })} - enhancePrompt={() => { - enhancePrompt(input, (input) => { - setInput(input); - scrollTextArea(); - }); - }} - > + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + enhancePrompt={() => { + enhancePrompt(input, (input) => { + setInput(input); + scrollTextArea(); + }); + }} + /> + + ); } diff --git a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx index d94689a..5b58bf2 100644 --- a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -2,7 +2,7 @@ import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/aut import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language'; import { searchKeymap } from '@codemirror/search'; -import { Compartment, EditorSelection, EditorState, type Extension } from '@codemirror/state'; +import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'; import { EditorView, drawSelection, @@ -27,8 +27,6 @@ const logger = createScopedLogger('CodeMirrorEditor'); export interface EditorDocument { value: string | Uint8Array; - previousValue?: string | Uint8Array; - commitPending: boolean; filePath: string; scroll?: ScrollPosition; } @@ -54,6 +52,7 @@ export interface EditorUpdate { export type OnChangeCallback = (update: EditorUpdate) => void; export type OnScrollCallback = (position: ScrollPosition) => void; +export type OnSaveCallback = () => void; interface Props { theme: Theme; @@ -65,12 +64,30 @@ interface Props { autoFocusOnDocumentChange?: boolean; onChange?: OnChangeCallback; onScroll?: OnScrollCallback; + onSave?: OnSaveCallback; className?: string; settings?: EditorSettings; } type EditorStates = Map; +const editableStateEffect = StateEffect.define(); + +const editableStateField = StateField.define({ + create() { + return true; + }, + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(editableStateEffect)) { + return effect.value; + } + } + + return value; + }, +}); + export const CodeMirrorEditor = memo( ({ id, @@ -81,15 +98,14 @@ export const CodeMirrorEditor = memo( editable = true, onScroll, onChange, + onSave, theme, settings, className = '', }: Props) => { - renderLogger.debug('CodeMirrorEditor'); + renderLogger.trace('CodeMirrorEditor'); const [languageCompartment] = useState(new Compartment()); - const [readOnlyCompartment] = useState(new Compartment()); - const [editableCompartment] = useState(new Compartment()); const containerRef = useRef(null); const viewRef = useRef(); @@ -98,14 +114,21 @@ export const CodeMirrorEditor = memo( const editorStatesRef = useRef(); const onScrollRef = useRef(onScroll); const onChangeRef = useRef(onChange); + const onSaveRef = useRef(onSave); const isBinaryFile = doc?.value instanceof Uint8Array; - onScrollRef.current = onScroll; - onChangeRef.current = onChange; - - docRef.current = doc; - themeRef.current = theme; + /** + * This effect is used to avoid side effects directly in the render function + * and instead the refs are updated after each render. + */ + useEffect(() => { + onScrollRef.current = onScroll; + onChangeRef.current = onChange; + onSaveRef.current = onSave; + docRef.current = doc; + themeRef.current = theme; + }); useEffect(() => { const onUpdate = debounce((update: EditorUpdate) => { @@ -164,10 +187,8 @@ export const CodeMirrorEditor = memo( const theme = themeRef.current!; if (!doc) { - const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [ + const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), - readOnlyCompartment.of([]), - editableCompartment.of([]), ]); view.setState(state); @@ -188,10 +209,8 @@ export const CodeMirrorEditor = memo( let state = editorStates.get(doc.filePath); if (!state) { - state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [ + state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), - readOnlyCompartment.of([EditorState.readOnly.of(!editable)]), - editableCompartment.of([EditorView.editable.of(editable)]), ]); editorStates.set(doc.filePath, state); @@ -204,8 +223,6 @@ export const CodeMirrorEditor = memo( theme, editable, languageCompartment, - readOnlyCompartment, - editableCompartment, autoFocusOnDocumentChange, doc as TextEditorDocument, ); @@ -230,20 +247,20 @@ function newEditorState( settings: EditorSettings | undefined, onScrollRef: MutableRefObject, debounceScroll: number, + onFileSaveRef: MutableRefObject, extensions: Extension[], ) { return EditorState.create({ doc: content, extensions: [ EditorView.domEventHandlers({ - scroll: debounce((_event, view) => { + scroll: debounce((event, view) => { + if (event.target !== view.scrollDOM) { + return; + } + onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop }); }, debounceScroll), - keydown: (event) => { - if (event.code === 'KeyS' && (event.ctrlKey || event.metaKey)) { - event.preventDefault(); - } - }, }), getTheme(theme, settings), history(), @@ -252,6 +269,14 @@ function newEditorState( ...historyKeymap, ...searchKeymap, { key: 'Tab', run: acceptCompletion }, + { + key: 'Mod-s', + preventDefault: true, + run: () => { + onFileSaveRef.current?.(); + return true; + }, + }, indentKeyBinding, ]), indentUnit.of('\t'), @@ -266,6 +291,9 @@ function newEditorState( bracketMatching(), EditorState.tabSize.of(settings?.tabSize ?? 2), indentOnInput(), + editableStateField, + EditorState.readOnly.from(editableStateField, (editable) => !editable), + EditorView.editable.from(editableStateField, (editable) => editable), highlightActiveLineGutter(), highlightActiveLine(), foldGutter({ @@ -300,8 +328,6 @@ function setEditorDocument( theme: Theme, editable: boolean, languageCompartment: Compartment, - readOnlyCompartment: Compartment, - editableCompartment: Compartment, autoFocus: boolean, doc: TextEditorDocument, ) { @@ -317,10 +343,7 @@ function setEditorDocument( } view.dispatch({ - effects: [ - readOnlyCompartment.reconfigure([EditorState.readOnly.of(!editable)]), - editableCompartment.reconfigure([EditorView.editable.of(editable)]), - ], + effects: [editableStateEffect.of(editable)], }); getLanguage(doc.filePath).then((languageSupport) => { @@ -340,7 +363,7 @@ function setEditorDocument( const needsScrolling = currentLeft !== newLeft || currentTop !== newTop; - if (autoFocus) { + if (autoFocus && editable) { if (needsScrolling) { // we have to wait until the scroll position was changed before we can set the focus view.scrollDOM.addEventListener( diff --git a/packages/bolt/app/components/editor/codemirror/cm-theme.ts b/packages/bolt/app/components/editor/codemirror/cm-theme.ts index ddf1a5b..5d097c0 100644 --- a/packages/bolt/app/components/editor/codemirror/cm-theme.ts +++ b/packages/bolt/app/components/editor/codemirror/cm-theme.ts @@ -38,6 +38,9 @@ function getEditorTheme(settings: EditorSettings) { }, '.cm-scroller': { lineHeight: '1.5', + '&:focus-visible': { + outline: 'none', + }, }, '.cm-line': { padding: '0 0 0 4px', diff --git a/packages/bolt/app/components/ui/PanelHeaderButton.tsx b/packages/bolt/app/components/ui/PanelHeaderButton.tsx new file mode 100644 index 0000000..bd0ba76 --- /dev/null +++ b/packages/bolt/app/components/ui/PanelHeaderButton.tsx @@ -0,0 +1,36 @@ +import { memo } from 'react'; +import { classNames } from '../../utils/classNames'; + +interface PanelHeaderButtonProps { + className?: string; + disabledClassName?: string; + disabled?: boolean; + children: string | JSX.Element | Array; + onClick?: (event: React.MouseEvent) => void; +} + +export const PanelHeaderButton = memo( + ({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => { + return ( + + ); + }, +); diff --git a/packages/bolt/app/components/workbench/EditorPanel.tsx b/packages/bolt/app/components/workbench/EditorPanel.tsx index 4c21ea8..dc4c570 100644 --- a/packages/bolt/app/components/workbench/EditorPanel.tsx +++ b/packages/bolt/app/components/workbench/EditorPanel.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import type { FileMap } from '../../lib/stores/files'; import { themeStore } from '../../lib/stores/theme'; @@ -8,43 +8,107 @@ import { isMobile } from '../../utils/mobile'; import { CodeMirrorEditor, type EditorDocument, + type EditorSettings, type OnChangeCallback as OnEditorChange, + type OnSaveCallback as OnEditorSave, type OnScrollCallback as OnEditorScroll, } from '../editor/codemirror/CodeMirrorEditor'; +import { PanelHeaderButton } from '../ui/PanelHeaderButton'; import { FileTreePanel } from './FileTreePanel'; interface EditorPanelProps { files?: FileMap; + unsavedFiles?: Set; editorDocument?: EditorDocument; selectedFile?: string | undefined; isStreaming?: boolean; onEditorChange?: OnEditorChange; onEditorScroll?: OnEditorScroll; onFileSelect?: (value?: string) => void; + onFileSave?: OnEditorSave; + onFileReset?: () => void; } +const editorSettings: EditorSettings = { tabSize: 2 }; + export const EditorPanel = memo( - ({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => { + ({ + files, + unsavedFiles, + editorDocument, + selectedFile, + isStreaming, + onFileSelect, + onEditorChange, + onEditorScroll, + onFileSave, + onFileReset, + }: EditorPanelProps) => { renderLogger.trace('EditorPanel'); const theme = useStore(themeStore); + const activeFile = useMemo(() => { + if (!editorDocument) { + return ''; + } + + return editorDocument.filePath.split('/').at(-1); + }, [editorDocument]); + + const activeFileUnsaved = useMemo(() => { + return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath); + }, [editorDocument, unsavedFiles]); + return ( - - + +
+
+
+ Files +
+ +
- - + +
+ {activeFile && ( +
+ {activeFile} {isStreaming && (read-only)} + {activeFileUnsaved && ( +
+ +
+ Save + + +
+ Reset + +
+ )} +
+ )} +
+
+ +
); diff --git a/packages/bolt/app/components/workbench/FileTree.tsx b/packages/bolt/app/components/workbench/FileTree.tsx index d69d483..90bbdde 100644 --- a/packages/bolt/app/components/workbench/FileTree.tsx +++ b/packages/bolt/app/components/workbench/FileTree.tsx @@ -12,19 +12,19 @@ interface Props { onFileSelect?: (filePath: string) => void; rootFolder?: string; hiddenFiles?: Array; + unsavedFiles?: Set; className?: string; } export const FileTree = memo( - ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => { + ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => { renderLogger.trace('FileTree'); const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); - const fileList = useMemo( - () => buildFileList(files, rootFolder, computedHiddenFiles), - [files, rootFolder, computedHiddenFiles], - ); + const fileList = useMemo(() => { + return buildFileList(files, rootFolder, computedHiddenFiles); + }, [files, rootFolder, computedHiddenFiles]); const [collapsedFolders, setCollapsedFolders] = useState(() => new Set()); @@ -95,6 +95,7 @@ export const FileTree = memo( key={fileOrFolder.id} selected={selectedFile === fileOrFolder.fullPath} file={fileOrFolder} + unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)} onClick={() => { onFileSelect?.(fileOrFolder.fullPath); }} @@ -134,7 +135,7 @@ interface FolderProps { function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { return ( void; } -function File({ file: { depth, name }, onClick, selected }: FileProps) { +function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) { return ( - {name} +
+
{name}
+ {unsavedChanges && } +
); } @@ -187,7 +192,7 @@ function NodeButton({ depth, iconClasses, onClick, className, children }: Button onClick={() => onClick?.()} >
- {children} +
{children}
); } diff --git a/packages/bolt/app/components/workbench/FileTreePanel.tsx b/packages/bolt/app/components/workbench/FileTreePanel.tsx index 0a3bce7..65c73b2 100644 --- a/packages/bolt/app/components/workbench/FileTreePanel.tsx +++ b/packages/bolt/app/components/workbench/FileTreePanel.tsx @@ -7,15 +7,22 @@ import { FileTree } from './FileTree'; interface FileTreePanelProps { files?: FileMap; selectedFile?: string; + unsavedFiles?: Set; onFileSelect?: (value?: string) => void; } -export const FileTreePanel = memo(({ files, selectedFile, onFileSelect }: FileTreePanelProps) => { +export const FileTreePanel = memo(({ files, unsavedFiles, selectedFile, onFileSelect }: FileTreePanelProps) => { renderLogger.trace('FileTreePanel'); return ( -
- +
+
); }); diff --git a/packages/bolt/app/components/workbench/Workbench.client.tsx b/packages/bolt/app/components/workbench/Workbench.client.tsx index d299c44..f5236ce 100644 --- a/packages/bolt/app/components/workbench/Workbench.client.tsx +++ b/packages/bolt/app/components/workbench/Workbench.client.tsx @@ -2,12 +2,13 @@ import { useStore } from '@nanostores/react'; import { AnimatePresence, motion, type Variants } from 'framer-motion'; import { memo, useCallback, useEffect } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { toast } from 'react-toastify'; import { workbenchStore } from '../../lib/stores/workbench'; import { cubicEasingFn } from '../../utils/easings'; import { renderLogger } from '../../utils/logger'; -import type { - OnChangeCallback as OnEditorChange, - OnScrollCallback as OnEditorScroll, +import { + type OnChangeCallback as OnEditorChange, + type OnScrollCallback as OnEditorScroll, } from '../editor/codemirror/CodeMirrorEditor'; import { IconButton } from '../ui/IconButton'; import { EditorPanel } from './EditorPanel'; @@ -41,6 +42,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => const showWorkbench = useStore(workbenchStore.showWorkbench); const selectedFile = useStore(workbenchStore.selectedFile); const currentDocument = useStore(workbenchStore.currentDocument); + const unsavedFiles = useStore(workbenchStore.unsavedFiles); const files = useStore(workbenchStore.files); @@ -60,6 +62,16 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => workbenchStore.setSelectedFile(filePath); }, []); + const onFileSave = useCallback(() => { + workbenchStore.saveCurrentDocument().catch(() => { + toast.error('Failed to update file content'); + }); + }, []); + + const onFileReset = useCallback(() => { + workbenchStore.resetCurrentDocument(); + }, []); + return ( chatStarted && ( @@ -70,7 +82,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
{ workbenchStore.showWorkbench.set(false); @@ -85,9 +97,12 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => isStreaming={isStreaming} selectedFile={selectedFile} files={files} + unsavedFiles={unsavedFiles} onFileSelect={onFileSelect} onEditorScroll={onEditorScroll} onEditorChange={onEditorChange} + onFileSave={onFileSave} + onFileReset={onFileReset} /> diff --git a/packages/bolt/app/lib/runtime/action-runner.ts b/packages/bolt/app/lib/runtime/action-runner.ts index 2b92b62..94536ee 100644 --- a/packages/bolt/app/lib/runtime/action-runner.ts +++ b/packages/bolt/app/lib/runtime/action-runner.ts @@ -167,7 +167,7 @@ export class ActionRunner { await webcontainer.fs.mkdir(folder, { recursive: true }); logger.debug('Created folder', folder); } catch (error) { - logger.error('Failed to create folder\n', error); + logger.error('Failed to create folder\n\n', error); } } @@ -175,7 +175,7 @@ export class ActionRunner { await webcontainer.fs.writeFile(action.filePath, action.content); logger.debug(`File written ${action.filePath}`); } catch (error) { - logger.error('Failed to write file\n', error); + logger.error('Failed to write file\n\n', error); } } diff --git a/packages/bolt/app/lib/stores/editor.ts b/packages/bolt/app/lib/stores/editor.ts index 11b6f88..8a19f30 100644 --- a/packages/bolt/app/lib/stores/editor.ts +++ b/packages/bolt/app/lib/stores/editor.ts @@ -1,15 +1,16 @@ -import type { WebContainer } from '@webcontainer/api'; -import { atom, computed, map } from 'nanostores'; +import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores'; import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor'; -import type { FileMap } from './files'; +import type { FileMap, FilesStore } from './files'; export type EditorDocuments = Record; -export class EditorStore { - #webcontainer: Promise; +type SelectedFile = WritableAtom; - selectedFile = atom(); - documents = map({}); +export class EditorStore { + #filesStore: FilesStore; + + selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom(); + documents: MapStore = import.meta.hot?.data.documents ?? map({}); currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => { if (!selectedFile) { @@ -19,12 +20,13 @@ export class EditorStore { return documents[selectedFile]; }); - constructor(webcontainerPromise: Promise) { - this.#webcontainer = webcontainerPromise; - } + constructor(filesStore: FilesStore) { + this.#filesStore = filesStore; - commitFileContent(_filePath: string) { - // TODO + if (import.meta.hot) { + import.meta.hot.data.documents = this.documents; + import.meta.hot.data.selectedFile = this.selectedFile; + } } setDocuments(files: FileMap) { @@ -38,13 +40,14 @@ export class EditorStore { return undefined; } + const previousDocument = previousDocuments?.[filePath]; + return [ filePath, { value: dirent.content, - commitPending: false, filePath, - scroll: previousDocuments?.[filePath]?.scroll, + scroll: previousDocument?.scroll, }, ] as [string, EditorDocument]; }) @@ -71,26 +74,22 @@ export class EditorStore { }); } - updateFile(filePath: string, content: string): boolean { + updateFile(filePath: string, newContent: string | Uint8Array) { const documents = this.documents.get(); const documentState = documents[filePath]; if (!documentState) { - return false; + return; } const currentContent = documentState.value; - const contentChanged = currentContent !== content; + const contentChanged = currentContent !== newContent; if (contentChanged) { this.documents.setKey(filePath, { ...documentState, - previousValue: !documentState.commitPending ? currentContent : documentState.previousValue, - commitPending: documentState.previousValue ? documentState.previousValue !== content : true, - value: content, + value: newContent, }); } - - return contentChanged; } } diff --git a/packages/bolt/app/lib/stores/files.ts b/packages/bolt/app/lib/stores/files.ts index 715a312..caf507c 100644 --- a/packages/bolt/app/lib/stores/files.ts +++ b/packages/bolt/app/lib/stores/files.ts @@ -1,16 +1,20 @@ import type { PathWatcherEvent, WebContainer } from '@webcontainer/api'; -import { map } from 'nanostores'; +import { map, type MapStore } from 'nanostores'; +import * as nodePath from 'node:path'; import { bufferWatchEvents } from '../../utils/buffer'; import { WORK_DIR } from '../../utils/constants'; +import { createScopedLogger } from '../../utils/logger'; + +const logger = createScopedLogger('FilesStore'); const textDecoder = new TextDecoder('utf8', { fatal: true }); -interface File { +export interface File { type: 'file'; - content: string; + content: string | Uint8Array; } -interface Folder { +export interface Folder { type: 'folder'; } @@ -21,14 +25,59 @@ export type FileMap = Record; export class FilesStore { #webcontainer: Promise; - files = map({}); + /** + * Tracks the number of files without folders. + */ + #size = 0; + + files: MapStore = import.meta.hot?.data.files ?? map({}); + + get filesCount() { + return this.#size; + } constructor(webcontainerPromise: Promise) { this.#webcontainer = webcontainerPromise; + if (import.meta.hot) { + import.meta.hot.data.files = this.files; + } + this.#init(); } + getFile(filePath: string) { + const dirent = this.files.get()[filePath]; + + if (dirent?.type !== 'file') { + return undefined; + } + + return dirent; + } + + async saveFile(filePath: string, content: string | Uint8Array) { + const webcontainer = await this.#webcontainer; + + try { + const relativePath = nodePath.relative(webcontainer.workdir, filePath); + + if (!relativePath) { + throw new Error(`EINVAL: invalid file path, write '${relativePath}'`); + } + + await webcontainer.fs.writeFile(relativePath, content); + + this.files.setKey(filePath, { type: 'file', content }); + + logger.info('File updated'); + } catch (error) { + logger.error('Failed to update file content\n\n', error); + + throw error; + } + } + async #init() { const webcontainer = await this.#webcontainer; @@ -64,10 +113,16 @@ export class FilesStore { } case 'add_file': case 'change': { + if (type === 'add_file') { + this.#size++; + } + this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) }); + break; } case 'remove_file': { + this.#size--; this.files.setKey(sanitizedPath, undefined); break; } diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index 5cebd73..cbe41dc 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -21,11 +21,20 @@ type Artifacts = MapStore>; export class WorkbenchStore { #previewsStore = new PreviewsStore(webcontainer); #filesStore = new FilesStore(webcontainer); - #editorStore = new EditorStore(webcontainer); + #editorStore = new EditorStore(this.#filesStore); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); - showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); + unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); + modifiedFiles = new Set(); + + constructor() { + if (import.meta.hot) { + import.meta.hot.data.artifacts = this.artifacts; + import.meta.hot.data.unsavedFiles = this.unsavedFiles; + import.meta.hot.data.showWorkbench = this.showWorkbench; + } + } get previews() { return this.#previewsStore.previews; @@ -45,20 +54,53 @@ export class WorkbenchStore { setDocuments(files: FileMap) { this.#editorStore.setDocuments(files); + + if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) { + // we find the first file and select it + for (const [filePath, dirent] of Object.entries(files)) { + if (dirent?.type === 'file') { + this.setSelectedFile(filePath); + break; + } + } + } } setShowWorkbench(show: boolean) { this.showWorkbench.set(show); } - setCurrentDocumentContent(newContent: string) { + setCurrentDocumentContent(newContent: string | Uint8Array) { const filePath = this.currentDocument.get()?.filePath; if (!filePath) { return; } + const originalContent = this.#filesStore.getFile(filePath)?.content; + const unsavedChanges = originalContent !== undefined && originalContent !== newContent; + this.#editorStore.updateFile(filePath, newContent); + + const currentDocument = this.currentDocument.get(); + + if (currentDocument) { + const previousUnsavedFiles = this.unsavedFiles.get(); + + if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) { + return; + } + + const newUnsavedFiles = new Set(previousUnsavedFiles); + + if (unsavedChanges) { + newUnsavedFiles.add(currentDocument.filePath); + } else { + newUnsavedFiles.delete(currentDocument.filePath); + } + + this.unsavedFiles.set(newUnsavedFiles); + } } setCurrentDocumentScrollPosition(position: ScrollPosition) { @@ -77,6 +119,40 @@ export class WorkbenchStore { this.#editorStore.setSelectedFile(filePath); } + async saveCurrentDocument() { + const currentDocument = this.currentDocument.get(); + + if (currentDocument === undefined) { + return; + } + + const { filePath } = currentDocument; + + await this.#filesStore.saveFile(filePath, currentDocument.value); + + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + newUnsavedFiles.delete(filePath); + + this.unsavedFiles.set(newUnsavedFiles); + } + + resetCurrentDocument() { + const currentDocument = this.currentDocument.get(); + + if (currentDocument === undefined) { + return; + } + + const { filePath } = currentDocument; + const file = this.#filesStore.getFile(filePath); + + if (!file) { + return; + } + + this.setCurrentDocumentContent(file.content); + } + abortAllActions() { // TODO: what do we wanna do and how do we wanna recover from this? } @@ -136,8 +212,3 @@ export class WorkbenchStore { } export const workbenchStore = new WorkbenchStore(); - -if (import.meta.hot) { - import.meta.hot.data.artifacts = workbenchStore.artifacts; - import.meta.hot.data.showWorkbench = workbenchStore.showWorkbench; -} diff --git a/packages/bolt/app/root.tsx b/packages/bolt/app/root.tsx index 5a78e5f..cf1b2e1 100644 --- a/packages/bolt/app/root.tsx +++ b/packages/bolt/app/root.tsx @@ -5,6 +5,7 @@ import tailwindReset from '@unocss/reset/tailwind-compat.css?url'; import { themeStore } from './lib/stores/theme'; import { stripIndents } from './utils/stripIndent'; +import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url'; import globalStyles from './styles/index.scss?url'; import 'virtual:uno.css'; @@ -17,6 +18,7 @@ export const links: LinksFunction = () => [ }, { rel: 'stylesheet', href: tailwindReset }, { rel: 'stylesheet', href: globalStyles }, + { rel: 'stylesheet', href: reactToastifyStyles }, { rel: 'preconnect', href: 'https://fonts.googleapis.com', diff --git a/packages/bolt/app/styles/animations.scss b/packages/bolt/app/styles/animations.scss new file mode 100644 index 0000000..29120aa --- /dev/null +++ b/packages/bolt/app/styles/animations.scss @@ -0,0 +1,36 @@ +.animated { + animation-fill-mode: both; + animation-duration: var(--animate-duration, 0.2s); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + + &.fadeInRight { + animation-name: fadeInRight; + } + + &.fadeOutRight { + animation-name: fadeOutRight; + } +} + +@keyframes fadeInRight { + from { + opacity: 0; + transform: translate3d(100%, 0, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes fadeOutRight { + from { + opacity: 1; + } + + to { + opacity: 0; + transform: translate3d(100%, 0, 0); + } +} diff --git a/packages/bolt/app/styles/index.scss b/packages/bolt/app/styles/index.scss index 0d565e2..d3e5016 100644 --- a/packages/bolt/app/styles/index.scss +++ b/packages/bolt/app/styles/index.scss @@ -1,4 +1,5 @@ @import './variables.scss'; +@import './animations.scss'; body { --at-apply: bg-bolt-elements-app-backgroundColor; diff --git a/packages/bolt/app/utils/logger.ts b/packages/bolt/app/utils/logger.ts index b7890aa..615c73a 100644 --- a/packages/bolt/app/utils/logger.ts +++ b/packages/bolt/app/utils/logger.ts @@ -57,7 +57,21 @@ function log(level: DebugLevel, scope: string | undefined, messages: any[]) { styles.push('', scopeStyles); } - console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, ...messages); + console.log( + `%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, + ...styles, + messages.reduce((acc, current) => { + if (acc.endsWith('\n')) { + return acc + current; + } + + if (!acc) { + return current; + } + + return `${acc} ${current}`; + }, ''), + ); } } diff --git a/packages/bolt/package.json b/packages/bolt/package.json index 589289d..8d6ef94 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -50,6 +50,7 @@ "react-dom": "^18.2.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.0.20", + "react-toastify": "^10.0.5", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "remix-utils": "^7.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991e2a4..fd78760 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: react-resizable-panels: specifier: ^2.0.20 version: 2.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-toastify: + specifier: ^10.0.5 + version: 10.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -2036,6 +2039,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + code-red@1.0.4: resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} @@ -4085,6 +4092,12 @@ packages: peerDependencies: react: '>=16.8' + react-toastify@10.0.5: + resolution: {integrity: sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -7147,6 +7160,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + code-red@1.0.4: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -9715,6 +9730,12 @@ snapshots: '@remix-run/router': 1.17.1 react: 18.3.1 + react-toastify@10.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0