diff --git a/package-lock.json b/package-lock.json
index d3324db..2db6efa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@vercel/speed-insights": "^2.0.0",
"lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1",
+ "pyodide": "^0.29.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sql.js": "^1.14.1"
@@ -1923,7 +1924,6 @@
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
@@ -6233,6 +6233,19 @@
"node": ">=6"
}
},
+ "node_modules/pyodide": {
+ "version": "0.29.4",
+ "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.4.tgz",
+ "integrity": "sha512-tCseTsqU3kSxZIjkue5zXxTMNEwrKZwOIIEQRBA/VzHxFN1hoCxe4w41phfCdHd9it9RcCNQb5K/Re0InqMgvA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@types/emscripten": "^1.41.4",
+ "ws": "^8.5.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7240,6 +7253,27 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xdg-app-paths": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz",
diff --git a/package.json b/package.json
index f311e9f..3686ee6 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@vercel/speed-insights": "^2.0.0",
"lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1",
+ "pyodide": "^0.29.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sql.js": "^1.14.1"
diff --git a/public/python-logo.png b/public/python-logo.png
new file mode 100644
index 0000000..755de39
Binary files /dev/null and b/public/python-logo.png differ
diff --git a/src/App.tsx b/src/App.tsx
index aa80561..556dec0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import PythonIDE from './PythonIDE';
import { SpeedInsights } from "@vercel/speed-insights/react";
import './App.css';
import SqlEditor from './SqlEditor';
@@ -94,7 +95,7 @@ function useResizer(initialPx: number, direction: 'bottom' | 'right') {
return { size, dragging, onMouseDown };
}
-function IDE() {
+function IDE({ onSwitchToPython }: { onSwitchToPython: () => void }) {
const { execute, schema, history, clearHistory, exportDb, importDb, ready, initError } = useSql();
const handleDownload = useCallback(() => {
@@ -692,6 +693,18 @@ function IDE() {
+
+
Other IDEs
+
+
+
+
)}
@@ -720,10 +733,22 @@ function IDE() {
}
export default function App() {
+ const [ideMode, setIdeMode] = useState<'sql' | 'python'>('sql');
+
return (
-
-
-
-
+ <>
+ {ideMode === 'sql' && (
+
+ setIdeMode('python')} />
+
+
+ )}
+ {ideMode === 'python' && (
+ <>
+ setIdeMode('sql')} />
+
+ >
+ )}
+ >
);
}
diff --git a/src/PyEditor.tsx b/src/PyEditor.tsx
new file mode 100644
index 0000000..cd57865
--- /dev/null
+++ b/src/PyEditor.tsx
@@ -0,0 +1,129 @@
+import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
+import Editor from '@monaco-editor/react';
+
+interface PyEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ onRun: () => void;
+ height?: string;
+ fontSize?: number;
+ wordWrap?: boolean;
+ minimap?: boolean;
+ theme?: 'dark' | 'noir';
+ errorLine?: number;
+}
+
+type MonacoInstance = any;
+
+export default function PyEditor({
+ value, onChange, onRun, height = '100%',
+ fontSize = 14, wordWrap = false, minimap = false,
+ theme = 'dark', errorLine,
+}: PyEditorProps) {
+ const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 });
+ const editorRef = useRef(null);
+ const monacoRef = useRef(null);
+ const decorationRef = useRef([]);
+
+ const onRunRef = useRef(onRun);
+ onRunRef.current = onRun;
+
+ useEffect(() => {
+ if (!editorRef.current || !monacoRef.current) return;
+ const editor = editorRef.current;
+ const monaco = monacoRef.current;
+
+ if (errorLine && errorLine > 0) {
+ decorationRef.current = editor.deltaDecorations(decorationRef.current, [
+ {
+ range: new monaco.Range(errorLine, 1, errorLine, 1),
+ options: {
+ isWholeLine: true,
+ className: 'error-line-highlight',
+ glyphMarginClassName: 'error-glyph-margin',
+ hoverMessage: { value: 'Error detected around here.' },
+ zIndex: 10,
+ }
+ }
+ ]);
+ editor.revealLineInCenterIfOutsideViewport(errorLine);
+ } else {
+ decorationRef.current = editor.deltaDecorations(decorationRef.current, []);
+ }
+ }, [errorLine]);
+
+ const handleMount = useCallback((editor: MonacoInstance, monaco: MonacoInstance) => {
+ editorRef.current = editor;
+ monacoRef.current = monaco;
+
+ // We can reuse the same themes
+ monaco.editor.setTheme(theme === 'noir' ? 'sqlide-noir' : 'sqlide-dark');
+
+ editor.addCommand(
+ monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
+ () => onRunRef.current()
+ );
+
+ editor.onDidChangeCursorPosition((e: MonacoInstance) => {
+ setCursorPos({ line: e.position.lineNumber, col: e.position.column });
+ });
+
+ editor.focus();
+ }, [theme]);
+
+ const options = useMemo(() => ({
+ fontSize,
+ fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
+ fontLigatures: true,
+ minimap: { enabled: minimap },
+ wordWrap: (wordWrap ? 'on' : 'off') as 'on' | 'off',
+ scrollBeyondLastLine: false,
+ lineNumbers: 'on' as 'on',
+ renderLineHighlight: 'gutter' as 'gutter',
+ cursorBlinking: 'smooth' as 'smooth',
+ cursorSmoothCaretAnimation: 'on' as 'on',
+ smoothScrolling: true,
+ padding: { top: 12, bottom: 12 },
+ tabSize: 4,
+ insertSpaces: true,
+ formatOnPaste: true,
+ bracketPairColorization: { enabled: true },
+ contextmenu: true,
+ scrollbar: {
+ vertical: 'auto' as 'auto',
+ horizontal: 'auto' as 'auto',
+ verticalScrollbarSize: 6,
+ horizontalScrollbarSize: 6,
+ },
+ }), [fontSize, wordWrap, minimap]);
+
+ return (
+
+
+ onChange(v ?? '')}
+ onMount={handleMount}
+ theme={theme === 'noir' ? 'sqlide-noir' : 'sqlide-dark'}
+ options={options}
+ />
+
+
+ Ln {cursorPos.line}, Col {cursorPos.col}
+
+
+ );
+}
diff --git a/src/PythonIDE.tsx b/src/PythonIDE.tsx
new file mode 100644
index 0000000..75d5430
--- /dev/null
+++ b/src/PythonIDE.tsx
@@ -0,0 +1,634 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import PyEditor from './PyEditor';
+
+interface Tab {
+ id: string;
+ name: string;
+ query: string;
+}
+
+const SNIPPETS: Record = {
+ 'New Script': `# A new Python script\nprint("Hello from PythonIDE!")`,
+ 'Variables & Logic': `x = 10\ny = 20\n\nif x < y:\n print(f"{x} is less than {y}")\nelse:\n print(f"{x} is greater than or equal to {y}")`,
+ 'List Comprehension': `squares = [x**2 for x in range(10)]\nprint("Squares:", squares)`,
+ 'Data Structures': `user = {\n "name": "Alice",\n "age": 28,\n "skills": ["Python", "SQL", "React"]\n}\n\nfor skill in user["skills"]:\n print(f"Skill: {skill}")`,
+};
+
+function useResizer(initialPx: number, direction: 'bottom' | 'right') {
+ const [size, setSize] = useState(initialPx);
+ const [dragging, setDragging] = useState(false);
+ const startPos = useRef(0);
+ const startSize = useRef(0);
+
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
+ startPos.current = direction === 'bottom' ? e.clientY : e.clientX;
+ startSize.current = size;
+ setDragging(true);
+ e.preventDefault();
+ }, [size, direction]);
+
+ useEffect(() => {
+ if (!dragging) return;
+ const onMove = (e: MouseEvent) => {
+ const currentPos = direction === 'bottom' ? e.clientY : e.clientX;
+ const delta = startPos.current - currentPos;
+ setSize(Math.max(100, Math.min(direction === 'bottom' ? 600 : 800, startSize.current + delta)));
+ };
+ const onUp = () => setDragging(false);
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseup', onUp);
+ return () => {
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseup', onUp);
+ };
+ }, [dragging, direction]);
+
+ return { size, dragging, onMouseDown };
+}
+
+export default function PythonIDE({ onSwitchToSql }: { onSwitchToSql: () => void }) {
+ const [ready, setReady] = useState(false);
+ const [initError, setInitError] = useState(null);
+ const pyodideRef = useRef(null);
+
+ const [tabs, setTabs] = useState([
+ { id: '1', name: 'main.py', query: SNIPPETS['New Script'] },
+ ]);
+ const [activeTabId, setActiveTabId] = useState('1');
+ const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0];
+
+ const [running, setRunning] = useState(false);
+ const [log, setLog] = useState<{level: string, message: string, timestamp: Date}[]>([]);
+ const [errorLine, setErrorLine] = useState();
+
+ useEffect(() => {
+ fetch('https://api.kanye.rest/')
+ .then(r => r.json())
+ .then(data => {
+ const kanyeQuote = `# "${data.quote}"\n# ~Kanye West\n\nprint("Welcome to PythonIDE!")`;
+ setTabs(prev => prev.map(t =>
+ (t.id === '1' && t.query === SNIPPETS['New Script']) ? { ...t, query: kanyeQuote } : t
+ ));
+ })
+ .catch(err => console.error('Kanye was not feeling it today:', err));
+ }, []);
+
+ const appendLog = useCallback((level: string, message: string) => {
+ setLog(prev => [...prev.slice(-499), { level, message, timestamp: new Date() }]);
+ }, []);
+
+ useEffect(() => {
+ if ((window as any).loadPyodide) {
+ initPyodide();
+ return;
+ }
+ const script = document.createElement('script');
+ script.src = 'https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js';
+ script.onload = () => initPyodide();
+ script.onerror = () => setInitError('Failed to load Pyodide from CDN');
+ document.head.appendChild(script);
+
+ async function initPyodide() {
+ try {
+ const pyodide = await (window as any).loadPyodide({
+ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/"
+ });
+ pyodideRef.current = pyodide;
+
+ pyodide.setStdout({ batched: (msg: string) => appendLog('info', msg) });
+ pyodide.setStderr({ batched: (msg: string) => appendLog('error', msg) });
+
+ setReady(true);
+ } catch (err: any) {
+ setInitError(err.toString());
+ }
+ }
+ }, [appendLog]);
+
+ const addTab = useCallback(() => {
+ const id = crypto.randomUUID();
+ const num = tabs.length + 1;
+ setTabs(prev => [...prev, { id, name: `script_${num}.py`, query: `# New script\n` }]);
+ setActiveTabId(id);
+ }, [tabs.length]);
+
+ const closeTab = useCallback((id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setTabs(prev => {
+ const next = prev.filter(t => t.id !== id);
+ if (next.length === 0) return [{ id: '1', name: 'main.py', query: SNIPPETS['New Script'] }];
+ return next;
+ });
+ if (activeTabId === id) {
+ setActiveTabId(() => {
+ const idx = tabs.findIndex(t => t.id === id);
+ const next = tabs.filter(t => t.id !== id);
+ return next[Math.max(0, idx - 1)]?.id ?? next[0]?.id ?? '1';
+ });
+ }
+ }, [activeTabId, tabs]);
+
+ const updateQuery = useCallback((val: string, autoRun = false) => {
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, query: val } : t));
+ if (autoRun) {
+ setTimeout(() => {
+ document.getElementById('btn-run')?.click();
+ }, 50);
+ }
+ }, [activeTabId]);
+
+ const runQuery = useCallback(async () => {
+ if (!ready || running || !pyodideRef.current) return;
+ const code = activeTab.query.trim();
+ if (!code) return;
+ setRunning(true);
+ setErrorLine(undefined);
+ const t0 = performance.now();
+
+ appendLog('ok', `> Executing ${activeTab.name}...`);
+
+ try {
+ // Clear variables from previous runs in the namespace if desired, but retaining state is common in IDEs.
+ const res = await pyodideRef.current.runPythonAsync(code);
+ const t1 = performance.now();
+ if (res !== undefined) {
+ appendLog('info', String(res));
+ }
+ appendLog('ok', `OK · Execution completed in ${(t1 - t0).toFixed(1)}ms`);
+ } catch (err: any) {
+ const errStr = String(err);
+ appendLog('error', errStr);
+
+ const lineMatch = errStr.match(/line (\d+)/i);
+ if (lineMatch) {
+ setErrorLine(parseInt(lineMatch[1]));
+ }
+ } finally {
+ setRunning(false);
+ }
+ }, [ready, running, activeTab.query, activeTab.name, appendLog]);
+
+ const [snippetOpen, setSnippetOpen] = useState(false);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [shareOpen, setShareOpen] = useState(false);
+
+ const [fontSize, setFontSize] = useState(14);
+ const [wordWrap, setWordWrap] = useState(false);
+ const [minimap, setMinimap] = useState(false);
+ const [outputPosition, setOutputPosition] = useState<'bottom' | 'right'>('bottom');
+ const [theme, setTheme] = useState<'dark' | 'noir'>(() => Math.random() > 0.5 ? 'noir' : 'dark');
+ const [sidebarOpen, setSidebarOpen] = useState(true);
+
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', theme);
+ }, [theme]);
+
+ const { size: outputHeight, dragging, onMouseDown } = useResizer(240, outputPosition);
+
+ const loadSnippet = useCallback((name: string) => {
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, query: SNIPPETS[name] } : t));
+ setSnippetOpen(false);
+ }, [activeTabId]);
+
+ const formatCode = useCallback(() => {
+ let code = activeTab.query;
+ code = code.replace(/\t/g, ' ');
+ code = code.split('\n').map(line => line.trimEnd()).join('\n');
+ updateQuery(code);
+ }, [activeTab.query, updateQuery]);
+
+ const handleDownload = useCallback(() => {
+ const code = activeTab.query;
+ const blob = new Blob([code], { type: 'text/x-python' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = activeTab.name;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [activeTab]);
+
+ const handleImport = useCallback((e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const text = reader.result as string;
+ const id = crypto.randomUUID();
+ setTabs(prev => [...prev, { id, name: file.name, query: text }]);
+ setActiveTabId(id);
+ };
+ reader.readAsText(file);
+ e.target.value = '';
+ }, []);
+
+ const shareUrl = `${window.location.origin}${window.location.pathname}?py=${btoa(encodeURIComponent(activeTab.query))}`;
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const q = params.get('py');
+ if (q) {
+ try {
+ const decoded = decodeURIComponent(atob(q));
+ setTabs(prev => prev.map((t, i) => i === 0 ? { ...t, query: decoded } : t));
+ } catch {}
+ }
+ }, []);
+
+ if (!ready && !initError) {
+ return (
+
+
+
Initializing Pyodide engine…
+
+ );
+ }
+
+ if (initError) {
+ return (
+
+ ⚠ Failed to load Pyodide engine
+ {initError}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {sidebarOpen && (
+
+ )}
+
+ {!sidebarOpen && (
+
+ )}
+
+
+
+
+
+ Pyodide Environment
+
+
+ Python 3.11
+
+
+
+ Ctrl+Enter to run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {log.length === 0 ? (
+
+ Output will appear here...
+
+ ) : (
+ log.map((l, i) => (
+
+ [{l.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}]
+ {l.message}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+ PythonIDE v1.0
+
+ Pyodide (in-browser)
+ {running && Running…}
+
+
+ {snippetOpen && (
+
setSnippetOpen(false)}>
+
e.stopPropagation()}>
+
+
+ Python Snippets
+
+
+ {Object.keys(SNIPPETS).map(name => (
+
+ ))}
+
+
+
+ )}
+
+ {settingsOpen && (
+
setSettingsOpen(false)}>
+
e.stopPropagation()}>
+
+
+ Editor Settings
+
+
+
Font Size
+
+
+ {fontSize}px
+
+
+
+
+
Word Wrap
+
+
+
+
Minimap
+
+
+
+
Interface Theme
+
+
+
+
+
+
+
Switch to SQLide
+
+
+
+
+
+
+ )}
+
+ {shareOpen && (
+
setShareOpen(false)}>
+
e.stopPropagation()}>
+
+
+ Copy this URL to share your current script with anyone:
+
+
+ (e.target as HTMLInputElement).select()} />
+
+
+
+
+ )}
+
+ );
+}