local-compile-context.js 19 KB

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