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

View File

@ -38,6 +38,9 @@ function getEditorTheme(settings: EditorSettings) {
},
'.cm-scroller': {
lineHeight: '1.5',
'&:focus-visible': {
outline: 'none',
},
},
'.cm-line': {
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 { memo } from 'react';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { FileMap } from '../../lib/stores/files';
import { themeStore } from '../../lib/stores/theme';
@ -8,43 +8,107 @@ import { isMobile } from '../../utils/mobile';
import {
CodeMirrorEditor,
type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '../editor/codemirror/CodeMirrorEditor';
import { PanelHeaderButton } from '../ui/PanelHeaderButton';
import { FileTreePanel } from './FileTreePanel';
interface EditorPanelProps {
files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
}
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo(
({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => {
({
files,
unsavedFiles,
editorDocument,
selectedFile,
isStreaming,
onFileSelect,
onEditorChange,
onEditorScroll,
onFileSave,
onFileReset,
}: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const activeFile = useMemo(() => {
if (!editorDocument) {
return '';
}
return editorDocument.filePath.split('/').at(-1);
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
return (
<PanelGroup direction="horizontal">
<Panel defaultSize={25} minSize={10} collapsible={true}>
<FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} />
<Panel className="flex flex-col" defaultSize={25} minSize={10} collapsible={true}>
<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>
<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
theme={theme}
editable={true}
settings={{ tabSize: 2 }}
editable={!isStreaming && editorDocument !== undefined}
settings={editorSettings}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
onSave={onFileSave}
/>
</div>
</Panel>
</PanelGroup>
);

View File

@ -12,19 +12,19 @@ interface Props {
onFileSelect?: (filePath: string) => void;
rootFolder?: string;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
className?: string;
}
export const FileTree = memo(
({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => {
({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => {
renderLogger.trace('FileTree');
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(
() => buildFileList(files, rootFolder, computedHiddenFiles),
[files, rootFolder, computedHiddenFiles],
);
const fileList = useMemo(() => {
return buildFileList(files, rootFolder, computedHiddenFiles);
}, [files, rootFolder, computedHiddenFiles]);
const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
@ -95,6 +95,7 @@ export const FileTree = memo(
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
@ -134,7 +135,7 @@ interface FolderProps {
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return (
<NodeButton
className="group bg-white hover:bg-gray-100 text-md"
className="group bg-white hover:bg-gray-50 text-md"
depth={depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
@ -150,10 +151,11 @@ function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected }: FileProps) {
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
return (
<NodeButton
className={classNames('group', {
@ -166,7 +168,10 @@ function File({ file: { depth, name }, onClick, selected }: FileProps) {
})}
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>
);
}
@ -187,7 +192,7 @@ function NodeButton({ depth, iconClasses, onClick, className, children }: Button
onClick={() => onClick?.()}
>
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,20 @@
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
import { map } from 'nanostores';
import { map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path';
import { bufferWatchEvents } from '../../utils/buffer';
import { WORK_DIR } from '../../utils/constants';
import { createScopedLogger } from '../../utils/logger';
const logger = createScopedLogger('FilesStore');
const textDecoder = new TextDecoder('utf8', { fatal: true });
interface File {
export interface File {
type: 'file';
content: string;
content: string | Uint8Array;
}
interface Folder {
export interface Folder {
type: 'folder';
}
@ -21,14 +25,59 @@ export type FileMap = Record<string, Dirent | undefined>;
export class FilesStore {
#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>) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.files = this.files;
}
this.#init();
}
getFile(filePath: string) {
const dirent = this.files.get()[filePath];
if (dirent?.type !== 'file') {
return undefined;
}
return dirent;
}
async saveFile(filePath: string, content: string | Uint8Array) {
const webcontainer = await this.#webcontainer;
try {
const relativePath = nodePath.relative(webcontainer.workdir, filePath);
if (!relativePath) {
throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
}
await webcontainer.fs.writeFile(relativePath, content);
this.files.setKey(filePath, { type: 'file', content });
logger.info('File updated');
} catch (error) {
logger.error('Failed to update file content\n\n', error);
throw error;
}
}
async #init() {
const webcontainer = await this.#webcontainer;
@ -64,10 +113,16 @@ export class FilesStore {
}
case 'add_file':
case 'change': {
if (type === 'add_file') {
this.#size++;
}
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
break;
}
case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined);
break;
}

View File

@ -21,11 +21,20 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom<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() {
return this.#previewsStore.previews;
@ -45,20 +54,53 @@ export class WorkbenchStore {
setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files);
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
// we find the first file and select it
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file') {
this.setSelectedFile(filePath);
break;
}
}
}
}
setShowWorkbench(show: boolean) {
this.showWorkbench.set(show);
}
setCurrentDocumentContent(newContent: string) {
setCurrentDocumentContent(newContent: string | Uint8Array) {
const filePath = this.currentDocument.get()?.filePath;
if (!filePath) {
return;
}
const originalContent = this.#filesStore.getFile(filePath)?.content;
const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
this.#editorStore.updateFile(filePath, newContent);
const currentDocument = this.currentDocument.get();
if (currentDocument) {
const previousUnsavedFiles = this.unsavedFiles.get();
if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) {
return;
}
const newUnsavedFiles = new Set(previousUnsavedFiles);
if (unsavedChanges) {
newUnsavedFiles.add(currentDocument.filePath);
} else {
newUnsavedFiles.delete(currentDocument.filePath);
}
this.unsavedFiles.set(newUnsavedFiles);
}
}
setCurrentDocumentScrollPosition(position: ScrollPosition) {
@ -77,6 +119,40 @@ export class WorkbenchStore {
this.#editorStore.setSelectedFile(filePath);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
const { filePath } = currentDocument;
await this.#filesStore.saveFile(filePath, currentDocument.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
}
resetCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
const { filePath } = currentDocument;
const file = this.#filesStore.getFile(filePath);
if (!file) {
return;
}
this.setCurrentDocumentContent(file.content);
}
abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
@ -136,8 +212,3 @@ export class WorkbenchStore {
}
export const workbenchStore = new WorkbenchStore();
if (import.meta.hot) {
import.meta.hot.data.artifacts = workbenchStore.artifacts;
import.meta.hot.data.showWorkbench = workbenchStore.showWorkbench;
}

View File

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

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 './animations.scss';
body {
--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);
}
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-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.20",
"react-toastify": "^10.0.5",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remix-utils": "^7.6.0",

View File

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