feat(workbench): sync file changes back to webcontainer (#5)

This commit is contained in:
Dominic Elm 2024-07-24 16:10:39 +02:00 committed by GitHub
parent df25c678d1
commit d45b95dd11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 491 additions and 129 deletions

View File

@ -1,6 +1,7 @@
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion'; import { useAnimate } from 'framer-motion';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ToastContainer, cssTransition } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks'; import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks';
import { chatStore } from '../../lib/stores/chat'; import { chatStore } from '../../lib/stores/chat';
import { workbenchStore } from '../../lib/stores/workbench'; import { workbenchStore } from '../../lib/stores/workbench';
@ -8,6 +9,11 @@ import { cubicEasingFn } from '../../utils/easings';
import { createScopedLogger } from '../../utils/logger'; import { createScopedLogger } from '../../utils/logger';
import { BaseChat } from './BaseChat'; import { BaseChat } from './BaseChat';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat'); const logger = createScopedLogger('Chat');
export function Chat() { export function Chat() {
@ -90,6 +96,7 @@ export function Chat() {
const [messageRef, scrollRef] = useSnapScroll(); const [messageRef, scrollRef] = useSnapScroll();
return ( return (
<>
<BaseChat <BaseChat
ref={animationScope} ref={animationScope}
textareaRef={textareaRef} textareaRef={textareaRef}
@ -119,6 +126,8 @@ export function Chat() {
scrollTextArea(); scrollTextArea();
}); });
}} }}
></BaseChat> />
<ToastContainer position="bottom-right" stacked={true} pauseOnFocusLoss={true} transition={toastAnimation} />
</>
); );
} }

View File

