├── .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 | [](https://opensource.org/licenses/MIT)
9 | [](https://github.com/oct4pie/pybox/stargazers)
10 | [](https://github.com/oct4pie/pybox/issues)
11 | [](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 |
39 | Reload Page
40 |
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 |
395 | Clear
396 |
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 | setTerminalLoaded(true)}>
170 |
171 | Load Terminal
172 |
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 |
--------------------------------------------------------------------------------