Spaces:
Paused
Paused
| // static/js/codeRunner.js | |
| import * as uiModule from './ui.js'; | |
| /** | |
| * In-browser code runner for Python (Pyodide), JavaScript, and HTML | |
| */ | |
| let pyodideInstance = null; | |
| let pyodideLoading = false; | |
| const pyodideQueue = []; | |
| /** | |
| * Get or create an output panel below the <pre> element | |
| */ | |
| function getOrCreatePanel(pre) { | |
| let panel = pre.nextElementSibling; | |
| if (panel && panel.classList.contains('code-runner-output')) { | |
| panel.innerHTML = ''; | |
| panel.style.display = 'block'; | |
| return panel; | |
| } | |
| panel = document.createElement('div'); | |
| panel.className = 'code-runner-output'; | |
| pre.parentNode.insertBefore(panel, pre.nextSibling); | |
| return panel; | |
| } | |
| /** | |
| * Show a loading message in the panel | |
| */ | |
| function showLoading(panel, msg) { | |
| panel.innerHTML = `<div class="code-runner-loading">${msg}</div>`; | |
| } | |
| /** | |
| * Show output text in the panel | |
| */ | |
| function showOutput(panel, text, isError) { | |
| const el = document.createElement('pre'); | |
| el.className = isError ? 'code-runner-pre code-runner-error' : 'code-runner-pre'; | |
| el.textContent = text; | |
| panel.innerHTML = ''; | |
| panel.appendChild(el); | |
| // Copy button — visible labeled pill at the top-right of the panel | |
| // itself (no separate footer / divider, no tiny icon corner). | |
| if (text) { | |
| const cbtn = document.createElement('button'); | |
| cbtn.type = 'button'; | |
| cbtn.className = 'code-runner-copy-inline'; | |
| cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy'; | |
| cbtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| let ok = false; | |
| try { | |
| const ta = document.createElement('textarea'); | |
| ta.value = text; | |
| ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;'; | |
| document.body.appendChild(ta); | |
| ta.focus(); | |
| ta.select(); | |
| ta.setSelectionRange(0, text.length); | |
| ok = document.execCommand && document.execCommand('copy'); | |
| ta.remove(); | |
| } catch (_) {} | |
| if (!ok && navigator.clipboard && window.isSecureContext) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| if (uiModule.showToast) uiModule.showToast('Copied'); | |
| cbtn.textContent = 'Copied!'; | |
| setTimeout(() => { cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy'; }, 1500); | |
| }).catch(() => { if (uiModule.showToast) uiModule.showToast('Copy failed'); }); | |
| return; | |
| } | |
| if (uiModule.showToast) uiModule.showToast(ok ? 'Copied' : 'Copy failed'); | |
| const orig = cbtn.innerHTML; | |
| cbtn.textContent = ok ? 'Copied!' : 'Copy failed'; | |
| setTimeout(() => { cbtn.innerHTML = orig; }, 1500); | |
| }); | |
| // Button lives directly in the panel — no wrapping bar. The panel is | |
| // position:relative so the button can sit absolute-top-right of it. | |
| panel.appendChild(cbtn); | |
| } | |
| if (isError) { | |
| setTimeout(() => { if (panel) panel.style.display = 'none'; }, 7000); | |
| } | |
| } | |
| /** | |
| * Legacy absolute-positioned copy button — replaced by the inline bar in | |
| * showOutput. Kept here as no-op so any earlier callers don't crash. | |
| */ | |
| function addCopyBtn_unused(panel, text) { | |
| if (!text) return; | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; // Default <button> type is 'submit' — explicit "button" avoids any accidental form submission. | |
| btn.className = 'code-runner-copy'; | |
| btn.title = 'Copy output'; | |
| btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; | |
| btn.addEventListener('click', async (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| // Synchronous copy via a hidden textarea + execCommand — this is the | |
| // single most reliable path across browsers / non-secure contexts / | |
| // mobile Firefox. Run BEFORE any async navigator.clipboard attempt so | |
| // the user-gesture context is preserved. | |
| let ok = false; | |
| try { | |
| const ta = document.createElement('textarea'); | |
| ta.value = text; | |
| ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;'; | |
| document.body.appendChild(ta); | |
| ta.focus(); | |
| ta.select(); | |
| ta.setSelectionRange(0, text.length); | |
| ok = document.execCommand && document.execCommand('copy'); | |
| ta.remove(); | |
| } catch (_) {} | |
| // As a backup, also try the modern clipboard API (won't hurt if the | |
| // legacy path already copied). | |
| if (!ok && navigator.clipboard && window.isSecureContext) { | |
| try { await navigator.clipboard.writeText(text); ok = true; } catch (_) {} | |
| } | |
| if (uiModule && uiModule.showToast) { | |
| uiModule.showToast(ok ? 'Copied' : 'Copy failed'); | |
| } | |
| const _orig = btn.innerHTML; | |
| btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; | |
| btn.classList.add('copied'); | |
| setTimeout(() => { btn.innerHTML = _orig; btn.classList.remove('copied'); }, 1500); | |
| }); | |
| panel.prepend(btn); | |
| } | |
| /** | |
| * Add a collapse/close button to the panel. | |
| * Disabled \u2014 the run-output panel is now closed via the unified Code\u2194Run | |
| * toggle in the editor footer, so a separate X was redundant + cluttered. | |
| */ | |
| function addCloseBtn(_panel) { /* no-op */ } | |
| /** | |
| * Lazy-load Pyodide from CDN | |
| */ | |
| function loadPyodide() { | |
| if (pyodideInstance) return Promise.resolve(pyodideInstance); | |
| if (pyodideLoading) { | |
| return new Promise((resolve, reject) => { | |
| pyodideQueue.push({ resolve, reject }); | |
| }); | |
| } | |
| pyodideLoading = true; | |
| return new Promise((resolve, reject) => { | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.js'; | |
| script.onload = () => { | |
| window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/' }) | |
| .then(py => { | |
| pyodideInstance = py; | |
| pyodideLoading = false; | |
| pyodideQueue.forEach(q => q.resolve(py)); | |
| pyodideQueue.length = 0; | |
| resolve(py); | |
| }) | |
| .catch(err => { | |
| pyodideLoading = false; | |
| pyodideQueue.forEach(q => q.reject(err)); | |
| pyodideQueue.length = 0; | |
| reject(err); | |
| }); | |
| }; | |
| script.onerror = () => { | |
| pyodideLoading = false; | |
| const err = new Error('Failed to load Pyodide'); | |
| pyodideQueue.forEach(q => q.reject(err)); | |
| pyodideQueue.length = 0; | |
| reject(err); | |
| }; | |
| document.head.appendChild(script); | |
| }); | |
| } | |
| /** | |
| * Run Python code via Pyodide | |
| */ | |
| export async function runPython(code, panel) { | |
| showLoading(panel, 'Loading Python runtime (first time ~10 MB)...'); | |
| let py; | |
| try { | |
| py = await loadPyodide(); | |
| } catch (e) { | |
| showOutput(panel, 'Failed to load Python runtime: ' + e.message, true); | |
| addCloseBtn(panel); | |
| return; | |
| } | |
| showLoading(panel, 'Running...'); | |
| const wrapper = ` | |
| import sys, io | |
| _stdout = io.StringIO() | |
| _stderr = io.StringIO() | |
| sys.stdout = _stdout | |
| sys.stderr = _stderr | |
| try: | |
| exec(${JSON.stringify(code)}) | |
| except Exception as _e: | |
| _stderr.write(str(_e)) | |
| finally: | |
| sys.stdout = sys.__stdout__ | |
| sys.stderr = sys.__stderr__ | |
| (_stdout.getvalue(), _stderr.getvalue()) | |
| `; | |
| try { | |
| const result = await Promise.race([ | |
| py.runPythonAsync(wrapper), | |
| new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out (10 s)')), 10000)) | |
| ]); | |
| const stdout = result.toJs ? result.toJs()[0] : (result[0] || ''); | |
| const stderr = result.toJs ? result.toJs()[1] : (result[1] || ''); | |
| if (result.destroy) result.destroy(); | |
| panel.innerHTML = ''; | |
| if (stderr) { | |
| showOutput(panel, stderr, true); | |
| } else if (stdout) { | |
| showOutput(panel, stdout, false); | |
| } else { | |
| showOutput(panel, '(no output)', false); | |
| } | |
| } catch (e) { | |
| showOutput(panel, e.message, true); | |
| } | |
| addCloseBtn(panel); | |
| } | |
| /** | |
| * Run JavaScript code in a sandboxed iframe | |
| */ | |
| export function runJavaScript(code, panel) { | |
| showLoading(panel, 'Running...'); | |
| const iframe = document.createElement('iframe'); | |
| iframe.style.display = 'none'; | |
| iframe.sandbox = 'allow-scripts'; | |
| document.body.appendChild(iframe); | |
| let settled = false; | |
| const cleanup = () => { | |
| if (iframe.parentNode) iframe.remove(); | |
| }; | |
| const failsafe = setTimeout(() => { | |
| if (!settled) { | |
| settled = true; | |
| showOutput(panel, 'Execution timed out (10 s)', true); | |
| addCloseBtn(panel); | |
| cleanup(); | |
| } | |
| }, 15000); | |
| const onMessage = (e) => { | |
| if (e.source !== iframe.contentWindow) return; | |
| if (settled) return; | |
| settled = true; | |
| clearTimeout(failsafe); | |
| window.removeEventListener('message', onMessage); | |
| const data = e.data; | |
| panel.innerHTML = ''; | |
| if (data.error) { | |
| showOutput(panel, data.error, true); | |
| } else if (data.logs && data.logs.length > 0) { | |
| showOutput(panel, data.logs.join('\n'), false); | |
| } else { | |
| showOutput(panel, '(no output)', false); | |
| } | |
| addCloseBtn(panel); | |
| cleanup(); | |
| }; | |
| window.addEventListener('message', onMessage); | |
| const wrappedCode = ` | |
| <!DOCTYPE html><html><body><script> | |
| var _logs = []; | |
| var _origLog = console.log; | |
| console.log = function() { _logs.push([].map.call(arguments, function(a) { try { return typeof a === 'object' ? JSON.stringify(a) : String(a); } catch(e) { return String(a); } }).join(' ')); }; | |
| console.warn = function() { _logs.push('[warn] ' + [].map.call(arguments, String).join(' ')); }; | |
| console.error = function() { _logs.push('[error] ' + [].map.call(arguments, String).join(' ')); }; | |
| try { | |
| var _timer = setTimeout(function() { parent.postMessage({error:'Execution timed out (10 s)'},'*'); }, 10000); | |
| ${code.replace(/<\/script>/gi, '<\\/script>')} | |
| clearTimeout(_timer); | |
| parent.postMessage({logs: _logs}, '*'); | |
| } catch(e) { | |
| parent.postMessage({error: e.toString()}, '*'); | |
| } | |
| <\/script></body></html>`; | |
| iframe.srcdoc = wrappedCode; | |
| } | |
| /** | |
| * Run code server-side via POST /api/shell/exec | |
| */ | |
| export async function runServer(code, panel, lang) { | |
| showLoading(panel, 'Running on server...'); | |
| // Base64-encode the script so newlines survive the shell quoting intact. | |
| // JSON.stringify turns \n into literal \\n which python3 -c sees as backslash-n; | |
| // base64 avoids every quoting/escaping pitfall. | |
| const b64 = btoa(unescape(encodeURIComponent(code))); | |
| var command; | |
| if (lang === 'python' || lang === 'py') { | |
| command = `python3 -c "import base64; exec(base64.b64decode('${b64}').decode('utf-8'))"`; | |
| } else { | |
| command = `python3 -c "import base64, subprocess, sys; sys.exit(subprocess.run(['bash','-c',base64.b64decode('${b64}').decode('utf-8')]).returncode)"`; | |
| } | |
| try { | |
| var res = await fetch('/api/shell/exec', { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ command: command }), | |
| }); | |
| var data = await res.json(); | |
| panel.innerHTML = ''; | |
| if (data.stderr && data.stderr.trim()) { | |
| showOutput(panel, data.stderr, true); | |
| if (data.stdout && data.stdout.trim()) { | |
| var stdoutEl = document.createElement('pre'); | |
| stdoutEl.className = 'code-runner-pre'; | |
| stdoutEl.textContent = data.stdout; | |
| panel.appendChild(stdoutEl); | |
| } | |
| } else if (data.stdout && data.stdout.trim()) { | |
| showOutput(panel, data.stdout, false); | |
| } else { | |
| showOutput(panel, '(no output)' + (data.exit_code ? ' — exit code ' + data.exit_code : ''), !data.exit_code ? false : true); | |
| } | |
| if (data.exit_code && data.exit_code !== 0) { | |
| var exitEl = document.createElement('div'); | |
| exitEl.style.cssText = 'font-size:0.75rem;opacity:0.5;padding:2px 8px;'; | |
| exitEl.textContent = 'Exit code: ' + data.exit_code; | |
| panel.appendChild(exitEl); | |
| } | |
| } catch (e) { | |
| showOutput(panel, 'Execution failed: ' + e.message, true); | |
| } | |
| addCloseBtn(panel); | |
| } | |
| /** | |
| * Run HTML code in its own popup window | |
| */ | |
| export function runHTML(code, panel) { | |
| panel.innerHTML = ''; | |
| const win = window.open('', '_blank', 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'); | |
| if (!win) { | |
| showOutput(panel, 'Popup blocked — please allow popups for this site.', true); | |
| addCloseBtn(panel); | |
| return; | |
| } | |
| try { win.opener = null; } catch (_) {} | |
| win.document.open(); | |
| win.document.write(code); | |
| win.document.close(); | |
| showOutput(panel, 'Opened in new window', false); | |
| addCloseBtn(panel); | |
| } | |
| /** | |
| * Main entry point — called when a Run button is clicked | |
| */ | |
| export function run(btn) { | |
| const code = btn.getAttribute('data-code'); | |
| const lang = (btn.getAttribute('data-lang') || '').toLowerCase(); | |
| if (!code) return; | |
| const pre = btn.closest('pre'); | |
| if (!pre) return; | |
| const panel = getOrCreatePanel(pre); | |
| if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') { | |
| runServer(code, panel, 'bash'); | |
| } else if (lang === 'python' || lang === 'py') { | |
| runServer(code, panel, 'python'); | |
| } else if (lang === 'javascript' || lang === 'js') { | |
| runJavaScript(code, panel); | |
| } else if (lang === 'html') { | |
| runHTML(code, panel); | |
| } | |
| } | |
| const codeRunnerModule = { run, runPython, runJavaScript, runHTML, runServer }; | |
| export default codeRunnerModule; | |