@ -2,7 +2,7 @@ import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/aut
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language'; import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search'; 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 { import {
EditorView, EditorView,
drawSelection, drawSelection,
@ -27,8 +27,6 @@ const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument { export interface EditorDocument {
value: string | Uint8Array; value: string | Uint8Array;
previousValue?: string | Uint8Array;
commitPending: boolean;
filePath: string; filePath: string;
scroll?: ScrollPosition; scroll?: ScrollPosition;
} }
@ -54,6 +52,7 @@ export interface EditorUpdate {
export type OnChangeCallback = (update: EditorUpdate) => void; export type OnChangeCallback = (update: EditorUpdate) => void;
export type OnScrollCallback = (position: ScrollPosition) => void; export type OnScrollCallback = (position: ScrollPosition) => void;
export type OnSaveCallback = () => void;
interface Props { interface Props {
theme: Theme; theme: Theme;
@ -65,12 +64,30 @@ interface Props {
autoFocusOnDocumentChange?: boolean; autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback; onChange?: OnChangeCallback;
onScroll?: OnScrollCallback; onScroll?: OnScrollCallback;
onSave?: OnSaveCallback;
className?: string; className?: string;
settings?: EditorSettings; settings?: EditorSettings;
} }
type EditorStates = Map<string, EditorState>; type EditorStates = Map<string, EditorState>;
const editableStateEffect = StateEffect.define<boolean>();
const editableStateField = StateField.define<boolean>({
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( export const CodeMirrorEditor = memo(
({ ({
id, id,
@ -81,15 +98,14 @@ export const CodeMirrorEditor = memo(
editable = true, editable = true,
onScroll, onScroll,
onChange, onChange,
onSave,
theme, theme,
settings, settings,
className = '', className = '',
}: Props) => { }: Props) => {
renderLogger.debug('CodeMirrorEditor'); renderLogger.trace('CodeMirrorEditor');
const [languageCompartment] = useState(new Compartment()); const [languageCompartment] = useState(new Compartment());
const [readOnlyCompartment] = useState(new Compartment());
const [editableCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>(); const viewRef = useRef<EditorView>();
@ -98,14 +114,21 @@ export const CodeMirrorEditor = memo(
const editorStatesRef = useRef<EditorStates>(); const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll); const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const isBinaryFile = doc?.value instanceof Uint8Array; const isBinaryFile = doc?.value instanceof Uint8Array;
/**
* 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; onScrollRef.current = onScroll;
onChangeRef.current = onChange; onChangeRef.current = onChange;
onSaveRef.current = onSave;
docRef.current = doc; docRef.current = doc;
themeRef.current = theme; themeRef.current = theme;
});
useEffect(() => { useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => { const onUpdate = debounce((update: EditorUpdate) => {
@ -164,10 +187,8 @@ export const CodeMirrorEditor = memo(
const theme = themeRef.current!; const theme = themeRef.current!;
if (!doc) { if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [ const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]), languageCompartment.of([]),
readOnlyCompartment.of([]),
editableCompartment.of([]),
]); ]);
view.setState(state); view.setState(state);
@ -188,10 +209,8 @@ export const CodeMirrorEditor = memo(
let state = editorStates.get(doc.filePath); let state = editorStates.get(doc.filePath);
if (!state) { if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [ state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]), languageCompartment.of([]),
readOnlyCompartment.of([EditorState.readOnly.of(!editable)]),
editableCompartment.of([EditorView.editable.of(editable)]),
]); ]);
editorStates.set(doc.filePath, state); editorStates.set(doc.filePath, state);
@ -204,8 +223,6 @@ export const CodeMirrorEditor = memo(
theme, theme,
editable, editable,
languageCompartment, languageCompartment,
readOnlyCompartment,
editableCompartment,
autoFocusOnDocumentChange, autoFocusOnDocumentChange,
doc as TextEditorDocument, doc as TextEditorDocument,
); );
@ -230,20 +247,20 @@ function newEditorState(
settings: EditorSettings | undefined, settings: EditorSettings | undefined,
onScrollRef: MutableRefObject<OnScrollCallback | undefined>, onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
debounceScroll: number, debounceScroll: number,
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
extensions: Extension[], extensions: Extension[],
) { ) {
return EditorState.create({ return EditorState.create({
doc: content, doc: content,
extensions: [ extensions: [
EditorView.domEventHandlers({ 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 }); onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
}, debounceScroll), }, debounceScroll),
keydown: (event) => {
if (event.code === 'KeyS' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
}
},
}), }),
getTheme(theme, settings), getTheme(theme, settings),
history(), history(),
@ -252,6 +269,14 @@ function newEditorState(
...historyKeymap, ...historyKeymap,
...searchKeymap, ...searchKeymap,
{ key: 'Tab', run: acceptCompletion }, { key: 'Tab', run: acceptCompletion },
{
key: 'Mod-s',
preventDefault: true,
run: () => {
onFileSaveRef.current?.();
return true;
},
},
indentKeyBinding, indentKeyBinding,
]), ]),
indentUnit.of('\t'), indentUnit.of('\t'),
@ -266,6 +291,9 @@ function newEditorState(
bracketMatching(), bracketMatching(),
EditorState.tabSize.of(settings?.tabSize ?? 2), EditorState.tabSize.of(settings?.tabSize ?? 2),
indentOnInput(), indentOnInput(),
editableStateField,
EditorState.readOnly.from(editableStateField, (editable) => !editable),
EditorView.editable.from(editableStateField, (editable) => editable),
highlightActiveLineGutter(), highlightActiveLineGutter(),
highlightActiveLine(), highlightActiveLine(),
foldGutter({ foldGutter({
@ -300,8 +328,6 @@ function setEditorDocument(
theme: Theme, theme: Theme,
editable: boolean, editable: boolean,
languageCompartment: Compartment, languageCompartment: Compartment,
readOnlyCompartment: Compartment,
editableCompartment: Compartment,
autoFocus: boolean, autoFocus: boolean,
doc: TextEditorDocument, doc: TextEditorDocument,
) { ) {
@ -317,10 +343,7 @@ function setEditorDocument(
} }
view.dispatch({ view.dispatch({
effects: [ effects: [editableStateEffect.of(editable)],
readOnlyCompartment.reconfigure([EditorState.readOnly.of(!editable)]),
editableCompartment.reconfigure([EditorView.editable.of(editable)]),
],
}); });
getLanguage(doc.filePath).then((languageSupport) => { getLanguage(doc.filePath).then((languageSupport) => {
@ -340,7 +363,7 @@ function setEditorDocument(
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop; const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
if (autoFocus) { if (autoFocus && editable) {
if (needsScrolling) { if (needsScrolling) {
// we have to wait until the scroll position was changed before we can set the focus // we have to wait until the scroll position was changed before we can set the focus
view.scrollDOM.addEventListener( view.scrollDOM.addEventListener(

View File

@ -38,6 +38,9 @@ function getEditorTheme(settings: EditorSettings) {
}, },
'.cm-scroller': { '.cm-scroller': {
lineHeight: '1.5', lineHeight: '1.5',
'&:focus-visible': {
outline: 'none',
},
}, },
'.cm-line': { '.cm-line': {
padding: '0 0 0 4px', padding: '0 0 0 4px',

View File

@ -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<JSX.Element | string>;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const PanelHeaderButton = memo(
({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
return (
<button
className={classNames(
'flex items-center gap-1.5 px-1.5 rounded-lg py-0.5 bg-transparent hover:bg-white disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children}
</button>
);
},
);

View File

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { memo } from 'react'; import { memo, useMemo } 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 type { FileMap } from '../../lib/stores/files';
import { themeStore } from '../../lib/stores/theme'; import { themeStore } from '../../lib/stores/theme';
@ -8,43 +8,107 @@ import { isMobile } from '../../utils/mobile';
import { import {
CodeMirrorEditor, CodeMirrorEditor,
type EditorDocument, type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange, type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll, type OnScrollCallback as OnEditorScroll,
} from '../editor/codemirror/CodeMirrorEditor'; } from '../editor/codemirror/CodeMirrorEditor';
import { PanelHeaderButton } from '../ui/PanelHeaderButton';
import { FileTreePanel } from './FileTreePanel'; import { FileTreePanel } from './FileTreePanel';
interface EditorPanelProps { interface EditorPanelProps {
files?: FileMap; files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument; editorDocument?: EditorDocument;
selectedFile?: string | undefined; selectedFile?: string | undefined;
isStreaming?: boolean; isStreaming?: boolean;
onEditorChange?: OnEditorChange; onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll; onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void; onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
} }
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo( 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'); renderLogger.trace('EditorPanel');
const theme = useStore(themeStore); 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 ( return (
<PanelGroup direction="horizontal"> <PanelGroup direction="horizontal">
<Panel defaultSize={25} minSize={10} collapsible={true}> <Panel className="flex flex-col" defaultSize={25} minSize={10} collapsible={true}>
<FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} /> <div className="border-r h-full">
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]">
<div className="i-ph:tree-structure-duotone shrink-0" />
Files
</div>
<FileTreePanel
files={files}
unsavedFiles={unsavedFiles}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Panel> </Panel>
<PanelResizeHandle /> <PanelResizeHandle />
<Panel defaultSize={75} minSize={20}> <Panel className="flex flex-col" defaultSize={75} minSize={20}>
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px] text-sm">
{activeFile && (
<div className="flex items-center flex-1">
{activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
{activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>
<div className="i-ph:floppy-disk-duotone" />
Save
</PanelHeaderButton>
<PanelHeaderButton onClick={onFileReset}>
<div className="i-ph:clock-counter-clockwise-duotone" />
Reset
</PanelHeaderButton>
</div>
)}
</div>
)}
</div>
<div className="h-full flex-1 overflow-hidden">
<CodeMirrorEditor <CodeMirrorEditor
theme={theme} theme={theme}
editable={true} editable={!isStreaming && editorDocument !== undefined}
settings={{ tabSize: 2 }} settings={editorSettings}
doc={editorDocument} doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()} autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll} onScroll={onEditorScroll}
onChange={onEditorChange} onChange={onEditorChange}
onSave={onFileSave}
/> />
</div>
</Panel> </Panel>
</PanelGroup> </PanelGroup>
); );

View File

@ -12,19 +12,19 @@ interface Props {
onFileSelect?: (filePath: string) => void; onFileSelect?: (filePath: string) => void;
rootFolder?: string; rootFolder?: string;
hiddenFiles?: Array<string | RegExp>; hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
className?: string; className?: string;
} }
export const FileTree = memo( export const FileTree = memo(
({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => { ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => {
renderLogger.trace('FileTree'); renderLogger.trace('FileTree');
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo( const fileList = useMemo(() => {
() => buildFileList(files, rootFolder, computedHiddenFiles), return buildFileList(files, rootFolder, computedHiddenFiles);
[files, rootFolder, computedHiddenFiles], }, [files, rootFolder, computedHiddenFiles]);
);
const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>()); const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
@ -95,6 +95,7 @@ export const FileTree = memo(
key={fileOrFolder.id} key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath} selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder} file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onClick={() => { onClick={() => {
onFileSelect?.(fileOrFolder.fullPath); onFileSelect?.(fileOrFolder.fullPath);
}} }}
@ -134,7 +135,7 @@ interface FolderProps {
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return ( return (
<NodeButton <NodeButton
className="group bg-white hover:bg-gray-100 text-md" className="group bg-white hover:bg-gray-50 text-md"
depth={depth} depth={depth}
iconClasses={classNames({ iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed, 'i-ph:caret-right scale-98': collapsed,
@ -150,10 +151,11 @@ function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
interface FileProps { interface FileProps {
file: FileNode; file: FileNode;
selected: boolean; selected: boolean;
unsavedChanges?: boolean;
onClick: () => void; onClick: () => void;
} }
function File({ file: { depth, name }, onClick, selected }: FileProps) { function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
return ( return (
<NodeButton <NodeButton
className={classNames('group', { className={classNames('group', {
@ -166,7 +168,10 @@ function File({ file: { depth, name }, onClick, selected }: FileProps) {
})} })}
onClick={onClick} onClick={onClick}
> >
{name} <div className="flex items-center">
<div className="flex-1 truncate pr-2">{name}</div>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-warning-400" />}
</div>
</NodeButton> </NodeButton>
); );
} }
@ -187,7 +192,7 @@ function NodeButton({ depth, iconClasses, onClick, className, children }: Button
onClick={() => onClick?.()} onClick={() => onClick?.()}
> >
<div className={classNames('scale-120 shrink-0', iconClasses)}></div> <div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<span className="whitespace-nowrap">{children}</span> <div className="truncate w-full text-left">{children}</div>
</button> </button>
); );
} }

View File

@ -7,15 +7,22 @@ import { FileTree } from './FileTree';
interface FileTreePanelProps { interface FileTreePanelProps {
files?: FileMap; files?: FileMap;
selectedFile?: string; selectedFile?: string;
unsavedFiles?: Set<string>;
onFileSelect?: (value?: string) => void; onFileSelect?: (value?: string) => void;
} }
export const FileTreePanel = memo(({ files, selectedFile, onFileSelect }: FileTreePanelProps) => { export const FileTreePanel = memo(({ files, unsavedFiles, selectedFile, onFileSelect }: FileTreePanelProps) => {
renderLogger.trace('FileTreePanel'); renderLogger.trace('FileTreePanel');
return ( return (
<div className="border-r h-full"> <div className="h-full">
<FileTree files={files} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} /> <FileTree
files={files}
unsavedFiles={unsavedFiles}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div> </div>
); );
}); });

View File

@ -2,12 +2,13 @@ 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 { memo, useCallback, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { toast } from 'react-toastify';
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 { renderLogger } from '../../utils/logger';
import type { import {
OnChangeCallback as OnEditorChange, type OnChangeCallback as OnEditorChange,
OnScrollCallback as OnEditorScroll, type OnScrollCallback as OnEditorScroll,
} from '../editor/codemirror/CodeMirrorEditor'; } from '../editor/codemirror/CodeMirrorEditor';
import { IconButton } from '../ui/IconButton'; import { IconButton } from '../ui/IconButton';
import { EditorPanel } from './EditorPanel'; import { EditorPanel } from './EditorPanel';
@ -41,6 +42,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
const showWorkbench = useStore(workbenchStore.showWorkbench); const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile); const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument); const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files); const files = useStore(workbenchStore.files);
@ -60,6 +62,16 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
workbenchStore.setSelectedFile(filePath); workbenchStore.setSelectedFile(filePath);
}, []); }, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
return ( return (
chatStarted && ( chatStarted && (
<AnimatePresence> <AnimatePresence>
@ -70,7 +82,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<div className="px-3 py-2 border-b border-gray-200"> <div className="px-3 py-2 border-b border-gray-200">
<IconButton <IconButton
icon="i-ph:x-circle" icon="i-ph:x-circle"
className="ml-auto" className="ml-auto -mr-1"
size="xxl" size="xxl"
onClick={() => { onClick={() => {
workbenchStore.showWorkbench.set(false); workbenchStore.showWorkbench.set(false);
@ -85,9 +97,12 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
isStreaming={isStreaming} isStreaming={isStreaming}
selectedFile={selectedFile} selectedFile={selectedFile}
files={files} files={files}
unsavedFiles={unsavedFiles}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll} onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange} onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/> />
</Panel> </Panel>
<PanelResizeHandle /> <PanelResizeHandle />

View File

@ -167,7 +167,7 @@ export class ActionRunner {
await webcontainer.fs.mkdir(folder, { recursive: true }); await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder); logger.debug('Created folder', folder);
} catch (error) { } 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); await webcontainer.fs.writeFile(action.filePath, action.content);
logger.debug(`File written ${action.filePath}`); logger.debug(`File written ${action.filePath}`);
} catch (error) { } catch (error) {
logger.error('Failed to write file\n', error); logger.error('Failed to write file\n\n', error);
} }
} }

View File

@ -1,15 +1,16 @@
import type { WebContainer } from '@webcontainer/api'; import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import { atom, computed, map } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor'; import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
import type { FileMap } from './files'; import type { FileMap, FilesStore } from './files';
export type EditorDocuments = Record<string, EditorDocument>; export type EditorDocuments = Record<string, EditorDocument>;
export class EditorStore { type SelectedFile = WritableAtom<string | undefined>;
#webcontainer: Promise<WebContainer>;
selectedFile = atom<string | undefined>(); export class EditorStore {
documents = map<EditorDocuments>({}); #filesStore: FilesStore;
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map<EditorDocuments>({});
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => { currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
if (!selectedFile) { if (!selectedFile) {
@ -19,12 +20,13 @@ export class EditorStore {
return documents[selectedFile]; return documents[selectedFile];
}); });
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(filesStore: FilesStore) {
this.#webcontainer = webcontainerPromise; this.#filesStore = filesStore;
}
commitFileContent(_filePath: string) { if (import.meta.hot) {
// TODO import.meta.hot.data.documents = this.documents;
import.meta.hot.data.selectedFile = this.selectedFile;
}
} }
setDocuments(files: FileMap) { setDocuments(files: FileMap) {
@ -38,13 +40,14 @@ export class EditorStore {
return undefined; return undefined;
} }
const previousDocument = previousDocuments?.[filePath];
return [ return [
filePath, filePath,
{ {
value: dirent.content, value: dirent.content,
commitPending: false,
filePath, filePath,
scroll: previousDocuments?.[filePath]?.scroll, scroll: previousDocument?.scroll,
}, },
] as [string, EditorDocument]; ] 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 documents = this.documents.get();
const documentState = documents[filePath]; const documentState = documents[filePath];
if (!documentState) { if (!documentState) {
return false; return;
} }
const currentContent = documentState.value; const currentContent = documentState.value;
const contentChanged = currentContent !== content; const contentChanged = currentContent !== newContent;
if (contentChanged) { if (contentChanged) {
this.documents.setKey(filePath, { this.documents.setKey(filePath, {
...documentState, ...documentState,
previousValue: !documentState.commitPending ? currentContent : documentState.previousValue, value: newContent,
commitPending: documentState.previousValue ? documentState.previousValue !== content : true,
value: content,
}); });
} }
return contentChanged;
} }
} }

View File

@ -1,16 +1,20 @@
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api'; 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 { bufferWatchEvents } from '../../utils/buffer';
import { WORK_DIR } from '../../utils/constants'; import { WORK_DIR } from '../../utils/constants';
import { createScopedLogger } from '../../utils/logger';
const logger = createScopedLogger('FilesStore');
const textDecoder = new TextDecoder('utf8', { fatal: true }); const textDecoder = new TextDecoder('utf8', { fatal: true });
interface File { export interface File {
type: 'file'; type: 'file';
content: string; content: string | Uint8Array;
} }
interface Folder { export interface Folder {
type: 'folder'; type: 'folder';
} }
@ -21,14 +25,59 @@ export type FileMap = Record<string, Dirent | undefined>;
export class FilesStore { export class FilesStore {
#webcontainer: Promise<WebContainer>; #webcontainer: Promise<WebContainer>;
files = map<FileMap>({}); /**
* Tracks the number of files without folders.
*/
#size = 0;
files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
get filesCount() {
return this.#size;
}
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.files = this.files;
}
this.#init(); 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() { async #init() {
const webcontainer = await this.#webcontainer; const webcontainer = await this.#webcontainer;
@ -64,10 +113,16 @@ export class FilesStore {
} }
case 'add_file': case 'add_file':
case 'change': { case 'change': {
if (type === 'add_file') {
this.#size++;
}
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) }); this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
break; break;
} }
case 'remove_file': { case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined); this.files.setKey(sanitizedPath, undefined);
break; break;
} }

View File

@ -21,11 +21,20 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
export class WorkbenchStore { export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer); #previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer); #filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(webcontainer); #editorStore = new EditorStore(this.#filesStore);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false); showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>();
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() { get previews() {
return this.#previewsStore.previews; return this.#previewsStore.previews;
@ -45,20 +54,53 @@ export class WorkbenchStore {
setDocuments(files: FileMap) { setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files); 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) { setShowWorkbench(show: boolean) {
this.showWorkbench.set(show); this.showWorkbench.set(show);
} }
setCurrentDocumentContent(newContent: string) { setCurrentDocumentContent(newContent: string | Uint8Array) {
const filePath = this.currentDocument.get()?.filePath; const filePath = this.currentDocument.get()?.filePath;
if (!filePath) { if (!filePath) {
return; return;
} }
const originalContent = this.#filesStore.getFile(filePath)?.content;
const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
this.#editorStore.updateFile(filePath, 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) { setCurrentDocumentScrollPosition(position: ScrollPosition) {
@ -77,6 +119,40 @@ export class WorkbenchStore {
this.#editorStore.setSelectedFile(filePath); 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() { abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this? // 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(); export const workbenchStore = new WorkbenchStore();
if (import.meta.hot) {
import.meta.hot.data.artifacts = workbenchStore.artifacts;
import.meta.hot.data.showWorkbench = workbenchStore.showWorkbench;
}

View File

@ -5,6 +5,7 @@ import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
import { themeStore } from './lib/stores/theme'; import { themeStore } from './lib/stores/theme';
import { stripIndents } from './utils/stripIndent'; import { stripIndents } from './utils/stripIndent';
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
import globalStyles from './styles/index.scss?url'; import globalStyles from './styles/index.scss?url';
import 'virtual:uno.css'; import 'virtual:uno.css';
@ -17,6 +18,7 @@ export const links: LinksFunction = () => [
}, },
{ rel: 'stylesheet', href: tailwindReset }, { rel: 'stylesheet', href: tailwindReset },
{ rel: 'stylesheet', href: globalStyles }, { rel: 'stylesheet', href: globalStyles },
{ rel: 'stylesheet', href: reactToastifyStyles },
{ {
rel: 'preconnect', rel: 'preconnect',
href: 'https://fonts.googleapis.com', href: 'https://fonts.googleapis.com',

View File

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

View File

@ -1,4 +1,5 @@
@import './variables.scss'; @import './variables.scss';
@import './animations.scss';
body { body {
--at-apply: bg-bolt-elements-app-backgroundColor; --at-apply: bg-bolt-elements-app-backgroundColor;

View File

@ -57,7 +57,21 @@ function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
styles.push('', scopeStyles); 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}`;
}, ''),
);
} }
} }

View File

@ -50,6 +50,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.20", "react-resizable-panels": "^2.0.20",
"react-toastify": "^10.0.5",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",

View File

@ -137,6 +137,9 @@ importers:
react-resizable-panels: react-resizable-panels:
specifier: ^2.0.20 specifier: ^2.0.20
version: 2.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: rehype-raw:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
@ -2036,6 +2039,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
code-red@1.0.4: code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
@ -4085,6 +4092,12 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=16.8'
react-toastify@10.0.5:
resolution: {integrity: sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -7147,6 +7160,8 @@ snapshots:
clone@1.0.4: {} clone@1.0.4: {}
clsx@2.1.1: {}
code-red@1.0.4: code-red@1.0.4:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
@ -9715,6 +9730,12 @@ snapshots:
'@remix-run/router': 1.17.1 '@remix-run/router': 1.17.1
react: 18.3.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: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0