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

This commit is contained in:
Dominic Elm 2024-07-18 23:07:04 +02:00
parent 012b5bae80
commit a7d8693d8c
17 changed files with 806 additions and 148 deletions

View File

@ -167,7 +167,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
</div> </div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly> <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div> </div>
</div> </div>
); );

View File

@ -13,11 +13,11 @@ import {
lineNumbers, lineNumbers,
scrollPastEnd, scrollPastEnd,
} from '@codemirror/view'; } 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 type { Theme } from '../../../types/theme';
import { classNames } from '../../../utils/classNames'; import { classNames } from '../../../utils/classNames';
import { debounce } from '../../../utils/debounce'; import { debounce } from '../../../utils/debounce';
import { createScopedLogger } from '../../../utils/logger'; import { createScopedLogger, renderLogger } from '../../../utils/logger';
import { BinaryContent } from './BinaryContent'; import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme'; import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent'; import { indentKeyBinding } from './indent';
@ -27,7 +27,8 @@ const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument { export interface EditorDocument {
value: string | Uint8Array; value: string | Uint8Array;
loading: boolean; previousValue?: string | Uint8Array;
commitPending: boolean;
filePath: string; filePath: string;
scroll?: ScrollPosition; scroll?: ScrollPosition;
} }
@ -58,6 +59,7 @@ interface Props {
theme: Theme; theme: Theme;
id?: unknown; id?: unknown;
doc?: EditorDocument; doc?: EditorDocument;
editable?: boolean;
debounceChange?: number; debounceChange?: number;
debounceScroll?: number; debounceScroll?: number;
autoFocusOnDocumentChange?: boolean; autoFocusOnDocumentChange?: boolean;
@ -69,138 +71,154 @@ interface Props {
type EditorStates = Map<string, EditorState>; type EditorStates = Map<string, EditorState>;
export function CodeMirrorEditor({ export const CodeMirrorEditor = memo(
id, ({
doc, id,
debounceScroll = 100, doc,
debounceChange = 150, debounceScroll = 100,
autoFocusOnDocumentChange = false, debounceChange = 150,
onScroll, autoFocusOnDocumentChange = false,
onChange, editable = true,
theme, onScroll,
settings, onChange,
className = '', theme,
}: Props) { settings,
const [language] = useState(new Compartment()); className = '',
const [readOnly] = useState(new Compartment()); }: Props) => {
renderLogger.debug('CodeMirrorEditor');
const containerRef = useRef<HTMLDivElement | null>(null); const [languageCompartment] = useState(new Compartment());
const viewRef = useRef<EditorView>(); const [readOnlyCompartment] = useState(new Compartment());
const themeRef = useRef<Theme>(); const [editableCompartment] = useState(new Compartment());
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const isBinaryFile = doc?.value instanceof Uint8Array; const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
onScrollRef.current = onScroll; const isBinaryFile = doc?.value instanceof Uint8Array;
onChangeRef.current = onChange;
docRef.current = doc; onScrollRef.current = onScroll;
themeRef.current = theme; onChangeRef.current = onChange;
useEffect(() => { docRef.current = doc;
const onUpdate = debounce((update: EditorUpdate) => { themeRef.current = theme;
onChangeRef.current?.(update);
}, debounceChange);
const view = new EditorView({ useEffect(() => {
parent: containerRef.current!, const onUpdate = debounce((update: EditorUpdate) => {
dispatchTransactions(transactions) { onChangeRef.current?.(update);
const previousSelection = view.state.selection; }, 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 = const newSelection = view.state.selection;
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
if ( const selectionChanged =
docRef.current && newSelection !== previousSelection &&
!docRef.current.loading && (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
(transactions.some((transaction) => transaction.docChanged) || selectionChanged)
) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
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 = view;
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
useEffect(() => { return () => {
if (!viewRef.current) { viewRef.current?.destroy();
return; viewRef.current = undefined;
} };
}, []);
viewRef.current.dispatch({ useEffect(() => {
effects: [reconfigureTheme(theme)], if (!viewRef.current) {
}); return;
}, [theme]); }
useEffect(() => { viewRef.current.dispatch({
editorStatesRef.current = new Map<string, EditorState>(); effects: [reconfigureTheme(theme)],
}, [id]); });
}, [theme]);
useEffect(() => { useEffect(() => {
const editorStates = editorStatesRef.current!; editorStatesRef.current = new Map<string, EditorState>();
const view = viewRef.current!; }, [id]);
const theme = themeRef.current!;
if (!doc) { useEffect(() => {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [language.of([])]); 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); view.setState(state);
setNoDocument(view); setEditorDocument(
view,
theme,
editable,
languageCompartment,
readOnlyCompartment,
editableCompartment,
autoFocusOnDocumentChange,
doc as TextEditorDocument,
);
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
return; return (
} <div className={classNames('relative h-full', className)}>
{isBinaryFile && <BinaryContent />}
if (doc.value instanceof Uint8Array) { <div className="h-full overflow-hidden" ref={containerRef} />
return; </div>
} );
},
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 (
<div className={classNames('relative h-full', className)}>
{isBinaryFile && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
}
export default CodeMirrorEditor; export default CodeMirrorEditor;
@ -280,8 +298,10 @@ function setNoDocument(view: EditorView) {
function setEditorDocument( function setEditorDocument(
view: EditorView, view: EditorView,
theme: Theme, theme: Theme,
language: Compartment, editable: boolean,
readOnly: Compartment, languageCompartment: Compartment,
readOnlyCompartment: Compartment,
editableCompartment: Compartment,
autoFocus: boolean, autoFocus: boolean,
doc: TextEditorDocument, doc: TextEditorDocument,
) { ) {
@ -297,7 +317,10 @@ function setEditorDocument(
} }
view.dispatch({ 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) => { getLanguage(doc.filePath).then((languageSupport) => {
@ -306,7 +329,7 @@ function setEditorDocument(
} }
view.dispatch({ view.dispatch({
effects: [language.reconfigure([languageSupport]), reconfigureTheme(theme)], effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
}); });
requestAnimationFrame(() => { requestAnimationFrame(() => {

View File

@ -65,7 +65,7 @@ function getEditorTheme(settings: EditorSettings) {
'&.cm-lineNumbers': { '&.cm-lineNumbers': {
fontFamily: 'Roboto Mono, monospace', fontFamily: 'Roboto Mono, monospace',
fontSize: '13px', fontSize: '13px',
minWidth: '28px', minWidth: '40px',
}, },
'& .cm-activeLineGutter': { '& .cm-activeLineGutter': {
background: 'transparent', background: 'transparent',

View File

@ -1,21 +1,52 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { memo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { FileMap } from '../../lib/stores/files';
import { themeStore } from '../../lib/stores/theme'; 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'; import { FileTreePanel } from './FileTreePanel';
export function EditorPanel() { interface EditorPanelProps {
const theme = useStore(themeStore); files?: FileMap;
editorDocument?: EditorDocument;
return ( selectedFile?: string | undefined;
<PanelGroup direction="horizontal"> isStreaming?: boolean;
<Panel defaultSize={30} minSize={20} collapsible={false}> onEditorChange?: OnEditorChange;
<FileTreePanel /> onEditorScroll?: OnEditorScroll;
</Panel> onFileSelect?: (value?: string) => void;
<PanelResizeHandle />
<Panel defaultSize={70} minSize={20}>
<CodeMirrorEditor theme={theme} settings={{ tabSize: 2 }} />
</Panel>
</PanelGroup>
);
} }
export const EditorPanel = memo(
({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
return (
<PanelGroup direction="horizontal">
<Panel defaultSize={25} minSize={10} collapsible={true}>
<FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} />
</Panel>
<PanelResizeHandle />
<Panel defaultSize={75} minSize={20}>
<CodeMirrorEditor
theme={theme}
editable={true}
settings={{ tabSize: 2 }}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
/>
</Panel>
</PanelGroup>
);
},
);

View File

@ -1,3 +1,281 @@
export function FileTree() { import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
return <div>File Tree</div>; 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<string | RegExp>;
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<string>());
useEffect(() => {
setCollapsedFolders((prevCollapsed) => {
const newCollapsed = new Set<string>();
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 (
<div className={className}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return (
<NodeButton
className="group bg-white hover:bg-gray-100 text-md"
depth={depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
'i-ph:caret-down scale-98': !collapsed,
})}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected }: FileProps) {
return (
<NodeButton
className={classNames('group', {
'bg-white hover:bg-gray-50': !selected,
'bg-gray-100': selected,
})}
depth={depth}
iconClasses={classNames('i-ph:file-duotone scale-98', {
'text-gray-600': !selected,
})}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface ButtonProps {
depth: number;
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
return (
<button
className={`flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded ${className ?? ''}`}
style={{ paddingLeft: `${12 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
>
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<span className="whitespace-nowrap">{children}</span>
</button>
);
}
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<string | RegExp>): Node[] {
const folderPaths = new Set<string>();
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<string | RegExp>) {
return hiddenFiles.some((pathOrRegex) => {
if (typeof pathOrRegex === 'string') {
return fileName === pathOrRegex;
}
return pathOrRegex.test(filePath);
});
} }

View File

@ -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'; 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 ( return (
<div className="border-r h-full p-4"> <div className="border-r h-full">
<FileTree /> <FileTree files={files} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} />
</div> </div>
); );
} });

View File

@ -1,14 +1,21 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { AnimatePresence, motion, type Variants } from 'framer-motion'; import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { memo, useCallback, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { workbenchStore } from '../../lib/stores/workbench'; import { workbenchStore } from '../../lib/stores/workbench';
import { cubicEasingFn } from '../../utils/easings'; 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 { IconButton } from '../ui/IconButton';
import { EditorPanel } from './EditorPanel'; import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview'; import { Preview } from './Preview';
interface WorkspaceProps { interface WorkspaceProps {
chatStarted?: boolean; chatStarted?: boolean;
isStreaming?: boolean;
} }
const workbenchVariants = { const workbenchVariants = {
@ -28,8 +35,30 @@ const workbenchVariants = {
}, },
} satisfies Variants; } satisfies Variants;
export function Workbench({ chatStarted }: WorkspaceProps) { export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const showWorkbench = useStore(workbenchStore.showWorkbench); 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<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
return ( return (
chatStarted && ( chatStarted && (
@ -51,7 +80,15 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<PanelGroup direction="vertical"> <PanelGroup direction="vertical">
<Panel defaultSize={50} minSize={20}> <Panel defaultSize={50} minSize={20}>
<EditorPanel /> <EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
/>
</Panel> </Panel>
<PanelResizeHandle /> <PanelResizeHandle />
<Panel defaultSize={50} minSize={20}> <Panel defaultSize={50} minSize={20}>
@ -66,4 +103,4 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
</AnimatePresence> </AnimatePresence>
) )
); );
} });

View File

@ -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. You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
<system_constraints> <system_constraints>

View File

@ -20,7 +20,7 @@ const messageParser = new StreamingMessageParser({
workbenchStore.updateArtifact(data, { closed: true }); workbenchStore.updateArtifact(data, { closed: true });
}, },
onActionOpen: (data) => { 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 // we only add shell actions when when the close tag got parsed because only then we have the content
if (data.action.type !== 'shell') { if (data.action.type !== 'shell') {
@ -28,7 +28,7 @@ const messageParser = new StreamingMessageParser({
} }
}, },
onActionClose: (data) => { onActionClose: (data) => {
logger.debug('onActionClose', data.action); logger.trace('onActionClose', data.action);
if (data.action.type === 'shell') { if (data.action.type === 'shell') {
workbenchStore.addAction(data); workbenchStore.addAction(data);

View File

@ -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<string, EditorDocument>;
export class EditorStore {
#webcontainer: Promise<WebContainer>;
selectedFile = atom<string | undefined>();
documents = map<EditorDocuments>({});
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
if (!selectedFile) {
return undefined;
}
return documents[selectedFile];
});
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
}
commitFileContent(_filePath: string) {
// TODO
}
setDocuments(files: FileMap) {
const previousDocuments = this.documents.value;
this.documents.set(
Object.fromEntries<EditorDocument>(
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;
}
}

View File

@ -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<string, Dirent | undefined>;
export class FilesStore {
#webcontainer: Promise<WebContainer>;
files = map<FileMap>({});
constructor(webcontainerPromise: Promise<WebContainer>) {
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 '';
}
}
}

View File

@ -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 type { BoltAction } from '../../types/actions';
import { unreachable } from '../../utils/unreachable'; import { unreachable } from '../../utils/unreachable';
import { ActionRunner } from '../runtime/action-runner'; import { ActionRunner } from '../runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser'; import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
import { webcontainer } from '../webcontainer'; import { webcontainer } from '../webcontainer';
import { chatStore } from './chat'; import { chatStore } from './chat';
import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews'; import { PreviewsStore } from './previews';
const MIN_SPINNER_TIME = 200; const MIN_SPINNER_TIME = 200;
@ -41,6 +44,8 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
export class WorkbenchStore { export class WorkbenchStore {
#actionRunner = new ActionRunner(webcontainer); #actionRunner = new ActionRunner(webcontainer);
#previewsStore = new PreviewsStore(webcontainer); #previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
@ -50,10 +55,52 @@ export class WorkbenchStore {
return this.#previewsStore.previews; return this.#previewsStore.previews;
} }
get files() {
return this.#filesStore.files;
}
get currentDocument(): ReadableAtom<EditorDocument | undefined> {
return this.#editorStore.currentDocument;
}
get selectedFile(): ReadableAtom<string | undefined> {
return this.#editorStore.selectedFile;
}
setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files);
}
setShowWorkbench(show: boolean) { setShowWorkbench(show: boolean) {
this.showWorkbench.set(show); 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() { abortAllActions() {
for (const [, artifact] of Object.entries(this.artifacts.get())) { for (const [, artifact] of Object.entries(this.artifacts.get())) {
for (const [, action] of Object.entries(artifact.actions.get())) { for (const [, action] of Object.entries(artifact.actions.get())) {

View File

@ -1,4 +1,5 @@
import { WebContainer } from '@webcontainer/api'; import { WebContainer } from '@webcontainer/api';
import { WORK_DIR_NAME } from '../../utils/constants';
interface WebContainerContext { interface WebContainerContext {
loaded: boolean; loaded: boolean;
@ -20,7 +21,7 @@ if (!import.meta.env.SSR) {
webcontainer = webcontainer =
import.meta.hot?.data.webcontainer ?? import.meta.hot?.data.webcontainer ??
Promise.resolve() Promise.resolve()
.then(() => WebContainer.boot({ workdirName: 'project' })) .then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME }))
.then((webcontainer) => { .then((webcontainer) => {
webcontainerContext.loaded = true; webcontainerContext.loaded = true;
return webcontainer; return webcontainer;

View File

@ -0,0 +1,29 @@
export function bufferWatchEvents<T extends unknown[]>(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<unknown> = 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();
}
};
}

View File

@ -0,0 +1,2 @@
export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;

View File

@ -85,3 +85,5 @@ function getColorForLevel(level: DebugLevel): string {
} }
} }
} }
export const renderLogger = createScopedLogger('Render');

View File

@ -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;
}