local-compile-context.js 19 KB

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