local-compile-context.jsx 20 KB

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