feat(workbench): sync file changes back to webcontainer (#5)
This commit is contained in:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user