local-compile-context.jsx 19 KB

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