From 6a8fd4714468e2b6fc86935dca22368b9ddfc4be Mon Sep 17 00:00:00 2001 From: Amina Date: Mon, 23 Dec 2024 17:09:52 -0600 Subject: [PATCH] feature: sidebar with loading indicator & dropzone --- apps/docs/components/Playground.js | 839 ++++-- apps/docs/package.json | 5 +- package-lock.json | 3912 +++++++++++++++------------- 3 files changed, 2845 insertions(+), 1911 deletions(-) diff --git a/apps/docs/components/Playground.js b/apps/docs/components/Playground.js index b6ffd58f..a6915b28 100644 --- a/apps/docs/components/Playground.js +++ b/apps/docs/components/Playground.js @@ -7,31 +7,39 @@ * @format */ -// Import necessary components and libraries import BrowserOnly from '@docusaurus/BrowserOnly'; import { library } from '@fortawesome/fontawesome-svg-core'; -import { faBars, faRotateRight } from '@fortawesome/free-solid-svg-icons'; +import { faFolderOpen, faTrashCan } from '@fortawesome/free-regular-svg-icons'; +import { + faBars, + faChevronDown, + faChevronRight, + faFileCirclePlus, + faFolder, + faRotateRight, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as stylex from '@stylexjs/stylex'; import { WebContainer, reloadPreview } from '@webcontainer/api'; import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; import { UnControlled as CodeMirror } from 'react-codemirror2'; -import 'codemirror/mode/javascript/javascript'; +import { useDropzone } from 'react-dropzone'; +import ClipLoader from 'react-spinners/ClipLoader'; import { files } from './playground-utils/files'; -import useDebounced from './hooks/useDebounced'; -// Add FontAwesome icons to the library -library.add(faBars, faRotateRight); +library.add( + faChevronDown, + faChevronRight, + faFileCirclePlus, + faTrashCan, + faFolder, + faFolderOpen, + faBars, + faRotateRight, +); -/** - * Function to spawn a command in the WebContainer instance. - * @param {WebContainer} instance - The WebContainer instance. - * @param {...string} args - Command arguments to be executed. - * @returns {Promise} - Promise that resolves when the command execution is successful. - */ async function wcSpawn(instance, ...args) { - console.log('Running:', args.join(' ')); const process = await instance.spawn(...args); process.output.pipeTo( new WritableStream({ @@ -42,54 +50,284 @@ async function wcSpawn(instance, ...args) { ); const exitCode = await process.exit; if (exitCode !== 0) { - console.log('Command Failed:', args.join(' '), 'with exit code', exitCode); throw new Error('Command Failed', args.join(' ')); } - - console.log('Command Successful:', args.join(' ')); return process; } -/** - * Function to initialize and configure the WebContainer. - * @returns {Promise} - Promise that resolves with the configured WebContainer instance. - */ async function makeWebcontainer() { - console.log('Booting WebContainer...'); const instance = await WebContainer.boot(); - console.log('Boot successful!'); - - console.log('Mounting files...'); await instance.mount(files); - console.log('Mounted files!'); - - console.log('Installing dependencies...'); await wcSpawn(instance, 'npm', ['install']); - console.log('Installed dependencies!'); - return instance; } -/** - * Main component for the Playground. - * @returns {JSX.Element} - The rendered JSX element. - */ export default function Playground() { const instance = useRef(null); + const codeChangeTimeout = useRef(null); + const loadingTimeout = useRef(null); + const urlRef = useRef(null); + const previewJSFiles = useRef(null); + const previewCSSFiles = useRef(null); + const filesRef = useRef([ + { + name: 'main.jsx', + content: files.src.directory['main.jsx'].file.contents, + isEditing: false, + }, + { + name: 'App.jsx', + content: files.src.directory['App.jsx'].file.contents, + isEditing: false, + }, + ]); const [url, setUrl] = useState(null); - const [code, _setCode] = useState( - files.src.directory['App.jsx'].file.contents, - ); + let [loading, setLoading] = useState(true); + let [color, setColor] = useState('var(--ifm-color-primary)'); + const [selectedPreviewFile, setSelectedPreviewFile] = useState(null); + const [resetPreviewFiles, setResetPreviewFiles] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [activeFileIndex, setActiveFileIndex] = useState(1); + const [editingValue, setEditingValue] = useState(null); const [error, setError] = useState(null); - const urlRef = useRef(null); + const [directories, setDirectories] = useState([ + { name: 'src', showFiles: true }, + { name: 'js', showFiles: false }, + { name: 'metadata', showFiles: false }, + ]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop: async (droppedFiles) => { + const jsxFiles = droppedFiles.filter((file) => + file.name.endsWith('.jsx'), + ); + const invalidFiles = droppedFiles.filter( + (file) => !file.name.endsWith('.jsx'), + ); + + let errorMessage = ''; + + if (droppedFiles.length === 1 && invalidFiles.length === 1) { + // single file dropped invalid + errorMessage = 'Only .jsx files are allowed in the src directory.'; + } else if (invalidFiles.length > 0) { + // multiple files dropped, some invalid + const invalidFileNames = invalidFiles + .map((file) => file.name) + .join(', '); + errorMessage = `The following files are invalid and were not added:\n${invalidFileNames}`; + } + + if (errorMessage) { + alert(errorMessage); + } + + if (jsxFiles.length === 0) { + return; + } + + const containerInstance = instance.current; + + for (const file of jsxFiles) { + const reader = new FileReader(); + + const fileContent = await new Promise((resolve, reject) => { + reader.onload = (e) => resolve(e.target.result); + reader.onerror = (err) => reject(err); + reader.readAsText(file); + }); + + const filesLength = filesRef.current.length; + const newFileName = file.name || `file${filesLength + 1}`; + const newFile = { name: newFileName, content: fileContent }; + + filesRef.current.push(newFile); + + await containerInstance.fs.writeFile( + `./src/${newFileName}`, + fileContent, + ); + + setActiveFileIndex(filesLength); + + console.log(`File added to src: ${newFileName}`); + console.log(`File content: ${fileContent}`); // debugging + } + }, + noDragEventsBubbling: true, + noClick: true, + }); + + const handleSelectedPreviewFile = (fileName, index) => { + setActiveFileIndex(null); + const content = fileName.includes('.json') + ? previewCSSFiles.current.contents[index] + : previewJSFiles.current.contents[index]; + setSelectedPreviewFile({ file: fileName, index, content }); + }; + + const handlePreviewFiles = async (removeFile = null) => { + // retrieves generated preview files inside the 'js' and 'metadata' directories + // if a file is removed it also removes the associated preview files + // NOTE: the current vite setup does not generate any preview files + try { + const jsFiles = await getPreviewFiles('js'); + const cssFiles = await getPreviewFiles('metadata'); + previewJSFiles.current = jsFiles; + previewCSSFiles.current = cssFiles; + } catch (error) { + console.error(`Failed to retrieve preview files.\n${error.message}`); + } + + async function getPreviewFiles(dirName) { + const containerInstance = instance.current; + const files = await containerInstance.fs.readdir(`./previews/${dirName}`); + const validFiles = removeFile + ? files.filter((fileName) => !fileName.includes(removeFile)) + : files; + const contentsPromises = validFiles.map((fileName) => + containerInstance.fs.readFile( + `./previews/${dirName}/${fileName}`, + 'utf-8', + ), + ); + const contents = await Promise.all(contentsPromises); + + if (removeFile) { + for (const fileName of files) { + if (fileName.includes(removeFile)) { + await containerInstance.fs.rm(`./previews/${dirName}/${fileName}`); + break; + } + } + } + + return { files: validFiles, contents }; + } + }; + + const enableEditMode = (index) => { + if (!url) return; + filesRef.current[index].isEditing = true; + setEditingValue(filesRef.current[index].name); + }; + + const disableEditMode = async (index) => { + filesRef.current[index].isEditing = false; + const newFilename = editingValue; + const oldFilename = filesRef.current[index].name; + + if (!editingValue || oldFilename === newFilename) { + setEditingValue(null); + return; + } + + for (const file of filesRef.current) { + if (file.name === newFilename) { + setEditingValue(null); + return alert('File already exists.'); + } + } + + const containerInstance = instance.current; + containerInstance.fs.rm(`./src/${oldFilename}`); + containerInstance.fs.writeFile( + `./src/${newFilename}`, + filesRef.current[index].content, + ); + filesRef.current[index].name = newFilename; + await handlePreviewFiles(oldFilename); + setEditingValue(null); + }; + + const toggleDirectory = (index) => { + setDirectories((prevDirectories) => + prevDirectories.map((dir, i) => + i === index ? { ...dir, showFiles: !dir.showFiles } : dir, + ), + ); + }; + + const addFile = async () => { + if (!url) return; + const containerInstance = instance.current; + const filesLength = filesRef.current.length; + const newFile = { name: `file${filesLength + 1}`, content: '' }; + filesRef.current.push(newFile); + await containerInstance.fs.writeFile(`./src/file${filesLength + 1}`, ''); + setActiveFileIndex(filesLength); + }; + const renameFile = (newName) => { + if (!url) return; + setEditingValue(newName); + }; + + const deleteFile = async (index) => { + if (!url) return; + + if (filesRef.current.length === 1) + return alert('At least one file must remain.'); + + const containerInstance = instance.current; + const fileName = filesRef.current[index].name; + const filePath = `./src/${fileName}`; + const updatedFiles = filesRef.current.filter((_, i) => i !== index); + + filesRef.current = updatedFiles; + await containerInstance.fs.rm(filePath); + await handlePreviewFiles(fileName); + + if (index === activeFileIndex && index > 0) { + setActiveFileIndex((prev) => prev - 1); + } else if (activeFileIndex > updatedFiles.length - 1) { + setActiveFileIndex(updatedFiles.length - 1); + } else { + setResetPreviewFiles((prev) => !prev); + } + }; + + const handleCodeChange = (updatedCode) => { + if (!url) return; + + if ( + selectedPreviewFile || + updatedCode === filesRef.current[activeFileIndex].content + ) + return; + + if (codeChangeTimeout.current) { + clearTimeout(codeChangeTimeout.current); + codeChangeTimeout.current = null; + } + + if (filesRef.current[activeFileIndex]) { + filesRef.current[activeFileIndex] = { + ...filesRef.current[activeFileIndex], + content: updatedCode, + }; + } + + codeChangeTimeout.current = setTimeout(async () => { + try { + await updateFiles(); + await handlePreviewFiles(); + } catch (error) { + console.error(`Failed to update files.\n${error.message}`); + } + + async function updateFiles() { + const containerInstance = instance.current; + const updateFilesPromise = filesRef.current.map(({ name, content }) => { + containerInstance.fs.writeFile(`./src/${name}`, content); + }); + await Promise.all(updateFilesPromise); + } + }, 1000); + }; - /** - * Function to build the WebContainer and start the development server. - */ const build = async () => { const containerInstance = instance.current; if (!containerInstance) { - console.log('error due to failed instance'); setError( 'WebContainer failed to load. Please try reloading or use a different browser.', ); @@ -106,125 +344,282 @@ export default function Playground() { }, }), ); + console.log('Waiting for server-ready event...'); - containerInstance.on('server-ready', (port, url) => { + containerInstance.on('server-ready', async (port, url) => { console.log('server-ready', port, url); - setUrl(url); urlRef.current = url; + await handlePreviewFiles(); + setUrl(urlRef.current); }); }; - /** - * Function to update files in the WebContainer. - */ - const updateFiles = async (updatedCode) => { - const containerInstance = instance.current; - const filePath = './src/App.jsx'; - await containerInstance.fs.writeFile(filePath, updatedCode); - }; - - const debouncedUpdateFiles = useDebounced(async (newCode) => { - await updateFiles(newCode); - }, 1000); - - /** - * Function to handle code changes in the CodeMirror editor. - * @param {string} newCode - The new code content from the editor. - */ - const handleCodeChange = (newCode) => { - // setCode(newCode); - debouncedUpdateFiles(newCode); - }; - - /** - * Function to reload the WebContainer preview. - */ - const reloadWebContainer = async () => { + const reloadContainerPreview = async () => { if (!url) return; - const iframe = document.querySelector('iframe'); - if (!iframe) return; try { - if (error) { - setError(null); + const iframe = document.querySelector('iframe'); + if (!iframe.src.includes(urlRef.current)) { + throw new Error('Cannot reload preview due to invalid iframe.'); } + if (error) setError(null); await reloadPreview(iframe); - } catch (err) { - console.error(`Error reloading preview: ${err.message}`); + } catch (error) { + console.error(`Error reloading preview: ${error.message}`); setError( 'WebContainer failed to load. Please try reloading or use a different browser.', ); } }; - // useEffect to initialize the WebContainer and build it useEffect(() => { - let loadingTimeout; + require('codemirror/mode/javascript/javascript'); makeWebcontainer().then((i) => { instance.current = i; build().then(() => { - loadingTimeout = setTimeout(() => { - if (!urlRef.current) { - console.log('error due to timeout...'); + loadingTimeout.current = setTimeout(() => { + const iframe = document.querySelector('iframe'); + if (!urlRef.current || !iframe.src.includes(urlRef.current)) { setError( 'WebContainer failed to load. Please try reloading or use a different browser.', ); } - loadingTimeout = null; }, 10000); }); }); - - // Cleanup function to unmount the WebContainer and clear timeouts - return () => { + () => { instance.current.unmount(); - if (loadingTimeout != null) { - clearTimeout(loadingTimeout); + if (codeChangeTimeout.current) { + clearTimeout(codeChangeTimeout.current); + } + if (loadingTimeout.current) { + clearTimeout(loadingTimeout.current); } }; }, []); - // Render the Playground component return (
- {/* */} +
-
+ +
setIsSidebarOpen(false)} + > + {isSidebarOpen && (!url || error) && ( +
+ +
+ )} + {isSidebarOpen && url && !error && ( +
e.stopPropagation()} + > + {directories.map((directory, dirIndex) => ( +
+
toggleDirectory(dirIndex)} + > + + {directory.showFiles ? ( + + ) : ( + + )} + + + {directory.showFiles ? ( + + ) : ( + + )} + + + {directory.name} + + {directory.name === 'src' && ( + + )} +
+ {directory.showFiles && directory.name === 'src' && ( +
+ + {filesRef.current.map((file, index) => ( +
{ + setSelectedPreviewFile(null); + setActiveFileIndex(index); + }} + onDoubleClick={() => enableEditMode(index)} + > + {file.isEditing ? ( + renameFile(e.target.value)} + onBlur={() => disableEditMode(index)} + onKeyDown={(e) => { + if (e.key === 'Enter') disableEditMode(index); + }} + {...stylex.props([ + styles.tabInput, + activeFileIndex === index && styles.editingInput, + ])} + /> + ) : ( + enableEditMode(index)} + {...stylex.props(styles.tabInput)} + /> + )} + +
+ ))} +
+ )} + {directory.showFiles && directory.name === 'js' && ( +
+ {previewJSFiles.current?.files.map((file, index) => ( +
handleSelectedPreviewFile(file, index)} + {...stylex.props([ + styles.tab, + selectedPreviewFile?.file === file && + selectedPreviewFile?.index === index && + styles.activeTab, + ])} + > + {file} +
+ ))} +
+ )} + {directory.showFiles && directory.name === 'metadata' && ( +
+ {previewCSSFiles.current?.files.map((file, index) => ( +
handleSelectedPreviewFile(file, index)} + {...stylex.props([ + styles.tab, + selectedPreviewFile?.file === file && + selectedPreviewFile?.index === index && + styles.activeTab, + ])} + > + {file} +
+ ))} +
+ )} +
+ ))} +
+ )} + {() => ( <> - handleCodeChange(newCode)} - options={{ - mode: 'javascript', - theme: 'material-darker', - lineNumbers: true, - }} - value={code} - /> - {error ? ( -
- {error} -
- ) : url ? ( -