python-execution-context.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import {
  2. createContext,
  3. FC,
  4. PropsWithChildren,
  5. useCallback,
  6. useContext,
  7. useEffect,
  8. useMemo,
  9. useRef,
  10. } from 'react'
  11. import getMeta from '@/utils/meta'
  12. import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
  13. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  14. import { useProjectContext } from '@/shared/context/project-context'
  15. import { useFileTreeData } from '@/shared/context/file-tree-data-context'
  16. import {
  17. uploadBatch,
  18. BatchUploadItem,
  19. } from '@/infrastructure/batch-file-uploader'
  20. import {
  21. PythonRunner,
  22. ExecutionContext,
  23. } from '@/features/ide-react/components/editor/python/python-runner'
  24. // Worker factory lives here (a .tsx file) so that the full
  25. // `new Worker(new URL(..., import.meta.url))` expression is in a single place
  26. // where webpack 5 can statically detect it and create a proper worker bundle.
  27. // Keeping import.meta.url out of .ts files also avoids Node.js 24 switching to
  28. // ESM mode and breaking CJS-based test loading via @babel/register.
  29. const createPyodideWorker = () =>
  30. new Worker(
  31. /* webpackChunkName: "pyodide-worker" */
  32. new URL('../components/editor/python/pyodide.worker.ts', import.meta.url),
  33. { type: 'module' }
  34. )
  35. export interface PythonExecutionContextValue {
  36. getPythonRunner: (fileId: string) => PythonRunner
  37. }
  38. export const PythonExecutionContext = createContext<
  39. PythonExecutionContextValue | undefined
  40. >(undefined)
  41. export const PythonExecutionProvider: FC<PropsWithChildren> = ({
  42. children,
  43. }) => {
  44. const { openDocs } = useEditorManagerContext()
  45. const { projectId, projectSnapshot } = useProjectContext()
  46. const { pathInFolder } = useFileTreePathContext()
  47. const { fileTreeData } = useFileTreeData()
  48. const runnersRef = useRef(new Map<string, PythonRunner>())
  49. const baseAssetPathRef = useRef<string | null>(null)
  50. const pathInFolderRef = useRef(pathInFolder)
  51. pathInFolderRef.current = pathInFolder
  52. // Ref so the upload closure built into each PythonRunner reads the
  53. // current value at call time rather than capturing a potentially-stale
  54. // value from when the runner was constructed (fileTreeData may load
  55. // after the runner is created).
  56. const fileTreeDataRef = useRef(fileTreeData)
  57. fileTreeDataRef.current = fileTreeData
  58. // Refreshes the project snapshot and resolves the source code and all project
  59. // files for the given fileId, to be passed to the executor for running.
  60. const getExecutionContext = useCallback(
  61. async (fileId: string): Promise<ExecutionContext | null> => {
  62. await openDocs.awaitBufferedOps(AbortSignal.timeout(5000))
  63. await projectSnapshot.refresh()
  64. const relativePath = pathInFolderRef.current(fileId)
  65. if (!relativePath) {
  66. return null
  67. }
  68. const code = projectSnapshot.getDocContents(relativePath)
  69. if (code == null) {
  70. return null
  71. }
  72. const docPaths = projectSnapshot.getDocPaths()
  73. const files = docPaths
  74. .map(docPath => {
  75. const content = projectSnapshot.getDocContents(docPath)
  76. return content != null ? { relativePath: docPath, content } : null
  77. })
  78. .filter(
  79. (f): f is { relativePath: string; content: string } => f != null
  80. )
  81. return { code, files }
  82. },
  83. [openDocs, projectSnapshot]
  84. )
  85. const getPythonRunner = useCallback(
  86. (fileId: string): PythonRunner => {
  87. const existing = runnersRef.current.get(fileId)
  88. if (existing) {
  89. return existing
  90. }
  91. if (!baseAssetPathRef.current) {
  92. baseAssetPathRef.current = new URL(
  93. getMeta('ol-baseAssetPath'),
  94. window.location.href
  95. ).toString()
  96. }
  97. const uploadOutputFiles = (items: BatchUploadItem[]) => {
  98. const folderId = fileTreeDataRef.current?._id
  99. if (!folderId) {
  100. return Promise.reject(
  101. new Error('File tree not loaded; cannot upload output files')
  102. )
  103. }
  104. return uploadBatch(items, {
  105. projectId,
  106. folderId,
  107. })
  108. }
  109. const runner = new PythonRunner(
  110. fileId,
  111. baseAssetPathRef.current,
  112. () => getExecutionContext(fileId),
  113. createPyodideWorker,
  114. uploadOutputFiles
  115. )
  116. runner.init()
  117. runnersRef.current.set(fileId, runner)
  118. return runner
  119. },
  120. [getExecutionContext, projectId]
  121. )
  122. useEffect(() => {
  123. const runners = runnersRef.current
  124. return () => {
  125. for (const runner of runners.values()) {
  126. runner.destroy()
  127. }
  128. runners.clear()
  129. }
  130. }, [])
  131. const value = useMemo(() => ({ getPythonRunner }), [getPythonRunner])
  132. return (
  133. <PythonExecutionContext.Provider value={value}>
  134. {children}
  135. </PythonExecutionContext.Provider>
  136. )
  137. }
  138. export const usePythonExecutionContext = (): PythonExecutionContextValue => {
  139. const context = useContext(PythonExecutionContext)
  140. if (!context) {
  141. throw new Error(
  142. 'usePythonExecutionContext is only available inside PythonExecutionContext.Provider'
  143. )
  144. }
  145. return context
  146. }