├── .gitignore ├── LICENSE ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── public ├── apple-touch-icon.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.svg ├── site.webmanifest ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── src ├── App.tsx ├── codebase.txt ├── components │ ├── BreadcrumbNavigation.tsx │ ├── Editor.tsx │ ├── EditorPanels.tsx │ ├── ErrorBoundary.tsx │ ├── FileSystem.tsx │ ├── MainLayout.tsx │ ├── Output.tsx │ ├── PackageManager.tsx │ ├── PackageManagerDrawer.tsx │ ├── PythonRepl.tsx │ ├── Settings.tsx │ ├── Spinner.tsx │ ├── Tab.tsx │ ├── Terminal.tsx │ └── TopNavigationBar.tsx ├── config │ └── keyboardShortcuts.ts ├── context │ └── FilesystemContext.tsx ├── hooks │ ├── useEditorState.ts │ ├── useHandlers.ts │ └── usePyodide.tsx ├── main.tsx ├── sharedFileSystem.ts ├── theme │ └── colors.ts └── wasmerInit.ts ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | yarn.lock 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 oct4pie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 | # 🐍 PyBox: Browser-Based Python IDE 7 | 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | [![GitHub stars](https://img.shields.io/github/stars/oct4pie/pybox.svg)](https://github.com/oct4pie/pybox/stargazers) 10 | [![GitHub issues](https://img.shields.io/github/issues/oct4pie/pybox.svg)](https://github.com/oct4pie/pybox/issues) 11 | [![GitHub forks](https://img.shields.io/github/forks/oct4pie/pybox.svg)](https://github.com/oct4pie/pybox/network) 12 | 13 | ## 🌟 Overview 14 | 15 | PyBox is an web-based Python development environment that enables users to write, run, and manage Python code directly in the browser. Using technologies like **Pyodide** and **Wasmer**, PyBox offers a seamless coding experience with robust file management, package installation, and experimental integrated terminal support. 16 | 17 | ## ✨ Features 18 | 19 | ### 🔧 Core Features 20 | - **Python Editor**: Write and edit Python scripts with syntax highlighting and intelligent autocompletion 21 | - **File Explorer**: Manage project files and directories with drag-and-drop support 22 | - **Resizable Panels**: Customize your workspace layout with adjustable file explorer and output panels 23 | - **Shortcuts**: Essential shortcuts like `Ctrl+R` to run code and `Ctrl+S` to save files 24 | - **Theme Support**: Switch between light and dark modes for a comfortable coding environment 25 | 26 | ### 📦 Package Management 27 | - **Package Installation**: Install Python packages using the built-in package manager 28 | - **Manage Packages List**: View all installed packages directly within the IDE 29 | 30 | ### 🖥 Terminal Integration 31 | - **Bash Terminal**: Access a functional Bash shell within the IDE for command-line operations 32 | - **File System Access**: Navigate and manipulate project files through the terminal interface 33 | - **Environment**: Use a Wasmer powered virtualized terminal for secure execution 34 | 35 | ### 🐍 Python REPL 36 | - **Interactive Console**: Execute Python commands in real-time and view immediate results 37 | - **History & Autocompletion**: Navigate through previous commands and utilize tab completion for faster coding 38 | - **Filesystem**: Interaction with the virtual filesystem from the REPL is unified and synchronized with the editor 39 | 40 | ### 📊 Visualization Support 41 | - **Matplotlib Integration**: Render and view plots and graphs generated by your Python scripts directly within the IDE 42 | - **HTML5 Integration**: Display HTML graph, images, SVGs, and styled text in the visual container 43 | - **Custom Visualization**: Possible to extend the visualization capabilities with custom rendering and display options 44 | 45 | ## 🚀 Getting Started 46 | 47 | ### Prerequisites 48 | - **Node.js** (v14 or higher) 49 | - **Yarn** or **npm** 50 | 51 | ### Installation Steps 52 | 53 | 1. **Clone the Repository** 54 | ```bash 55 | git clone https://github.com/oct4pie/pybox.git 56 | cd pybox 57 | ``` 58 | 59 | 2. **Install Dependencies** 60 | ```bash 61 | yarn install 62 | ``` 63 | or 64 | ```bash 65 | npm install 66 | ``` 67 | 68 | 3. **Launch Development Server** 69 | ```bash 70 | yarn dev 71 | ``` 72 | or 73 | ```bash 74 | npm run dev 75 | ``` 76 | 77 | 4. **Access PyBox** 78 | Open your browser and navigate to: 79 | ``` 80 | http://localhost:5173/ 81 | ``` 82 | 83 | ## 📂 File Management 84 | 85 | ### Creating a New File 86 | - Click the **Add File** button or use the keyboard shortcut `Ctrl + N` (Windows/Linux) or `Cmd + N` (macOS) 87 | - Enter the file name and press **Enter** 88 | 89 | ### Renaming 90 | - Click the rename icon next to the file tab or right-click the file in the File Explorer and select **Rename** 91 | - Enter the new name and press **Enter** 92 | 93 | ### Deleting 94 | - Click the delete icon next to the file tab or right-click the file in the File Explorer and select **Delete** 95 | 96 | ### Uploading Files and Directories 97 | - Drag and drop files or directories into the File Explorer panel 98 | - Alternatively, use the upload button in the File Explorer to select files from your system 99 | 100 | ### Exporting 101 | - Click the **Export** button in the File Explorer to download your entire project as a ZIP archive 102 | 103 | ## ⌨️ Keyboard Shortcuts (under dev) 104 | 105 | | Action | Shortcut | 106 | |-------------------------|--------------------------------------| 107 | | Run Code | `Ctrl + R` (Windows/Linux)
`Cmd + R` (macOS) | 108 | | Save File | `Ctrl + S` (Windows/Linux)
`Cmd + S` (macOS) | 109 | | New File | `Ctrl + N` (Windows/Linux)
`Cmd + N` (macOS) | 110 | | Toggle Bottom Panel | `Ctrl + B` (Windows/Linux)
`Cmd + B` (macOS) | 111 | | Open Package Manager | `Ctrl + Shift + P` (Windows/Linux)
`Cmd + Shift + P` (macOS) | 112 | 113 | ## 🛠 Available Scripts 114 | 115 | - **Development** 116 | ```bash 117 | yarn dev 118 | ``` 119 | or 120 | ```bash 121 | npm run dev 122 | ``` 123 | Runs the app in development mode with hot-reloading. 124 | 125 | - **Production Build** 126 | ```bash 127 | yarn build 128 | ``` 129 | or 130 | ```bash 131 | npm run build 132 | ``` 133 | Builds the app for production to the `build` folder. 134 | 135 | - **Preview Production Build** 136 | ```bash 137 | yarn preview 138 | ``` 139 | or 140 | ```bash 141 | npm run preview 142 | ``` 143 | Serves the production build locally for previewing. 144 | 145 | - **Linting** 146 | ```bash 147 | yarn lint 148 | ``` 149 | or 150 | ```bash 151 | npm run lint 152 | ``` 153 | Runs ESLint to analyze code for potential errors and enforce coding standards. 154 | 155 | ## 📝 To-Do List 156 | 157 | - [ ] Implement real-time collaboration features. 158 | - [ ] Add support for additional programming languages. 159 | - [ ] Enhance debugging tools and capabilities. 160 | - [ ] Integrate version control systems like Git. 161 | - [ ] Improve accessibility for users with disabilities. 162 | - [ ] Expand package manager functionalities. 163 | 164 | ## 📄 License 165 | 166 | PyBox is open-sourced under the [MIT License](LICENSE). 167 | 168 | ## 🤝 Contributing 169 | 170 | Contributions are welcome! Please follow these steps: 171 | 172 | 1. **Fork the Repository** 173 | 2. **Create a Feature Branch** 174 | ```bash 175 | git checkout -b feature/YourFeature 176 | ``` 177 | 3. **Commit Your Changes** 178 | ```bash 179 | git commit -m "Add Your Feature" 180 | ``` 181 | 4. **Push to the Branch** 182 | ```bash 183 | git push origin feature/YourFeature 184 | ``` 185 | 5. **Open a Pull Request** 186 | 187 | Please ensure your contributions adhere to the project's coding standards and include relevant tests. 188 | 189 | ## 🆘 Issues 190 | 191 | If you encounter any issues or have questions, feel free to open an issue on [GitHub Issues](https://github.com/oct4pie/pybox/issues) 192 | 193 | --- 194 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import reactHooks from 'eslint-plugin-react-hooks' 3 | import reactRefresh from 'eslint-plugin-react-refresh' 4 | import tsParser from '@typescript-eslint/parser' 5 | import tsPlugin from '@typescript-eslint/eslint-plugin' 6 | 7 | export default [ 8 | { 9 | ignores: ['dist'] 10 | }, 11 | { 12 | files: ['**/*.{ts,tsx}'], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | globals: globals.browser, 16 | parser: tsParser, 17 | parserOptions: { 18 | project: './tsconfig.json' 19 | } 20 | }, 21 | plugins: { 22 | '@typescript-eslint': tsPlugin, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh 25 | }, 26 | 27 | rules: { 28 | 'react-refresh/only-export-components': [ 29 | 'warn', 30 | { allowConstantExport: true } 31 | ] 32 | } 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | PyBox 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pybox", 3 | "private": false, 4 | "version": "1.0.0", 5 | "description": "A browser-based Python IDE with syntax highlighting, file management, package installation, an integrated Bash terminal, and support for visualizations like Matplotlib—no installation required.", 6 | "author": "oct4pie", 7 | "license": "MIT", 8 | "homepage": "https://oct4pie.github.io/pybox", 9 | "type": "module", 10 | "scripts": { 11 | "dev": "vite", 12 | "build": "vite build", 13 | "lint": "eslint .", 14 | "preview": "vite preview", 15 | "deploy": "vite build && gh-pages -d dist" 16 | }, 17 | "dependencies": { 18 | "@chakra-ui/icons": "^2.2.4", 19 | "@chakra-ui/react": "^2.10.4", 20 | "@emotion/react": "^11.13.3", 21 | "@emotion/styled": "^11.13.0", 22 | "@preact/preset-vite": "^2.9.2", 23 | "@wasmer/sdk": "^0.9.0", 24 | "ace-builds": "^1.36.2", 25 | "dayjs": "^1.11.13", 26 | "framer-motion": "^11.11.9", 27 | "jszip": "^3.10.1", 28 | "preact": "^10.25.0", 29 | "react": "^18.3.1", 30 | "react-ace": "^13.0.0", 31 | "react-dom": "^18.3.1", 32 | "react-hotkeys": "^2.0.0", 33 | "react-hotkeys-hook": "^4.5.1", 34 | "react-icons": "^5.3.0", 35 | "react-resizable-panels": "^2.1.4", 36 | "vite-plugin-static-copy": "^2.2.0", 37 | "xterm": "^5.3.0", 38 | "xterm-addon-fit": "^0.8.0" 39 | }, 40 | "devDependencies": { 41 | "@types/react": "^18.3.12", 42 | "@types/react-dom": "^18.3.1", 43 | "@typescript-eslint/eslint-plugin": "^8.16.0", 44 | "@typescript-eslint/parser": "^8.16.0", 45 | "depcheck": "^1.4.7", 46 | "eslint": "^9.11.1", 47 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 48 | "eslint-plugin-react-refresh": "^0.4.12", 49 | "gh-pages": "^6.2.0", 50 | "globals": "^15.9.0", 51 | "terser": "^5.36.0", 52 | "typescript": "^5.5.3", 53 | "typescript-eslint": "^8.7.0", 54 | "vite": "6.0.1", 55 | "vite-plugin-compression": "^0.5.1", 56 | "vite-plugin-svgr": "^4.3.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oct4Pie/pybox/5d09ac61a66928750cc4882e9595edbb4a510caa/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oct4Pie/pybox/5d09ac61a66928750cc4882e9595edbb4a510caa/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oct4Pie/pybox/5d09ac61a66928750cc4882e9595edbb4a510caa/public/favicon.ico -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PyBoxEditor", 3 | "short_name": "PyBox", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oct4Pie/pybox/5d09ac61a66928750cc4882e9595edbb4a510caa/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oct4Pie/pybox/5d09ac61a66928750cc4882e9595edbb4a510caa/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, Suspense, useEffect } from 'react' 2 | import { 3 | Flex, 4 | Center, 5 | Text, 6 | IconButton, 7 | Tooltip, 8 | useDisclosure, 9 | useBreakpointValue, 10 | useColorMode 11 | } from '@chakra-ui/react' 12 | import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' 13 | import { HotKeys } from 'react-hotkeys' 14 | 15 | import Spinner from './components/Spinner' 16 | import usePyodide from './hooks/usePyodide' 17 | import { useFilesystem } from './context/FilesystemContext' 18 | import { keyMap, createHandlers } from './config/keyboardShortcuts' 19 | import { useEditorState } from './hooks/useEditorState' 20 | import { useThemeColors } from './theme/colors' 21 | import { useHandlers } from './hooks/useHandlers' 22 | import TopNavigationBar from './components/TopNavigationBar' 23 | 24 | const PackageManagerDrawer = React.lazy( 25 | () => import('./components/PackageManagerDrawer') 26 | ) 27 | const MainLayout = React.lazy(() => import('./components/MainLayout')) 28 | 29 | const App: React.FC = () => { 30 | const { colorMode, toggleColorMode } = useColorMode() 31 | const rawIsMobile = useBreakpointValue({ base: true, md: false }) || false 32 | const [isMobile, setIsMobile] = useState(rawIsMobile) 33 | const { bgColor, panelBgColor } = useThemeColors() 34 | 35 | useEffect(() => { 36 | const intervalId = setInterval(() => { 37 | setIsMobile(rawIsMobile) 38 | }, 1000) 39 | 40 | return () => clearInterval(intervalId) 41 | }, [rawIsMobile]) 42 | 43 | const { 44 | openFiles, 45 | setOpenFiles, 46 | activeFile, 47 | setActiveFile, 48 | unsavedFiles, 49 | markFileAsUnsaved, 50 | markFileAsSaved 51 | } = useEditorState() 52 | 53 | const [output, setOutput] = useState('') 54 | const [isRunning, setIsRunning] = useState(false) 55 | const [isBottomPanelVisible, setIsBottomPanelVisible] = 56 | useState(true) 57 | const [activeBottomPanel, setActiveBottomPanel] = useState('Output') 58 | 59 | const { isLoading, error, runCode, installPackage, installedPackages } = 60 | usePyodide() 61 | const { refreshFS } = useFilesystem() 62 | const { isOpen, onOpen, onClose } = useDisclosure() 63 | 64 | const editorRef = useRef(null) 65 | 66 | const { 67 | handleRunCode, 68 | handleFileSelect, 69 | handleInstallPackage, 70 | handleAddNewFile, 71 | handleCloseFile, 72 | toggleBottomPanel, 73 | handleManualSave, 74 | handleSaveFile, 75 | clearOutput, 76 | handleRenameFile 77 | } = useHandlers({ 78 | activeFile, 79 | setActiveFile, 80 | openFiles, 81 | setOpenFiles, 82 | markFileAsSaved, 83 | markFileAsUnsaved, 84 | refreshFS, 85 | installPackage, 86 | runCode, 87 | setOutput, 88 | setIsRunning, 89 | isBottomPanelVisible, 90 | setIsBottomPanelVisible, 91 | setActiveBottomPanel, 92 | unsavedFiles, 93 | editorRef 94 | }) 95 | 96 | const handlers = createHandlers( 97 | handleRunCode, 98 | handleAddNewFile, 99 | toggleBottomPanel, 100 | handleManualSave, 101 | onOpen 102 | ) 103 | 104 | const [fileExplorerSize, setFileExplorerSize] = useState(() => { 105 | return parseInt(localStorage.getItem('fileExplorerSize') || '20') 106 | }) 107 | 108 | const [mobileFileExplorerOpen, setMobileFileExplorerOpen] = useState(false) 109 | 110 | const handleOpenFileExplorer = () => { 111 | if (isMobile) { 112 | setMobileFileExplorerOpen(true) 113 | } 114 | } 115 | 116 | if (isLoading) { 117 | return ( 118 |
119 | 120 | Loading PyBox... 121 |
122 | ) 123 | } 124 | 125 | if (error) { 126 | return ( 127 |
128 | Error loading Pyodide: {error.message} 129 |
130 | ) 131 | } 132 | 133 | return ( 134 | 135 | 136 | {/* Top Navigation Bar */} 137 | 149 | 150 | {/* Main Layout */} 151 | 154 | 155 | 156 | } 157 | > 158 | 183 | 184 | 185 | {/* Toggle Bottom Panel Button */} 186 | 190 | : 193 | } 194 | size='md' 195 | position='fixed' 196 | bottom={4} 197 | right={4} 198 | onClick={toggleBottomPanel} 199 | colorScheme='teal' 200 | isRound 201 | shadow='md' 202 | _hover={{ transform: 'scale(1.1)', bg: 'teal.600' }} 203 | transition='transform 0.2s, background-color 0.3s' 204 | aria-label='Toggle Bottom Panel' 205 | /> 206 | 207 | 208 | {/* Package Manager Drawer */} 209 | 212 | 213 | 214 | } 215 | > 216 | 223 | 224 | 225 | 226 | ) 227 | } 228 | 229 | export default App 230 | -------------------------------------------------------------------------------- /src/components/BreadcrumbNavigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Breadcrumb as ChakraBreadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink 6 | } from '@chakra-ui/react' 7 | 8 | interface BreadcrumbNavigationProps { 9 | activeFile: string 10 | } 11 | 12 | const BreadcrumbNavigation: React.FC = ({ 13 | activeFile 14 | }) => { 15 | const filePath = ['home', 'runner', activeFile] 16 | 17 | return ( 18 | 19 | {filePath.map((segment, index) => ( 20 | 21 | {segment} 22 | 23 | ))} 24 | 25 | ) 26 | } 27 | 28 | export default BreadcrumbNavigation 29 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useEffect, 4 | useCallback, 5 | forwardRef, 6 | useImperativeHandle 7 | } from 'react' 8 | import AceEditor from 'react-ace' 9 | import { useFilesystem } from '../context/FilesystemContext' 10 | import { useToast, Center, useColorMode } from '@chakra-ui/react' 11 | import { useHotkeys } from 'react-hotkeys-hook' 12 | 13 | import 'ace-builds/src-noconflict/mode-python' 14 | import 'ace-builds/src-noconflict/theme-one_dark' 15 | import 'ace-builds/src-noconflict/snippets/python' 16 | import 'ace-builds/src-noconflict/ext-language_tools' 17 | import 'ace-builds/src-noconflict/ext-searchbox' 18 | import 'ace-builds/src-noconflict/theme-chrome' 19 | import 'ace-builds/src-noconflict/ace' 20 | import ace from 'ace-builds/src-noconflict/ace' 21 | 22 | ace.config.set('basePath', '/') 23 | 24 | interface EditorProps { 25 | activeFile: string 26 | markFileAsUnsaved: (filename: string) => void 27 | } 28 | 29 | const Editor = forwardRef((props: EditorProps, ref) => { 30 | const { activeFile, markFileAsUnsaved } = props 31 | const { sharedDir } = useFilesystem() 32 | const [value, setValue] = useState('') 33 | const [loading, setLoading] = useState(true) 34 | const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) 35 | const { colorMode } = useColorMode() 36 | const toast = useToast() 37 | 38 | // Load file content 39 | const loadFileContent = useCallback(async () => { 40 | if (!activeFile) return 41 | setLoading(true) 42 | const filePath = `runner/${activeFile}` 43 | console.log(`Loading file: ${filePath}`) 44 | try { 45 | const contentBytes = await sharedDir.readFile(filePath) 46 | const content = new TextDecoder().decode(contentBytes) 47 | setValue(content) 48 | setHasUnsavedChanges(false) 49 | console.log(`File loaded successfully: ${filePath}`) 50 | } catch (err: any) { 51 | // If the file doesn't exist, create it 52 | if (err.message.includes('No such file or directory')) { 53 | await sharedDir.writeFile(filePath, new TextEncoder().encode('')) 54 | setValue('') 55 | setHasUnsavedChanges(false) 56 | console.log(`Created new file: ${filePath}`) 57 | } else { 58 | console.error('Error loading file:', err) 59 | toast({ 60 | title: 'Error Loading File', 61 | description: err.message, 62 | status: 'error', 63 | duration: 5000, 64 | isClosable: true 65 | }) 66 | } 67 | } finally { 68 | setLoading(false) 69 | } 70 | }, [activeFile, sharedDir, toast]) 71 | 72 | useEffect(() => { 73 | loadFileContent() 74 | }, [loadFileContent, activeFile]) 75 | 76 | const saveToFile = useCallback(async () => { 77 | const filePath = `runner/${activeFile}` 78 | console.log(`Attempting to save file: ${filePath}`) 79 | try { 80 | try { 81 | await sharedDir.removeFile(filePath) 82 | console.log(`Existing file deleted: ${filePath}`) 83 | } catch (err) { 84 | console.log(`File does not exist, no need to delete: ${filePath}`) 85 | } 86 | 87 | await sharedDir.writeFile(filePath, new TextEncoder().encode(value)) 88 | console.log(`File saved successfully: ${filePath}`) 89 | 90 | const savedContentBytes = await sharedDir.readFile(filePath) 91 | const decodedContent = new TextDecoder().decode(savedContentBytes) 92 | console.log('decodedContent:', decodedContent) 93 | console.log('value:', value) 94 | if (decodedContent !== value) { 95 | throw new Error('File content does not match after saving.') 96 | } 97 | 98 | setHasUnsavedChanges(false) 99 | 100 | toast({ 101 | title: 'File Saved', 102 | description: `File '${activeFile}' has been saved.`, 103 | status: 'success', 104 | duration: 2000, 105 | isClosable: true 106 | }) 107 | } catch (err: any) { 108 | console.error('Error writing file:', err) 109 | toast({ 110 | title: 'Save Error', 111 | description: err.message, 112 | status: 'error', 113 | duration: 5000, 114 | isClosable: true 115 | }) 116 | } 117 | }, [activeFile, sharedDir, value, toast]) 118 | 119 | useImperativeHandle(ref, () => ({ 120 | saveFile: async () => { 121 | await saveToFile() 122 | } 123 | })) 124 | 125 | const handleManualSave = useCallback(async () => { 126 | if (hasUnsavedChanges) { 127 | await saveToFile() 128 | } else { 129 | toast({ 130 | title: 'No changes to save.', 131 | status: 'info', 132 | duration: 2000, 133 | isClosable: true 134 | }) 135 | } 136 | }, [hasUnsavedChanges, saveToFile, toast]) 137 | 138 | useHotkeys( 139 | 'ctrl+s, cmd+s', 140 | event => { 141 | event.preventDefault() 142 | handleManualSave() 143 | }, 144 | [handleManualSave] 145 | ) 146 | 147 | const handleChange = (newValue: string) => { 148 | setValue(newValue) 149 | if (!hasUnsavedChanges) { 150 | setHasUnsavedChanges(true) 151 | markFileAsUnsaved(activeFile) 152 | console.log(`File marked as having unsaved changes: ${activeFile}`) 153 | } 154 | } 155 | 156 | if (loading) { 157 | return
Loading Editor...
158 | } 159 | 160 | return ( 161 | 184 | ) 185 | }) 186 | 187 | export default Editor 188 | -------------------------------------------------------------------------------- /src/components/EditorPanels.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useEffect, useState } from 'react' 2 | import { 3 | Flex, 4 | Box, 5 | Tabs, 6 | Tooltip, 7 | TabList, 8 | TabPanels, 9 | TabPanel, 10 | Tab as ChakraTab, 11 | IconButton, 12 | Text, 13 | Center, 14 | Icon, 15 | useColorModeValue 16 | } from '@chakra-ui/react' 17 | import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels' 18 | import Spinner from './Spinner' 19 | import Output from './Output' 20 | import Tab from './Tab' 21 | import { 22 | FaChartBar, 23 | FaCode, 24 | FaImage, 25 | FaTerminal, 26 | FaPython 27 | } from 'react-icons/fa' 28 | import { CloseIcon, AddIcon } from '@chakra-ui/icons' 29 | 30 | const Editor = React.lazy(() => import('./Editor')) 31 | const PythonRepl = React.lazy(() => import('./PythonRepl')) 32 | const Terminal = React.lazy(() => import('./Terminal')) 33 | 34 | interface EditorAndBottomPanelsProps { 35 | panelBgColor: string 36 | activeFile: string 37 | openFiles: string[] 38 | setActiveFile: (file: string) => void 39 | handleCloseFile: (file: string) => void 40 | handleRenameFile: (oldName: string, newName: string) => void 41 | handleAddNewFile: () => void 42 | unsavedFiles: Set 43 | markFileAsUnsaved: (file: string) => void 44 | handleSaveFile: () => Promise 45 | isBottomPanelVisible: boolean 46 | setIsBottomPanelVisible: (visible: boolean) => void 47 | activeBottomPanel: string 48 | setActiveBottomPanel: (panel: string) => void 49 | output: string 50 | clearOutput: () => void 51 | editorRef: React.RefObject 52 | } 53 | 54 | const EditorAndBottomPanels: React.FC = ({ 55 | panelBgColor, 56 | activeFile, 57 | openFiles, 58 | setActiveFile, 59 | handleCloseFile, 60 | handleRenameFile, 61 | handleAddNewFile, 62 | unsavedFiles, 63 | markFileAsUnsaved, 64 | handleSaveFile, 65 | isBottomPanelVisible, 66 | setIsBottomPanelVisible, 67 | activeBottomPanel, 68 | setActiveBottomPanel, 69 | output, 70 | clearOutput, 71 | editorRef 72 | }) => { 73 | const [plotContent, setPlotContent] = useState(null) 74 | 75 | useEffect(() => { 76 | const plotContainer = document.getElementById('plot-container') 77 | ;(document as any).pyodideMplTarget = plotContainer 78 | if (plotContent && plotContent.indexOf('matplotlib_') !== -1) { 79 | plotContainer!.innerHTML = plotContent 80 | } 81 | }, []) 82 | 83 | useEffect(() => { 84 | const plotContainer = (document as any).pyodideMplTarget 85 | if (plotContainer) { 86 | if (plotContent && plotContent.indexOf('matplotlib_') !== -1) { 87 | plotContainer.innerHTML = plotContent 88 | } 89 | 90 | const observer = new MutationObserver(() => { 91 | const lastChild = Array.from(plotContainer.children).at(-1) 92 | const content = lastChild ? lastChild.innerHTML.trim() : '' 93 | if (!content) { 94 | setPlotContent(null) 95 | } else { 96 | if (content.indexOf('matplotlib_') !== -1) { 97 | setPlotContent(content) 98 | } 99 | } 100 | }) 101 | 102 | observer.observe(plotContainer, { childList: true, subtree: true }) 103 | 104 | return () => observer.disconnect() 105 | } 106 | }, [plotContent]) 107 | 108 | const getActiveTabIndex = () => { 109 | switch (activeBottomPanel) { 110 | case 'Output': 111 | return 0 112 | case 'Visual': 113 | return 1 114 | case 'Terminal': 115 | return 2 116 | case 'Python REPL': 117 | return 3 118 | default: 119 | return 0 120 | } 121 | } 122 | 123 | return ( 124 | 125 | 133 | 134 | {/* Tabs */} 135 | 143 | 144 | setActiveFile(openFiles[index])} 150 | > 151 | 152 | {openFiles.map(file => ( 153 | setActiveFile(file)} 158 | onClose={() => handleCloseFile(file)} 159 | onRename={handleRenameFile} 160 | hasUnsavedChanges={unsavedFiles.has(file)} 161 | /> 162 | ))} 163 | 164 | 165 | {/* Move "Add New File" button outside of TabList */} 166 | 167 | } 169 | size='xs' 170 | variant='ghost' 171 | aria-label='Add New File' 172 | onClick={e => { 173 | e.stopPropagation() 174 | handleAddNewFile() 175 | }} 176 | _hover={{ bg: 'teal.500', color: 'white' }} 177 | ml={2} 178 | /> 179 | 180 | 181 | 182 | 183 | 184 | 193 | 194 | }> 195 | 200 | 201 | 202 | 203 | 204 | { 205 | <> 206 | 215 | 216 | 226 | 227 | { 232 | const panels = [ 233 | 'Output', 234 | 'Visual', 235 | 'Terminal', 236 | 'Python REPL' 237 | ] 238 | setActiveBottomPanel(panels[index]) 239 | }} 240 | > 241 | 242 | 243 | 244 | 245 | 246 | 247 | Output 248 | 249 | 250 | 251 | 252 | 253 | Visual 254 | 255 | 256 | 257 | 258 | 262 | Terminal 263 | 264 | 265 | 266 | 267 | 271 | Python REPL 272 | 273 | 274 | 275 | } 278 | size='sm' 279 | onClick={() => setIsBottomPanelVisible(false)} 280 | ml='auto' 281 | variant='ghost' 282 | _hover={{ bg: 'red.500', color: 'white' }} 283 | mt={1} 284 | mr={2} 285 | /> 286 | 287 | 288 | 289 | 290 | {output ? ( 291 | 292 | ) : ( 293 |
294 | 299 | 303 | 304 | Run your code 305 | 306 | 307 | Results of your code will appear here when you run 308 | the project. 309 | 310 | 311 |
312 | )} 313 |
314 | 315 | 328 | {plotContent && 329 | plotContent.indexOf('matplotlib_') === -1 && ( 330 | <> 331 | 337 | 338 | Render a graph and it will show here 339 | 340 | 341 | )} 342 | 356 | {plotContent && 357 | plotContent.includes('matplotlib_') ? null : ( 358 | 363 | 364 |
365 | When a graph is rendered into the container, it 366 | will show here 367 |
368 | )} 369 |
370 |
371 |
372 | 373 | 374 | }> 375 | 376 | 377 | 378 | 379 | }> 380 | 381 | 382 | 383 |
384 |
385 |
386 |
387 | 388 | } 389 |
390 |
391 | ) 392 | } 393 | 394 | export default EditorAndBottomPanels 395 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box, Text, Button } from '@chakra-ui/react' 3 | 4 | interface ErrorBoundaryState { 5 | hasError: boolean 6 | error: Error | null 7 | } 8 | 9 | class ErrorBoundary extends React.Component< 10 | { children: React.ReactNode }, 11 | ErrorBoundaryState 12 | > { 13 | constructor (props: any) { 14 | super(props) 15 | this.state = { hasError: false, error: null } 16 | } 17 | 18 | static getDerivedStateFromError (error: any): ErrorBoundaryState { 19 | return { hasError: true, error } 20 | } 21 | 22 | componentDidCatch (error: any, errorInfo: any) { 23 | console.error('ErrorBoundary caught an error:', error, errorInfo) 24 | } 25 | 26 | handleReload = () => { 27 | window.location.reload() 28 | } 29 | 30 | render () { 31 | if (this.state.hasError && this.state.error) { 32 | return ( 33 | 34 | 35 | Something went wrong. 36 | 37 | {this.state.error.message} 38 | 41 | 42 | ) 43 | } 44 | 45 | return this.props.children 46 | } 47 | } 48 | 49 | export default ErrorBoundary 50 | -------------------------------------------------------------------------------- /src/components/FileSystem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react' 2 | import { 3 | Box, 4 | HStack, 5 | Heading, 6 | Input, 7 | IconButton, 8 | List, 9 | ListItem, 10 | Text, 11 | useColorModeValue, 12 | Tooltip, 13 | useToast, 14 | Collapse 15 | } from '@chakra-ui/react' 16 | import { AddIcon, EditIcon, DeleteIcon, DownloadIcon } from '@chakra-ui/icons' 17 | import { 18 | FaFolderOpen, 19 | FaFolder, 20 | FaFileAlt, 21 | FaDotCircle, 22 | FaFileDownload 23 | } from 'react-icons/fa' 24 | 25 | import { TiExport } from 'react-icons/ti' 26 | import { useFilesystem } from '../context/FilesystemContext' 27 | import { renameFile, renameDir } from '../sharedFileSystem' 28 | import JSZip from 'jszip' 29 | import dayjs from 'dayjs' 30 | 31 | interface FileSystemProps { 32 | onFileSelect: (filename: string) => void 33 | activeFile: string 34 | unsavedFiles: Set 35 | } 36 | 37 | interface FileSystemEntry { 38 | name: string 39 | path: string 40 | type: 'file' | 'dir' 41 | children?: FileSystemEntry[] 42 | } 43 | 44 | const FileSystem: React.FC = ({ 45 | onFileSelect, 46 | activeFile, 47 | unsavedFiles 48 | }) => { 49 | const { sharedDir, refreshFS } = useFilesystem() 50 | const [fileSystemTree, setFileSystemTree] = useState([]) 51 | const [newName, setNewName] = useState('') 52 | const [renamingItem, setRenamingItem] = useState(null) 53 | const [renameValue, setRenameValue] = useState('') 54 | const [expandedDirs, setExpandedDirs] = useState>(new Set()) 55 | const [creatingInDirectory, setCreatingInDirectory] = useState( 56 | null 57 | ) 58 | const [newItemName, setNewItemName] = useState('') 59 | const toast = useToast() 60 | 61 | const readDirRecursive = useCallback( 62 | async (dirPath: string): Promise => { 63 | try { 64 | const entries = await sharedDir.readDir(dirPath) 65 | const result: FileSystemEntry[] = [] 66 | 67 | for (const entry of entries) { 68 | const entryPath = `${dirPath}/${entry.name}` 69 | if (entry.type === 'dir') { 70 | const children = await readDirRecursive(entryPath) 71 | result.push({ 72 | name: entry.name, 73 | path: entryPath, 74 | type: 'dir', 75 | children 76 | }) 77 | } else if (entry.type === 'file') { 78 | result.push({ name: entry.name, path: entryPath, type: 'file' }) 79 | } 80 | } 81 | return result 82 | } catch (err: any) { 83 | console.error('Error reading directory:', err) 84 | toast({ 85 | title: 'Error Reading Directory', 86 | description: err.message, 87 | status: 'error', 88 | duration: 3000, 89 | isClosable: true 90 | }) 91 | return [] 92 | } 93 | }, 94 | [sharedDir, toast] 95 | ) 96 | 97 | const updateFileList = useCallback(async () => { 98 | const tree = await readDirRecursive('runner') 99 | setFileSystemTree(tree) 100 | }, [readDirRecursive]) 101 | 102 | useEffect(() => { 103 | updateFileList() 104 | const interval = setInterval(() => { 105 | updateFileList() 106 | }, 5000) 107 | return () => clearInterval(interval) 108 | }, [updateFileList]) 109 | 110 | const ensureParentDirectoriesExist = async (path: string) => { 111 | const pathSegments = path.split('/') 112 | pathSegments.pop() 113 | let currentPath = '' 114 | for (const segment of pathSegments) { 115 | if (segment === '') continue 116 | currentPath += '/' + segment 117 | try { 118 | await sharedDir.createDir(currentPath) 119 | } catch (err) {} 120 | } 121 | } 122 | 123 | const handleCreate = async () => { 124 | const trimmedName = newName.trim() 125 | if (!trimmedName) { 126 | toast({ 127 | title: 'Name cannot be empty.', 128 | status: 'warning', 129 | duration: 3000, 130 | isClosable: true 131 | }) 132 | return 133 | } 134 | 135 | const isDirectory = trimmedName.endsWith('/') 136 | const finalName = isDirectory ? trimmedName.slice(0, -1) : trimmedName 137 | 138 | const path = `runner/${finalName}` 139 | 140 | try { 141 | await ensureParentDirectoriesExist(path) 142 | 143 | if (isDirectory) { 144 | await sharedDir.createDir(path) 145 | toast({ 146 | title: `Directory '${finalName}' created.`, 147 | status: 'success', 148 | duration: 2000, 149 | isClosable: true 150 | }) 151 | } else { 152 | await sharedDir.writeFile(path, new TextEncoder().encode('')) 153 | toast({ 154 | title: `File '${finalName}' created.`, 155 | status: 'success', 156 | duration: 2000, 157 | isClosable: true 158 | }) 159 | } 160 | setNewName('') 161 | await refreshFS() 162 | updateFileList() 163 | } catch (err: any) { 164 | console.error('Error creating:', err) 165 | toast({ 166 | title: 'Error creating item.', 167 | description: err.message, 168 | status: 'error', 169 | duration: 3000, 170 | isClosable: true 171 | }) 172 | } 173 | } 174 | 175 | const saveAs = (blob: Blob, filename: string) => { 176 | const url = window.URL.createObjectURL(blob) 177 | const a = document.createElement('a') 178 | a.href = url 179 | a.download = filename 180 | a.click() 181 | window.URL.revokeObjectURL(url) 182 | } 183 | 184 | const handleCreateInDirectory = async (parentPath: string) => { 185 | const trimmedName = newItemName.trim() 186 | if (!trimmedName) { 187 | toast({ 188 | title: 'Name cannot be empty.', 189 | status: 'warning', 190 | duration: 3000, 191 | isClosable: true 192 | }) 193 | return 194 | } 195 | 196 | const isDirectory = trimmedName.endsWith('/') 197 | const finalName = isDirectory ? trimmedName.slice(0, -1) : trimmedName 198 | 199 | const path = `${parentPath}/${finalName}` 200 | 201 | try { 202 | await ensureParentDirectoriesExist(path) 203 | 204 | if (isDirectory) { 205 | await sharedDir.createDir(path) 206 | toast({ 207 | title: `Directory '${finalName}' created.`, 208 | status: 'success', 209 | duration: 2000, 210 | isClosable: true 211 | }) 212 | } else { 213 | await sharedDir.writeFile(path, new TextEncoder().encode('')) 214 | toast({ 215 | title: `File '${finalName}' created.`, 216 | status: 'success', 217 | duration: 2000, 218 | isClosable: true 219 | }) 220 | } 221 | setNewItemName('') 222 | setCreatingInDirectory(null) 223 | await refreshFS() 224 | updateFileList() 225 | } catch (err: any) { 226 | console.error('Error creating:', err) 227 | toast({ 228 | title: 'Error creating item.', 229 | description: err.message, 230 | status: 'error', 231 | duration: 3000, 232 | isClosable: true 233 | }) 234 | } 235 | } 236 | 237 | const handleRename = (path: string) => { 238 | setRenamingItem(path) 239 | setRenameValue(path.split('/').pop() || '') 240 | } 241 | 242 | const handleRenameSubmit = async () => { 243 | if (!renamingItem) return 244 | const trimmedNewName = renameValue.trim() 245 | const oldName = renamingItem.split('/').pop() || '' 246 | if (!trimmedNewName || trimmedNewName === oldName) { 247 | setRenamingItem(null) 248 | setRenameValue('') 249 | return 250 | } 251 | 252 | const isDirectory = renamingItem.endsWith('/') 253 | const parentPath = renamingItem.substring(0, renamingItem.lastIndexOf('/')) 254 | const newPath = `${parentPath}/${trimmedNewName}` 255 | 256 | try { 257 | await ensureParentDirectoriesExist(newPath) 258 | 259 | if (isDirectory) { 260 | await renameDir(renamingItem, newPath) 261 | } else { 262 | await renameFile(renamingItem, newPath) 263 | } 264 | 265 | setRenamingItem(null) 266 | setRenameValue('') 267 | toast({ 268 | title: `Renamed to '${trimmedNewName}'.`, 269 | status: 'success', 270 | duration: 2000, 271 | isClosable: true 272 | }) 273 | 274 | await refreshFS() 275 | updateFileList() 276 | 277 | if (activeFile === renamingItem) { 278 | onFileSelect(newPath) 279 | } 280 | } catch (err: any) { 281 | console.error('Error renaming:', err) 282 | toast({ 283 | title: 'Error renaming.', 284 | description: err.message, 285 | status: 'error', 286 | duration: 3000, 287 | isClosable: true 288 | }) 289 | } 290 | } 291 | 292 | const handleDelete = async (path: string) => { 293 | try { 294 | const isFile = path.split('/').pop()?.includes('.') ?? false 295 | if (isFile) { 296 | await sharedDir.removeFile(path) 297 | } else { 298 | await deleteDirectoryRecursively(path) 299 | } 300 | 301 | await refreshFS() 302 | updateFileList() 303 | 304 | if (activeFile === path) { 305 | onFileSelect('') 306 | } 307 | 308 | toast({ 309 | title: `Removed '${path}'.`, 310 | status: 'success', 311 | duration: 2000, 312 | isClosable: true 313 | }) 314 | } catch (err: any) { 315 | console.error('Error deleting:', err) 316 | toast({ 317 | title: 'Error deleting.', 318 | description: err.message, 319 | status: 'error', 320 | duration: 3000, 321 | isClosable: true 322 | }) 323 | } 324 | } 325 | 326 | const deleteDirectoryRecursively = async (dirPath: string) => { 327 | const entries = await sharedDir.readDir(dirPath) 328 | for (const entry of entries) { 329 | const entryPath = `${dirPath}/${entry.name}` 330 | if (entry.type === 'dir') { 331 | await deleteDirectoryRecursively(entryPath) 332 | } else { 333 | await sharedDir.removeFile(entryPath) 334 | } 335 | } 336 | await sharedDir.removeDir(dirPath) 337 | } 338 | 339 | const toggleDirectory = (dirPath: string) => { 340 | const newExpandedDirs = new Set(expandedDirs) 341 | if (newExpandedDirs.has(dirPath)) { 342 | newExpandedDirs.delete(dirPath) 343 | } else { 344 | newExpandedDirs.add(dirPath) 345 | } 346 | setExpandedDirs(newExpandedDirs) 347 | } 348 | 349 | const handleFileSelectInternal = (path: string) => { 350 | const relativePath = path.startsWith('runner/') 351 | ? path.slice('runner/'.length) 352 | : path 353 | onFileSelect(relativePath) 354 | } 355 | 356 | const handleDragStart = (event: React.DragEvent, entry: FileSystemEntry) => { 357 | event.stopPropagation() 358 | event.dataTransfer.setData('application/json', JSON.stringify(entry)) 359 | event.dataTransfer.effectAllowed = 'move' 360 | } 361 | 362 | const handleDragOver = (event: React.DragEvent) => { 363 | event.preventDefault() 364 | event.dataTransfer.dropEffect = 'move' 365 | } 366 | 367 | const handleDrop = async ( 368 | event: React.DragEvent, 369 | destination: FileSystemEntry 370 | ) => { 371 | event.preventDefault() 372 | event.stopPropagation() 373 | 374 | const data = event.dataTransfer.getData('application/json') 375 | const sourceEntry: FileSystemEntry = JSON.parse(data) 376 | 377 | if (sourceEntry.path === destination.path) { 378 | return 379 | } 380 | 381 | const isSubPath = (parentPath: string, childPath: string) => { 382 | if (childPath === parentPath) return true 383 | return childPath.startsWith(parentPath + '/') 384 | } 385 | 386 | if (isSubPath(sourceEntry.path, destination.path)) { 387 | toast({ 388 | title: 'Invalid Move', 389 | description: 'Cannot move a directory into itself or its subdirectory.', 390 | status: 'warning', 391 | duration: 3000, 392 | isClosable: true 393 | }) 394 | return 395 | } 396 | 397 | const destinationPath = 398 | destination.type === 'dir' 399 | ? destination.path 400 | : destination.path.substring(0, destination.path.lastIndexOf('/')) 401 | 402 | const newPath = `${destinationPath}/${sourceEntry.name}` 403 | 404 | try { 405 | if (sourceEntry.type === 'file') { 406 | await renameFile(sourceEntry.path, newPath) 407 | } else { 408 | await renameDir(sourceEntry.path, newPath) 409 | } 410 | 411 | toast({ 412 | title: 'Item Moved', 413 | description: `Moved '${sourceEntry.name}' to '${destinationPath}'`, 414 | status: 'success', 415 | duration: 2000, 416 | isClosable: true 417 | }) 418 | 419 | await refreshFS() 420 | updateFileList() 421 | } catch (err: any) { 422 | console.error('Error moving item:', err) 423 | toast({ 424 | title: 'Error Moving Item', 425 | description: err.message, 426 | status: 'error', 427 | duration: 3000, 428 | isClosable: true 429 | }) 430 | } 431 | } 432 | 433 | const handleDragEnter = (event: React.DragEvent) => { 434 | event.preventDefault() 435 | event.stopPropagation() 436 | } 437 | 438 | const handleDragLeave = (event: React.DragEvent) => { 439 | event.preventDefault() 440 | event.stopPropagation() 441 | } 442 | 443 | const handleFileUpload = async (files: FileList, path: string) => { 444 | for (const file of Array.from(files)) { 445 | const filePath = `${path}/${file.name}` 446 | 447 | await ensureParentDirectoriesExist(filePath) 448 | 449 | const content = await file.arrayBuffer() 450 | await sharedDir.writeFile(filePath, new Uint8Array(content)) 451 | } 452 | toast({ 453 | title: 'Files Uploaded', 454 | description: 'Your files have been uploaded successfully.', 455 | status: 'success', 456 | duration: 2000, 457 | isClosable: true 458 | }) 459 | await refreshFS() 460 | updateFileList() 461 | } 462 | 463 | const handleDirectoryUpload = async ( 464 | items: DataTransferItemList, 465 | path: string 466 | ) => { 467 | const traverseFileTree = async (entry: any, currentPath: string) => { 468 | if (entry.isFile) { 469 | const file = await new Promise((resolve, reject) => 470 | entry.file(resolve, reject) 471 | ) 472 | const filePath = `${currentPath}/${file.name}` 473 | 474 | await ensureParentDirectoriesExist(filePath) 475 | 476 | const content = await file.arrayBuffer() 477 | await sharedDir.writeFile(filePath, new Uint8Array(content)) 478 | } else if (entry.isDirectory) { 479 | const dirPath = `${currentPath}/${entry.name}` 480 | 481 | try { 482 | await sharedDir.createDir(dirPath) 483 | } catch (err) {} 484 | 485 | const dirReader = entry.createReader() 486 | let entries: any[] = [] 487 | 488 | const readEntries = async () => { 489 | return new Promise((resolve, reject) => 490 | dirReader.readEntries(resolve, reject) 491 | ) 492 | } 493 | 494 | let batch: any[] 495 | do { 496 | batch = await readEntries() 497 | entries = entries.concat(batch) 498 | } while (batch.length > 0) 499 | 500 | for (const childEntry of entries) { 501 | await traverseFileTree(childEntry, dirPath) 502 | } 503 | } 504 | } 505 | 506 | for (let i = 0; i < items.length; i++) { 507 | const entry = items[i].webkitGetAsEntry() 508 | if (entry) { 509 | await traverseFileTree(entry, path) 510 | } 511 | } 512 | 513 | toast({ 514 | title: 'Directories Uploaded', 515 | description: 'Your directories have been uploaded successfully.', 516 | status: 'success', 517 | duration: 2000, 518 | isClosable: true 519 | }) 520 | await refreshFS() 521 | updateFileList() 522 | } 523 | 524 | const handleDropUpload = async (event: React.DragEvent, path: string) => { 525 | event.preventDefault() 526 | event.stopPropagation() 527 | 528 | const files = event.dataTransfer.files 529 | const items = event.dataTransfer.items 530 | 531 | if (items.length > 0) { 532 | const hasDirectory = Array.from(items).some(item => { 533 | const entry = item.webkitGetAsEntry() 534 | return entry && entry.isDirectory 535 | }) 536 | 537 | if (hasDirectory) { 538 | await handleDirectoryUpload(items, path) 539 | } else { 540 | await handleFileUpload(files, path) 541 | } 542 | } 543 | } 544 | 545 | const handleExport = async (entry: FileSystemEntry) => { 546 | try { 547 | if (entry.type === 'file') { 548 | const content = await sharedDir.readFile(entry.path) 549 | const blob = new Blob([content]) 550 | saveAs(blob, entry.name) 551 | 552 | toast({ 553 | title: 'Export Successful', 554 | description: `${entry.name} has been exported.`, 555 | status: 'success', 556 | duration: 3000, 557 | isClosable: true 558 | }) 559 | } else if (entry.type === 'dir') { 560 | const zip = new JSZip() 561 | 562 | const addToZip = async ( 563 | zipFolder: JSZip, 564 | dirEntry: FileSystemEntry 565 | ) => { 566 | if (dirEntry.type === 'file') { 567 | const content = await sharedDir.readFile(dirEntry.path) 568 | zipFolder.file(dirEntry.name, content) 569 | } else if (dirEntry.type === 'dir' && dirEntry.children) { 570 | const folder = zipFolder.folder(dirEntry.name)! 571 | for (const child of dirEntry.children) { 572 | await addToZip(folder, child) 573 | } 574 | } 575 | } 576 | 577 | await addToZip(zip, entry) 578 | 579 | const content = await zip.generateAsync({ type: 'blob' }) 580 | saveAs(content, `${entry.name}.zip`) 581 | 582 | toast({ 583 | title: 'Export Successful', 584 | description: `Directory '${entry.name}' has been exported as a ZIP.`, 585 | status: 'success', 586 | duration: 3000, 587 | isClosable: true 588 | }) 589 | } 590 | } catch (err: any) { 591 | console.error('Error exporting entry:', err) 592 | toast({ 593 | title: 'Export Failed', 594 | description: err.message || 'An error occurred during export.', 595 | status: 'error', 596 | duration: 3000, 597 | isClosable: true 598 | }) 599 | } 600 | } 601 | 602 | const handleExportAll = async () => { 603 | const zip = new JSZip() 604 | 605 | const addToZip = async (zipFolder: JSZip, dirPath: string) => { 606 | const entries = await sharedDir.readDir(dirPath) 607 | for (const entry of entries) { 608 | const entryPath = `${dirPath}/${entry.name}` 609 | if (entry.type === 'file') { 610 | const content = await sharedDir.readFile(entryPath) 611 | zipFolder.file(entryPath.replace('runner/', ''), content) 612 | } else if (entry.type === 'dir') { 613 | const folder = zip.folder(entryPath.replace('runner/', ''))! 614 | await addToZip(folder, entryPath) 615 | } 616 | } 617 | } 618 | 619 | try { 620 | await addToZip(zip, 'runner') 621 | 622 | const content = await zip.generateAsync({ type: 'blob' }) 623 | const timestamp = dayjs().format('YYYY-MM-DD_HH-mm-ss') 624 | const filename = `PyBox_${timestamp}.zip` 625 | saveAs(content, filename) 626 | 627 | toast({ 628 | title: 'Export Successful', 629 | description: `All files have been exported as ${filename}`, 630 | status: 'success', 631 | duration: 3000, 632 | isClosable: true 633 | }) 634 | } catch (err: any) { 635 | console.error('Error exporting all files:', err) 636 | toast({ 637 | title: 'Export Failed', 638 | description: err.message || 'An error occurred while exporting files.', 639 | status: 'error', 640 | duration: 3000, 641 | isClosable: true 642 | }) 643 | } 644 | } 645 | 646 | const renderFileSystemEntry = (entry: FileSystemEntry) => { 647 | const isDir = entry.type === 'dir' 648 | const isExpanded = expandedDirs.has(entry.path) 649 | 650 | const entryName = entry.name 651 | const displayEntryName = 652 | renamingItem === entry.path ? ( 653 | setRenameValue(e.target.value)} 656 | onBlur={handleRenameSubmit} 657 | onKeyPress={e => e.key === 'Enter' && handleRenameSubmit()} 658 | size='sm' 659 | autoFocus 660 | /> 661 | ) : ( 662 | 667 | isDir 668 | ? toggleDirectory(entry.path) 669 | : handleFileSelectInternal(entry.path) 670 | } 671 | cursor={isDir ? 'pointer' : 'default'} 672 | _hover={{ textDecoration: isDir ? 'underline' : 'none' }} 673 | > 674 | {entryName} 675 | 676 | ) 677 | 678 | const renderAddItemInput = (parentPath: string) => ( 679 | 680 | setNewItemName(e.target.value)} 683 | placeholder='new_file.py or new_directory/' 684 | onKeyPress={e => 685 | e.key === 'Enter' && handleCreateInDirectory(parentPath) 686 | } 687 | size='sm' 688 | /> 689 | 690 | } 692 | onClick={() => handleCreateInDirectory(parentPath)} 693 | aria-label='Create File or Directory' 694 | size='sm' 695 | colorScheme='teal' 696 | /> 697 | 698 | 699 | ) 700 | 701 | const icon = isDir ? (isExpanded ? FaFolderOpen : FaFolder) : FaFileAlt 702 | 703 | return ( 704 | handleDragStart(e, entry)} 713 | onDragOver={handleDragOver} 714 | onDrop={e => handleDrop(e, entry)} 715 | onDragEnter={handleDragEnter} 716 | onDragLeave={handleDragLeave} 717 | onDoubleClick={() => isDir && toggleDirectory(entry.path)} 718 | onClick={() => !isDir && handleFileSelectInternal(entry.path)} 719 | onDropCapture={e => handleDropUpload(e, entry.path)} 720 | > 721 | 722 | 723 | {displayEntryName} 724 | {unsavedFiles.has(entry.path) && ( 725 | 733 | )} 734 | {isDir && ( 735 | 736 | } 738 | size='xs' 739 | onClick={e => { 740 | e.stopPropagation() 741 | setCreatingInDirectory(entry.path) 742 | }} 743 | aria-label='Add File or Directory' 744 | variant='ghost' 745 | /> 746 | 747 | )} 748 | 749 | } 751 | size='xs' 752 | onClick={e => { 753 | e.stopPropagation() 754 | handleRename(entry.path) 755 | }} 756 | aria-label='Rename' 757 | variant='ghost' 758 | /> 759 | 760 | 761 | } 763 | size='xs' 764 | onClick={e => { 765 | e.stopPropagation() 766 | handleDelete(entry.path) 767 | }} 768 | aria-label='Delete' 769 | variant='ghost' 770 | /> 771 | 772 | 773 | } 775 | size='xs' 776 | onClick={e => { 777 | e.stopPropagation() 778 | handleExport(entry) 779 | }} 780 | aria-label='Download' 781 | variant='ghost' 782 | /> 783 | 784 | 785 | {creatingInDirectory === entry.path && renderAddItemInput(entry.path)} 786 | {isDir && ( 787 | 788 | 789 | 790 | {entry.children && 791 | entry.children.map(child => renderFileSystemEntry(child))} 792 | 793 | 794 | 795 | )} 796 | 797 | ) 798 | } 799 | 800 | const bgColor = useColorModeValue('gray.100', 'gray.900') 801 | 802 | interface HTMLInputProps extends React.InputHTMLAttributes { 803 | webkitdirectory?: string 804 | } 805 | 806 | return ( 807 | handleDropUpload(e, 'runner')} 817 | > 818 | 819 | 820 | 821 | File Explorer 822 | 823 | } 825 | aria-label='Upload' 826 | size='sm' 827 | variant='ghost' 828 | onClick={() => { 829 | document.getElementById('file-upload-input')?.click() 830 | }} 831 | /> 832 | 833 | input && (input.webkitdirectory = true)} 839 | onChange={(e: React.ChangeEvent) => { 840 | const files = e.target.files 841 | if (files) { 842 | handleFileUpload(files, 'runner') 843 | } 844 | }} 845 | /> 846 | 847 | 848 | } 850 | aria-label='Export All Files' 851 | size='sm' 852 | variant='ghost' 853 | onClick={handleExportAll} 854 | /> 855 | 856 | 857 | 858 | setNewName(e.target.value)} 861 | placeholder='new_file.py or new_directory/' 862 | onKeyPress={e => e.key === 'Enter' && handleCreate()} 863 | size='sm' 864 | /> 865 | 866 | } 868 | onClick={handleCreate} 869 | aria-label='Create File or Directory' 870 | size='sm' 871 | colorScheme='teal' 872 | /> 873 | 874 | 875 | 876 | {fileSystemTree.map(entry => renderFileSystemEntry(entry))} 877 | 878 | 879 | ) 880 | } 881 | 882 | export default FileSystem 883 | -------------------------------------------------------------------------------- /src/components/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react' 2 | import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels' 3 | import { 4 | Box, 5 | Center, 6 | Drawer, 7 | DrawerOverlay, 8 | DrawerContent, 9 | DrawerBody 10 | } from '@chakra-ui/react' 11 | import Spinner from './Spinner' 12 | import FileSystem from './FileSystem' 13 | 14 | const EditorAndBottomPanels = React.lazy(() => import('./EditorPanels')) 15 | 16 | interface MainLayoutProps { 17 | fileExplorerSize: number 18 | setFileExplorerSize: (size: number) => void 19 | panelBgColor: string 20 | activeFile: string 21 | openFiles: string[] 22 | setActiveFile: (file: string) => void 23 | handleFileSelect: (file: string) => void 24 | unsavedFiles: Set 25 | handleCloseFile: (file: string) => void 26 | handleRenameFile: (oldName: string, newName: string) => void 27 | handleAddNewFile: () => void 28 | markFileAsUnsaved: (file: string) => void 29 | handleSaveFile: () => Promise 30 | isBottomPanelVisible: boolean 31 | setIsBottomPanelVisible: (visible: boolean) => void 32 | activeBottomPanel: string 33 | setActiveBottomPanel: (panel: string) => void 34 | output: string 35 | clearOutput: () => void 36 | editorRef: React.RefObject 37 | isMobile: boolean 38 | mobileFileExplorerOpen: boolean // NEW prop from App 39 | setMobileFileExplorerOpen: (open: boolean) => void // NEW prop from App 40 | } 41 | 42 | const MainLayout: React.FC = ({ 43 | fileExplorerSize, 44 | setFileExplorerSize, 45 | panelBgColor, 46 | activeFile, 47 | openFiles, 48 | setActiveFile, 49 | handleFileSelect, 50 | unsavedFiles, 51 | handleCloseFile, 52 | handleRenameFile, 53 | handleAddNewFile, 54 | markFileAsUnsaved, 55 | handleSaveFile, 56 | isBottomPanelVisible, 57 | setIsBottomPanelVisible, 58 | activeBottomPanel, 59 | setActiveBottomPanel, 60 | output, 61 | clearOutput, 62 | editorRef, 63 | isMobile, 64 | mobileFileExplorerOpen, 65 | setMobileFileExplorerOpen 66 | }) => { 67 | if (isMobile) { 68 | return ( 69 | <> 70 | setMobileFileExplorerOpen(false)} 73 | size='xs' 74 | placement='left' 75 | > 76 | 77 | 78 | 79 | 82 | 83 | 84 | } 85 | > 86 | { 88 | handleFileSelect(file) 89 | setMobileFileExplorerOpen(false) 90 | }} 91 | activeFile={activeFile} 92 | unsavedFiles={unsavedFiles} 93 | /> 94 | 95 | 96 | 97 | 98 | 99 | 102 | 103 | 104 | } 105 | > 106 | 125 | 126 | 127 | ) 128 | } 129 | 130 | // Desktop Layout: Traditional split panels (file explorer always visible) 131 | return ( 132 | 133 | {/* File Explorer Panel */} 134 | setFileExplorerSize(size)} 138 | className='panel-sidebar' 139 | style={{ 140 | display: 'flex', 141 | flexDirection: 'column', 142 | backgroundColor: panelBgColor, 143 | overflow: 'hidden' 144 | }} 145 | > 146 | 147 | 150 | 151 | 152 | } 153 | > 154 | 159 | 160 | 161 | 162 | 163 | {/* Resize Handle */} 164 | 174 | 175 | {/* Main Content Panel */} 176 | 184 | 187 | 188 | 189 | } 190 | > 191 | 210 | 211 | 212 | 213 | ) 214 | } 215 | 216 | export default MainLayout 217 | -------------------------------------------------------------------------------- /src/components/Output.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | Box, 4 | Flex, 5 | Heading, 6 | IconButton, 7 | Collapse, 8 | useColorModeValue, 9 | Tooltip, 10 | useColorMode, 11 | Text 12 | } from '@chakra-ui/react' 13 | import { DeleteIcon, ChevronUpIcon, ChevronDownIcon } from '@chakra-ui/icons' 14 | 15 | interface OutputProps { 16 | output: string 17 | clearOutput: () => void 18 | } 19 | 20 | const Output: React.FC = ({ output, clearOutput }) => { 21 | const [isCollapsed, setIsCollapsed] = useState(false) 22 | 23 | const bgColor = useColorModeValue('gray.100', 'gray.800') 24 | 25 | return ( 26 | 27 | 28 | Output 29 | 30 | 31 | } 33 | size='sm' 34 | onClick={clearOutput} 35 | aria-label='Clear Output' 36 | variant='ghost' 37 | /> 38 | 39 | 40 | : } 42 | size='sm' 43 | onClick={() => setIsCollapsed(!isCollapsed)} 44 | aria-label='Toggle Output' 45 | variant='ghost' 46 | /> 47 | 48 | 49 | 50 | 51 | 64 | {output} 69 | 70 | 71 | 72 | 73 | ) 74 | } 75 | 76 | export default Output 77 | -------------------------------------------------------------------------------- /src/components/PackageManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | Box, 4 | Heading, 5 | Input, 6 | IconButton, 7 | HStack, 8 | useColorModeValue, 9 | Tooltip, 10 | List, 11 | ListItem, 12 | Text 13 | } from '@chakra-ui/react' 14 | import { RiInstallFill } from 'react-icons/ri' 15 | interface PackageManagerProps { 16 | onInstall: (packageName: string) => void 17 | installedPackages: string[] 18 | } 19 | 20 | const PackageManager: React.FC = ({ 21 | onInstall, 22 | installedPackages 23 | }) => { 24 | const [packageName, setPackageName] = useState('') 25 | 26 | const handleInstall = () => { 27 | if (packageName.trim()) { 28 | onInstall(packageName.trim()) 29 | setPackageName('') 30 | } 31 | } 32 | 33 | const bgColor = useColorModeValue('gray.50', 'gray.800') 34 | 35 | return ( 36 | 37 | 38 | Package Manager 39 | 40 | 41 | setPackageName(e.target.value)} 44 | placeholder='Package name (e.g., numpy)' 45 | onKeyPress={e => e.key === 'Enter' && handleInstall()} 46 | size='sm' 47 | /> 48 | 49 | } 51 | onClick={handleInstall} 52 | aria-label='Install Package' 53 | size='sm' 54 | colorScheme='teal' 55 | /> 56 | 57 | 58 | {installedPackages && installedPackages.length > 0 && ( 59 | 60 | 61 | Installed Packages 62 | 63 | 64 | {installedPackages.map(pkg => ( 65 | 66 | {pkg} 67 | 68 | ))} 69 | 70 | 71 | )} 72 | 73 | ) 74 | } 75 | 76 | export default PackageManager 77 | -------------------------------------------------------------------------------- /src/components/PackageManagerDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react' 2 | import { 3 | Drawer, 4 | DrawerOverlay, 5 | DrawerCloseButton, 6 | DrawerBody, 7 | DrawerContent, 8 | useColorModeValue 9 | } from '@chakra-ui/react' 10 | import Spinner from './Spinner' 11 | const PackageManager = React.lazy(() => import('./PackageManager')) 12 | 13 | interface PackageManagerDrawerProps { 14 | isOpen: boolean 15 | onClose: () => void 16 | onInstall: (packageName: string) => void 17 | installedPackages: string[] 18 | isMobile?: boolean 19 | } 20 | 21 | const PackageManagerDrawer: React.FC = ({ 22 | isOpen, 23 | onClose, 24 | onInstall, 25 | installedPackages, 26 | isMobile = false 27 | }) => { 28 | const drawerPlacement = isMobile ? 'bottom' : 'right' 29 | const drawerSize = isMobile ? 'full' : 'md' 30 | 31 | return ( 32 | 39 | 40 | 41 | 42 | 43 | }> 44 | 48 | 49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | export default PackageManagerDrawer 56 | -------------------------------------------------------------------------------- /src/components/PythonRepl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { 3 | Box, 4 | useColorModeValue, 5 | useColorMode, 6 | Button, 7 | Flex 8 | } from '@chakra-ui/react' 9 | import { Terminal as XTerm } from 'xterm' 10 | import { FitAddon } from 'xterm-addon-fit' 11 | import 'xterm/css/xterm.css' 12 | import usePyodide from '../hooks/usePyodide' 13 | 14 | const PythonRepl: React.FC = () => { 15 | const terminalRef = useRef(null) 16 | const { pyodide, isLoading, error } = usePyodide() 17 | const { colorMode } = useColorMode() 18 | const [replKey, setReplKey] = useState(0) 19 | const [termI, setTermI] = useState(null) 20 | 21 | const terminalTheme = { 22 | background: colorMode === 'dark' ? '#2D3748' : 'white', 23 | foreground: colorMode === 'dark' ? '#E2E8F0' : '#2D3748', 24 | cursor: 25 | colorMode === 'dark' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)', 26 | selectionBackground: 27 | colorMode === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' 28 | } 29 | 30 | useEffect(() => { 31 | if (termI) { 32 | termI.options.theme = terminalTheme 33 | } 34 | }, [colorMode]) 35 | 36 | useEffect(() => { 37 | if (!pyodide || isLoading || error) return 38 | 39 | let term: XTerm 40 | let pyconsole: any 41 | let await_fut: any 42 | let commandHistory: string[] = [] 43 | let historyIndex: number = -1 44 | let inputBuffer: string = '' 45 | let codeBuffer: string = '' 46 | let prompt = '>>> ' 47 | let currentFuture: any = null 48 | let fitAddon: FitAddon 49 | let cursorPosition: number = 0 50 | 51 | const initializeRepl = async () => { 52 | term = new XTerm({ 53 | cursorBlink: true, 54 | convertEol: true, 55 | fontFamily: '"Fira Code", monospace', 56 | fontSize: 14, 57 | theme: terminalTheme 58 | }) 59 | setTermI(term) 60 | fitAddon = new FitAddon() 61 | term.loadAddon(fitAddon) 62 | term.open(terminalRef.current!) 63 | term.element!.style.padding = '16px' 64 | fitAddon.fit() 65 | term.focus() 66 | 67 | setTimeout(() => fitAddon.fit(), 0) 68 | 69 | const resizeObserver = new ResizeObserver(() => { 70 | fitAddon.fit() 71 | }) 72 | resizeObserver.observe(terminalRef.current!) 73 | 74 | const pyConsoleModule = pyodide.pyimport('pyodide.console') 75 | const { BANNER, PyodideConsole } = pyConsoleModule 76 | pyconsole = PyodideConsole(pyodide.globals) 77 | 78 | await_fut = pyodide.runPython(` 79 | import builtins 80 | from pyodide.ffi import to_js 81 | 82 | async def await_fut(fut): 83 | try: 84 | res = await fut 85 | if res is not None: 86 | builtins._ = res 87 | return to_js([res], depth=1) 88 | except KeyboardInterrupt: 89 | return to_js([None], depth=1) 90 | 91 | await_fut 92 | `) 93 | 94 | pyconsole.stdout_callback = (s: string) => 95 | term.write(s.replace(/\n/g, '\r\n')) 96 | pyconsole.stderr_callback = (s: string) => 97 | term.write(s.replace(/\n/g, '\r\n')) 98 | 99 | term.writeln( 100 | `Welcome to the Pyodide ${pyodide.version} terminal emulator 🐍` 101 | ) 102 | term.writeln(BANNER) 103 | 104 | prompt = '>>> ' 105 | commandHistory = [] 106 | historyIndex = -1 107 | inputBuffer = '' 108 | codeBuffer = '' 109 | cursorPosition = 0 110 | currentFuture = null 111 | 112 | term.write(prompt) 113 | 114 | term.onKey(handleKey) 115 | } 116 | 117 | const refreshLine = () => { 118 | term.write('\r') 119 | 120 | term.write(prompt + inputBuffer) 121 | 122 | term.write('\x1b[K') 123 | 124 | const cursorPos = prompt.length + cursorPosition 125 | const totalLength = prompt.length + inputBuffer.length 126 | const moveLeft = totalLength - cursorPos 127 | if (moveLeft > 0) { 128 | term.write(`\x1b[${moveLeft}D`) 129 | } 130 | } 131 | 132 | const handleKey = (event: { key: string; domEvent: KeyboardEvent }) => { 133 | const { key, domEvent } = event 134 | const printable = 135 | !domEvent.altKey && 136 | !domEvent.ctrlKey && 137 | !domEvent.metaKey && 138 | domEvent.key.length === 1 139 | 140 | if (domEvent.key === 'Enter') { 141 | handleEnter() 142 | } else if (domEvent.key === 'Backspace') { 143 | handleBackspace() 144 | } else if (domEvent.key === 'ArrowUp') { 145 | handleHistoryNavigation('up') 146 | } else if (domEvent.key === 'ArrowDown') { 147 | handleHistoryNavigation('down') 148 | } else if (domEvent.key === 'ArrowLeft') { 149 | if (cursorPosition > 0) { 150 | cursorPosition-- 151 | term.write('\x1b[D') 152 | } 153 | } else if (domEvent.key === 'ArrowRight') { 154 | if (cursorPosition < inputBuffer.length) { 155 | cursorPosition++ 156 | term.write('\x1b[C') 157 | } 158 | } else if (domEvent.key === 'Home') { 159 | term.write(`\x1b[${cursorPosition}D`) 160 | cursorPosition = 0 161 | } else if (domEvent.key === 'End') { 162 | term.write(`\x1b[${inputBuffer.length - cursorPosition}C`) 163 | cursorPosition = inputBuffer.length 164 | } else if (domEvent.key === 'Tab') { 165 | handleTab() 166 | } else if (domEvent.ctrlKey && domEvent.key === 'c') { 167 | handleCtrlC() 168 | } else if (printable) { 169 | inputBuffer = 170 | inputBuffer.slice(0, cursorPosition) + 171 | key + 172 | inputBuffer.slice(cursorPosition) 173 | cursorPosition++ 174 | refreshLine() 175 | } 176 | 177 | domEvent.preventDefault() 178 | } 179 | 180 | const handleEnter = async () => { 181 | term.write('\r\n') 182 | const code = codeBuffer + inputBuffer 183 | 184 | if (code.trim() !== '') { 185 | commandHistory.push(codeBuffer + inputBuffer) 186 | } 187 | historyIndex = commandHistory.length 188 | 189 | const currentInput = inputBuffer 190 | 191 | inputBuffer = '' 192 | cursorPosition = 0 193 | 194 | term.options.disableStdin = true 195 | 196 | try { 197 | currentFuture = pyconsole.push(currentInput + '\n') 198 | 199 | if (currentFuture.syntax_check === 'syntax-error') { 200 | term.writeln(currentFuture.formatted_error.trimEnd()) 201 | codeBuffer = '' 202 | prompt = '>>> ' 203 | term.write(prompt) 204 | term.options.disableStdin = false 205 | } else if (currentFuture.syntax_check === 'incomplete') { 206 | codeBuffer += currentInput + '\n' 207 | prompt = '... ' 208 | term.write(prompt) 209 | term.options.disableStdin = false 210 | } else if (currentFuture.syntax_check === 'complete') { 211 | codeBuffer += currentInput + '\n' 212 | try { 213 | const wrapped = await_fut(currentFuture) 214 | const [value] = await wrapped 215 | if (value !== undefined) { 216 | const resultStr = pyodide.runPython('repr(_)') 217 | term.writeln(resultStr) 218 | } 219 | } catch (e: any) { 220 | if (e.constructor.name === 'PythonError') { 221 | const message = currentFuture.formatted_error || e.message 222 | term.writeln(message.trimEnd()) 223 | } else { 224 | console.error(e) 225 | term.writeln(`Error: ${e.message}`) 226 | } 227 | } finally { 228 | currentFuture.destroy() 229 | currentFuture = null 230 | } 231 | codeBuffer = '' 232 | prompt = '>>> ' 233 | term.write(prompt) 234 | term.options.disableStdin = false 235 | } 236 | } catch (e: any) { 237 | console.error(e) 238 | term.writeln(`Error: ${e.message}`) 239 | codeBuffer = '' 240 | prompt = '>>> ' 241 | term.write(prompt) 242 | term.options.disableStdin = false 243 | } 244 | } 245 | 246 | const handleCtrlC = () => { 247 | if (inputBuffer.length > 0 || codeBuffer.length > 0) { 248 | inputBuffer = '' 249 | codeBuffer = '' 250 | cursorPosition = 0 251 | term.write('^C\r\n') 252 | prompt = '>>> ' 253 | term.write(prompt) 254 | term.options.disableStdin = false 255 | } else if (currentFuture && !currentFuture.f_done) { 256 | try { 257 | currentFuture.cancel() 258 | 259 | pyodide.runPython(` 260 | import sys 261 | import _asyncio 262 | _asyncio.set_fatal_error_handler(lambda *args: None) 263 | raise KeyboardInterrupt 264 | `) 265 | term.write('^C\r\nExecution interrupted\r\n') 266 | } catch (e: any) { 267 | console.error('Error during Ctrl+C handling:', e) 268 | } finally { 269 | currentFuture.destroy() 270 | currentFuture = null 271 | prompt = '>>> ' 272 | term.write(prompt) 273 | term.options.disableStdin = false 274 | } 275 | } else { 276 | term.write('^C\r\n') 277 | prompt = '>>> ' 278 | term.write(prompt) 279 | term.options.disableStdin = false 280 | } 281 | } 282 | 283 | const handleBackspace = () => { 284 | if (cursorPosition > 0) { 285 | inputBuffer = 286 | inputBuffer.slice(0, cursorPosition - 1) + 287 | inputBuffer.slice(cursorPosition) 288 | cursorPosition-- 289 | refreshLine() 290 | } 291 | } 292 | 293 | const handleHistoryNavigation = (direction: 'up' | 'down') => { 294 | if (direction === 'up' && historyIndex > 0) { 295 | historyIndex-- 296 | const historyEntry = commandHistory[historyIndex] 297 | 298 | codeBuffer = '' 299 | prompt = '>>> ' 300 | inputBuffer = historyEntry 301 | cursorPosition = inputBuffer.length 302 | refreshLine() 303 | } else if (direction === 'down') { 304 | if (historyIndex < commandHistory.length - 1) { 305 | historyIndex++ 306 | const historyEntry = commandHistory[historyIndex] 307 | 308 | codeBuffer = '' 309 | prompt = '>>> ' 310 | inputBuffer = historyEntry 311 | } else { 312 | historyIndex = commandHistory.length 313 | codeBuffer = '' 314 | prompt = '>>> ' 315 | inputBuffer = '' 316 | } 317 | cursorPosition = inputBuffer.length 318 | refreshLine() 319 | } 320 | } 321 | 322 | const handleTab = () => { 323 | const inputUpToCursor = inputBuffer.slice(0, cursorPosition) 324 | const match = inputUpToCursor.match(/([a-zA-Z0-9_\.]+)$/) 325 | const currentWord = match ? match[1] : '' 326 | 327 | if (currentWord === '') { 328 | const indent = ' ' 329 | inputBuffer = 330 | inputBuffer.slice(0, cursorPosition) + 331 | indent + 332 | inputBuffer.slice(cursorPosition) 333 | cursorPosition += indent.length 334 | refreshLine() 335 | } else { 336 | handleTabCompletion() 337 | } 338 | } 339 | 340 | const handleTabCompletion = () => { 341 | const inputUpToCursor = inputBuffer.slice(0, cursorPosition) 342 | const completionResult = pyconsole.complete(inputUpToCursor).toJs() 343 | const completions = completionResult[0] 344 | const offset = completionResult[1] 345 | 346 | if (completions.length === 0) { 347 | } else if (completions.length === 1) { 348 | const completion = completions[0] 349 | const toInsert = completion.slice(offset) 350 | inputBuffer = 351 | inputBuffer.slice(0, cursorPosition) + 352 | toInsert + 353 | inputBuffer.slice(cursorPosition) 354 | cursorPosition += toInsert.length 355 | refreshLine() 356 | } else if (completions.length > 1) { 357 | term.write('\r\n') 358 | term.writeln(completions.join(' ')) 359 | term.write(prompt + inputBuffer) 360 | 361 | const cursorPos = prompt.length + cursorPosition 362 | const totalLength = prompt.length + inputBuffer.length 363 | if (cursorPos < totalLength) { 364 | term.write(`\x1b[${totalLength - cursorPos}D`) 365 | } 366 | } 367 | } 368 | 369 | initializeRepl() 370 | 371 | return () => { 372 | term?.dispose() 373 | } 374 | }, [pyodide, isLoading, error, replKey]) 375 | 376 | const resetRepl = () => { 377 | setReplKey(prev => prev + 1) 378 | } 379 | 380 | return ( 381 | 382 | 392 | 393 | 394 | 397 | 398 | 399 | ) 400 | } 401 | 402 | export default PythonRepl 403 | -------------------------------------------------------------------------------- /src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { VStack, FormControl, FormLabel, Switch, Box } from '@chakra-ui/react' 3 | 4 | interface SettingsProps { 5 | isAutosaveEnabled: boolean 6 | setIsAutosaveEnabled: (value: boolean) => void 7 | } 8 | 9 | const Settings: React.FC = ({ 10 | isAutosaveEnabled, 11 | setIsAutosaveEnabled 12 | }) => { 13 | const handleAutosaveToggle = (e: React.ChangeEvent) => { 14 | setIsAutosaveEnabled(e.target.checked) 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | Autosave 22 | 23 | 29 | 30 | 31 | {/* Add more settings here as needed */} 32 | {/* Placeholder for additional settings */} 33 | 34 | ) 35 | } 36 | 37 | export default Settings 38 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Spinner as ChakraSpinner, Flex } from '@chakra-ui/react' 3 | 4 | const SpinnerComponent: React.FC = React.memo(() => { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | }) 11 | 12 | export default SpinnerComponent 13 | -------------------------------------------------------------------------------- /src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | Flex, 4 | Text, 5 | Input, 6 | IconButton, 7 | Tooltip, 8 | Box, 9 | useColorModeValue 10 | } from '@chakra-ui/react' 11 | import { CloseIcon, EditIcon } from '@chakra-ui/icons' 12 | import { FaDotCircle } from 'react-icons/fa' 13 | 14 | interface TabProps { 15 | filename: string 16 | isActive: boolean 17 | onClick: () => void 18 | onClose: () => void 19 | onRename: (oldName: string, newName: string) => void 20 | hasUnsavedChanges: boolean 21 | } 22 | 23 | const Tab: React.FC = React.memo( 24 | ({ filename, isActive, onClick, onClose, onRename, hasUnsavedChanges }) => { 25 | const [renaming, setRenaming] = useState(false) 26 | const [newName, setNewName] = useState(filename) 27 | 28 | const handleRename = () => { 29 | if (renaming && newName.trim() && newName !== filename) { 30 | onRename(filename, newName.trim()) 31 | } 32 | setRenaming(!renaming) 33 | } 34 | 35 | // light and dark modes 36 | const activeBg = useColorModeValue('teal.300', 'teal.700') 37 | const inactiveBg = useColorModeValue('gray.200', 'gray.700') 38 | const hoverBg = useColorModeValue('teal.200', 'teal.600') 39 | const activeBorderColor = useColorModeValue('teal.500', 'teal.300') 40 | const unsavedColor = useColorModeValue('red.600', 'red.500') 41 | 42 | return ( 43 | 54 | {renaming ? ( 55 | setNewName(e.target.value)} 58 | onBlur={handleRename} 59 | onKeyPress={e => e.key === 'Enter' && handleRename()} 60 | size='sm' 61 | autoFocus 62 | /> 63 | ) : ( 64 | 65 | 66 | {filename} 67 | 68 | {hasUnsavedChanges && ( 69 | 77 | )} 78 | 79 | )} 80 | 81 | } 83 | size='xs' 84 | onClick={e => { 85 | e.stopPropagation() 86 | handleRename() 87 | }} 88 | aria-label='Rename Tab' 89 | variant='ghost' 90 | ml={2} 91 | /> 92 | 93 | 94 | } 96 | size='xs' 97 | onClick={e => { 98 | e.stopPropagation() 99 | onClose() 100 | }} 101 | aria-label='Close Tab' 102 | variant='ghost' 103 | ml={1} 104 | /> 105 | 106 | 107 | ) 108 | } 109 | ) 110 | 111 | export default Tab 112 | -------------------------------------------------------------------------------- /src/components/Terminal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { Box, Button, Flex, Icon, useColorMode } from '@chakra-ui/react' 3 | import { FiTerminal } from 'react-icons/fi' 4 | import { Terminal as XTerm } from 'xterm' 5 | import { FitAddon } from 'xterm-addon-fit' 6 | import 'xterm/css/xterm.css' 7 | import { Wasmer, init } from '@wasmer/sdk' 8 | //@ts-ignore 9 | import WasmModule from '@wasmer/sdk/wasm?url' 10 | import { useFilesystem } from '../context/FilesystemContext' 11 | 12 | const encoder = new TextEncoder() 13 | 14 | const Terminal: React.FC = () => { 15 | const terminalRef = useRef(null) 16 | const termRef = useRef(null) 17 | const fitAddonRef = useRef(null) 18 | const { sharedDir } = useFilesystem() 19 | const { colorMode } = useColorMode() 20 | const [instance, setInstance] = useState(null) 21 | const [termI, setTermI] = useState(null) 22 | const [terminalLoaded, setTerminalLoaded] = useState(false) 23 | 24 | const terminalTheme = { 25 | background: colorMode === 'dark' ? '#2D3748' : 'white', 26 | foreground: colorMode === 'dark' ? '#E2E8F0' : '#2D3748', 27 | cursor: 28 | colorMode === 'dark' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)', 29 | selectionBackground: 30 | colorMode === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' 31 | } 32 | 33 | useEffect(() => { 34 | if (termI) { 35 | termI.options.theme = terminalTheme 36 | } 37 | }, [colorMode]) 38 | 39 | useEffect(() => { 40 | if (!terminalLoaded) return 41 | 42 | const handleResize = () => { 43 | fitAddonRef.current?.fit() 44 | } 45 | 46 | const runTerminal = async () => { 47 | try { 48 | await init({ 49 | module: WasmModule, 50 | sdkUrl: `${window.location.href}/sdk/index.mjs` 51 | }) 52 | console.log('Wasmer SDK initialized') 53 | 54 | const term = new XTerm({ 55 | cursorBlink: true, 56 | convertEol: true, 57 | fontFamily: '"Fira Code", monospace', 58 | fontSize: 13, 59 | theme: terminalTheme 60 | }) 61 | 62 | setTermI(term) 63 | const fitAddon = new FitAddon() 64 | term.loadAddon(fitAddon) 65 | term.open(terminalRef.current!) 66 | fitAddon.fit() 67 | term.focus() 68 | 69 | term.element!.style.padding = '16px' 70 | 71 | const pkg = await Wasmer.fromRegistry('sharrattj/bash') 72 | console.log('Bash package fetched from Wasmer registry') 73 | 74 | const instance = await pkg.entrypoint!.run({ 75 | args: [ 76 | '-c', 77 | 'echo "root:x:0:0:root:/root:/bin/bash" > /etc/passwd && echo "runner" > /etc/hostname && echo "127.0.0.1 runner" >> /etc/hosts && bash' 78 | ], 79 | mount: { '/home': sharedDir }, 80 | cwd: '/home', 81 | env: { 82 | HOME: '/home', 83 | PS1: '\\u@runner:\\w\\$ ' 84 | } 85 | }) 86 | console.log( 87 | "Bash package running with sharedDir mounted at '.' and cwd set to 'home'" 88 | ) 89 | 90 | connectStreams(instance, term) 91 | setInstance(instance) 92 | 93 | termRef.current = term 94 | fitAddonRef.current = fitAddon 95 | 96 | window.addEventListener('resize', handleResize) 97 | } catch (err: any) { 98 | console.error('Error initializing Terminal:', err) 99 | termRef.current?.writeln(`\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`) 100 | } 101 | } 102 | 103 | runTerminal() 104 | 105 | return () => { 106 | termRef.current?.dispose() 107 | fitAddonRef.current?.dispose() 108 | window.removeEventListener('resize', handleResize) 109 | } 110 | }, [terminalLoaded, sharedDir]) 111 | 112 | const connectStreams = (instance: any, term: XTerm) => { 113 | let stdin = instance.stdin?.getWriter() 114 | 115 | term.onData(data => { 116 | let command = term.buffer.active 117 | .getLine(term.buffer.active.cursorY) 118 | ?.translateToString(true) 119 | .trim() 120 | 121 | command = command!.replace(/^bash-\d+\.\d+#\s*/, '') 122 | 123 | if (command === 'clear') { 124 | term.clear() 125 | stdin?.write(encoder.encode('\x03')) 126 | stdin?.write(encoder.encode('\n')) 127 | } else { 128 | stdin?.write(encoder.encode(data)) 129 | } 130 | }) 131 | 132 | instance.stdout 133 | .pipeTo( 134 | new WritableStream({ 135 | write: (chunk: Uint8Array) => { 136 | const output = new TextDecoder().decode(chunk) 137 | term.write(output) 138 | } 139 | }) 140 | ) 141 | .catch((err: any) => { 142 | console.error('Error piping stdout:', err) 143 | }) 144 | 145 | instance.stderr 146 | .pipeTo( 147 | new WritableStream({ 148 | write: (chunk: Uint8Array) => { 149 | const output = new TextDecoder().decode(chunk) 150 | term.write(output) 151 | } 152 | }) 153 | ) 154 | .catch((err: any) => { 155 | console.error('Error piping stderr:', err) 156 | }) 157 | } 158 | 159 | return ( 160 | <> 161 | {!terminalLoaded ? ( 162 | 169 | 173 | 174 | ) : ( 175 | 185 | )} 186 | 187 | ) 188 | } 189 | 190 | export default Terminal 191 | -------------------------------------------------------------------------------- /src/components/TopNavigationBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Flex, 4 | IconButton, 5 | Text, 6 | Tooltip, 7 | useColorModeValue 8 | } from '@chakra-ui/react' 9 | import { SunIcon, MoonIcon, SearchIcon, SettingsIcon } from '@chakra-ui/icons' 10 | import { FaPlay, FaSave, FaFolderOpen } from 'react-icons/fa' 11 | 12 | interface TopNavigationBarProps { 13 | colorMode: string 14 | toggleColorMode: () => void 15 | onOpen: () => void 16 | handleRunCode: () => void 17 | handleManualSave: () => void 18 | isRunning: boolean 19 | unsavedFiles: Set 20 | activeFile: string 21 | isMobile?: boolean 22 | onOpenFileExplorer: () => void 23 | } 24 | 25 | const TopNavigationBar: React.FC = ({ 26 | colorMode, 27 | toggleColorMode, 28 | onOpen, 29 | handleRunCode, 30 | handleManualSave, 31 | isRunning, 32 | unsavedFiles, 33 | activeFile, 34 | onOpenFileExplorer, 35 | isMobile 36 | }) => { 37 | const bgColor = useColorModeValue('gray.100', 'gray.800') 38 | const borderColor = useColorModeValue('gray.300', 'gray.600') 39 | const textColor = useColorModeValue('gray.800', 'white') 40 | const iconHoverBg = useColorModeValue('gray.200', 'gray.700') 41 | const titleColor = useColorModeValue('teal.600', 'teal.300') 42 | 43 | return ( 44 | 56 | 57 | 58 | : } 61 | variant='ghost' 62 | color={textColor} 63 | onClick={toggleColorMode} 64 | size={{ base: 'sm', md: 'md' }} 65 | _hover={{ bg: iconHoverBg }} 66 | transition='transform 0.2s' 67 | _active={{ transform: 'scale(0.95)' }} 68 | /> 69 | 70 | 71 | 77 | PyBox 78 | 79 | 80 | 81 | 82 | {isMobile && ( 83 | 84 | } 86 | aria-label='Open File Explorer' 87 | variant='ghost' 88 | color={textColor} 89 | onClick={onOpenFileExplorer} 90 | size={{ base: 'sm', md: 'md' }} 91 | mr={{ base: 2, md: 4 }} 92 | _hover={{ bg: iconHoverBg }} 93 | /> 94 | 95 | )} 96 | 97 | 98 | } 100 | aria-label='Search Files' 101 | variant='ghost' 102 | color={textColor} 103 | onClick={() => {}} 104 | size={{ base: 'sm', md: 'md' }} 105 | mr={{ base: 2, md: 4 }} 106 | _hover={{ bg: iconHoverBg }} 107 | display={{ base: 'none', md: 'inline-flex' }} 108 | /> 109 | 110 | 111 | 112 | } 114 | aria-label='Run Code' 115 | colorScheme='green' 116 | onClick={handleRunCode} 117 | isLoading={isRunning} 118 | size={{ base: 'sm', md: 'md' }} 119 | mr={{ base: 2, md: 4 }} 120 | _hover={{ bg: 'green.600' }} 121 | transition='background-color 0.2s' 122 | /> 123 | 124 | 125 | 126 | } 128 | aria-label='Save File' 129 | colorScheme='blue' 130 | onClick={handleManualSave} 131 | isDisabled={!unsavedFiles.has(activeFile)} 132 | size={{ base: 'sm', md: 'md' }} 133 | mr={{ base: 2, md: 4 }} 134 | _hover={{ bg: 'blue.600' }} 135 | transition='background-color 0.2s' 136 | /> 137 | 138 | 139 | 140 | } 142 | aria-label='Open Package Manager' 143 | colorScheme='purple' 144 | variant='ghost' 145 | onClick={onOpen} 146 | size={{ base: 'sm', md: 'md' }} 147 | _hover={{ bg: iconHoverBg }} 148 | /> 149 | 150 | 151 | 152 | ) 153 | } 154 | 155 | export default TopNavigationBar 156 | -------------------------------------------------------------------------------- /src/config/keyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | export const keyMap = { 2 | RUN_CODE: 'ctrl+r, cmd+r', 3 | NEW_FILE: 'ctrl+n, cmd+n', 4 | TOGGLE_BOTTOM_PANEL: 'ctrl+b, cmd+b', 5 | SAVE_FILE: 'ctrl+s, cmd+s', 6 | OPEN_PACKAGE_MANAGER: 'ctrl+shift+p, cmd+shift+p', 7 | }; 8 | 9 | export const createHandlers = ( 10 | handleRunCode: () => void, 11 | handleAddNewFile: () => void, 12 | toggleBottomPanel: () => void, 13 | handleManualSave: () => void, 14 | onOpen: () => void 15 | ) => ({ 16 | RUN_CODE: (event?: KeyboardEvent) => { 17 | if (event) event.preventDefault(); 18 | handleRunCode(); 19 | }, 20 | NEW_FILE: (event?: KeyboardEvent) => { 21 | if (event) event.preventDefault(); 22 | handleAddNewFile(); 23 | }, 24 | TOGGLE_BOTTOM_PANEL: (event?: KeyboardEvent) => { 25 | if (event) event.preventDefault(); 26 | toggleBottomPanel(); 27 | }, 28 | SAVE_FILE: (event?: KeyboardEvent) => { 29 | if (event) event.preventDefault(); 30 | handleManualSave(); 31 | }, 32 | OPEN_PACKAGE_MANAGER: (event?: KeyboardEvent) => { 33 | if (event) event.preventDefault(); 34 | onOpen(); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/context/FilesystemContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from 'react' 2 | import { Directory } from '@wasmer/sdk' 3 | import { 4 | initializeSharedDirectory, 5 | getSharedDirectory 6 | } from '../sharedFileSystem' 7 | import { useToast, Center, Spinner, Text } from '@chakra-ui/react' 8 | 9 | interface FilesystemContextProps { 10 | sharedDir: Directory 11 | refreshFS: () => Promise 12 | } 13 | 14 | const FilesystemContext = createContext( 15 | undefined 16 | ) 17 | 18 | export const FilesystemProvider: React.FC<{ children: React.ReactNode }> = ({ 19 | children 20 | }) => { 21 | const [sharedDir, setSharedDir] = useState(null) 22 | const toast = useToast() 23 | 24 | useEffect(() => { 25 | let isMounted = true 26 | 27 | const initFS = async () => { 28 | try { 29 | const dir = await initializeSharedDirectory() 30 | if (isMounted) { 31 | setSharedDir(dir) 32 | toast({ 33 | title: 'Filesystem Initialized', 34 | status: 'success', 35 | duration: 2000, 36 | isClosable: true 37 | }) 38 | } 39 | } catch (err: any) { 40 | console.error('Failed to initialize shared Directory:', err) 41 | if (isMounted) { 42 | toast({ 43 | title: 'Filesystem Initialization Error', 44 | description: err.message, 45 | status: 'error', 46 | duration: 5000, 47 | isClosable: true 48 | }) 49 | } 50 | } 51 | } 52 | 53 | initFS() 54 | 55 | return () => { 56 | isMounted = false 57 | } 58 | }, [toast]) 59 | 60 | const refreshFS = async () => { 61 | try { 62 | const dir = getSharedDirectory() 63 | setSharedDir(dir) 64 | } catch (err: any) { 65 | console.error('Error refreshing filesystem:', err) 66 | toast({ 67 | title: 'Filesystem Refresh Error', 68 | description: err.message, 69 | status: 'error', 70 | duration: 5000, 71 | isClosable: true 72 | }) 73 | } 74 | } 75 | 76 | if (!sharedDir) { 77 | return ( 78 |
79 | 80 | Initializing Filesystem... 81 |
82 | ) 83 | } 84 | 85 | return ( 86 | 87 | {children} 88 | 89 | ) 90 | } 91 | 92 | export const useFilesystem = (): FilesystemContextProps => { 93 | const context = useContext(FilesystemContext) 94 | if (!context) { 95 | throw new Error('useFilesystem must be used within a FilesystemProvider') 96 | } 97 | return context 98 | } 99 | -------------------------------------------------------------------------------- /src/hooks/useEditorState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export const useEditorState = () => { 4 | const [openFiles, setOpenFiles] = useState(['main.py']) 5 | const [activeFile, setActiveFile] = useState('main.py') 6 | const [unsavedFiles, setUnsavedFiles] = useState>(new Set()) 7 | 8 | const markFileAsUnsaved = (filename: string) => { 9 | setUnsavedFiles(prev => new Set(prev).add(filename)) 10 | } 11 | 12 | const markFileAsSaved = (filename: string) => { 13 | setUnsavedFiles(prev => { 14 | const updated = new Set(prev) 15 | updated.delete(filename) 16 | return updated 17 | }) 18 | } 19 | 20 | return { 21 | openFiles, 22 | setOpenFiles, 23 | activeFile, 24 | setActiveFile, 25 | unsavedFiles, 26 | markFileAsUnsaved, 27 | markFileAsSaved 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useHandlers.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from '@chakra-ui/react' 2 | import { useFilesystem } from '../context/FilesystemContext' 3 | 4 | interface UseHandlersParams { 5 | activeFile: string 6 | setActiveFile: React.Dispatch> 7 | openFiles: string[] 8 | setOpenFiles: React.Dispatch> 9 | markFileAsSaved: (filename: string) => void 10 | markFileAsUnsaved: (filename: string) => void 11 | refreshFS: () => Promise 12 | installPackage: (packageName: string) => Promise 13 | runCode: (filename: string) => Promise 14 | setOutput: React.Dispatch> 15 | setIsRunning: React.Dispatch> 16 | isBottomPanelVisible: boolean 17 | setIsBottomPanelVisible: React.Dispatch> 18 | setActiveBottomPanel: React.Dispatch> 19 | unsavedFiles: Set 20 | editorRef: React.RefObject 21 | } 22 | 23 | export const useHandlers = ({ 24 | activeFile, 25 | setActiveFile, 26 | openFiles, 27 | setOpenFiles, 28 | markFileAsSaved, 29 | markFileAsUnsaved, 30 | refreshFS, 31 | installPackage, 32 | runCode, 33 | setOutput, 34 | setIsRunning, 35 | isBottomPanelVisible, 36 | setIsBottomPanelVisible, 37 | setActiveBottomPanel, 38 | unsavedFiles, 39 | editorRef 40 | }: UseHandlersParams) => { 41 | const toast = useToast() 42 | const { sharedDir } = useFilesystem() 43 | 44 | const handleManualSave = async () => { 45 | if (editorRef.current && editorRef.current.saveFile) { 46 | await editorRef.current.saveFile() 47 | markFileAsSaved(activeFile) 48 | } else { 49 | console.error('Editor ref is not available.') 50 | } 51 | } 52 | 53 | const handleSaveFile = async () => { 54 | await handleManualSave() 55 | } 56 | 57 | const handleRunCode = async () => { 58 | if (!activeFile) { 59 | toast({ 60 | title: 'No Active File', 61 | description: 'Please select a file to run.', 62 | status: 'warning', 63 | duration: 3000, 64 | isClosable: true 65 | }) 66 | return 67 | } 68 | 69 | if (unsavedFiles.has(activeFile)) { 70 | await handleManualSave() 71 | } 72 | 73 | setIsRunning(true) 74 | try { 75 | const output = await runCode(activeFile) 76 | setOutput(output) 77 | if (!isBottomPanelVisible) { 78 | setIsBottomPanelVisible(true) 79 | } 80 | setActiveBottomPanel('Output') 81 | } catch (err: any) { 82 | console.error('Error running code:', err) 83 | toast({ 84 | title: 'Execution Error', 85 | description: err.message || 'An error occurred while running the code.', 86 | status: 'error', 87 | duration: 5000, 88 | isClosable: true 89 | }) 90 | } finally { 91 | setIsRunning(false) 92 | } 93 | } 94 | 95 | const handleFileSelect = (filename: string) => { 96 | setActiveFile(filename) 97 | 98 | setOpenFiles(prevOpenFiles => { 99 | if (!prevOpenFiles.includes(filename)) { 100 | return [...prevOpenFiles, filename] 101 | } 102 | return prevOpenFiles 103 | }) 104 | } 105 | 106 | const handleInstallPackage = async (packageName: string) => { 107 | if (!packageName.trim()) { 108 | toast({ 109 | title: 'Invalid Package Name', 110 | description: 'Package name cannot be empty.', 111 | status: 'warning', 112 | duration: 3000, 113 | isClosable: true 114 | }) 115 | return 116 | } 117 | 118 | setIsRunning(true) 119 | try { 120 | await installPackage(packageName.trim()) 121 | setOutput(`Package '${packageName.trim()}' installed successfully.`) 122 | toast({ 123 | title: 'Package Installed', 124 | description: `Package '${packageName.trim()}' installed successfully.`, 125 | status: 'success', 126 | duration: 3000, 127 | isClosable: true 128 | }) 129 | await refreshFS() 130 | } catch (error: any) { 131 | setOutput( 132 | `Error installing package '${packageName.trim()}': ${error.message}` 133 | ) 134 | toast({ 135 | title: 'Package Installation Error', 136 | description: 137 | error.message || 'An error occurred while installing the package.', 138 | status: 'error', 139 | duration: 5000, 140 | isClosable: true 141 | }) 142 | } finally { 143 | setIsRunning(false) 144 | } 145 | } 146 | 147 | const handleAddNewFile = async () => { 148 | const newFile = `new_file${Date.now()}.py` 149 | try { 150 | await sharedDir.writeFile( 151 | `runner/${newFile}`, 152 | new TextEncoder().encode('') 153 | ) 154 | setOpenFiles(prev => [...prev, newFile]) 155 | setActiveFile(newFile) 156 | toast({ 157 | title: 'New File Created', 158 | description: `File '${newFile}' has been created.`, 159 | status: 'info', 160 | duration: 3000, 161 | isClosable: true 162 | }) 163 | await refreshFS() 164 | if (!isBottomPanelVisible) { 165 | setIsBottomPanelVisible(true) 166 | } 167 | setActiveBottomPanel('Output') 168 | } catch (err: any) { 169 | console.error('Error creating new file:', err) 170 | toast({ 171 | title: 'Error Creating File', 172 | description: 173 | err.message || 'An error occurred while creating the file.', 174 | status: 'error', 175 | duration: 5000, 176 | isClosable: true 177 | }) 178 | } 179 | } 180 | 181 | const handleCloseFile = (file: string) => { 182 | setOpenFiles(prev => { 183 | const newOpenFiles = prev.filter(f => f !== file) 184 | if (activeFile === file && newOpenFiles.length > 0) { 185 | setActiveFile(newOpenFiles[0]) 186 | } else if (newOpenFiles.length === 0) { 187 | setActiveFile('') 188 | } 189 | return newOpenFiles 190 | }) 191 | toast({ 192 | title: 'File Closed', 193 | description: `File '${file}' has been closed.`, 194 | status: 'warning', 195 | duration: 3000, 196 | isClosable: true 197 | }) 198 | } 199 | 200 | const toggleBottomPanel = () => { 201 | setIsBottomPanelVisible(prev => !prev) 202 | toast({ 203 | title: isBottomPanelVisible 204 | ? 'Output Panel Hidden' 205 | : 'Output Panel Shown', 206 | status: 'info', 207 | duration: 2000, 208 | isClosable: true 209 | }) 210 | } 211 | 212 | const clearOutput = () => { 213 | setOutput('') 214 | toast({ 215 | title: 'Output Cleared', 216 | status: 'success', 217 | duration: 2000, 218 | isClosable: true 219 | }) 220 | } 221 | 222 | const handleRenameFile = async (oldName: string, newName: string) => { 223 | try { 224 | const oldPath = `/home/runner/${oldName}` 225 | const newPath = `/home/runner/${newName}` 226 | const content = await sharedDir.readFile(oldPath) 227 | 228 | await sharedDir.writeFile(newPath, content) 229 | 230 | await sharedDir.removeFile(oldPath) 231 | 232 | setOpenFiles(prev => prev.map(f => (f === oldName ? newName : f))) 233 | if (activeFile === oldName) { 234 | setActiveFile(newName) 235 | } 236 | 237 | toast({ 238 | title: 'File Renamed', 239 | description: `File '${oldName}' has been renamed to '${newName}'.`, 240 | status: 'success', 241 | duration: 3000, 242 | isClosable: true 243 | }) 244 | 245 | await refreshFS() 246 | } catch (err: any) { 247 | console.error('Error renaming file:', err) 248 | toast({ 249 | title: 'Rename Error', 250 | description: 251 | err.message || 'An error occurred while renaming the file.', 252 | status: 'error', 253 | duration: 5000, 254 | isClosable: true 255 | }) 256 | } 257 | } 258 | 259 | return { 260 | handleRunCode, 261 | handleFileSelect, 262 | handleInstallPackage, 263 | handleAddNewFile, 264 | handleCloseFile, 265 | toggleBottomPanel, 266 | handleManualSave, 267 | handleSaveFile, 268 | clearOutput, 269 | handleRenameFile 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/hooks/usePyodide.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { useFilesystem } from '../context/FilesystemContext' 3 | 4 | declare global { 5 | interface Window { 6 | loadPyodide: any 7 | } 8 | } 9 | 10 | const usePyodide = () => { 11 | const { sharedDir } = useFilesystem() 12 | const [pyodide, setPyodide] = useState(null) 13 | const [isLoading, setIsLoading] = useState(true) 14 | const [error, setError] = useState(null) 15 | const [installedPackages, setInstalledPackages] = useState([]) 16 | 17 | useEffect(() => { 18 | let isMounted = true 19 | 20 | const loadPyodideInstance = async () => { 21 | try { 22 | if (!window.loadPyodide) { 23 | await new Promise((resolve, reject) => { 24 | const script = document.createElement('script') 25 | script.src = 26 | 'https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js' // Use the latest version 27 | script.onload = () => resolve() 28 | script.onerror = () => 29 | reject(new Error('Failed to load Pyodide script')) 30 | document.head.appendChild(script) 31 | }) 32 | } 33 | 34 | const pyodideInstance = await window.loadPyodide({ 35 | stdout: (msg: string) => console.log(msg), 36 | stderr: (msg: string) => console.error(msg) 37 | }) 38 | 39 | if (isMounted) { 40 | setPyodide(pyodideInstance) 41 | setIsLoading(false) 42 | console.log('Pyodide successfully loaded and initialized') 43 | } 44 | 45 | await synchronizeFSToPyodide(pyodideInstance) 46 | console.log('Initial synchronization complete') 47 | } catch (err: any) { 48 | if (isMounted) { 49 | setError(err) 50 | setIsLoading(false) 51 | console.error('Error loading Pyodide:', err) 52 | } 53 | } 54 | } 55 | 56 | loadPyodideInstance() 57 | 58 | return () => { 59 | isMounted = false 60 | } 61 | }, [sharedDir]) 62 | 63 | const synchronizeFSToPyodide = useCallback( 64 | async (pyodideInstance: { 65 | FS: { 66 | mkdir: (arg0: string) => void 67 | readdir: (arg0: string) => any[] 68 | stat: (arg0: string) => any 69 | isDir: (arg0: any) => any 70 | unlink: (arg0: string) => void 71 | writeFile: (arg0: string, arg1: Uint8Array) => void 72 | } 73 | }) => { 74 | console.log('Synchronizing from sharedDir:/runner to Pyodide FS:/runner') 75 | 76 | try { 77 | pyodideInstance.FS.mkdir('/runner') 78 | } catch (e) {} 79 | 80 | const sharedEntries = await sharedDir.readDir('/runner') 81 | const sharedEntryNames = sharedEntries.map(entry => entry.name) 82 | 83 | const pyodideEntries = pyodideInstance.FS.readdir('/runner').filter( 84 | (name: string) => name !== '.' && name !== '..' 85 | ) 86 | 87 | for (const entryName of pyodideEntries) { 88 | if (!sharedEntryNames.includes(entryName)) { 89 | const pyodideEntryPath = `/runner/${entryName}` 90 | const stat = pyodideInstance.FS.stat(pyodideEntryPath) 91 | if (pyodideInstance.FS.isDir(stat.mode)) { 92 | removeDirRecursivelyPyodide(pyodideInstance, pyodideEntryPath) 93 | } else { 94 | pyodideInstance.FS.unlink(pyodideEntryPath) 95 | } 96 | console.log(`Deleted from Pyodide FS: ${pyodideEntryPath}`) 97 | } 98 | } 99 | 100 | for (const entry of sharedEntries) { 101 | const sharedEntryPath = `/runner/${entry.name}` 102 | const pyodideEntryPath = `/runner/${entry.name}` 103 | if (entry.type === 'file') { 104 | const content = await sharedDir.readFile(sharedEntryPath) 105 | pyodideInstance.FS.writeFile(pyodideEntryPath, content) 106 | console.log(`Synchronized file to Pyodide FS: ${pyodideEntryPath}`) 107 | } else if (entry.type === 'dir') { 108 | await synchronizeDirectoryToPyodide( 109 | pyodideInstance, 110 | sharedEntryPath, 111 | pyodideEntryPath 112 | ) 113 | } 114 | } 115 | }, 116 | [sharedDir] 117 | ) 118 | 119 | async function synchronizeDirectoryToPyodide ( 120 | pyodideInstance: any, 121 | sharedDirPath: string, 122 | pyodideDirPath: string 123 | ) { 124 | try { 125 | pyodideInstance.FS.mkdir(pyodideDirPath) 126 | } catch (e) {} 127 | 128 | const sharedEntries = await sharedDir.readDir(sharedDirPath) 129 | const sharedEntryNames = sharedEntries.map(entry => entry.name) 130 | 131 | const pyodideEntries = pyodideInstance.FS.readdir(pyodideDirPath).filter( 132 | (name: string) => name !== '.' && name !== '..' 133 | ) 134 | 135 | for (const entryName of pyodideEntries) { 136 | if (!sharedEntryNames.includes(entryName)) { 137 | const pyodideEntryPath = `${pyodideDirPath}/${entryName}` 138 | const stat = pyodideInstance.FS.stat(pyodideEntryPath) 139 | if (pyodideInstance.FS.isDir(stat.mode)) { 140 | removeDirRecursivelyPyodide(pyodideInstance, pyodideEntryPath) 141 | } else { 142 | pyodideInstance.FS.unlink(pyodideEntryPath) 143 | } 144 | console.log(`Deleted from Pyodide FS: ${pyodideEntryPath}`) 145 | } 146 | } 147 | 148 | for (const entry of sharedEntries) { 149 | const sharedEntryPath = `${sharedDirPath}/${entry.name}` 150 | const pyodideEntryPath = `${pyodideDirPath}/${entry.name}` 151 | if (entry.type === 'file') { 152 | const content = await sharedDir.readFile(sharedEntryPath) 153 | pyodideInstance.FS.writeFile(pyodideEntryPath, content) 154 | console.log(`Synchronized file to Pyodide FS: ${pyodideEntryPath}`) 155 | } else if (entry.type === 'dir') { 156 | await synchronizeDirectoryToPyodide( 157 | pyodideInstance, 158 | sharedEntryPath, 159 | pyodideEntryPath 160 | ) 161 | } 162 | } 163 | } 164 | 165 | async function removeDirRecursively (dirPath: string): Promise { 166 | const entries = await sharedDir.readDir(dirPath) 167 | for (const entry of entries) { 168 | const entryPath = `${dirPath}/${entry.name}` 169 | if (entry.type === 'dir') { 170 | await removeDirRecursively(entryPath) 171 | } else { 172 | await sharedDir.removeFile(entryPath) 173 | } 174 | } 175 | await sharedDir.removeDir(dirPath) 176 | } 177 | 178 | const synchronizePyodideToFS = useCallback( 179 | async (pyodideInstance: any) => { 180 | console.log('Synchronizing from Pyodide FS:/runner to sharedDir:/runner') 181 | 182 | try { 183 | await sharedDir.createDir('/runner') 184 | } catch (e) {} 185 | 186 | const pyodideEntries = pyodideInstance.FS.readdir('/runner').filter( 187 | (name: string) => name !== '.' && name !== '..' 188 | ) 189 | const pyodideEntrySet = new Set(pyodideEntries) 190 | 191 | const sharedEntries = await sharedDir.readDir('/runner') 192 | 193 | for (const entry of sharedEntries) { 194 | if (!pyodideEntrySet.has(entry.name)) { 195 | const sharedEntryPath = `/runner/${entry.name}` 196 | if (entry.type === 'dir') { 197 | await removeDirRecursively(sharedEntryPath) 198 | } else { 199 | await sharedDir.removeFile(sharedEntryPath) 200 | } 201 | console.log(`Deleted from sharedDir: ${sharedEntryPath}`) 202 | } 203 | } 204 | 205 | for (const entryName of pyodideEntries) { 206 | const pyodideEntryPath = `/runner/${entryName}` 207 | const sharedEntryPath = `/runner/${entryName}` 208 | const stat = pyodideInstance.FS.stat(pyodideEntryPath) 209 | if (pyodideInstance.FS.isFile(stat.mode)) { 210 | const content = pyodideInstance.FS.readFile(pyodideEntryPath, { 211 | encoding: 'binary' 212 | }) 213 | await sharedDir.writeFile(sharedEntryPath, content) 214 | console.log( 215 | `Synchronized file from Pyodide FS to sharedDir: ${sharedEntryPath}` 216 | ) 217 | } else if (pyodideInstance.FS.isDir(stat.mode)) { 218 | await synchronizeDirectoryFromPyodide( 219 | pyodideInstance, 220 | pyodideEntryPath, 221 | sharedEntryPath 222 | ) 223 | } 224 | } 225 | }, 226 | [sharedDir] 227 | ) 228 | 229 | async function synchronizeDirectoryFromPyodide ( 230 | pyodideInstance: any, 231 | pyodideDirPath: string, 232 | sharedDirPath: string 233 | ) { 234 | try { 235 | await sharedDir.createDir(sharedDirPath) 236 | } catch (e) {} 237 | 238 | const pyodideEntries = pyodideInstance.FS.readdir(pyodideDirPath).filter( 239 | (name: string) => name !== '.' && name !== '..' 240 | ) 241 | const pyodideEntrySet = new Set(pyodideEntries) 242 | 243 | const sharedEntries = await sharedDir.readDir(sharedDirPath) 244 | 245 | for (const entry of sharedEntries) { 246 | if (!pyodideEntrySet.has(entry.name)) { 247 | const sharedEntryPath = `${sharedDirPath}/${entry.name}` 248 | if (entry.type === 'dir') { 249 | await removeDirRecursively(sharedEntryPath) 250 | } else { 251 | await sharedDir.removeFile(sharedEntryPath) 252 | } 253 | console.log(`Deleted from sharedDir: ${sharedEntryPath}`) 254 | } 255 | } 256 | 257 | for (const entryName of pyodideEntries) { 258 | const pyodideEntryPath = `${pyodideDirPath}/${entryName}` 259 | const sharedEntryPath = `${sharedDirPath}/${entryName}` 260 | const stat = pyodideInstance.FS.stat(pyodideEntryPath) 261 | if (pyodideInstance.FS.isFile(stat.mode)) { 262 | const content = pyodideInstance.FS.readFile(pyodideEntryPath, { 263 | encoding: 'binary' 264 | }) 265 | await sharedDir.writeFile(sharedEntryPath, content) 266 | console.log( 267 | `Synchronized file from Pyodide FS to sharedDir: ${sharedEntryPath}` 268 | ) 269 | } else if (pyodideInstance.FS.isDir(stat.mode)) { 270 | await synchronizeDirectoryFromPyodide( 271 | pyodideInstance, 272 | pyodideEntryPath, 273 | sharedEntryPath 274 | ) 275 | } 276 | } 277 | } 278 | 279 | function removeDirRecursivelyPyodide ( 280 | pyodideInstance: any, 281 | dirPath: string 282 | ): void { 283 | const entries = pyodideInstance.FS.readdir(dirPath).filter( 284 | (name: string) => name !== '.' && name !== '..' 285 | ) 286 | for (const entryName of entries) { 287 | const entryPath = `${dirPath}/${entryName}` 288 | const stat = pyodideInstance.FS.stat(entryPath) 289 | if (pyodideInstance.FS.isDir(stat.mode)) { 290 | removeDirRecursivelyPyodide(pyodideInstance, entryPath) 291 | } else { 292 | pyodideInstance.FS.unlink(entryPath) 293 | } 294 | } 295 | pyodideInstance.FS.rmdir(dirPath) 296 | } 297 | 298 | const runCode = useCallback( 299 | async (activeFile: string) => { 300 | if (!pyodide) { 301 | throw new Error('Pyodide is not loaded') 302 | } 303 | 304 | try { 305 | await synchronizeFSToPyodide(pyodide) 306 | 307 | await pyodide.runPythonAsync(` 308 | import sys 309 | from io import StringIO 310 | import traceback 311 | sys.stdout = StringIO() 312 | sys.stderr = StringIO() 313 | import os 314 | os.chdir('/runner') 315 | `) 316 | 317 | await pyodide.runPythonAsync(` 318 | try: 319 | exec(open('${activeFile}').read()) 320 | except Exception: 321 | traceback.print_exc() 322 | `) 323 | 324 | const output = pyodide.runPython(` 325 | stdout = sys.stdout.getvalue() 326 | stderr = sys.stderr.getvalue() 327 | sys.stdout = sys.__stdout__ 328 | sys.stderr = sys.__stderr__ 329 | stdout + stderr 330 | `) 331 | 332 | await synchronizePyodideToFS(pyodide) 333 | 334 | return output 335 | } catch (error: any) { 336 | console.error('Error during code execution:', error) 337 | return `Error: ${error}` 338 | } 339 | }, 340 | [pyodide, synchronizeFSToPyodide, synchronizePyodideToFS] 341 | ) 342 | 343 | const installPackage = useCallback( 344 | async (packageName: string) => { 345 | if (!pyodide) { 346 | throw new Error('Pyodide is not loaded') 347 | } 348 | 349 | try { 350 | if (pyodide.loadedPackages[packageName]) { 351 | console.log(`Package '${packageName}' is already loaded.`) 352 | return 353 | } 354 | 355 | if (!pyodide.loadedPackages['micropip']) { 356 | await pyodide.loadPackage('micropip') 357 | console.log('Loaded micropip') 358 | } 359 | 360 | const micropip = pyodide.pyimport('micropip') 361 | await micropip.install(packageName) 362 | setInstalledPackages(prev => [...new Set([...prev, packageName])]) 363 | console.log(`Installed package: ${packageName}`) 364 | } catch (error) { 365 | console.error(`Failed to install package '${packageName}':`, error) 366 | throw new Error(`Failed to install package '${packageName}': ${error}`) 367 | } 368 | }, 369 | [pyodide] 370 | ) 371 | 372 | return { 373 | pyodide, 374 | isLoading, 375 | error, 376 | runCode, 377 | installPackage, 378 | installedPackages 379 | } 380 | } 381 | 382 | export default usePyodide 383 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { ChakraProvider, extendTheme, ColorModeScript } from '@chakra-ui/react' 4 | import App from './App' 5 | import { FilesystemProvider } from './context/FilesystemContext' 6 | import ErrorBoundary from './components/ErrorBoundary' 7 | 8 | const theme = extendTheme({ 9 | config: { 10 | initialColorMode: 'dark', 11 | useSystemColorMode: true 12 | }, 13 | styles: { 14 | global: { 15 | 'html, body, #root': { 16 | height: '100%', 17 | margin: '0', 18 | padding: '0', 19 | overflow: 'hidden' 20 | } 21 | } 22 | } 23 | }) 24 | 25 | const Root = () => { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | const root = ReactDOM.createRoot(document.getElementById('root')!) 36 | root.render( 37 | 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | -------------------------------------------------------------------------------- /src/sharedFileSystem.ts: -------------------------------------------------------------------------------- 1 | import { Directory } from '@wasmer/sdk' 2 | import { initializeWasmer } from './wasmerInit' 3 | 4 | let sharedDirectory: Directory | null = null 5 | 6 | export const initializeSharedDirectory = async (): Promise => { 7 | if (!sharedDirectory) { 8 | try { 9 | await initializeWasmer() 10 | sharedDirectory = new Directory() 11 | console.log('Shared Directory initialized') 12 | 13 | try { 14 | await sharedDirectory.createDir('/runner') 15 | console.log("Created '/runner' directory") 16 | } catch (error: any) { 17 | if (error.message.includes('File exists')) { 18 | console.log("'/runner' directory already exists") 19 | } else { 20 | throw error 21 | } 22 | } 23 | 24 | try { 25 | await sharedDirectory.readFile('/runner/main.py') 26 | console.log("'/runner/main.py' already exists") 27 | } catch (error: any) { 28 | if ( 29 | error.message.includes('No such file or directory') || 30 | error.message.includes('entry not found') 31 | ) { 32 | const initialContent = `# main.py\nprint("Hello from PyBox!")\n` 33 | await sharedDirectory.writeFile( 34 | '/runner/main.py', 35 | new TextEncoder().encode(initialContent) 36 | ) 37 | console.log("Created initial '/runner/main.py'") 38 | } else { 39 | throw error 40 | } 41 | } 42 | } catch (err) { 43 | console.error('Error initializing shared Directory:', err) 44 | throw err 45 | } 46 | } else { 47 | console.log('Shared Directory already initialized') 48 | } 49 | return sharedDirectory 50 | } 51 | 52 | export const getSharedDirectory = (): Directory => { 53 | if (!sharedDirectory) { 54 | throw new Error( 55 | 'Shared Directory is not initialized. Call initializeSharedDirectory first.' 56 | ) 57 | } 58 | return sharedDirectory 59 | } 60 | 61 | export const renameFile = async ( 62 | oldPath: string, 63 | newPath: string 64 | ): Promise => { 65 | if (!sharedDirectory) { 66 | throw new Error('Shared Directory is not initialized.') 67 | } 68 | try { 69 | const content = await sharedDirectory.readFile(oldPath) 70 | await sharedDirectory.writeFile(newPath, content) 71 | await sharedDirectory.removeFile(oldPath) 72 | console.log(`Renamed file from ${oldPath} to ${newPath}`) 73 | } catch (err) { 74 | console.error(`Error renaming file from ${oldPath} to ${newPath}:`, err) 75 | throw err 76 | } 77 | } 78 | 79 | export const renameDir = async ( 80 | oldPath: string, 81 | newPath: string 82 | ): Promise => { 83 | if (!sharedDirectory) { 84 | throw new Error('Shared Directory is not initialized.') 85 | } 86 | try { 87 | const entries = await sharedDirectory.readDir(oldPath) 88 | 89 | await sharedDirectory.createDir(newPath) 90 | 91 | for (const entry of entries) { 92 | const oldEntryPath = `${oldPath}/${entry.name}` 93 | const newEntryPath = `${newPath}/${entry.name}` 94 | if (entry.type === 'file') { 95 | const content = await sharedDirectory.readFile(oldEntryPath) 96 | await sharedDirectory.writeFile(newEntryPath, content) 97 | } else if (entry.type === 'dir') { 98 | await renameDir(oldEntryPath, newEntryPath) 99 | } 100 | } 101 | 102 | await sharedDirectory.removeDir(oldPath) 103 | console.log(`Renamed directory from ${oldPath} to ${newPath}`) 104 | } catch (err) { 105 | console.error( 106 | `Error renaming directory from ${oldPath} to ${newPath}:`, 107 | err 108 | ) 109 | throw err 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/theme/colors.ts: -------------------------------------------------------------------------------- 1 | import { useColorModeValue } from '@chakra-ui/react' 2 | 3 | export const useThemeColors = () => { 4 | const bgColor = useColorModeValue('gray.50', 'gray.900') 5 | const viewBgColor = useColorModeValue('gray.800', 'gray.700') 6 | const panelBgColor = useColorModeValue('gray.100', 'gray.800') 7 | 8 | return { bgColor, viewBgColor, panelBgColor } 9 | } 10 | -------------------------------------------------------------------------------- /src/wasmerInit.ts: -------------------------------------------------------------------------------- 1 | import { init } from '@wasmer/sdk'; 2 | // path to the Wasm module 3 | //@ts-ignore 4 | import WasmModule from '@wasmer/sdk/wasm?url'; 5 | 6 | let wasmerInitialized = false; 7 | 8 | export const initializeWasmer = async () => { 9 | if (!wasmerInitialized) { 10 | await await init({ module: WasmModule, sdkUrl: `${window.location.href}/sdk/index.mjs` }); 11 | wasmerInitialized = true; 12 | console.log('Wasmer SDK initialized'); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "skipLibCheck": true, 8 | "jsx": "react-jsx", 9 | "strict": true, 10 | "isolatedModules": true, 11 | "allowImportingTsExtensions": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "noEmit": true 18 | }, 19 | "include": ["src", "vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import compression from 'vite-plugin-compression' 4 | import svgr from 'vite-plugin-svgr' 5 | import { viteStaticCopy } from 'vite-plugin-static-copy' 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | preact(), 10 | compression({ 11 | algorithm: 'brotliCompress', 12 | ext: '.br', 13 | filter: /\.(js|mjs|css|html|wasm)$/i 14 | }), 15 | compression({ 16 | algorithm: 'gzip', 17 | ext: '.gz', 18 | filter: /\.(js|mjs|css|html|wasm)$/i, 19 | compressionOptions: { level: 9 } 20 | }), 21 | svgr(), 22 | viteStaticCopy({ 23 | targets: [ 24 | { 25 | src: 'node_modules/@wasmer/sdk/dist/index.mjs', 26 | dest: 'sdk' 27 | } 28 | ] 29 | }) 30 | ], 31 | resolve: { 32 | alias: { 33 | react: 'preact/compat', 34 | 'react-dom': 'preact/compat' 35 | } 36 | }, 37 | server: { 38 | headers: { 39 | 'Cross-Origin-Opener-Policy': 'same-origin', 40 | 'Cross-Origin-Embedder-Policy': 'require-corp' 41 | }, 42 | fs: { 43 | allow: ['../..', 'node_modules/@wasmer/sdk/dist'] 44 | } 45 | }, 46 | base: '/pybox', 47 | 48 | build: { 49 | cssMinify: 'esbuild', 50 | cssCodeSplit: true, 51 | assetsInlineLimit: 1024, 52 | cssTarget: 'es2020', 53 | target: 'es2020', 54 | minify: 'terser', 55 | terserOptions: { 56 | compress: { 57 | drop_console: true, 58 | drop_debugger: true, 59 | passes: 2, 60 | reduce_vars: true, 61 | unused: true, 62 | module: true, 63 | toplevel: true 64 | }, 65 | ecma: 2020, 66 | module: true, 67 | output: { 68 | comments: false, 69 | beautify: false 70 | }, 71 | mangle: { 72 | toplevel: true 73 | } 74 | }, 75 | outDir: 'dist', 76 | rollupOptions: { 77 | output: { 78 | manualChunks (id) { 79 | if (id.includes('ace-builds/src-noconflict/ace')) { 80 | return 'ace' 81 | } 82 | if (id.includes('xterm')) { 83 | return 'xterm' 84 | } 85 | }, 86 | chunkFileNames (_) { 87 | return '[name]-[hash].js' 88 | }, 89 | assetFileNames: '[name]-[hash][extname]' 90 | }, 91 | plugins: [] 92 | } 93 | }, 94 | }) 95 | --------------------------------------------------------------------------------