mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
feat: add Python support with Pyodide integration
GitOrigin-RevId: 382ce102c43050aace691dd89d825a94abf347a8
This commit is contained in:
committed by
Copybot
parent
bb5d90a332
commit
138f7f8023
49
package-lock.json
generated
49
package-lock.json
generated
@@ -4602,6 +4602,20 @@
|
||||
"@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": {
|
||||
"version": "6.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||
@@ -9056,6 +9070,18 @@
|
||||
"@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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
|
||||
@@ -16044,6 +16070,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -39596,6 +39629,20 @@
|
||||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
|
||||
@@ -51152,6 +51199,7 @@
|
||||
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/lint": "6.9.2",
|
||||
"@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
|
||||
@@ -51315,6 +51363,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"pyodide": "^0.29.0",
|
||||
"qrcode": "^1.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import { Suspense } from 'react'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split'
|
||||
|
||||
export const Editor = () => {
|
||||
const { opening, errorState, showSymbolPalette } =
|
||||
useEditorPropertiesContext()
|
||||
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
|
||||
const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
|
||||
const isPythonDocument =
|
||||
openEntity?.type === 'doc' &&
|
||||
openEntity.entity.name.toLowerCase().endsWith('.py')
|
||||
|
||||
if (!currentDocumentId) {
|
||||
return null
|
||||
@@ -39,7 +43,7 @@ export const Editor = () => {
|
||||
order={1}
|
||||
className="ide-redesign-editor-panel"
|
||||
>
|
||||
<SourceEditor />
|
||||
{isPythonDocument ? <PythonEditorSplit /> : <SourceEditor />}
|
||||
{isLoading && <EditorLoadingPane />}
|
||||
</Panel>
|
||||
{showSymbolPalette && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -65,4 +65,11 @@ export const languages = [
|
||||
return import('./markdown').then(m => m.markdown())
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'python',
|
||||
extensions: ['py'],
|
||||
load: () => {
|
||||
return import('@codemirror/lang-python').then(m => m.python())
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -54,6 +54,47 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/lint": "6.9.2",
|
||||
"@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
|
||||
@@ -368,6 +369,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"pyodide": "^0.29.0",
|
||||
"qrcode": "^1.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,7 @@ function getModuleDirectory(moduleName) {
|
||||
const mathjaxDir = getModuleDirectory('mathjax')
|
||||
const pdfjsDir = getModuleDirectory('pdfjs-dist')
|
||||
const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
|
||||
const pyodideDir = getModuleDirectory('pyodide')
|
||||
|
||||
const vendorDir = path.join(__dirname, 'frontend/js/vendor')
|
||||
|
||||
@@ -407,6 +408,37 @@ module.exports = {
|
||||
toType: 'dir',
|
||||
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),
|
||||
// wasm, ICC profiles, fonts and images from pdfjs-dist package to build output.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user