local-compile-context.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react'
  10. import PropTypes from 'prop-types'
  11. import useScopeValue from '../hooks/use-scope-value'
  12. import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
  13. import usePersistedState from '../hooks/use-persisted-state'
  14. import useAbortController from '../hooks/use-abort-controller'
  15. import DocumentCompiler from '../../features/pdf-preview/util/compiler'
  16. import {
  17. send,
  18. sendMBOnce,
  19. sendMBSampled,
  20. } from '../../infrastructure/event-tracking'
  21. import {
  22. buildLogEntryAnnotations,
  23. handleLogFiles,
  24. handleOutputFiles,
  25. } from '../../features/pdf-preview/util/output-files'
  26. import { useIdeContext } from './ide-context'
  27. import { useProjectContext } from './project-context'
  28. import { useEditorContext } from './editor-context'
  29. import { buildFileList } from '../../features/pdf-preview/util/file-list'
  30. export const LocalCompileContext = createContext()
  31. export const CompileContextPropTypes = {
  32. value: PropTypes.shape({
  33. autoCompile: PropTypes.bool.isRequired,
  34. clearingCache: PropTypes.bool.isRequired,
  35. clsiServerId: PropTypes.string,
  36. codeCheckFailed: PropTypes.bool.isRequired,
  37. compiling: PropTypes.bool.isRequired,
  38. draft: PropTypes.bool.isRequired,
  39. error: PropTypes.string,
  40. fileList: PropTypes.object,
  41. hasChanges: PropTypes.bool.isRequired,
  42. highlights: PropTypes.arrayOf(PropTypes.object),
  43. logEntries: PropTypes.object,
  44. logEntryAnnotations: PropTypes.object,
  45. pdfDownloadUrl: PropTypes.string,
  46. pdfUrl: PropTypes.string,
  47. pdfViewer: PropTypes.string,
  48. position: PropTypes.object,
  49. rawLog: PropTypes.string,
  50. setAutoCompile: PropTypes.func.isRequired,
  51. setDraft: PropTypes.func.isRequired,
  52. setError: PropTypes.func.isRequired,
  53. setHasLintingError: PropTypes.func.isRequired, // only for storybook
  54. setHighlights: PropTypes.func.isRequired,
  55. setPosition: PropTypes.func.isRequired,
  56. setShowLogs: PropTypes.func.isRequired,
  57. toggleLogs: PropTypes.func.isRequired,
  58. setStopOnFirstError: PropTypes.func.isRequired,
  59. setStopOnValidationError: PropTypes.func.isRequired,
  60. showLogs: PropTypes.bool.isRequired,
  61. stopOnFirstError: PropTypes.bool.isRequired,
  62. stopOnValidationError: PropTypes.bool.isRequired,
  63. stoppedOnFirstError: PropTypes.bool.isRequired,
  64. uncompiled: PropTypes.bool,
  65. validationIssues: PropTypes.object,
  66. firstRenderDone: PropTypes.func,
  67. cleanupCompileResult: PropTypes.func,
  68. }),
  69. }
  70. LocalCompileContext.Provider.propTypes = CompileContextPropTypes
  71. export function LocalCompileProvider({ children }) {
  72. const ide = useIdeContext()
  73. const { hasPremiumCompile, isProjectOwner } = useEditorContext()
  74. const { _id: projectId, rootDocId } = useProjectContext()
  75. // whether a compile is in progress
  76. const [compiling, setCompiling] = useState(false)
  77. // the log entries parsed from the compile output log
  78. const [logEntries, setLogEntries] = useScopeValueSetterOnly('pdf.logEntries')
  79. // annotations for display in the editor, built from the log entries
  80. const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
  81. 'pdf.logEntryAnnotations'
  82. )
  83. // the PDF viewer
  84. const [pdfViewer] = useScopeValue('settings.pdfViewer')
  85. // the URL for downloading the PDF
  86. const [pdfDownloadUrl, setPdfDownloadUrl] =
  87. useScopeValueSetterOnly('pdf.downloadUrl')
  88. // the URL for loading the PDF in the preview pane
  89. const [pdfUrl, setPdfUrl] = useScopeValueSetterOnly('pdf.url')
  90. // the project is considered to be "uncompiled" if a doc has changed since the last compile started
  91. const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
  92. // the id of the CLSI server which ran the compile
  93. const [clsiServerId, setClsiServerId] = useState()
  94. // data received in response to a compile request
  95. const [data, setData] = useState()
  96. // callback to be invoked for PdfJsMetrics
  97. const [firstRenderDone, setFirstRenderDone] = useState()
  98. // whether the project has been compiled yet
  99. const [compiledOnce, setCompiledOnce] = useState(false)
  100. // whether the cache is being cleared
  101. const [clearingCache, setClearingCache] = useState(false)
  102. // whether the logs should be visible
  103. const [showLogs, setShowLogs] = useState(false)
  104. // whether the compile dropdown arrow should be animated
  105. const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
  106. useState(false)
  107. const toggleLogs = useCallback(() => {
  108. setShowLogs(prev => {
  109. if (!prev) {
  110. sendMBOnce('ide-open-logs-once')
  111. }
  112. return !prev
  113. })
  114. }, [setShowLogs])
  115. // an error that occurred
  116. const [error, setError] = useState()
  117. // the list of files that can be downloaded
  118. const [fileList, setFileList] = useState()
  119. // the raw contents of the log file
  120. const [rawLog, setRawLog] = useState()
  121. // validation issues from CLSI
  122. const [validationIssues, setValidationIssues] = useState()
  123. // areas to highlight on the PDF, from synctex
  124. const [highlights, setHighlights] = useState()
  125. // scroll position of the PDF
  126. const [position, setPosition] = usePersistedState(`pdf.position.${projectId}`)
  127. // whether autocompile is switched on
  128. const [autoCompile, setAutoCompile] = usePersistedState(
  129. `autocompile_enabled:${projectId}`,
  130. false,
  131. true
  132. )
  133. // whether the compile should run in draft mode
  134. const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
  135. // whether compiling should stop on first error
  136. const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
  137. `stop_on_first_error:${projectId}`,
  138. false,
  139. true
  140. )
  141. // whether the last compiles stopped on first error
  142. const [stoppedOnFirstError, setStoppedOnFirstError] = useState(false)
  143. // whether compiling should be prevented if there are linting errors
  144. const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
  145. `stop_on_validation_error:${projectId}`,
  146. true,
  147. true
  148. )
  149. // the Document currently open in the editor
  150. const [currentDoc] = useScopeValue('editor.sharejs_doc')
  151. // whether the editor linter found errors
  152. const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
  153. // whether syntax validation is enabled globally
  154. const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
  155. // the timestamp that a doc was last changed or saved
  156. const [changedAt, setChangedAt] = useState(0)
  157. const { signal } = useAbortController()
  158. const cleanupCompileResult = useCallback(() => {
  159. setPdfUrl(null)
  160. setPdfDownloadUrl(null)
  161. setLogEntries(null)
  162. setLogEntryAnnotations({})
  163. }, [setPdfUrl, setPdfDownloadUrl, setLogEntries, setLogEntryAnnotations])
  164. const compilingRef = useRef(false)
  165. useEffect(() => {
  166. compilingRef.current = compiling
  167. }, [compiling])
  168. // the document compiler
  169. const [compiler] = useState(() => {
  170. return new DocumentCompiler({
  171. projectId,
  172. rootDocId,
  173. setChangedAt,
  174. setCompiling,
  175. setData,
  176. setFirstRenderDone,
  177. setError,
  178. cleanupCompileResult,
  179. compilingRef,
  180. signal,
  181. })
  182. })
  183. // keep currentDoc in sync with the compiler
  184. useEffect(() => {
  185. compiler.currentDoc = currentDoc
  186. }, [compiler, currentDoc])
  187. // keep draft setting in sync with the compiler
  188. useEffect(() => {
  189. compiler.setOption('draft', draft)
  190. }, [compiler, draft])
  191. // keep stop on first error setting in sync with the compiler
  192. useEffect(() => {
  193. compiler.setOption('stopOnFirstError', stopOnFirstError)
  194. }, [compiler, stopOnFirstError])
  195. // pass the "uncompiled" value up into the scope for use outside this context provider
  196. useEffect(() => {
  197. setUncompiled(changedAt > 0)
  198. }, [setUncompiled, changedAt])
  199. // always compile the PDF once after opening the project, after the doc has loaded
  200. useEffect(() => {
  201. if (!compiledOnce && currentDoc) {
  202. setCompiledOnce(true)
  203. compiler.compile({ isAutoCompileOnLoad: true })
  204. }
  205. }, [compiledOnce, currentDoc, compiler])
  206. // handle the data returned from a compile request
  207. // note: this should _only_ run when `data` changes,
  208. // the other dependencies must all be static
  209. useEffect(() => {
  210. const abortController = new AbortController()
  211. if (data) {
  212. if (data.clsiServerId) {
  213. setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
  214. }
  215. if (data.outputFiles) {
  216. const outputFiles = new Map()
  217. for (const outputFile of data.outputFiles) {
  218. outputFiles.set(outputFile.path, outputFile)
  219. }
  220. // set the PDF URLs
  221. const result = handleOutputFiles(outputFiles, projectId, data)
  222. if (data.status === 'success') {
  223. setPdfDownloadUrl(result.pdfDownloadUrl)
  224. setPdfUrl(result.pdfUrl)
  225. }
  226. setFileList(
  227. buildFileList(outputFiles, data.clsiServerId, data.compileGroup)
  228. )
  229. // handle log files
  230. // asynchronous (TODO: cancel on new compile?)
  231. setLogEntryAnnotations(null)
  232. setLogEntries(null)
  233. setRawLog(null)
  234. handleLogFiles(outputFiles, data, abortController.signal).then(
  235. result => {
  236. setRawLog(result.log)
  237. setLogEntries(result.logEntries)
  238. setLogEntryAnnotations(
  239. buildLogEntryAnnotations(
  240. result.logEntries.all,
  241. ide.fileTreeManager
  242. )
  243. )
  244. // sample compile stats for real users
  245. if (
  246. !window.user.alphaProgram &&
  247. ['success', 'stopped-on-first-error'].includes(data.status)
  248. ) {
  249. sendMBSampled(
  250. 'compile-result',
  251. {
  252. errors: result.logEntries.errors.length,
  253. warnings: result.logEntries.warnings.length,
  254. typesetting: result.logEntries.typesetting.length,
  255. newPdfPreview: true, // TODO: is this useful?
  256. stopOnFirstError: data.options.stopOnFirstError,
  257. },
  258. 0.01
  259. )
  260. }
  261. }
  262. )
  263. }
  264. switch (data.status) {
  265. case 'success':
  266. setError(undefined)
  267. setShowLogs(false)
  268. break
  269. case 'stopped-on-first-error':
  270. setError(undefined)
  271. setShowLogs(true)
  272. break
  273. case 'clsi-maintenance':
  274. case 'compile-in-progress':
  275. case 'exited':
  276. case 'failure':
  277. case 'project-too-large':
  278. case 'rate-limited':
  279. case 'terminated':
  280. case 'too-recently-compiled':
  281. setError(data.status)
  282. break
  283. case 'timedout':
  284. setError('timedout')
  285. if (!hasPremiumCompile && isProjectOwner) {
  286. send(
  287. 'subscription-funnel',
  288. 'editor-click-feature',
  289. 'compile-timeout'
  290. )
  291. }
  292. break
  293. case 'autocompile-backoff':
  294. if (!data.options.isAutoCompileOnLoad) {
  295. setError('autocompile-disabled')
  296. setAutoCompile(false)
  297. }
  298. break
  299. case 'unavailable':
  300. setError('clsi-unavailable')
  301. break
  302. case 'validation-problems':
  303. setError('validation-problems')
  304. setValidationIssues(data.validationProblems)
  305. break
  306. default:
  307. setError('error')
  308. break
  309. }
  310. setStoppedOnFirstError(data.status === 'stopped-on-first-error')
  311. }
  312. return () => {
  313. abortController.abort()
  314. }
  315. }, [
  316. data,
  317. ide,
  318. hasPremiumCompile,
  319. isProjectOwner,
  320. projectId,
  321. setAutoCompile,
  322. setClsiServerId,
  323. setLogEntries,
  324. setLogEntryAnnotations,
  325. setPdfDownloadUrl,
  326. setPdfUrl,
  327. ])
  328. // switch to logs if there's an error
  329. useEffect(() => {
  330. if (error) {
  331. setShowLogs(true)
  332. }
  333. }, [error])
  334. // whether there has been an autocompile linting error, if syntax validation is switched on
  335. const autoCompileLintingError = Boolean(
  336. autoCompile && syntaxValidation && hasLintingError
  337. )
  338. const codeCheckFailed = stopOnValidationError && autoCompileLintingError
  339. // the project is available for auto-compiling
  340. const canAutoCompile = Boolean(autoCompile && !codeCheckFailed)
  341. // show that the project has pending changes
  342. const hasChanges = Boolean(canAutoCompile && uncompiled && compiledOnce)
  343. // call the debounced autocompile function if the project is available for auto-compiling and it has changed
  344. useEffect(() => {
  345. if (canAutoCompile) {
  346. if (changedAt > 0) {
  347. compiler.debouncedAutoCompile()
  348. }
  349. } else {
  350. compiler.debouncedAutoCompile.cancel()
  351. }
  352. }, [compiler, canAutoCompile, changedAt])
  353. // cancel debounced recompile on unmount
  354. useEffect(() => {
  355. return () => {
  356. compiler.debouncedAutoCompile.cancel()
  357. }
  358. }, [compiler])
  359. // start a compile manually
  360. const startCompile = useCallback(
  361. options => {
  362. compiler.compile(options)
  363. },
  364. [compiler]
  365. )
  366. // stop a compile manually
  367. const stopCompile = useCallback(() => {
  368. compiler.stopCompile()
  369. }, [compiler])
  370. // clear the compile cache
  371. const clearCache = useCallback(() => {
  372. setClearingCache(true)
  373. return compiler
  374. .clearCache()
  375. .then(() => {
  376. setFileList(undefined)
  377. setPdfDownloadUrl(undefined)
  378. setPdfUrl(undefined)
  379. })
  380. .finally(() => {
  381. setClearingCache(false)
  382. })
  383. }, [compiler, setPdfDownloadUrl, setPdfUrl])
  384. // clear the cache then run a compile, triggered by a menu item
  385. const recompileFromScratch = useCallback(() => {
  386. clearCache().then(() => {
  387. compiler.compile()
  388. })
  389. }, [clearCache, compiler])
  390. // After a compile, the compiler sets `data.options` to the options that were
  391. // used for that compile.
  392. const lastCompileOptions = useMemo(() => data?.options || {}, [data])
  393. const value = useMemo(
  394. () => ({
  395. animateCompileDropdownArrow,
  396. autoCompile,
  397. clearCache,
  398. clearingCache,
  399. clsiServerId,
  400. codeCheckFailed,
  401. compiling,
  402. draft,
  403. error,
  404. fileList,
  405. hasChanges,
  406. highlights,
  407. lastCompileOptions,
  408. logEntryAnnotations,
  409. logEntries,
  410. pdfDownloadUrl,
  411. pdfUrl,
  412. pdfViewer,
  413. position,
  414. rawLog,
  415. recompileFromScratch,
  416. setAnimateCompileDropdownArrow,
  417. setAutoCompile,
  418. setCompiling,
  419. setDraft,
  420. setError,
  421. setHasLintingError, // only for stories
  422. setHighlights,
  423. setPosition,
  424. setShowLogs,
  425. toggleLogs,
  426. setStopOnFirstError,
  427. setStopOnValidationError,
  428. showLogs,
  429. startCompile,
  430. stopCompile,
  431. stopOnFirstError,
  432. stopOnValidationError,
  433. stoppedOnFirstError,
  434. uncompiled,
  435. validationIssues,
  436. firstRenderDone,
  437. setChangedAt,
  438. cleanupCompileResult,
  439. }),
  440. [
  441. animateCompileDropdownArrow,
  442. autoCompile,
  443. clearCache,
  444. clearingCache,
  445. clsiServerId,
  446. codeCheckFailed,
  447. compiling,
  448. draft,
  449. error,
  450. fileList,
  451. hasChanges,
  452. highlights,
  453. lastCompileOptions,
  454. logEntries,
  455. logEntryAnnotations,
  456. position,
  457. pdfDownloadUrl,
  458. pdfUrl,
  459. pdfViewer,
  460. rawLog,
  461. recompileFromScratch,
  462. setAnimateCompileDropdownArrow,
  463. setAutoCompile,
  464. setDraft,
  465. setError,
  466. setHasLintingError, // only for stories
  467. setHighlights,
  468. setPosition,
  469. setStopOnFirstError,
  470. setStopOnValidationError,
  471. showLogs,
  472. startCompile,
  473. stopCompile,
  474. stopOnFirstError,
  475. stopOnValidationError,
  476. stoppedOnFirstError,
  477. uncompiled,
  478. validationIssues,
  479. firstRenderDone,
  480. setChangedAt,
  481. cleanupCompileResult,
  482. setShowLogs,
  483. toggleLogs,
  484. ]
  485. )
  486. return (
  487. <LocalCompileContext.Provider value={value}>
  488. {children}
  489. </LocalCompileContext.Provider>
  490. )
  491. }
  492. LocalCompileProvider.propTypes = {
  493. children: PropTypes.any,
  494. }
  495. export function useLocalCompileContext(propTypes) {
  496. const data = useContext(LocalCompileContext)
  497. PropTypes.checkPropTypes(
  498. propTypes,
  499. data,
  500. 'data',
  501. 'LocalCompileContext.Provider'
  502. )
  503. return data
  504. }