From a7d8693d8c9b3ae77c8dc4a0bc6439ae109044a1 Mon Sep 17 00:00:00 2001 From: Dominic Elm Date: Thu, 18 Jul 2024 23:07:04 +0200 Subject: [PATCH] feat(workbench): add file tree and hook up editor --- .../bolt/app/components/chat/BaseChat.tsx | 2 +- .../editor/codemirror/CodeMirrorEditor.tsx | 257 ++++++++-------- .../components/editor/codemirror/cm-theme.ts | 2 +- .../app/components/workbench/EditorPanel.tsx | 61 +++- .../app/components/workbench/FileTree.tsx | 282 +++++++++++++++++- .../components/workbench/FileTreePanel.tsx | 20 +- .../components/workbench/Workbench.client.tsx | 43 ++- packages/bolt/app/lib/.server/llm/prompts.ts | 4 +- .../bolt/app/lib/hooks/useMessageParser.ts | 4 +- packages/bolt/app/lib/stores/editor.ts | 96 ++++++ packages/bolt/app/lib/stores/files.ts | 94 ++++++ packages/bolt/app/lib/stores/workbench.ts | 49 ++- packages/bolt/app/lib/webcontainer/index.ts | 3 +- packages/bolt/app/utils/buffer.ts | 29 ++ packages/bolt/app/utils/constants.ts | 2 + packages/bolt/app/utils/logger.ts | 2 + packages/bolt/app/utils/mobile.ts | 4 + 17 files changed, 806 insertions(+), 148 deletions(-) create mode 100644 packages/bolt/app/lib/stores/editor.ts create mode 100644 packages/bolt/app/lib/stores/files.ts create mode 100644 packages/bolt/app/utils/buffer.ts create mode 100644 packages/bolt/app/utils/constants.ts create mode 100644 packages/bolt/app/utils/mobile.ts diff --git a/packages/bolt/app/components/chat/BaseChat.tsx b/packages/bolt/app/components/chat/BaseChat.tsx index 2177aa7..9bad347 100644 --- a/packages/bolt/app/components/chat/BaseChat.tsx +++ b/packages/bolt/app/components/chat/BaseChat.tsx @@ -167,7 +167,7 @@ export const BaseChat = React.forwardRef( - {() => } + {() => } ); diff --git a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx index 6e61306..d94689a 100644 --- a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -13,11 +13,11 @@ import { lineNumbers, scrollPastEnd, } from '@codemirror/view'; -import { useEffect, useRef, useState, type MutableRefObject } from 'react'; +import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react'; import type { Theme } from '../../../types/theme'; import { classNames } from '../../../utils/classNames'; import { debounce } from '../../../utils/debounce'; -import { createScopedLogger } from '../../../utils/logger'; +import { createScopedLogger, renderLogger } from '../../../utils/logger'; import { BinaryContent } from './BinaryContent'; import { getTheme, reconfigureTheme } from './cm-theme'; import { indentKeyBinding } from './indent'; @@ -27,7 +27,8 @@ const logger = createScopedLogger('CodeMirrorEditor'); export interface EditorDocument { value: string | Uint8Array; - loading: boolean; + previousValue?: string | Uint8Array; + commitPending: boolean; filePath: string; scroll?: ScrollPosition; } @@ -58,6 +59,7 @@ interface Props { theme: Theme; id?: unknown; doc?: EditorDocument; + editable?: boolean; debounceChange?: number; debounceScroll?: number; autoFocusOnDocumentChange?: boolean; @@ -69,138 +71,154 @@ interface Props { type EditorStates = Map; -export function CodeMirrorEditor({ - id, - doc, - debounceScroll = 100, - debounceChange = 150, - autoFocusOnDocumentChange = false, - onScroll, - onChange, - theme, - settings, - className = '', -}: Props) { - const [language] = useState(new Compartment()); - const [readOnly] = useState(new Compartment()); +export const CodeMirrorEditor = memo( + ({ + id, + doc, + debounceScroll = 100, + debounceChange = 150, + autoFocusOnDocumentChange = false, + editable = true, + onScroll, + onChange, + theme, + settings, + className = '', + }: Props) => { + renderLogger.debug('CodeMirrorEditor'); - const containerRef = useRef(null); - const viewRef = useRef(); - const themeRef = useRef(); - const docRef = useRef(); - const editorStatesRef = useRef(); - const onScrollRef = useRef(onScroll); - const onChangeRef = useRef(onChange); + const [languageCompartment] = useState(new Compartment()); + const [readOnlyCompartment] = useState(new Compartment()); + const [editableCompartment] = useState(new Compartment()); - const isBinaryFile = doc?.value instanceof Uint8Array; + const containerRef = useRef(null); + const viewRef = useRef(); + const themeRef = useRef(); + const docRef = useRef(); + const editorStatesRef = useRef(); + const onScrollRef = useRef(onScroll); + const onChangeRef = useRef(onChange); - onScrollRef.current = onScroll; - onChangeRef.current = onChange; + const isBinaryFile = doc?.value instanceof Uint8Array; - docRef.current = doc; - themeRef.current = theme; + onScrollRef.current = onScroll; + onChangeRef.current = onChange; - useEffect(() => { - const onUpdate = debounce((update: EditorUpdate) => { - onChangeRef.current?.(update); - }, debounceChange); + docRef.current = doc; + themeRef.current = theme; - const view = new EditorView({ - parent: containerRef.current!, - dispatchTransactions(transactions) { - const previousSelection = view.state.selection; + useEffect(() => { + const onUpdate = debounce((update: EditorUpdate) => { + onChangeRef.current?.(update); + }, debounceChange); - view.update(transactions); + const view = new EditorView({ + parent: containerRef.current!, + dispatchTransactions(transactions) { + const previousSelection = view.state.selection; - const newSelection = view.state.selection; + view.update(transactions); - const selectionChanged = - newSelection !== previousSelection && - (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection)); + const newSelection = view.state.selection; - if ( - docRef.current && - !docRef.current.loading && - (transactions.some((transaction) => transaction.docChanged) || selectionChanged) - ) { - onUpdate({ - selection: view.state.selection, - content: view.state.doc.toString(), - }); + const selectionChanged = + newSelection !== previousSelection && + (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection)); - editorStatesRef.current!.set(docRef.current.filePath, view.state); - } - }, - }); + if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) { + onUpdate({ + selection: view.state.selection, + content: view.state.doc.toString(), + }); - viewRef.current = view; + editorStatesRef.current!.set(docRef.current.filePath, view.state); + } + }, + }); - return () => { - viewRef.current?.destroy(); - viewRef.current = undefined; - }; - }, []); + viewRef.current = view; - useEffect(() => { - if (!viewRef.current) { - return; - } + return () => { + viewRef.current?.destroy(); + viewRef.current = undefined; + }; + }, []); - viewRef.current.dispatch({ - effects: [reconfigureTheme(theme)], - }); - }, [theme]); + useEffect(() => { + if (!viewRef.current) { + return; + } - useEffect(() => { - editorStatesRef.current = new Map(); - }, [id]); + viewRef.current.dispatch({ + effects: [reconfigureTheme(theme)], + }); + }, [theme]); - useEffect(() => { - const editorStates = editorStatesRef.current!; - const view = viewRef.current!; - const theme = themeRef.current!; + useEffect(() => { + editorStatesRef.current = new Map(); + }, [id]); - if (!doc) { - const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [language.of([])]); + useEffect(() => { + const editorStates = editorStatesRef.current!; + const view = viewRef.current!; + const theme = themeRef.current!; + + if (!doc) { + const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [ + languageCompartment.of([]), + readOnlyCompartment.of([]), + editableCompartment.of([]), + ]); + + view.setState(state); + + setNoDocument(view); + + return; + } + + if (doc.value instanceof Uint8Array) { + return; + } + + if (doc.filePath === '') { + logger.warn('File path should not be empty'); + } + + let state = editorStates.get(doc.filePath); + + if (!state) { + state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [ + languageCompartment.of([]), + readOnlyCompartment.of([EditorState.readOnly.of(!editable)]), + editableCompartment.of([EditorView.editable.of(editable)]), + ]); + + editorStates.set(doc.filePath, state); + } view.setState(state); - setNoDocument(view); + setEditorDocument( + view, + theme, + editable, + languageCompartment, + readOnlyCompartment, + editableCompartment, + autoFocusOnDocumentChange, + doc as TextEditorDocument, + ); + }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]); - return; - } - - if (doc.value instanceof Uint8Array) { - return; - } - - if (doc.filePath === '') { - logger.warn('File path should not be empty'); - } - - let state = editorStates.get(doc.filePath); - - if (!state) { - state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [ - language.of([]), - readOnly.of([EditorState.readOnly.of(doc.loading)]), - ]); - - editorStates.set(doc.filePath, state); - } - - view.setState(state); - - setEditorDocument(view, theme, language, readOnly, autoFocusOnDocumentChange, doc as TextEditorDocument); - }, [doc?.value, doc?.filePath, doc?.loading, autoFocusOnDocumentChange]); - - return ( -
- {isBinaryFile && } -
-
- ); -} + return ( +
+ {isBinaryFile && } +
+
+ ); + }, +); export default CodeMirrorEditor; @@ -280,8 +298,10 @@ function setNoDocument(view: EditorView) { function setEditorDocument( view: EditorView, theme: Theme, - language: Compartment, - readOnly: Compartment, + editable: boolean, + languageCompartment: Compartment, + readOnlyCompartment: Compartment, + editableCompartment: Compartment, autoFocus: boolean, doc: TextEditorDocument, ) { @@ -297,7 +317,10 @@ function setEditorDocument( } view.dispatch({ - effects: [readOnly.reconfigure([EditorState.readOnly.of(doc.loading)])], + effects: [ + readOnlyCompartment.reconfigure([EditorState.readOnly.of(!editable)]), + editableCompartment.reconfigure([EditorView.editable.of(editable)]), + ], }); getLanguage(doc.filePath).then((languageSupport) => { @@ -306,7 +329,7 @@ function setEditorDocument( } view.dispatch({ - effects: [language.reconfigure([languageSupport]), reconfigureTheme(theme)], + effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)], }); requestAnimationFrame(() => { diff --git a/packages/bolt/app/components/editor/codemirror/cm-theme.ts b/packages/bolt/app/components/editor/codemirror/cm-theme.ts index dd3c027..ddf1a5b 100644 --- a/packages/bolt/app/components/editor/codemirror/cm-theme.ts +++ b/packages/bolt/app/components/editor/codemirror/cm-theme.ts @@ -65,7 +65,7 @@ function getEditorTheme(settings: EditorSettings) { '&.cm-lineNumbers': { fontFamily: 'Roboto Mono, monospace', fontSize: '13px', - minWidth: '28px', + minWidth: '40px', }, '& .cm-activeLineGutter': { background: 'transparent', diff --git a/packages/bolt/app/components/workbench/EditorPanel.tsx b/packages/bolt/app/components/workbench/EditorPanel.tsx index 46fb382..4c21ea8 100644 --- a/packages/bolt/app/components/workbench/EditorPanel.tsx +++ b/packages/bolt/app/components/workbench/EditorPanel.tsx @@ -1,21 +1,52 @@ import { useStore } from '@nanostores/react'; +import { memo } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import type { FileMap } from '../../lib/stores/files'; import { themeStore } from '../../lib/stores/theme'; -import CodeMirrorEditor from '../editor/codemirror/CodeMirrorEditor'; +import { renderLogger } from '../../utils/logger'; +import { isMobile } from '../../utils/mobile'; +import { + CodeMirrorEditor, + type EditorDocument, + type OnChangeCallback as OnEditorChange, + type OnScrollCallback as OnEditorScroll, +} from '../editor/codemirror/CodeMirrorEditor'; import { FileTreePanel } from './FileTreePanel'; -export function EditorPanel() { - const theme = useStore(themeStore); - - return ( - - - - - - - - - - ); +interface EditorPanelProps { + files?: FileMap; + editorDocument?: EditorDocument; + selectedFile?: string | undefined; + isStreaming?: boolean; + onEditorChange?: OnEditorChange; + onEditorScroll?: OnEditorScroll; + onFileSelect?: (value?: string) => void; } + +export const EditorPanel = memo( + ({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => { + renderLogger.trace('EditorPanel'); + + const theme = useStore(themeStore); + + return ( + + + + + + + + + + ); + }, +); diff --git a/packages/bolt/app/components/workbench/FileTree.tsx b/packages/bolt/app/components/workbench/FileTree.tsx index 33311b3..d69d483 100644 --- a/packages/bolt/app/components/workbench/FileTree.tsx +++ b/packages/bolt/app/components/workbench/FileTree.tsx @@ -1,3 +1,281 @@ -export function FileTree() { - return
File Tree
; +import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'; +import type { FileMap } from '../../lib/stores/files'; +import { classNames } from '../../utils/classNames'; +import { renderLogger } from '../../utils/logger'; + +const NODE_PADDING_LEFT = 12; +const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; + +interface Props { + files?: FileMap; + selectedFile?: string; + onFileSelect?: (filePath: string) => void; + rootFolder?: string; + hiddenFiles?: Array; + className?: string; +} + +export const FileTree = memo( + ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => { + renderLogger.trace('FileTree'); + + const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); + + const fileList = useMemo( + () => buildFileList(files, rootFolder, computedHiddenFiles), + [files, rootFolder, computedHiddenFiles], + ); + + const [collapsedFolders, setCollapsedFolders] = useState(() => new Set()); + + useEffect(() => { + setCollapsedFolders((prevCollapsed) => { + const newCollapsed = new Set(); + + for (const folder of fileList) { + if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) { + newCollapsed.add(folder.fullPath); + } + } + + return newCollapsed; + }); + }, [fileList]); + + const filteredFileList = useMemo(() => { + const list = []; + + let lastDepth = Number.MAX_SAFE_INTEGER; + + for (const fileOrFolder of fileList) { + const depth = fileOrFolder.depth; + + // if the depth is equal we reached the end of the collaped group + if (lastDepth === depth) { + lastDepth = Number.MAX_SAFE_INTEGER; + } + + // ignore collapsed folders + if (collapsedFolders.has(fileOrFolder.fullPath)) { + lastDepth = Math.min(lastDepth, depth); + } + + // ignore files and folders below the last collapsed folder + if (lastDepth < depth) { + continue; + } + + list.push(fileOrFolder); + } + + return list; + }, [fileList, collapsedFolders]); + + const toggleCollapseState = (fullPath: string) => { + setCollapsedFolders((prevSet) => { + const newSet = new Set(prevSet); + + if (newSet.has(fullPath)) { + newSet.delete(fullPath); + } else { + newSet.add(fullPath); + } + + return newSet; + }); + }; + + return ( +
+ {filteredFileList.map((fileOrFolder) => { + switch (fileOrFolder.kind) { + case 'file': { + return ( + { + onFileSelect?.(fileOrFolder.fullPath); + }} + /> + ); + } + case 'folder': { + return ( + { + toggleCollapseState(fileOrFolder.fullPath); + }} + /> + ); + } + default: { + return undefined; + } + } + })} +
+ ); + }, +); + +export default FileTree; + +interface FolderProps { + folder: FolderNode; + collapsed: boolean; + onClick: () => void; +} + +function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { + return ( + + {name} + + ); +} + +interface FileProps { + file: FileNode; + selected: boolean; + onClick: () => void; +} + +function File({ file: { depth, name }, onClick, selected }: FileProps) { + return ( + + {name} + + ); +} + +interface ButtonProps { + depth: number; + iconClasses: string; + children: ReactNode; + className?: string; + onClick?: () => void; +} + +function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) { + return ( + + ); +} + +type Node = FileNode | FolderNode; + +interface BaseNode { + id: number; + depth: number; + name: string; + fullPath: string; +} + +interface FileNode extends BaseNode { + kind: 'file'; +} + +interface FolderNode extends BaseNode { + kind: 'folder'; +} + +function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array): Node[] { + const folderPaths = new Set(); + const fileList: Node[] = []; + + let defaultDepth = 0; + + if (rootFolder === '/') { + defaultDepth = 1; + fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' }); + } + + for (const [filePath, dirent] of Object.entries(files)) { + const segments = filePath.split('/').filter((segment) => segment); + const fileName = segments.at(-1); + + if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) { + continue; + } + + let currentPath = ''; + + let i = 0; + let depth = 0; + + while (i < segments.length) { + const name = segments[i]; + const fullPath = (currentPath += `/${name}`); + + if (!fullPath.startsWith(rootFolder)) { + i++; + continue; + } + + if (i === segments.length - 1 && dirent?.type === 'file') { + fileList.push({ + kind: 'file', + id: fileList.length, + name, + fullPath, + depth: depth + defaultDepth, + }); + } else if (!folderPaths.has(fullPath)) { + folderPaths.add(fullPath); + + fileList.push({ + kind: 'folder', + id: fileList.length, + name, + fullPath, + depth: depth + defaultDepth, + }); + } + + i++; + depth++; + } + } + + return fileList; +} + +function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array) { + return hiddenFiles.some((pathOrRegex) => { + if (typeof pathOrRegex === 'string') { + return fileName === pathOrRegex; + } + + return pathOrRegex.test(filePath); + }); } diff --git a/packages/bolt/app/components/workbench/FileTreePanel.tsx b/packages/bolt/app/components/workbench/FileTreePanel.tsx index d79c15b..0a3bce7 100644 --- a/packages/bolt/app/components/workbench/FileTreePanel.tsx +++ b/packages/bolt/app/components/workbench/FileTreePanel.tsx @@ -1,9 +1,21 @@ +import { memo } from 'react'; +import type { FileMap } from '../../lib/stores/files'; +import { WORK_DIR } from '../../utils/constants'; +import { renderLogger } from '../../utils/logger'; import { FileTree } from './FileTree'; -export function FileTreePanel() { +interface FileTreePanelProps { + files?: FileMap; + selectedFile?: string; + onFileSelect?: (value?: string) => void; +} + +export const FileTreePanel = memo(({ files, 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 fd4e694..d299c44 100644 --- a/packages/bolt/app/components/workbench/Workbench.client.tsx +++ b/packages/bolt/app/components/workbench/Workbench.client.tsx @@ -1,14 +1,21 @@ 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 { workbenchStore } from '../../lib/stores/workbench'; import { cubicEasingFn } from '../../utils/easings'; +import { renderLogger } from '../../utils/logger'; +import type { + OnChangeCallback as OnEditorChange, + OnScrollCallback as OnEditorScroll, +} from '../editor/codemirror/CodeMirrorEditor'; import { IconButton } from '../ui/IconButton'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; interface WorkspaceProps { chatStarted?: boolean; + isStreaming?: boolean; } const workbenchVariants = { @@ -28,8 +35,30 @@ const workbenchVariants = { }, } satisfies Variants; -export function Workbench({ chatStarted }: WorkspaceProps) { +export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => { + renderLogger.trace('Workbench'); + const showWorkbench = useStore(workbenchStore.showWorkbench); + const selectedFile = useStore(workbenchStore.selectedFile); + const currentDocument = useStore(workbenchStore.currentDocument); + + const files = useStore(workbenchStore.files); + + useEffect(() => { + workbenchStore.setDocuments(files); + }, [files]); + + const onEditorChange = useCallback((update) => { + workbenchStore.setCurrentDocumentContent(update.content); + }, []); + + const onEditorScroll = useCallback((position) => { + workbenchStore.setCurrentDocumentScrollPosition(position); + }, []); + + const onFileSelect = useCallback((filePath: string | undefined) => { + workbenchStore.setSelectedFile(filePath); + }, []); return ( chatStarted && ( @@ -51,7 +80,15 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
- + @@ -66,4 +103,4 @@ export function Workbench({ chatStarted }: WorkspaceProps) { ) ); -} +}); diff --git a/packages/bolt/app/lib/.server/llm/prompts.ts b/packages/bolt/app/lib/.server/llm/prompts.ts index bbe6afe..f811376 100644 --- a/packages/bolt/app/lib/.server/llm/prompts.ts +++ b/packages/bolt/app/lib/.server/llm/prompts.ts @@ -1,4 +1,6 @@ -export const getSystemPrompt = (cwd: string = '/home/project') => ` +import { WORK_DIR } from '../../../utils/constants'; + +export const getSystemPrompt = (cwd: string = WORK_DIR) => ` You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. diff --git a/packages/bolt/app/lib/hooks/useMessageParser.ts b/packages/bolt/app/lib/hooks/useMessageParser.ts index cef1601..9aad5c3 100644 --- a/packages/bolt/app/lib/hooks/useMessageParser.ts +++ b/packages/bolt/app/lib/hooks/useMessageParser.ts @@ -20,7 +20,7 @@ const messageParser = new StreamingMessageParser({ workbenchStore.updateArtifact(data, { closed: true }); }, onActionOpen: (data) => { - logger.debug('onActionOpen', data.action); + logger.trace('onActionOpen', data.action); // we only add shell actions when when the close tag got parsed because only then we have the content if (data.action.type !== 'shell') { @@ -28,7 +28,7 @@ const messageParser = new StreamingMessageParser({ } }, onActionClose: (data) => { - logger.debug('onActionClose', data.action); + logger.trace('onActionClose', data.action); if (data.action.type === 'shell') { workbenchStore.addAction(data); diff --git a/packages/bolt/app/lib/stores/editor.ts b/packages/bolt/app/lib/stores/editor.ts new file mode 100644 index 0000000..11b6f88 --- /dev/null +++ b/packages/bolt/app/lib/stores/editor.ts @@ -0,0 +1,96 @@ +import type { WebContainer } from '@webcontainer/api'; +import { atom, computed, map } from 'nanostores'; +import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor'; +import type { FileMap } from './files'; + +export type EditorDocuments = Record; + +export class EditorStore { + #webcontainer: Promise; + + selectedFile = atom(); + documents = map({}); + + currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => { + if (!selectedFile) { + return undefined; + } + + return documents[selectedFile]; + }); + + constructor(webcontainerPromise: Promise) { + this.#webcontainer = webcontainerPromise; + } + + commitFileContent(_filePath: string) { + // TODO + } + + setDocuments(files: FileMap) { + const previousDocuments = this.documents.value; + + this.documents.set( + Object.fromEntries( + Object.entries(files) + .map(([filePath, dirent]) => { + if (dirent === undefined || dirent.type === 'folder') { + return undefined; + } + + return [ + filePath, + { + value: dirent.content, + commitPending: false, + filePath, + scroll: previousDocuments?.[filePath]?.scroll, + }, + ] as [string, EditorDocument]; + }) + .filter(Boolean) as Array<[string, EditorDocument]>, + ), + ); + } + + setSelectedFile(filePath: string | undefined) { + this.selectedFile.set(filePath); + } + + updateScrollPosition(filePath: string, position: ScrollPosition) { + const documents = this.documents.get(); + const documentState = documents[filePath]; + + if (!documentState) { + return; + } + + this.documents.setKey(filePath, { + ...documentState, + scroll: position, + }); + } + + updateFile(filePath: string, content: string): boolean { + const documents = this.documents.get(); + const documentState = documents[filePath]; + + if (!documentState) { + return false; + } + + const currentContent = documentState.value; + const contentChanged = currentContent !== content; + + if (contentChanged) { + this.documents.setKey(filePath, { + ...documentState, + previousValue: !documentState.commitPending ? currentContent : documentState.previousValue, + commitPending: documentState.previousValue ? documentState.previousValue !== content : true, + value: content, + }); + } + + return contentChanged; + } +} diff --git a/packages/bolt/app/lib/stores/files.ts b/packages/bolt/app/lib/stores/files.ts new file mode 100644 index 0000000..715a312 --- /dev/null +++ b/packages/bolt/app/lib/stores/files.ts @@ -0,0 +1,94 @@ +import type { PathWatcherEvent, WebContainer } from '@webcontainer/api'; +import { map } from 'nanostores'; +import { bufferWatchEvents } from '../../utils/buffer'; +import { WORK_DIR } from '../../utils/constants'; + +const textDecoder = new TextDecoder('utf8', { fatal: true }); + +interface File { + type: 'file'; + content: string; +} + +interface Folder { + type: 'folder'; +} + +type Dirent = File | Folder; + +export type FileMap = Record; + +export class FilesStore { + #webcontainer: Promise; + + files = map({}); + + constructor(webcontainerPromise: Promise) { + this.#webcontainer = webcontainerPromise; + + this.#init(); + } + + async #init() { + const webcontainer = await this.#webcontainer; + + webcontainer.watchPaths( + { include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true }, + bufferWatchEvents(100, this.#processEventBuffer.bind(this)), + ); + } + + #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) { + const watchEvents = events.flat(2); + + for (const { type, path, buffer } of watchEvents) { + // remove any trailing slashes + const sanitizedPath = path.replace(/\/+$/g, ''); + + switch (type) { + case 'add_dir': { + // we intentionally add a trailing slash so we can distinguish files from folders in the file tree + this.files.setKey(sanitizedPath, { type: 'folder' }); + break; + } + case 'remove_dir': { + this.files.setKey(sanitizedPath, undefined); + + for (const [direntPath] of Object.entries(this.files)) { + if (direntPath.startsWith(sanitizedPath)) { + this.files.setKey(direntPath, undefined); + } + } + + break; + } + case 'add_file': + case 'change': { + this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) }); + break; + } + case 'remove_file': { + this.files.setKey(sanitizedPath, undefined); + break; + } + case 'update_directory': { + // we don't care about these events + break; + } + } + } + } + + #decodeFileContent(buffer?: Uint8Array) { + if (!buffer || buffer.byteLength === 0) { + return ''; + } + + try { + return textDecoder.decode(buffer); + } catch (error) { + console.log(error); + return ''; + } + } +} diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index 3b457f8..4a86539 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -1,10 +1,13 @@ -import { atom, map, type MapStore, type WritableAtom } from 'nanostores'; +import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores'; +import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor'; import type { BoltAction } from '../../types/actions'; import { unreachable } from '../../utils/unreachable'; import { ActionRunner } from '../runtime/action-runner'; import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser'; import { webcontainer } from '../webcontainer'; import { chatStore } from './chat'; +import { EditorStore } from './editor'; +import { FilesStore, type FileMap } from './files'; import { PreviewsStore } from './previews'; const MIN_SPINNER_TIME = 200; @@ -41,6 +44,8 @@ type Artifacts = MapStore>; export class WorkbenchStore { #actionRunner = new ActionRunner(webcontainer); #previewsStore = new PreviewsStore(webcontainer); + #filesStore = new FilesStore(webcontainer); + #editorStore = new EditorStore(webcontainer); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); @@ -50,10 +55,52 @@ export class WorkbenchStore { return this.#previewsStore.previews; } + get files() { + return this.#filesStore.files; + } + + get currentDocument(): ReadableAtom { + return this.#editorStore.currentDocument; + } + + get selectedFile(): ReadableAtom { + return this.#editorStore.selectedFile; + } + + setDocuments(files: FileMap) { + this.#editorStore.setDocuments(files); + } + setShowWorkbench(show: boolean) { this.showWorkbench.set(show); } + setCurrentDocumentContent(newContent: string) { + const filePath = this.currentDocument.get()?.filePath; + + if (!filePath) { + return; + } + + this.#editorStore.updateFile(filePath, newContent); + } + + setCurrentDocumentScrollPosition(position: ScrollPosition) { + const editorDocument = this.currentDocument.get(); + + if (!editorDocument) { + return; + } + + const { filePath } = editorDocument; + + this.#editorStore.updateScrollPosition(filePath, position); + } + + setSelectedFile(filePath: string | undefined) { + this.#editorStore.setSelectedFile(filePath); + } + abortAllActions() { for (const [, artifact] of Object.entries(this.artifacts.get())) { for (const [, action] of Object.entries(artifact.actions.get())) { diff --git a/packages/bolt/app/lib/webcontainer/index.ts b/packages/bolt/app/lib/webcontainer/index.ts index 8cb4506..2926759 100644 --- a/packages/bolt/app/lib/webcontainer/index.ts +++ b/packages/bolt/app/lib/webcontainer/index.ts @@ -1,4 +1,5 @@ import { WebContainer } from '@webcontainer/api'; +import { WORK_DIR_NAME } from '../../utils/constants'; interface WebContainerContext { loaded: boolean; @@ -20,7 +21,7 @@ if (!import.meta.env.SSR) { webcontainer = import.meta.hot?.data.webcontainer ?? Promise.resolve() - .then(() => WebContainer.boot({ workdirName: 'project' })) + .then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME })) .then((webcontainer) => { webcontainerContext.loaded = true; return webcontainer; diff --git a/packages/bolt/app/utils/buffer.ts b/packages/bolt/app/utils/buffer.ts new file mode 100644 index 0000000..e191f60 --- /dev/null +++ b/packages/bolt/app/utils/buffer.ts @@ -0,0 +1,29 @@ +export function bufferWatchEvents(timeInMs: number, cb: (events: T[]) => unknown) { + let timeoutId: number | undefined; + let events: T[] = []; + + // keep track of the processing of the previous batch so we can wait for it + let processing: Promise = Promise.resolve(); + + const scheduleBufferTick = () => { + timeoutId = self.setTimeout(async () => { + // we wait until the previous batch is entirely processed so events are processed in order + await processing; + + if (events.length > 0) { + processing = Promise.resolve(cb(events)); + } + + timeoutId = undefined; + events = []; + }, timeInMs); + }; + + return (...args: T) => { + events.push(args); + + if (!timeoutId) { + scheduleBufferTick(); + } + }; +} diff --git a/packages/bolt/app/utils/constants.ts b/packages/bolt/app/utils/constants.ts new file mode 100644 index 0000000..f4a3136 --- /dev/null +++ b/packages/bolt/app/utils/constants.ts @@ -0,0 +1,2 @@ +export const WORK_DIR_NAME = 'project'; +export const WORK_DIR = `/home/${WORK_DIR_NAME}`; diff --git a/packages/bolt/app/utils/logger.ts b/packages/bolt/app/utils/logger.ts index fcfeeb5..b7890aa 100644 --- a/packages/bolt/app/utils/logger.ts +++ b/packages/bolt/app/utils/logger.ts @@ -85,3 +85,5 @@ function getColorForLevel(level: DebugLevel): string { } } } + +export const renderLogger = createScopedLogger('Render'); diff --git a/packages/bolt/app/utils/mobile.ts b/packages/bolt/app/utils/mobile.ts new file mode 100644 index 0000000..ca8cb58 --- /dev/null +++ b/packages/bolt/app/utils/mobile.ts @@ -0,0 +1,4 @@ +export function isMobile() { + // we use sm: as the breakpoint for mobile. It's currently set to 640px + return globalThis.innerWidth < 640; +}