[web] Add PythonExecutionContext with per-file output buffers (#32737)
* [web] extract PythonExecutionContext and PythonRunner to manage pyodide execution per file * [web] define worker URL in python execution context in order to avoid breaking cjs-based tests * [web] use null check for doc contents to allow running empty python files * [web] flush buffered editor ops before refreshing snapshot for python execution * [web] catch getExecutionContext errors in python runner to prevent unhandled rejections * [web] add PythonRunner unit tests and extract shared WorkerMock * refactor: rename snapshot to state in PythonRunner * fix: remove unnecessary path normalization in PythonExecutionProvider * fix cypress tests GitOrigin-RevId: 9c55586d982fe8df5b90374227005c6b83e94d1f
This commit is contained in:
committed by
Copybot
parent
e30a2a5beb
commit
415db24ba4
+31
-45
@@ -7,30 +7,45 @@ import type {
|
||||
export type OutputCallback = (
|
||||
stream: 'stdout' | 'stderr',
|
||||
line: string,
|
||||
requestId?: string
|
||||
fileId: string,
|
||||
executionId: string
|
||||
) => void
|
||||
|
||||
export type LifecycleCallback = (
|
||||
event:
|
||||
| { type: 'loaded' }
|
||||
| { type: 'loading-failed'; error: string }
|
||||
| { type: 'run-finished'; requestId: string; outputs: string[] }
|
||||
| {
|
||||
type: 'run-finished'
|
||||
fileId: string
|
||||
executionId: string
|
||||
outputs: string[]
|
||||
}
|
||||
) => void
|
||||
|
||||
export class PyodideWorkerClient {
|
||||
private worker: Worker
|
||||
private baseAssetPath: string
|
||||
private createWorker: () => Worker
|
||||
private listening = false
|
||||
private loaded = false
|
||||
private destroyed = false
|
||||
private loadingError: string | null = null
|
||||
private pendingMessages: PyodideWorkerRequest[] = []
|
||||
private outputCallback: OutputCallback | null = null
|
||||
private lifecycleCallback: LifecycleCallback | null = null
|
||||
private outputCallback: OutputCallback | null
|
||||
private lifecycleCallback: LifecycleCallback | null
|
||||
|
||||
constructor(options: { baseAssetPath: string }) {
|
||||
constructor(options: {
|
||||
baseAssetPath: string
|
||||
createWorker: () => Worker
|
||||
onOutput?: OutputCallback
|
||||
onLifecycle?: LifecycleCallback
|
||||
}) {
|
||||
this.baseAssetPath = options.baseAssetPath
|
||||
this.createWorker = options.createWorker
|
||||
this.outputCallback = options.onOutput ?? null
|
||||
this.lifecycleCallback = options.onLifecycle ?? null
|
||||
this.worker = this.createWorker()
|
||||
this.worker.addEventListener('message', this.receive)
|
||||
|
||||
this.queueMessage({
|
||||
type: 'init',
|
||||
@@ -38,25 +53,9 @@ export class PyodideWorkerClient {
|
||||
})
|
||||
}
|
||||
|
||||
setOutputCallback(callback: OutputCallback) {
|
||||
this.outputCallback = callback
|
||||
}
|
||||
|
||||
setLifecycleCallback(handler: LifecycleCallback) {
|
||||
this.lifecycleCallback = handler
|
||||
|
||||
if (this.loaded) {
|
||||
handler({ type: 'loaded' })
|
||||
return
|
||||
}
|
||||
if (this.loadingError) {
|
||||
handler({ type: 'loading-failed', error: this.loadingError })
|
||||
}
|
||||
}
|
||||
|
||||
runCode(
|
||||
code: string,
|
||||
options: { requestId: string; files: ProjectFileData[] }
|
||||
options: { fileId: string; executionId: string; files: ProjectFileData[] }
|
||||
): void {
|
||||
if (this.destroyed) {
|
||||
throw new Error('Pyodide worker client has been destroyed')
|
||||
@@ -69,12 +68,13 @@ export class PyodideWorkerClient {
|
||||
this.queueMessage({
|
||||
type: 'run-code',
|
||||
code,
|
||||
id: options.requestId,
|
||||
fileId: options.fileId,
|
||||
executionId: options.executionId,
|
||||
files: options.files,
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
reset(): void {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
@@ -85,11 +85,11 @@ export class PyodideWorkerClient {
|
||||
|
||||
// Reset state for the new worker
|
||||
this.listening = false
|
||||
this.loaded = false
|
||||
this.loadingError = null
|
||||
|
||||
// Create a fresh worker and re-initialize Pyodide
|
||||
this.worker = this.createWorker()
|
||||
this.worker.addEventListener('message', this.receive)
|
||||
this.queueMessage({
|
||||
type: 'init',
|
||||
baseAssetPath: this.baseAssetPath,
|
||||
@@ -104,23 +104,9 @@ export class PyodideWorkerClient {
|
||||
this.destroyed = true
|
||||
this.pendingMessages.length = 0
|
||||
|
||||
if (!this.loaded && !this.loadingError) {
|
||||
this.loadingError = 'Pyodide worker was destroyed before loading finished'
|
||||
}
|
||||
|
||||
this.worker.terminate()
|
||||
}
|
||||
|
||||
private createWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
/* webpackChunkName: "pyodide-worker" */
|
||||
new URL('./pyodide.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
)
|
||||
worker.addEventListener('message', this.receive)
|
||||
return worker
|
||||
}
|
||||
|
||||
private queueMessage(message: PyodideWorkerRequest) {
|
||||
if (this.listening) {
|
||||
this.worker.postMessage(message)
|
||||
@@ -147,7 +133,6 @@ export class PyodideWorkerClient {
|
||||
return
|
||||
|
||||
case 'loaded':
|
||||
this.loaded = true
|
||||
this.lifecycleCallback?.({ type: 'loaded' })
|
||||
return
|
||||
|
||||
@@ -164,17 +149,18 @@ export class PyodideWorkerClient {
|
||||
this.outputCallback?.(
|
||||
response.stream,
|
||||
response.line,
|
||||
response.requestId
|
||||
response.fileId,
|
||||
response.executionId
|
||||
)
|
||||
break
|
||||
return
|
||||
|
||||
case 'run-code-result':
|
||||
this.lifecycleCallback?.({
|
||||
type: 'run-finished',
|
||||
requestId: response.id,
|
||||
fileId: response.fileId,
|
||||
executionId: response.executionId,
|
||||
outputs: response.outputs,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -12,7 +12,8 @@ export type InitRequest = {
|
||||
|
||||
export type RunCodeRequest = {
|
||||
type: 'run-code'
|
||||
id: string
|
||||
fileId: string
|
||||
executionId: string
|
||||
code: string
|
||||
files: ProjectFileData[]
|
||||
}
|
||||
@@ -29,7 +30,8 @@ export type OutputLineEvent = {
|
||||
type: 'output-line'
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
requestId?: string
|
||||
fileId: string
|
||||
executionId: string
|
||||
}
|
||||
|
||||
export type PyodideWorkerEvent =
|
||||
@@ -42,7 +44,8 @@ export type PyodideWorkerEvent =
|
||||
|
||||
export type RunCodeResult = {
|
||||
type: 'run-code-result'
|
||||
id: string
|
||||
fileId: string
|
||||
executionId: string
|
||||
outputs: string[]
|
||||
}
|
||||
|
||||
|
||||
+17
-9
@@ -84,17 +84,20 @@ async function handleInit(msg: { baseAssetPath: string }) {
|
||||
}
|
||||
|
||||
async function handleRunCode(msg: RunCodeRequest) {
|
||||
const { fileId, executionId } = msg
|
||||
|
||||
if (!pyodideModule) {
|
||||
const error = 'Pyodide is not initialized'
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: error,
|
||||
requestId: msg.id,
|
||||
line: 'Pyodide is not initialized',
|
||||
fileId,
|
||||
executionId,
|
||||
})
|
||||
self.postMessage({
|
||||
type: 'run-code-result',
|
||||
id: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
outputs: [],
|
||||
})
|
||||
return
|
||||
@@ -112,7 +115,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line,
|
||||
requestId: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -122,7 +126,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line,
|
||||
requestId: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -153,7 +158,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line: String(result),
|
||||
requestId: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
})
|
||||
}
|
||||
} catch (runError) {
|
||||
@@ -164,14 +170,16 @@ async function handleRunCode(msg: RunCodeRequest) {
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: errorMessage,
|
||||
requestId: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
})
|
||||
} finally {
|
||||
fs.write = originalWrite
|
||||
const outputs = [...writtenPaths].sort()
|
||||
self.postMessage({
|
||||
type: 'run-code-result',
|
||||
id: msg.id,
|
||||
fileId,
|
||||
executionId,
|
||||
outputs,
|
||||
})
|
||||
}
|
||||
|
||||
+35
-177
@@ -1,174 +1,32 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import path from 'path-browserify'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { PyodideWorkerClient } from './pyodide-worker-client'
|
||||
import { usePythonExecutionContext } from '@/features/ide-react/context/python-execution-context'
|
||||
import { DEFAULT_STATE } from './python-runner'
|
||||
|
||||
const emptySubscribe = () => () => {}
|
||||
const getDefaultState = () => DEFAULT_STATE
|
||||
|
||||
export default function PythonOutputPane() {
|
||||
const { t } = useTranslation()
|
||||
const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
|
||||
const { pathInFolder } = useFileTreePathContext()
|
||||
const { projectSnapshot } = useProjectContext()
|
||||
const clientRef = useRef<PyodideWorkerClient | null>(null)
|
||||
const currentRequestIdRef = useRef<string | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [output, setOutput] = useState<string[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
|
||||
const { currentDocumentId } = useEditorOpenDocContext()
|
||||
const { getPythonRunner } = usePythonExecutionContext()
|
||||
const pythonRunner = useMemo(
|
||||
() => (currentDocumentId ? getPythonRunner(currentDocumentId) : null),
|
||||
[currentDocumentId, getPythonRunner]
|
||||
)
|
||||
|
||||
const appendOutput = useCallback((line: string) => {
|
||||
setOutput(previousOutput => [...previousOutput, line])
|
||||
}, [])
|
||||
const { output, error, status } = useSyncExternalStore(
|
||||
pythonRunner ? pythonRunner.subscribe : emptySubscribe,
|
||||
pythonRunner ? pythonRunner.getState : getDefaultState
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const baseAssetPath = new URL(
|
||||
getMeta('ol-baseAssetPath'),
|
||||
window.location.href
|
||||
).toString()
|
||||
const client = new PyodideWorkerClient({ baseAssetPath })
|
||||
clientRef.current = client
|
||||
let cancelled = false
|
||||
|
||||
client.setLifecycleCallback(event => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'loaded':
|
||||
setIsReady(true)
|
||||
setIsLoadingPyodide(false)
|
||||
return
|
||||
|
||||
case 'loading-failed':
|
||||
debugConsole.error('Failed to load Python runtime', event.error)
|
||||
setIsLoadingPyodide(false)
|
||||
setError(formatError(event.error))
|
||||
setIsRunning(false)
|
||||
return
|
||||
|
||||
case 'run-finished':
|
||||
if (event.requestId !== currentRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
client.setOutputCallback((_stream, line, requestId) => {
|
||||
if (!requestId || requestId !== currentRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
appendOutput(line)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
currentRequestIdRef.current = null
|
||||
client.destroy()
|
||||
clientRef.current = null
|
||||
}
|
||||
}, [appendOutput])
|
||||
|
||||
useEffect(() => {
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
setOutput([])
|
||||
setError(null)
|
||||
}, [currentDocumentId])
|
||||
|
||||
const buildCurrentDocumentSyncFile = useCallback(() => {
|
||||
if (!currentDocument || !currentDocumentId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = currentDocument.getSnapshot()
|
||||
if (typeof content !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentPath = pathInFolder(currentDocumentId)
|
||||
if (!currentPath) {
|
||||
throw new Error(
|
||||
'Unable to resolve current document path for Python sync.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath: path.posix.normalize(currentPath),
|
||||
content,
|
||||
}
|
||||
}, [currentDocument, currentDocumentId, pathInFolder])
|
||||
|
||||
const getLatestProjectFiles = useCallback(async () => {
|
||||
await projectSnapshot.refresh()
|
||||
return projectSnapshot.getDocPaths().reduce(
|
||||
(files, relativePath) => {
|
||||
const content = projectSnapshot.getDocContents(relativePath)
|
||||
if (content !== null) {
|
||||
files.push({ relativePath, content })
|
||||
}
|
||||
return files
|
||||
},
|
||||
[] as { relativePath: string; content: string }[]
|
||||
)
|
||||
}, [projectSnapshot])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
if (!client || !isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const syncFile = buildCurrentDocumentSyncFile()
|
||||
if (!syncFile) {
|
||||
return
|
||||
}
|
||||
|
||||
setOutput([])
|
||||
setError(null)
|
||||
|
||||
const requestId = syncFile.relativePath
|
||||
currentRequestIdRef.current = requestId
|
||||
setIsRunning(true)
|
||||
|
||||
const isCancelled = () => currentRequestIdRef.current !== requestId
|
||||
|
||||
try {
|
||||
const files = await getLatestProjectFiles()
|
||||
if (isCancelled()) return
|
||||
client.runCode(syncFile.content, { requestId, files })
|
||||
} catch (runError) {
|
||||
if (isCancelled()) return
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
setError(formatError(runError))
|
||||
}
|
||||
}, [buildCurrentDocumentSyncFile, getLatestProjectFiles, isReady])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
const client = clientRef.current
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
currentRequestIdRef.current = null
|
||||
client.stop()
|
||||
setIsRunning(false)
|
||||
setIsReady(false)
|
||||
setIsLoadingPyodide(true)
|
||||
appendOutput(t('execution_stopped'))
|
||||
}, [appendOutput, t])
|
||||
if (!pythonRunner) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ide-redesign-python-output-pane">
|
||||
@@ -176,17 +34,25 @@ export default function PythonOutputPane() {
|
||||
<div className="toolbar-pdf-left">
|
||||
<div className="compile-button-group">
|
||||
<OLButton
|
||||
onClick={isRunning ? handleStop : handleRun}
|
||||
variant={isRunning ? 'danger' : 'primary'}
|
||||
onClick={() => {
|
||||
if (status === 'running') {
|
||||
pythonRunner.interrupt()
|
||||
} else {
|
||||
pythonRunner.run()
|
||||
}
|
||||
}}
|
||||
variant={status === 'running' ? 'danger' : 'primary'}
|
||||
className="compile-button align-items-center py-0 px-3"
|
||||
disabled={!isRunning && !isReady}
|
||||
disabled={status === 'loading'}
|
||||
aria-label={
|
||||
isRunning ? t('stop_python_execution') : t('run_python_code')
|
||||
status === 'running'
|
||||
? t('stop_python_execution')
|
||||
: t('run_python_code')
|
||||
}
|
||||
>
|
||||
{isRunning ? t('stop') : t('run')}
|
||||
{status === 'running' ? t('stop') : t('run')}
|
||||
<MaterialIcon
|
||||
type={isRunning ? 'stop' : 'play_arrow'}
|
||||
type={status === 'running' ? 'stop' : 'play_arrow'}
|
||||
className="ml-2"
|
||||
/>
|
||||
</OLButton>
|
||||
@@ -195,12 +61,12 @@ export default function PythonOutputPane() {
|
||||
</OLButtonToolbar>
|
||||
|
||||
<div className="ide-redesign-python-output-pane-body">
|
||||
{isLoadingPyodide && (
|
||||
{status === 'loading' && (
|
||||
<div className="ide-redesign-python-output-pane-placeholder">
|
||||
{t('loading_python_runtime')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingPyodide && !error && output.length === 0 && (
|
||||
{status !== 'loading' && !error && output.length === 0 && (
|
||||
<div className="ide-redesign-python-output-pane-placeholder">
|
||||
{t('run_current_script_to_see_output')}
|
||||
</div>
|
||||
@@ -217,11 +83,3 @@ export default function PythonOutputPane() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Per-file Python execution manager. Each PythonRunner owns a PyodideWorkerClient
|
||||
// and exposes a subscribe/getState API for use with useSyncExternalStore,
|
||||
// so React components can reactively read execution status and output.
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PyodideWorkerClient } from './pyodide-worker-client'
|
||||
|
||||
const MAX_OUTPUT_LINES = 100
|
||||
|
||||
export type ExecutionStatus =
|
||||
| 'loading'
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'errored'
|
||||
|
||||
export type ExecutionContext = {
|
||||
code: string
|
||||
files: { relativePath: string; content: string }[]
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
export type PythonRunnerState = {
|
||||
output: string[]
|
||||
status: ExecutionStatus
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: PythonRunnerState = {
|
||||
output: [],
|
||||
status: 'loading',
|
||||
error: null,
|
||||
}
|
||||
|
||||
export class PythonRunner {
|
||||
readonly fileId: string
|
||||
private client: PyodideWorkerClient | null = null
|
||||
private readonly baseAssetPath: string
|
||||
private readonly createWorker: () => Worker
|
||||
private readonly getExecutionContext: () => Promise<ExecutionContext | null>
|
||||
private listeners = new Set<Listener>()
|
||||
|
||||
private activeExecutionId: string | null = null
|
||||
private state: PythonRunnerState = DEFAULT_STATE
|
||||
|
||||
constructor(
|
||||
fileId: string,
|
||||
baseAssetPath: string,
|
||||
getExecutionContext: () => Promise<ExecutionContext | null>,
|
||||
createWorker: () => Worker
|
||||
) {
|
||||
this.fileId = fileId
|
||||
this.baseAssetPath = baseAssetPath
|
||||
this.createWorker = createWorker
|
||||
this.getExecutionContext = getExecutionContext
|
||||
}
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
getState = () => this.state
|
||||
|
||||
private updateState(fields: Partial<PythonRunnerState>) {
|
||||
const prev = this.state
|
||||
const output = fields.output ?? prev.output
|
||||
const status = fields.status ?? prev.status
|
||||
const error = fields.error !== undefined ? fields.error : prev.error
|
||||
|
||||
if (
|
||||
output === prev.output &&
|
||||
status === prev.status &&
|
||||
error === prev.error
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.state = { output, status, error }
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.client) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateState({ status: 'loading', error: null })
|
||||
|
||||
this.client = new PyodideWorkerClient({
|
||||
baseAssetPath: this.baseAssetPath,
|
||||
createWorker: this.createWorker,
|
||||
onLifecycle: event => {
|
||||
switch (event.type) {
|
||||
case 'loaded':
|
||||
this.updateState({ status: 'idle', error: null })
|
||||
return
|
||||
|
||||
case 'loading-failed':
|
||||
debugConsole.error('Failed to load Python runtime', event.error)
|
||||
this.updateState({ status: 'errored', error: event.error })
|
||||
return
|
||||
|
||||
case 'run-finished':
|
||||
if (
|
||||
event.fileId !== this.fileId ||
|
||||
this.activeExecutionId !== event.executionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.activeExecutionId = null
|
||||
this.updateState({ status: 'finished' })
|
||||
}
|
||||
},
|
||||
onOutput: (_stream, line, fileId, executionId) => {
|
||||
if (fileId !== this.fileId || this.activeExecutionId !== executionId) {
|
||||
return
|
||||
}
|
||||
this.updateState({ output: appendCapped(this.state.output, line) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (!this.client || this.state.status === 'loading') {
|
||||
return
|
||||
}
|
||||
|
||||
let context: ExecutionContext | null
|
||||
try {
|
||||
context = await this.getExecutionContext()
|
||||
} catch (err) {
|
||||
debugConsole.error('Failed to build execution context', err)
|
||||
this.updateState({ status: 'errored', error: formatError(err) })
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check after await — status may have changed but TypeScript
|
||||
// still narrows from the pre-await check, so we cast back.
|
||||
if (
|
||||
!context ||
|
||||
!this.client ||
|
||||
(this.state.status as ExecutionStatus) === 'loading'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, files } = context
|
||||
|
||||
const executionId = uuid()
|
||||
this.activeExecutionId = executionId
|
||||
this.updateState({ status: 'running', output: [], error: null })
|
||||
|
||||
try {
|
||||
this.client.runCode(code, {
|
||||
fileId: this.fileId,
|
||||
executionId,
|
||||
files,
|
||||
})
|
||||
} catch (runError) {
|
||||
if (this.activeExecutionId !== executionId) {
|
||||
return
|
||||
}
|
||||
this.activeExecutionId = null
|
||||
this.updateState({ status: 'errored', error: formatError(runError) })
|
||||
}
|
||||
}
|
||||
|
||||
interrupt() {
|
||||
if (!this.client) {
|
||||
return
|
||||
}
|
||||
|
||||
this.client.reset()
|
||||
this.activeExecutionId = null
|
||||
|
||||
// The worker is terminated and recreated by reset(), so it needs to
|
||||
// reload Pyodide. The 'loaded' lifecycle callback will transition
|
||||
// back to 'idle'.
|
||||
this.updateState({
|
||||
status: 'loading',
|
||||
output:
|
||||
this.state.status === 'running'
|
||||
? appendCapped(this.state.output, 'Execution interrupted')
|
||||
: this.state.output,
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.client) {
|
||||
this.client.destroy()
|
||||
this.client = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendCapped(existing: string[], line: string): string[] {
|
||||
const updated = [...existing, line]
|
||||
return updated.length > MAX_OUTPUT_LINES
|
||||
? updated.slice(-MAX_OUTPUT_LINES)
|
||||
: updated
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
+20
-17
@@ -2,26 +2,29 @@ import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||
import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
|
||||
import SourceEditor from '@/features/source-editor/components/source-editor'
|
||||
import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
|
||||
|
||||
export const PythonEditorSplit = () => {
|
||||
return (
|
||||
<PanelGroup
|
||||
autoSaveId="ide-redesign-editor-python-output"
|
||||
direction="vertical"
|
||||
className="ide-redesign-python-editor-split"
|
||||
>
|
||||
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
|
||||
<SourceEditor />
|
||||
</Panel>
|
||||
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
|
||||
<Panel
|
||||
id="ide-redesign-panel-python-output"
|
||||
order={2}
|
||||
defaultSize={35}
|
||||
minSize={10}
|
||||
<PythonExecutionProvider>
|
||||
<PanelGroup
|
||||
autoSaveId="ide-redesign-editor-python-output"
|
||||
direction="vertical"
|
||||
className="ide-redesign-python-editor-split"
|
||||
>
|
||||
<PythonOutputPane />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
|
||||
<SourceEditor />
|
||||
</Panel>
|
||||
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
|
||||
<Panel
|
||||
id="ide-redesign-panel-python-output"
|
||||
order={2}
|
||||
defaultSize={35}
|
||||
minSize={10}
|
||||
>
|
||||
<PythonOutputPane />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</PythonExecutionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user