Просмотр исходного кода

feat: add Python support with Pyodide integration

GitOrigin-RevId: 382ce102c43050aace691dd89d825a94abf347a8
Domagoj Kriskovic 5 месяцев назад
Родитель
Сommit
138f7f8023

+ 49 - 0
package-lock.json

@@ -4602,6 +4602,20 @@
         "@lezer/markdown": "^1.0.0"
         "@lezer/markdown": "^1.0.0"
       }
       }
     },
     },
+    "node_modules/@codemirror/lang-python": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+      "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/autocomplete": "^6.3.2",
+        "@codemirror/language": "^6.8.0",
+        "@codemirror/state": "^6.0.0",
+        "@lezer/common": "^1.2.1",
+        "@lezer/python": "^1.1.4"
+      }
+    },
     "node_modules/@codemirror/language": {
     "node_modules/@codemirror/language": {
       "version": "6.12.1",
       "version": "6.12.1",
       "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
       "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
@@ -9056,6 +9070,18 @@
         "@lezer/highlight": "^1.0.0"
         "@lezer/highlight": "^1.0.0"
       }
       }
     },
     },
+    "node_modules/@lezer/python": {
+      "version": "1.1.18",
+      "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
+      "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.2.0",
+        "@lezer/highlight": "^1.0.0",
+        "@lezer/lr": "^1.0.0"
+      }
+    },
     "node_modules/@lit-labs/react": {
     "node_modules/@lit-labs/react": {
       "version": "1.2.1",
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
       "resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
@@ -16044,6 +16070,13 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/@types/emscripten": {
+      "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/eslint": {
     "node_modules/@types/eslint": {
       "version": "9.6.1",
       "version": "9.6.1",
       "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -39596,6 +39629,20 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
+    "node_modules/pyodide": {
+      "version": "0.29.3",
+      "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.3.tgz",
+      "integrity": "sha512-22UBuhOJawj7vKUnS7/F3xK+515LJdjiMAHoCfuS6/PbHiOrSQVnYwDe+2sbVwiOZ3sMMexdXICew6NqOMQGgA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "@types/emscripten": "^1.41.4",
+        "ws": "^8.5.0"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/qified": {
     "node_modules/qified": {
       "version": "0.6.0",
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
       "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
@@ -51152,6 +51199,7 @@
         "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
         "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
         "@codemirror/commands": "6.10.1",
         "@codemirror/commands": "6.10.1",
         "@codemirror/lang-markdown": "6.5.0",
         "@codemirror/lang-markdown": "6.5.0",
+        "@codemirror/lang-python": "6.2.1",
         "@codemirror/language": "6.12.1",
         "@codemirror/language": "6.12.1",
         "@codemirror/lint": "6.9.2",
         "@codemirror/lint": "6.9.2",
         "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
         "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
@@ -51315,6 +51363,7 @@
         "postcss": "^8.4.31",
         "postcss": "^8.4.31",
         "postcss-loader": "^7.3.3",
         "postcss-loader": "^7.3.3",
         "prop-types": "^15.7.2",
         "prop-types": "^15.7.2",
+        "pyodide": "^0.29.0",
         "qrcode": "^1.4.4",
         "qrcode": "^1.4.4",
         "react": "^18.3.1",
         "react": "^18.3.1",
         "react-bootstrap": "^2.10.10",
         "react-bootstrap": "^2.10.10",

+ 148 - 0
services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts

@@ -0,0 +1,148 @@
+import type {
+  ProjectFileData,
+  PyodideWorkerRequest,
+  PyodideWorkerResponse,
+} from './pyodide-worker-messages'
+
+export type OutputCallback = (
+  stream: 'stdout' | 'stderr',
+  line: string,
+  requestId?: string
+) => void
+
+export type LifecycleCallback = (
+  event:
+    | { type: 'loaded' }
+    | { type: 'loading-failed'; error: string }
+    | { type: 'run-finished'; requestId: string }
+) => void
+
+export class PyodideWorkerClient {
+  private worker: 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
+
+  constructor(options: { baseAssetPath: string }) {
+    const { baseAssetPath } = options
+
+    this.worker = new Worker(
+      /* webpackChunkName: "pyodide-worker" */
+      new URL('./pyodide.worker.ts', import.meta.url),
+      { type: 'module' }
+    )
+
+    this.worker.addEventListener('message', this.receive.bind(this))
+
+    this.queueMessage({
+      type: 'init',
+      baseAssetPath,
+    })
+  }
+
+  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[] }
+  ): void {
+    if (this.destroyed) {
+      throw new Error('Pyodide worker client has been destroyed')
+    }
+
+    if (this.loadingError) {
+      throw new Error(this.loadingError)
+    }
+
+    this.queueMessage({
+      type: 'run-code',
+      code,
+      id: options.requestId,
+      files: options.files,
+    })
+  }
+
+  destroy() {
+    if (this.destroyed) {
+      return
+    }
+
+    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 queueMessage(message: PyodideWorkerRequest) {
+    if (this.listening) {
+      this.worker.postMessage(message)
+    } else {
+      this.pendingMessages.push(message)
+    }
+  }
+
+  private receive(event: MessageEvent<PyodideWorkerResponse>) {
+    const response = event.data
+
+    switch (response.type) {
+      case 'listening':
+        this.listening = true
+        for (const message of this.pendingMessages) {
+          this.worker.postMessage(message)
+        }
+        this.pendingMessages.length = 0
+        return
+
+      case 'loaded':
+        this.loaded = true
+        this.lifecycleCallback?.({ type: 'loaded' })
+        return
+
+      case 'loading-failed':
+        this.loadingError = response.error
+        this.pendingMessages.length = 0
+        this.lifecycleCallback?.({
+          type: 'loading-failed',
+          error: response.error,
+        })
+        return
+
+      case 'output-line':
+        this.outputCallback?.(
+          response.stream,
+          response.line,
+          response.requestId
+        )
+        break
+
+      case 'run-code-result':
+        this.lifecycleCallback?.({
+          type: 'run-finished',
+          requestId: response.id,
+        })
+        break
+    }
+  }
+}

+ 48 - 0
services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts

@@ -0,0 +1,48 @@
+export type ProjectFileData = {
+  relativePath: string
+  content: string
+}
+
+// Main thread -> Worker messages
+
+export type InitRequest = {
+  type: 'init'
+  baseAssetPath: string
+}
+
+export type RunCodeRequest = {
+  type: 'run-code'
+  id: string
+  code: string
+  files: ProjectFileData[]
+}
+
+export type PyodideWorkerRequest = InitRequest | RunCodeRequest
+
+// Worker -> Main thread lifecycle and streaming events
+
+export type ListeningEvent = { type: 'listening' }
+export type LoadedEvent = { type: 'loaded' }
+export type LoadingFailedEvent = { type: 'loading-failed'; error: string }
+
+export type OutputLineEvent = {
+  type: 'output-line'
+  stream: 'stdout' | 'stderr'
+  line: string
+  requestId?: string
+}
+
+export type PyodideWorkerEvent =
+  | ListeningEvent
+  | LoadedEvent
+  | LoadingFailedEvent
+  | OutputLineEvent
+
+// Worker -> Main thread ID responses
+
+export type RunCodeResult = {
+  type: 'run-code-result'
+  id: string
+}
+
+export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult

+ 216 - 0
services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts

@@ -0,0 +1,216 @@
+import path from 'path-browserify'
+import type {
+  ProjectFileData,
+  PyodideWorkerRequest,
+  RunCodeRequest,
+} from './pyodide-worker-messages'
+
+type PyodideRuntimeModule = {
+  loadPyodide: (options: {
+    indexURL: string
+    packageBaseUrl?: string
+    env?: Record<string, string>
+    packages?: string[]
+  }) => Promise<PyodideInstance>
+}
+
+type PyodideInstance = {
+  FS: unknown
+  runPythonAsync: (code: string) => Promise<unknown>
+  setStdout: (options: { batched: (line: string) => void }) => void
+  setStderr: (options: { batched: (line: string) => void }) => void
+}
+
+type PyodideFs = {
+  analyzePath: (filePath: string) => { exists: boolean }
+  mkdir: (filePath: string) => void
+  writeFile: (
+    filePath: string,
+    content: string | ArrayBuffer | Uint8Array
+  ) => void
+  chdir: (filePath: string) => void
+}
+
+const PROJECT_FS_ROOT = '/project'
+const PYODIDE_INDEX_PATH = 'js/libs/pyodide/'
+
+function getPyodideIndexUrl(baseAssetPath: string): string {
+  return new URL(PYODIDE_INDEX_PATH, baseAssetPath).toString()
+}
+
+function toRuntimeProjectPath(relativePath: string): string {
+  return path.posix.join(PROJECT_FS_ROOT, path.posix.normalize(relativePath))
+}
+
+function ensureProjectRootExists(fs: PyodideFs) {
+  try {
+    const projectRootAnalysis = fs.analyzePath(PROJECT_FS_ROOT)
+    if (!projectRootAnalysis.exists) {
+      fs.mkdir(PROJECT_FS_ROOT)
+    }
+  } catch {
+    fs.mkdir(PROJECT_FS_ROOT)
+  }
+}
+
+function ensureDirectoryExists(fs: PyodideFs, filePath: string) {
+  const directory = path.dirname(filePath)
+  if (directory === '.' || directory === '/') {
+    return
+  }
+
+  let currentPath = directory.startsWith('/') ? '/' : ''
+  for (const part of directory.split('/').filter(Boolean)) {
+    currentPath = path.posix.join(currentPath, part)
+    try {
+      const analysis = fs.analyzePath(currentPath)
+      if (!analysis.exists) {
+        fs.mkdir(currentPath)
+      }
+    } catch {
+      // Ignore failures when a directory already exists.
+    }
+  }
+}
+
+function syncProjectFiles(fs: PyodideFs, files: ProjectFileData[]) {
+  ensureProjectRootExists(fs)
+
+  for (const file of files) {
+    const runtimePath = toRuntimeProjectPath(file.relativePath)
+    ensureDirectoryExists(fs, runtimePath)
+    fs.writeFile(runtimePath, file.content)
+  }
+
+  fs.chdir(PROJECT_FS_ROOT)
+}
+
+let pyodideInstance: PyodideInstance | null = null
+let activeRunRequestId: string | null = null
+
+async function loadPyodideModule(
+  pyodideIndexUrl: string
+): Promise<PyodideRuntimeModule> {
+  const runtimeModuleUrl = `${pyodideIndexUrl}pyodide.mjs`
+
+  try {
+    return (await import(
+      /* webpackIgnore: true */ runtimeModuleUrl
+    )) as PyodideRuntimeModule
+  } catch (loadError) {
+    const loadErrorMessage =
+      loadError instanceof Error ? loadError.message : String(loadError)
+    throw new Error(
+      `Unable to load Pyodide module from ${runtimeModuleUrl}. Original error: ${loadErrorMessage}`
+    )
+  }
+}
+
+async function handleInit(msg: { baseAssetPath: string }) {
+  const pyodideIndexUrl = getPyodideIndexUrl(msg.baseAssetPath)
+
+  try {
+    const pyodideModule = await loadPyodideModule(pyodideIndexUrl)
+    const instance = await pyodideModule.loadPyodide({
+      indexURL: pyodideIndexUrl,
+      packageBaseUrl: pyodideIndexUrl,
+      env: { MPLBACKEND: 'Agg' },
+    })
+
+    instance.setStdout({
+      batched: (line: string) => {
+        self.postMessage({
+          type: 'output-line',
+          stream: 'stdout',
+          line,
+          requestId: activeRunRequestId ?? undefined,
+        })
+      },
+    })
+    instance.setStderr({
+      batched: (line: string) => {
+        self.postMessage({
+          type: 'output-line',
+          stream: 'stderr',
+          line,
+          requestId: activeRunRequestId ?? undefined,
+        })
+      },
+    })
+
+    pyodideInstance = instance
+    self.postMessage({ type: 'loaded' })
+  } catch (error) {
+    const errorMessage = error instanceof Error ? error.message : String(error)
+    console.error('Pyodide initialization failed', error)
+    self.postMessage({
+      type: 'loading-failed',
+      error: errorMessage,
+    })
+  }
+}
+
+async function handleRunCode(msg: RunCodeRequest) {
+  if (!pyodideInstance) {
+    const error = 'Pyodide is not initialized'
+    self.postMessage({
+      type: 'output-line',
+      stream: 'stderr',
+      line: error,
+      requestId: msg.id,
+    })
+    self.postMessage({
+      type: 'run-code-result',
+      id: msg.id,
+    })
+    return
+  }
+
+  activeRunRequestId = msg.id
+  try {
+    if (msg.files.length > 0) {
+      const fs = pyodideInstance.FS as PyodideFs
+      syncProjectFiles(fs, msg.files)
+    }
+
+    const result = await pyodideInstance.runPythonAsync(msg.code)
+    if (result !== undefined) {
+      self.postMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: String(result),
+        requestId: activeRunRequestId ?? undefined,
+      })
+    }
+  } catch (runError) {
+    const errorMessage =
+      runError instanceof Error ? runError.message : String(runError)
+    self.postMessage({
+      type: 'output-line',
+      stream: 'stderr',
+      line: errorMessage,
+      requestId: activeRunRequestId ?? undefined,
+    })
+  } finally {
+    activeRunRequestId = null
+  }
+
+  self.postMessage({
+    type: 'run-code-result',
+    id: msg.id,
+  })
+}
+
+self.addEventListener('message', async event => {
+  const msg = event.data as PyodideWorkerRequest
+  switch (msg.type) {
+    case 'init':
+      await handleInit(msg)
+      break
+    case 'run-code':
+      await handleRunCode(msg)
+      break
+  }
+})
+
+self.postMessage({ type: 'listening' })

+ 189 - 0
services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx

@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+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 { 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'
+
+export default function PythonOutputPane() {
+  const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
+  const { pathInFolder } = useFileTreePathContext()
+  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 [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+
+  const appendOutput = useCallback((line: string) => {
+    setOutput(previousOutput => [...previousOutput, line])
+  }, [])
+
+  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)
+          setError(formatError(event.error))
+          setIsLoadingPyodide(false)
+          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 handleRun = useCallback(() => {
+    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)
+
+    try {
+      client.runCode(syncFile.content, { requestId, files: [syncFile] })
+    } catch (runError) {
+      if (currentRequestIdRef.current !== requestId) {
+        return
+      }
+      currentRequestIdRef.current = null
+      setIsRunning(false)
+      setError(formatError(runError))
+    }
+  }, [buildCurrentDocumentSyncFile, isReady])
+
+  return (
+    <div className="ide-redesign-python-output-pane">
+      <OLButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
+        <div className="toolbar-pdf-left">
+          <div className="compile-button-group">
+            <OLButton
+              onClick={handleRun}
+              variant="primary"
+              className="compile-button align-items-center py-0 px-3"
+              disabled={!isReady || isLoadingPyodide || isRunning}
+              aria-label="Run Python Code"
+              style={{ borderRadius: '12px' }}
+            >
+              {isRunning ? 'Running...' : 'Run'}
+              <MaterialIcon type="play_arrow" className="ml-2" />
+            </OLButton>
+          </div>
+        </div>
+      </OLButtonToolbar>
+
+      <div className="ide-redesign-python-output-pane-body">
+        {isLoadingPyodide && (
+          <div className="ide-redesign-python-output-pane-placeholder">
+            Loading Python runtime...
+          </div>
+        )}
+        {!isLoadingPyodide && !error && output.length === 0 && (
+          <div className="ide-redesign-python-output-pane-placeholder">
+            Run the current script to see output.
+          </div>
+        )}
+        {error && (
+          <div className="ide-redesign-python-output-pane-error">{error}</div>
+        )}
+        {output.map((line, index) => (
+          <div className="ide-redesign-python-output-pane-line" key={index}>
+            {line}
+          </div>
+        ))}
+      </div>
+    </div>
+  )
+}
+
+function formatError(error: unknown): string {
+  if (error instanceof Error) {
+    return error.message
+  }
+
+  return String(error)
+}

+ 5 - 1
services/web/frontend/js/features/ide-react/components/layout/editor.tsx

@@ -9,12 +9,16 @@ import { Suspense } from 'react'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
 import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
 import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
 import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
+import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split'
 
 
 export const Editor = () => {
 export const Editor = () => {
   const { opening, errorState, showSymbolPalette } =
   const { opening, errorState, showSymbolPalette } =
     useEditorPropertiesContext()
     useEditorPropertiesContext()
   const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
   const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
   const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
   const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
+  const isPythonDocument =
+    openEntity?.type === 'doc' &&
+    openEntity.entity.name.toLowerCase().endsWith('.py')
 
 
   if (!currentDocumentId) {
   if (!currentDocumentId) {
     return null
     return null
@@ -39,7 +43,7 @@ export const Editor = () => {
           order={1}
           order={1}
           className="ide-redesign-editor-panel"
           className="ide-redesign-editor-panel"
         >
         >
-          <SourceEditor />
+          {isPythonDocument ? <PythonEditorSplit /> : <SourceEditor />}
           {isLoading && <EditorLoadingPane />}
           {isLoading && <EditorLoadingPane />}
         </Panel>
         </Panel>
         {showSymbolPalette && (
         {showSymbolPalette && (

+ 27 - 0
services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx

@@ -0,0 +1,27 @@
+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'
+
+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}
+      >
+        <PythonOutputPane />
+      </Panel>
+    </PanelGroup>
+  )
+}

+ 7 - 0
services/web/frontend/js/features/source-editor/languages/index.ts

@@ -65,4 +65,11 @@ export const languages = [
       return import('./markdown').then(m => m.markdown())
       return import('./markdown').then(m => m.markdown())
     },
     },
   }),
   }),
+  LanguageDescription.of({
+    name: 'python',
+    extensions: ['py'],
+    load: () => {
+      return import('@codemirror/lang-python').then(m => m.python())
+    },
+  }),
 ]
 ]

+ 41 - 0
services/web/frontend/stylesheets/pages/editor/ide-redesign.scss

@@ -54,6 +54,47 @@
   height: 100%;
   height: 100%;
 }
 }
 
 
