feat(workbench): sync file changes back to webcontainer (#5)
This commit is contained in:
@@ -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