mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
feat(workbench): sync file changes back to webcontainer (#5)
This commit is contained in:
parent
df25c678d1
commit
d45b95dd11
@ -1,6 +1,7 @@
|
||||
import { useChat } from 'ai/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ToastContainer, cssTransition } from 'react-toastify';
|
||||
import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks';
|
||||
import { chatStore } from '../../lib/stores/chat';
|
||||
import { workbenchStore } from '../../lib/stores/workbench';
|
||||
@ -8,6 +9,11 @@ import { cubicEasingFn } from '../../utils/easings';
|
||||
import { createScopedLogger } from '../../utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
exit: 'animated fadeOutRight',
|
||||
});
|
||||
|
||||
const logger = createScopedLogger('Chat');
|
||||
|
||||
export function Chat() {
|
||||
@ -90,35 +96,38 @@ export function Chat() {
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={handleInputChange}
|
||||
handleStop={abort}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
<>
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={handleInputChange}
|
||||
handleStop={abort}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(input, (input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
});
|
||||
}}
|
||||
></BaseChat>
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(input, (input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToastContainer position="bottom-right" stacked={true} pauseOnFocusLoss={true} transition={toastAnimation} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
onScrollRef.current = onScroll;
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
docRef.current = doc;
|
||||
themeRef.current = theme;
|
||||
/**
|
||||
* This effect is used to avoid side effects directly in the render function
|
||||
* and instead the refs are updated after each render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
onChangeRef.current = onChange;
|
||||
onSaveRef.current = onSave;
|
||||
docRef.current = doc;
|
||||
themeRef.current = theme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdate = debounce((update: EditorUpdate) => {
|
||||
@ -164,10 +187,8 @@ export const CodeMirrorEditor = memo(
|
||||
const theme = themeRef.current!;
|
||||
|
||||
if (!doc) {
|
||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [
|
||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
readOnlyCompartment.of([]),
|
||||
editableCompartment.of([]),
|
||||
]);
|
||||
|
||||
view.setState(state);
|
||||
@ -188,10 +209,8 @@ export const CodeMirrorEditor = memo(
|
||||
let state = editorStates.get(doc.filePath);
|
||||
|
||||
if (!state) {
|
||||
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
|
||||
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
readOnlyCompartment.of([EditorState.readOnly.of(!editable)]),
|
||||
editableCompartment.of([EditorView.editable.of(editable)]),
|
||||
]);
|
||||
|
||||
editorStates.set(doc.filePath, state);
|
||||
@ -204,8 +223,6 @@ export const CodeMirrorEditor = memo(
|
||||
theme,
|
||||
editable,
|
||||
languageCompartment,
|
||||
readOnlyCompartment,
|
||||
editableCompartment,
|
||||
autoFocusOnDocumentChange,
|
||||
doc as TextEditorDocument,
|
||||
);
|
||||
@ -230,20 +247,20 @@ function newEditorState(
|
||||
settings: EditorSettings | undefined,
|
||||
onScrollRef: MutableRefObject<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(
|
||||
|
@ -38,6 +38,9 @@ function getEditorTheme(settings: EditorSettings) {
|
||||
},
|
||||
'.cm-scroller': {
|
||||
lineHeight: '1.5',
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0 0 4px',
|
||||
|
36
packages/bolt/app/components/ui/PanelHeaderButton.tsx
Normal file
36
packages/bolt/app/components/ui/PanelHeaderButton.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
@ -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}>
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={true}
|
||||
settings={{ tabSize: 2 }}
|
||||
doc={editorDocument}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onScroll={onEditorScroll}
|
||||
onChange={onEditorChange}
|
||||
/>
|
||||
<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={!isStreaming && editorDocument !== undefined}
|
||||
settings={editorSettings}
|
||||
doc={editorDocument}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onScroll={onEditorScroll}
|
||||
onChange={onEditorChange}
|
||||
onSave={onFileSave}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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 />
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
36
packages/bolt/app/styles/animations.scss
Normal file
36
packages/bolt/app/styles/animations.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
@import './variables.scss';
|
||||
@import './animations.scss';
|
||||
|
||||
body {
|
||||
--at-apply: bg-bolt-elements-app-backgroundColor;
|
||||
|
@ -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}`;
|
||||
}, ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user