local-compile-context.js 18 KB

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