local-compile-context.js 14 KB

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