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

This commit is contained in:
Dominic Elm
2024-07-24 16:10:39 +02:00
committed by GitHub
parent df25c678d1
commit d45b95dd11
18 changed files with 491 additions and 129 deletions

View File

@@ -1,6 +1,7 @@
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { useEffect, useRef, useState } from 'react';
import { ToastContainer, cssTransition } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useSnapScroll } from '../../lib/hooks';
import { chatStore } from '../../lib/stores/chat';
import { workbenchStore } from '../../lib/stores/workbench';
@@ -8,6 +9,11 @@ import { cubicEasingFn } from '../../utils/easings';
import { createScopedLogger } from '../../utils/logger';
import { BaseChat } from './BaseChat';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat');
export function Chat() {
@@ -90,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} />
</>
);
}

View File

@@ -2,7 +2,7 @@ import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/aut
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search';
import { Compartment, EditorSelection, EditorState, type Extension } from '@codemirror/state';
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
import {
EditorView,
drawSelection,
@@ -27,8 +27,6 @@ const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument {
value: string | Uint8Array;
previousValue?: string | Uint8Array;
commitPending: boolean;
filePath: string;
scroll?: ScrollPosition;
}
@@ -54,6 +52,7 @@ export interface EditorUpdate {
export type OnChangeCallback = (update: EditorUpdate) => void;
export type OnScrollCallback = (position: ScrollPosition) => void;
export type OnSaveCallback = () => void;
interface Props {
theme: Theme;
@@ -65,12 +64,30 @@ interface Props {
autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback;
onScroll?: OnScrollCallback;
onSave?: OnSaveCallback;
className?: string;
settings?: EditorSettings;
}
type EditorStates = Map<string, EditorState>;
const editableStateEffect = StateEffect.define<boolean>();
const editableStateField = StateField.define<boolean>({
create() {
return true;
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(editableStateEffect)) {
return effect.value;
}
}
return value;
},
});
export const CodeMirrorEditor = memo(
({
id,
@@ -81,15 +98,14 @@ export const CodeMirrorEditor = memo(
editable = true,
onScroll,
onChange,
onSave,
theme,
settings,
className = '',
}: Props) => {
renderLogger.debug('CodeMirrorEditor');
renderLogger.trace('CodeMirrorEditor');
const [languageCompartment] = useState(new Compartment());
const [readOnlyCompartment] = useState(new Compartment());
const [editableCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
@@ -98,14 +114,21 @@ export const CodeMirrorEditor = memo(
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const isBinaryFile = doc?.value instanceof Uint8Array;
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(

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { FileMap } from '../../lib/stores/files';
import { themeStore } from '../../lib/stores/theme';
@@ -8,43 +8,107 @@ import { isMobile } from '../../utils/mobile';
import {
CodeMirrorEditor,
type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '../editor/codemirror/CodeMirrorEditor';
import { PanelHeaderButton } from '../ui/PanelHeaderButton';
import { FileTreePanel } from './FileTreePanel';
interface EditorPanelProps {
files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
}
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo(
({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => {
({
files,
unsavedFiles,
editorDocument,
selectedFile,
isStreaming,
onFileSelect,
onEditorChange,
onEditorScroll,
onFileSave,
onFileReset,
}: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const activeFile = useMemo(() => {
if (!editorDocument) {
return '';
}
return editorDocument.filePath.split('/').at(-1);
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
return (
<PanelGroup direction="horizontal">
<Panel defaultSize={25} minSize={10} collapsible={true}>
<FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} />
<Panel className="flex flex-col" defaultSize={25} minSize={10} collapsible={true}>
<div className="border-r h-full">
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]">
<div className="i-ph:tree-structure-duotone shrink-0" />
Files
</div>
<FileTreePanel
files={files}
unsavedFiles={unsavedFiles}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Panel>
<PanelResizeHandle />
<Panel defaultSize={75} minSize={20}>
<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>
);

View File

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

View File

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

View File

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