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 +