App.tsx•11 kB
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { PyodideInterface } from 'pyodide';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { PackageManager } from './components/PackageManager';
import { CodeEditor } from './components/CodeEditor';
import { Console } from './components/Console';
import { Spinner } from './components/Spinner';
import { ActivityBar, Activity } from './components/ActivityBar';
import { FileExplorer } from './components/FileExplorer';
import { Terminal } from './components/Terminal';
import { saveState, loadState, FileSystemState } from './lib/vfs';
import { PythonLogo, PlayIcon } from './components/icons';
declare global {
interface Window {
loadPyodide: (config: { indexURL: string }) => Promise<PyodideInterface>;
}
}
const DEFAULT_CODE = `import os
print("Hello from PyForge IDE!")
print("Current directory contains:", os.listdir('.'))
# Try creating a new file and writing to it!
# with open("new_file.txt", "w") as f:
# f.write("This file was created by a Python script.")
# print("Current directory now contains:", os.listdir('.'))
`;
const DEFAULT_FS: FileSystemState = {
'/main.py': {
type: 'file',
content: DEFAULT_CODE,
}
};
enum Status {
LOADING,
READY,
INSTALLING,
RUNNING,
}
const App: React.FC = () => {
const [pyodide, setPyodide] = useState<PyodideInterface | null>(null);
const [status, setStatus] = useState<Status>(Status.LOADING);
const [consoleOutput, setConsoleOutput] = useState<string>('');
const [installedPackages, setInstalledPackages] = useState<string[]>(['numpy', 'micropip']);
const [activeActivity, setActiveActivity] = useState<Activity>('files');
const [fsState, setFsState] = useState<FileSystemState>({});
const [activeFile, setActiveFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState<string>('');
const terminalRef = useRef<{ write: (data: string) => void }>(null);
const syncFsToVfs = (py: PyodideInterface, path: string = '/') => {
const newState: FileSystemState = {};
const traverse = (currentPath: string) => {
const entries = py.FS.readdir(currentPath);
for (const entry of entries) {
if (entry === '.' || entry === '..') continue;
const fullPath = `${currentPath === '/' ? '' : currentPath}/${entry}`;
const stat = py.FS.stat(fullPath);
if (py.FS.isDir(stat.mode)) {
newState[fullPath] = { type: 'directory' };
traverse(fullPath);
} else if (py.FS.isFile(stat.mode)) {
try {
const content = py.FS.readFile(fullPath, { encoding: 'utf8' });
newState[fullPath] = { type: 'file', content: content as string };
} catch (e) {
console.warn(`Could not read file ${fullPath}:`, e);
}
}
}
};
traverse(path);
if(Object.keys(fsState).length === 0 && Object.keys(newState).length > 0) {
setFsState(newState);
}
};
const updateFsState = (updater: (prevState: FileSystemState) => FileSystemState) => {
setFsState(prevState => {
const newState = updater(prevState);
saveState(newState);
return newState;
});
};
const addToConsole = useCallback((s: string) => {
setConsoleOutput((prev) => prev + s);
}, []);
const clearConsole = useCallback(() => setConsoleOutput(''), []);
useEffect(() => {
const initPyodide = async () => {
try {
addToConsole('Initializing Pyodide runtime...\n');
const pyodideInstance = await window.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.25.1/full/',
});
const vfsState = loadState() || DEFAULT_FS;
Object.entries(vfsState).forEach(([path, node]) => {
if (node.type === 'directory') {
pyodideInstance.FS.mkdirTree(path);
} else {
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) {
pyodideInstance.FS.mkdirTree(dir);
}
pyodideInstance.FS.writeFile(path, node.content || '', { encoding: 'utf8' });
}
});
setFsState(vfsState);
const firstFile = Object.keys(vfsState).find(k => vfsState[k].type === 'file');
if (firstFile) {
setActiveFile(firstFile);
setEditorContent(vfsState[firstFile].content || '');
}
pyodideInstance.setStdout({ batched: (s: string) => addToConsole(s + '\n') });
pyodideInstance.setStderr({ batched: (s: string) => addToConsole(`[ERROR] ${s}\n`) });
addToConsole('Loading initial packages (numpy)...\n');
await pyodideInstance.loadPackage('numpy');
setPyodide(pyodideInstance);
setStatus(Status.READY);
addToConsole('✅ Pyodide is ready. You can now run Python code and install packages.\n');
} catch (error) {
console.error('Failed to load Pyodide:', error);
addToConsole(`[FATAL] Failed to initialize Pyodide runtime: ${error}\n`);
}
};
initPyodide();
}, [addToConsole]);
useEffect(() => {
const handleSave = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's' && activeFile && pyodide) {
e.preventDefault();
pyodide.FS.writeFile(activeFile, editorContent, { encoding: 'utf8' });
updateFsState(prev => ({
...prev,
[activeFile]: { type: 'file', content: editorContent }
}));
console.log(`Saved ${activeFile}`);
}
};
window.addEventListener('keydown', handleSave);
return () => window.removeEventListener('keydown', handleSave);
}, [activeFile, editorContent, pyodide]);
const handleRunCode = async () => {
if (!pyodide || status !== Status.READY || !activeFile) return;
setStatus(Status.RUNNING);
clearConsole();
addToConsole(`>>> Running ${activeFile}...\n`);
try {
const codeToRun = pyodide.FS.readFile(activeFile, { encoding: 'utf8' });
await pyodide.runPythonAsync(codeToRun as string);
} catch (error) {
console.error(error);
addToConsole(`[EXECUTION ERROR] ${error}\n`);
} finally {
setStatus(Status.READY);
}
};
const handleInstallPackage = async (packageName: string) => {
if (!pyodide || status !== Status.READY || !packageName.trim()) return;
setStatus(Status.INSTALLING);
const installMsg = `\n>>> Installing package: ${packageName}...\n`;
terminalRef.current?.write(installMsg);
try {
await pyodide.loadPackage('micropip');
const micropip = pyodide.pyimport('micropip');
// Redirect micropip output to terminal
pyodide.globals.set('print_to_terminal', (s: string) => terminalRef.current?.write(s + '\r\n'));
await pyodide.runPythonAsync(`
import micropip
import sys
from contextlib import redirect_stdout, redirect_stderr
import io
f = io.StringIO()
with redirect_stdout(f), redirect_stderr(f):
try:
await micropip.install('${packageName}')
except Exception as e:
print(e)
output = f.getvalue()
print_to_terminal(output)
`);
setInstalledPackages((prev) => [...new Set([...prev, packageName.trim()])].sort());
terminalRef.current?.write(`✅ Successfully installed ${packageName}.\n`);
} catch (error) {
console.error(error);
terminalRef.current?.write(`[INSTALLATION ERROR] Failed to install ${packageName}: ${error}\n`);
} finally {
setStatus(Status.READY);
}
};
const handleFileSelect = (path: string) => {
const node = fsState[path];
if (node?.type === 'file') {
setActiveFile(path);
setEditorContent(node.content || '');
}
};
if (status === Status.LOADING) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-primary font-sans">
<PythonLogo />
<h1 className="text-2xl font-bold mt-4 text-text-primary">PyForge IDE</h1>
<p className="text-text-secondary mt-2">Initializing Python Runtime Environment...</p>
<div className="mt-6">
<Spinner />
</div>
<pre className="mt-8 text-xs text-left bg-secondary p-4 rounded-lg w-full max-w-xl h-48 overflow-y-auto font-mono text-text-secondary">
{consoleOutput}
</pre>
</div>
);
}
return (
<div className="h-screen w-screen flex font-sans bg-secondary">
<ActivityBar activeActivity={activeActivity} onActivityChange={setActiveActivity} />
<PanelGroup direction="horizontal" className="flex-grow">
<Panel defaultSize={20} minSize={15} maxSize={40}>
<div className="h-full w-full bg-secondary p-2">
{activeActivity === 'files' && pyodide && (
<FileExplorer
pyodide={pyodide}
fsState={fsState}
onStateChange={updateFsState}
onFileSelect={handleFileSelect}
activeFile={activeFile}
/>
)}
{activeActivity === 'packages' && (
<PackageManager
onInstall={handleInstallPackage}
isInstalling={status === Status.INSTALLING}
installedPackages={installedPackages}
/>
)}
</div>
</Panel>
<PanelResizeHandle />
<Panel minSize={30}>
<PanelGroup direction="vertical">
<Panel minSize={30}>
<header className="flex items-center justify-between px-4 py-2 bg-secondary border-b border-border-color flex-shrink-0">
<div className="text-sm text-text-secondary">{activeFile || 'No file selected'}</div>
<button
onClick={handleRunCode}
disabled={status !== Status.READY || !activeFile}
className="flex items-center gap-2 px-4 py-1 bg-success text-white font-semibold rounded-md hover:bg-green-600 transition-colors disabled:bg-gray-500 disabled:cursor-not-allowed"
>
{status === Status.RUNNING ? (
<> <Spinner /> Running... </>
) : (
<> <PlayIcon /> Run </>
)}
</button>
</header>
<CodeEditor value={editorContent} onValueChange={setEditorContent} />
</Panel>
<PanelResizeHandle />
<Panel defaultSize={35} minSize={15}>
<Terminal
pyodide={pyodide!}
onInstallPackage={handleInstallPackage}
terminalRef={terminalRef}
consoleOutput={consoleOutput}
clearConsole={clearConsole}
/>
</Panel>
</PanelGroup>
</Panel>
</PanelGroup>
</div>
);
};
export default App;