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 { 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,35 +96,38 @@ export function Chat() {
|
|||||||
const [messageRef, scrollRef] = useSnapScroll();
|
const [messageRef, scrollRef] = useSnapScroll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseChat
|
<>
|
||||||
ref={animationScope}
|
<BaseChat
|
||||||
textareaRef={textareaRef}
|
ref={animationScope}
|
||||||
input={input}
|
textareaRef={textareaRef}
|
||||||
chatStarted={chatStarted}
|
input={input}
|
||||||
isStreaming={isLoading}
|
chatStarted={chatStarted}
|
||||||
enhancingPrompt={enhancingPrompt}
|
isStreaming={isLoading}
|
||||||
promptEnhanced={promptEnhanced}
|
enhancingPrompt={enhancingPrompt}
|
||||||
sendMessage={sendMessage}
|
promptEnhanced={promptEnhanced}
|
||||||
messageRef={messageRef}
|
sendMessage={sendMessage}
|
||||||
scrollRef={scrollRef}
|
messageRef={messageRef}
|
||||||
handleInputChange={handleInputChange}
|
scrollRef={scrollRef}
|
||||||
handleStop={abort}
|
handleInputChange={handleInputChange}
|
||||||
messages={messages.map((message, i) => {
|
handleStop={abort}
|
||||||
if (message.role === 'user') {
|
messages={messages.map((message, i) => {
|
||||||
return message;
|
if (message.role === 'user') {
|
||||||
}
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
content: parsedMessages[i] || '',
|
content: parsedMessages[i] || '',
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
enhancePrompt={() => {
|
enhancePrompt={() => {
|
||||||
enhancePrompt(input, (input) => {
|
enhancePrompt(input, (input) => {
|
||||||
setInput(input);
|
setInput(input);
|
||||||
scrollTextArea();
|
scrollTextArea();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
></BaseChat>
|
/>
|
||||||
|
<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 { 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;
|
||||||
|
|
||||||
onScrollRef.current = onScroll;
|
/**
|
||||||
onChangeRef.current = onChange;
|
* This effect is used to avoid side effects directly in the render function
|
||||||
|
* and instead the refs are updated after each render.
|
||||||
docRef.current = doc;
|
*/
|
||||||
themeRef.current = theme;
|
useEffect(() => {
|
||||||
|
onScrollRef.current = onScroll;
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
onSaveRef.current = onSave;
|
||||||
|
docRef.current = doc;
|
||||||
|
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(
|
||||||
|
@ -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',
|
||||||
|
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 { 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}>
|
||||||
<CodeMirrorEditor
|
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px] text-sm">
|
||||||
theme={theme}
|
{activeFile && (
|
||||||
editable={true}
|
<div className="flex items-center flex-1">
|
||||||
settings={{ tabSize: 2 }}
|
{activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
|
||||||
doc={editorDocument}
|
{activeFileUnsaved && (
|
||||||
autoFocusOnDocumentChange={!isMobile()}
|
<div className="flex gap-1 ml-auto -mr-1.5">
|
||||||
onScroll={onEditorScroll}
|
<PanelHeaderButton onClick={onFileSave}>
|
||||||
onChange={onEditorChange}
|
<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>
|
</Panel>
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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 />
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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',
|
||||||
|
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 './variables.scss';
|
||||||
|
@import './animations.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
--at-apply: bg-bolt-elements-app-backgroundColor;
|
--at-apply: bg-bolt-elements-app-backgroundColor;
|
||||||
|
@ -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}`;
|
||||||
|
}, ''),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user