diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx index 4e9a3923..0e535d30 100644 --- a/packages/react/src/Panels/EditorPanel.tsx +++ b/packages/react/src/Panels/EditorPanel.tsx @@ -1,5 +1,5 @@ import type { I18n } from '@tutorialkit/types'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, type ComponentProps } from 'react'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; import { CodeMirrorEditor, @@ -29,6 +29,7 @@ interface Props { onEditorScroll?: OnEditorScroll; onHelpClick?: () => void; onFileSelect?: (value?: string) => void; + onFileTreeChange?: ComponentProps['onFileChange']; } export function EditorPanel({ @@ -46,6 +47,7 @@ export function EditorPanel({ onEditorScroll, onHelpClick, onFileSelect, + onFileTreeChange, }: Props) { const fileTreePanelRef = useRef(null); @@ -81,6 +83,7 @@ export function EditorPanel({ files={files} scope={fileTreeScope} onFileSelect={onFileSelect} + onFileChange={onFileTreeChange} /> ['onFileTreeChange']>>[0]; + interface Props { tutorialStore: TutorialStore; theme: Theme; @@ -111,6 +113,16 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { } } + function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) { + if (method == 'ADD' && type === 'FILE') { + return tutorialStore.addFile(value); + } + + if (method == 'ADD' && type === 'DIRECTORY') { + return tutorialStore.addFolder(value); + } + } + useEffect(() => { if (tutorialStore.hasSolution()) { setHelpAction('solve'); @@ -139,6 +151,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { helpAction={helpAction} onHelpClick={lessonFullyLoaded ? onHelpClick : undefined} onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)} + onFileTreeChange={onFileTreeChange} selectedFile={selectedFile} onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)} onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)} diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx index d3ccccd4..5859b5e8 100644 --- a/packages/react/src/core/FileTree.tsx +++ b/packages/react/src/core/FileTree.tsx @@ -4,10 +4,17 @@ import { classNames } from '../utils/classnames.js'; const NODE_PADDING_LEFT = 12; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; +interface FileChangeEvent { + type: 'FILE' | 'DIRECTORY'; + method: 'ADD' | 'REMOVE' | 'RENAME'; + value: string; +} + interface Props { files: string[]; selectedFile?: string; onFileSelect?: (filePath: string) => void; + onFileChange?: (event: FileChangeEvent) => void; hideRoot: boolean; scope?: string; hiddenFiles?: Array; diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index f5e92f64..b3310a1a 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -13,7 +13,7 @@ export interface ScrollPosition { left: number; } -export type EditorDocuments = Record; +export type EditorDocuments = Record; export class EditorStore { selectedFile = atom(); @@ -83,6 +83,21 @@ export class EditorStore { }); } + addFileOrFolder(filePath: string) { + // when adding file to empty folder, remove the empty folder from documents + const emptyDirectory = this.files.value?.find((path) => filePath.startsWith(path)); + + if (emptyDirectory) { + this.documents.setKey(emptyDirectory, undefined); + } + + this.documents.setKey(filePath, { + filePath, + value: '', + loading: false, + }); + } + updateFile(filePath: string, content: string): boolean { const documentState = this.documents.get()[filePath]; diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index 64e365fa..523e9958 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -308,6 +308,27 @@ export class TutorialStore { this._editorStore.setSelectedFile(filePath); } + addFile(filePath: string) { + // prevent creating duplicates + if (this._editorStore.files.get().includes(filePath)) { + return this.setSelectedFile(filePath); + } + + this._editorStore.addFileOrFolder(filePath); + this.setSelectedFile(filePath); + this._runner.updateFile(filePath, ''); + } + + addFolder(folderPath: string) { + // prevent creating duplicates + if (this._editorStore.files.get().includes(folderPath)) { + return this.setSelectedFile(folderPath); + } + + this._editorStore.addFileOrFolder(folderPath); + this._runner.createFolder(folderPath); + } + updateFile(filePath: string, content: string) { const hasChanged = this._editorStore.updateFile(filePath, content); diff --git a/packages/runtime/src/tutorial-runner.ts b/packages/runtime/src/tutorial-runner.ts index 1d73bbc6..05fd7e9f 100644 --- a/packages/runtime/src/tutorial-runner.ts +++ b/packages/runtime/src/tutorial-runner.ts @@ -98,6 +98,23 @@ export class TutorialRunner { this._currentCommandProcess?.resize({ cols, rows }); } + createFolder(folderPAth: string): void { + const previousLoadPromise = this._currentLoadTask?.promise; + + this._currentLoadTask = newTask( + async (signal) => { + await previousLoadPromise; + + const webcontainer = await this._webcontainer; + + signal.throwIfAborted(); + + await webcontainer.fs.mkdir(folderPAth); + }, + { ignoreCancel: true }, + ); + } + /** * Update the content of a single file in WebContainer. *