local-compile-context.tsx 20 KB

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