local-compile-context.js 15 KB

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