+.ide-redesign-python-editor-split {
+  height: 100%;
+}
+
+.ide-redesign-python-output-pane {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: var(--ide-redesign-background);
+  color: var(--ide-redesign-color);
+  border-top: 1px solid var(--border-divider);
+}
+
+.ide-redesign-python-output-pane-body {
+  flex: 1;
+  overflow: auto;
+  padding: var(--spacing-06);
+  font-family: 'DM Mono', monospace;
+  font-size: var(--font-size-02);
+  line-height: var(--line-height-03);
+}
+
+.ide-redesign-python-output-pane-placeholder {
+  color: var(--content-secondary);
+}
+
+.ide-redesign-python-output-pane-line {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.ide-redesign-python-output-pane-placeholder,
+.ide-redesign-python-output-pane-error {
+  white-space: pre-wrap;
+}
+
+.ide-redesign-python-output-pane-error {
+  margin-bottom: var(--spacing-03);
+  color: var(--red-50);
+}
+
 .ide-redesign-labs-user-beta-promo {
 .ide-redesign-labs-user-beta-promo {
   position: absolute;
   position: absolute;
   top: 60px;
   top: 60px;

+ 2 - 0
services/web/package.json

@@ -205,6 +205,7 @@
     "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
     "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
     "@codemirror/commands": "6.10.1",
     "@codemirror/commands": "6.10.1",
     "@codemirror/lang-markdown": "6.5.0",
     "@codemirror/lang-markdown": "6.5.0",
+    "@codemirror/lang-python": "6.2.1",
     "@codemirror/language": "6.12.1",
     "@codemirror/language": "6.12.1",
     "@codemirror/lint": "6.9.2",
     "@codemirror/lint": "6.9.2",
     "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
     "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
@@ -368,6 +369,7 @@
     "postcss": "^8.4.31",
     "postcss": "^8.4.31",
     "postcss-loader": "^7.3.3",
     "postcss-loader": "^7.3.3",
     "prop-types": "^15.7.2",
     "prop-types": "^15.7.2",
+    "pyodide": "^0.29.0",
     "qrcode": "^1.4.4",
     "qrcode": "^1.4.4",
     "react": "^18.3.1",
     "react": "^18.3.1",
     "react-bootstrap": "^2.10.10",
     "react-bootstrap": "^2.10.10",

+ 156 - 0
services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts

@@ -0,0 +1,156 @@
+import { expect } from 'chai'
+import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
+
+type WorkerMessageListener = (event: MessageEvent) => void
+
+const BASE_ASSET_PATH = 'https://assets.example.test/'
+
+class WorkerMock {
+  static instances: WorkerMock[] = []
+
+  readonly postedMessages: any[] = []
+  terminated = false
+  private messageListeners: WorkerMessageListener[] = []
+
+  constructor() {
+    WorkerMock.instances.push(this)
+  }
+
+  addEventListener(type: string, listener: WorkerMessageListener) {
+    if (type === 'message') {
+      this.messageListeners.push(listener)
+    }
+  }
+
+  postMessage(message: unknown) {
+    this.postedMessages.push(message)
+  }
+
+  terminate() {
+    this.terminated = true
+  }
+
+  emitMessage(message: unknown) {
+    for (const listener of this.messageListeners) {
+      listener({ data: message } as MessageEvent)
+    }
+  }
+}
+
+describe('PyodideWorkerClient', function () {
+  let originalWorker: typeof Worker | undefined
+
+  beforeEach(function () {
+    originalWorker = window.Worker
+    // @ts-ignore - allow mocking Worker
+    window.Worker = WorkerMock
+    WorkerMock.instances.length = 0
+  })
+
+  afterEach(function () {
+    if (originalWorker) {
+      window.Worker = originalWorker
+    }
+  })
+
+  it('queues runCode until the worker reports listening', function () {
+    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const worker = WorkerMock.instances[0]
+
+    client.runCode('print("ok")', {
+      requestId: 'main.py',
+      files: [{ relativePath: 'main.py', content: 'print("ok")' }],
+    })
+    expect(worker.postedMessages).to.have.length(0)
+
+    worker.emitMessage({ type: 'listening' })
+    expect(worker.postedMessages.map(message => message.type)).to.deep.equal([
+      'init',
+      'run-code',
+    ])
+
+    const runRequest = worker.postedMessages.find(
+      message => message.type === 'run-code'
+    )
+    expect(runRequest).to.include({
+      type: 'run-code',
+      id: 'main.py',
+      code: 'print("ok")',
+    })
+    expect(runRequest.files).to.deep.equal([
+      { relativePath: 'main.py', content: 'print("ok")' },
+    ])
+  })
+
+  it('sends runCode as fire-and-forget', function () {
+    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const worker = WorkerMock.instances[0]
+    worker.emitMessage({ type: 'listening' })
+
+    client.runCode('raise RuntimeError("boom")', {
+      requestId: 'boom.py',
+      files: [],
+    })
+    const runRequest = worker.postedMessages.find(
+      message => message.type === 'run-code'
+    )
+    expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' })
+  })
+
+  it('emits run-finished lifecycle event from run-code-result', function () {
+    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const worker = WorkerMock.instances[0]
+    const lifecycleEvents: Array<{ type: string; requestId?: string }> = []
+
+    client.setLifecycleCallback(event => {
+      lifecycleEvents.push(event)
+    })
+    worker.emitMessage({ type: 'listening' })
+
+    client.runCode('print("ok")', { requestId: 'main.py', files: [] })
+    worker.emitMessage({
+      type: 'run-code-result',
+      id: 'main.py',
+    })
+
+    expect(lifecycleEvents).to.deep.include({
+      type: 'run-finished',
+      requestId: 'main.py',
+    })
+  })
+
+  it('reports lifecycle failure and rejects future run requests when loading fails', function () {
+    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const worker = WorkerMock.instances[0]
+    const lifecycleEvents: Array<{ type: string; error?: string }> = []
+
+    client.setLifecycleCallback(event => {
+      lifecycleEvents.push(event)
+    })
+
+    worker.emitMessage({
+      type: 'loading-failed',
+      error: 'runtime unavailable',
+    })
+
+    expect(lifecycleEvents).to.deep.equal([
+      { type: 'loading-failed', error: 'runtime unavailable' },
+    ])
+    expect(() =>
+      client.runCode('print("ok")', { requestId: 'main.py', files: [] })
+    ).to.throw('runtime unavailable')
+  })
+
+  it('terminates the worker even when destroy is called after loading failure', function () {
+    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const worker = WorkerMock.instances[0]
+
+    worker.emitMessage({
+      type: 'loading-failed',
+      error: 'runtime unavailable',
+    })
+    client.destroy()
+
+    expect(worker.terminated).to.equal(true)
+  })
+})

+ 32 - 0
services/web/webpack.config.js

@@ -62,6 +62,7 @@ function getModuleDirectory(moduleName) {
 const mathjaxDir = getModuleDirectory('mathjax')
 const mathjaxDir = getModuleDirectory('mathjax')
 const pdfjsDir = getModuleDirectory('pdfjs-dist')
 const pdfjsDir = getModuleDirectory('pdfjs-dist')
 const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
 const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
+const pyodideDir = getModuleDirectory('pyodide')
 
 
 const vendorDir = path.join(__dirname, 'frontend/js/vendor')
 const vendorDir = path.join(__dirname, 'frontend/js/vendor')
 
 
@@ -407,6 +408,37 @@ module.exports = {
           toType: 'dir',
           toType: 'dir',
           context: `${dictionariesDir}/dictionaries`,
           context: `${dictionariesDir}/dictionaries`,
         },
         },
+        // Copy Pyodide runtime assets from npm package for local serving.
+        {
+          from: 'pyodide.mjs',
+          to: 'js/libs/pyodide',
+          toType: 'dir',
+          context: pyodideDir,
+        },
+        {
+          from: 'pyodide.asm.js',
+          to: 'js/libs/pyodide',
+          toType: 'dir',
+          context: pyodideDir,
+        },
+        {
+          from: 'pyodide.asm.wasm',
+          to: 'js/libs/pyodide',
+          toType: 'dir',
+          context: pyodideDir,
+        },
+        {
+          from: 'python_stdlib.zip',
+          to: 'js/libs/pyodide',
+          toType: 'dir',
+          context: pyodideDir,
+        },
+        {
+          from: 'pyodide-lock.json',
+          to: 'js/libs/pyodide',
+          toType: 'dir',
+          context: pyodideDir,
+        },
         // Copy CMap files (used to provide support for non-Latin characters),
         // Copy CMap files (used to provide support for non-Latin characters),
         // wasm, ICC profiles, fonts and images from pdfjs-dist package to build output.
         // wasm, ICC profiles, fonts and images from pdfjs-dist package to build output.
         {
         {