references-context.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { generateSHA1Hash } from '../../../shared/utils/sha1'
  2. import {
  3. createContext,
  4. useContext,
  5. useEffect,
  6. FC,
  7. useCallback,
  8. useMemo,
  9. useState,
  10. useRef,
  11. } from 'react'
  12. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  13. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  14. import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
  15. import { useFileTreeData } from '@/shared/context/file-tree-data-context'
  16. import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
  17. import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
  18. import useEventListener from '@/shared/hooks/use-event-listener'
  19. import { useProjectContext } from '@/shared/context/project-context'
  20. import { useEditorManagerContext } from './editor-manager-context'
  21. import { signalWithTimeout } from '@/utils/abort-signal'
  22. import { postJSON } from '@/infrastructure/fetch-json'
  23. import { debugConsole } from '@/utils/debugging'
  24. import type { ReferenceIndexer } from '../references/reference-indexer'
  25. import { AdvancedReferenceSearchResult } from '@/features/ide-react/references/types'
  26. import clientId from '@/utils/client-id'
  27. import { sendMBOnce } from '@/infrastructure/event-tracking'
  28. export const ReferencesContext = createContext<
  29. | {
  30. referenceKeys: Set<string>
  31. indexAllReferences: (shouldBroadcast: boolean) => Promise<void>
  32. searchLocalReferences: (
  33. query: string
  34. ) => Promise<AdvancedReferenceSearchResult>
  35. }
  36. | undefined
  37. >(undefined)
  38. export const ReferencesProvider: FC<React.PropsWithChildren> = ({
  39. children,
  40. }) => {
  41. const { fileTreeData } = useFileTreeData()
  42. const { eventEmitter, projectId, permissionsLevel, projectJoined } =
  43. useIdeReactContext()
  44. const { socket } = useConnectionContext()
  45. const { projectSnapshot } = useProjectContext()
  46. const { openDocs } = useEditorManagerContext()
  47. const abortControllerRef = useRef<AbortController | null>(null)
  48. const [referenceKeys, setReferenceKeys] = useState(new Set<string>())
  49. const [existingIndexHash, setExistingIndexHash] = useState<
  50. Record<string, { hash: string; timestamp: number }>
  51. >({})
  52. const indexerRef = useRef<Promise<ReferenceIndexer> | null>(null)
  53. if (indexerRef.current === null) {
  54. indexerRef.current = import('../references/reference-indexer').then(
  55. m => new m.ReferenceIndexer()
  56. )
  57. }
  58. const indexAllReferences = useCallback(
  59. async (shouldBroadcast: boolean) => {
  60. if (permissionsLevel === 'readOnly') {
  61. // Not going to search the references, so let's not index them.
  62. return
  63. }
  64. sendMBOnce('client-side-references-index')
  65. abortControllerRef.current?.abort()
  66. if (!indexerRef.current) {
  67. return
  68. }
  69. abortControllerRef.current = new AbortController()
  70. const signal = abortControllerRef.current.signal
  71. await openDocs.awaitBufferedOps(signalWithTimeout(signal, 5000))
  72. await projectSnapshot.refresh()
  73. if (signal.aborted) {
  74. return
  75. }
  76. const indexer = await indexerRef.current
  77. const keys = await indexer.updateFromSnapshot(projectSnapshot, { signal })
  78. if (signal.aborted) {
  79. return
  80. }
  81. setReferenceKeys(keys)
  82. if (shouldBroadcast) {
  83. // Inform other clients about change in keys
  84. await postJSON(`/project/${projectId}/references/indexAll`, {
  85. body: { shouldBroadcast: true, clientId: clientId.get() },
  86. }).catch(error => {
  87. // allow the request to fail
  88. debugConsole.error(error)
  89. })
  90. }
  91. },
  92. [projectSnapshot, openDocs, projectId, permissionsLevel]
  93. )
  94. const indexReferencesIfDocModified = useCallback(
  95. (doc: ShareJsDoc, shouldBroadcast: boolean) => {
  96. // avoid reindexing references if the bib file has not changed since the
  97. // last time they were indexed
  98. const docId = doc.doc_id
  99. const snapshot = doc.getSnapshot()
  100. const now = Date.now()
  101. const sha1 = generateSHA1Hash(
  102. 'blob ' + snapshot.length + '\x00' + snapshot
  103. )
  104. const CACHE_LIFETIME = 6 * 3600 * 1000 // allow reindexing every 6 hours
  105. const cacheEntry = existingIndexHash[docId]
  106. const isCached =
  107. cacheEntry &&
  108. cacheEntry.timestamp > now - CACHE_LIFETIME &&
  109. cacheEntry.hash === sha1
  110. if (!isCached) {
  111. indexAllReferences(shouldBroadcast)
  112. setExistingIndexHash(existingIndexHash => ({
  113. ...existingIndexHash,
  114. [docId]: { hash: sha1, timestamp: now },
  115. }))
  116. }
  117. },
  118. [existingIndexHash, indexAllReferences]
  119. )
  120. useEffect(() => {
  121. const handleDocClosed = ({
  122. detail: [doc],
  123. }: CustomEvent<IdeEvents['document:closed']>) => {
  124. if (
  125. doc.doc_id &&
  126. findDocEntityById(fileTreeData, doc.doc_id)?.name?.endsWith('.bib')
  127. ) {
  128. indexReferencesIfDocModified(doc, true)
  129. }
  130. }
  131. eventEmitter.on('document:closed', handleDocClosed)
  132. return () => {
  133. eventEmitter.off('document:closed', handleDocClosed)
  134. }
  135. }, [eventEmitter, fileTreeData, indexReferencesIfDocModified])
  136. useEventListener(
  137. 'reference:added',
  138. useCallback(() => {
  139. indexAllReferences(true)
  140. }, [indexAllReferences])
  141. )
  142. const doneInitialIndex = useRef(false)
  143. useEffect(() => {
  144. // We wait for projectJoined to ensure that the correct permission level
  145. // has been received and stored on the client.
  146. if (projectJoined && !doneInitialIndex.current) {
  147. doneInitialIndex.current = true
  148. indexAllReferences(false)
  149. }
  150. if (projectJoined && socket) {
  151. const processUpdatedReferenceKeys = (
  152. keys: string[],
  153. allDocs: boolean,
  154. refresherId: string
  155. ) => {
  156. if (refresherId === clientId.get()) {
  157. // We asked for this broadcast, so we must have already done the indexing
  158. return
  159. }
  160. indexAllReferences(false)
  161. }
  162. socket.on('references:keys:updated', processUpdatedReferenceKeys)
  163. return () => {
  164. socket.removeListener(
  165. 'references:keys:updated',
  166. processUpdatedReferenceKeys
  167. )
  168. }
  169. }
  170. }, [projectJoined, indexAllReferences, socket])
  171. const searchLocalReferences = useCallback(
  172. async (query: string): Promise<AdvancedReferenceSearchResult> => {
  173. if (!indexerRef.current) {
  174. return { hits: [] }
  175. }
  176. const indexer = await indexerRef.current
  177. return await indexer.search(query, 'searchLocalReferences')
  178. },
  179. []
  180. )
  181. const value = useMemo(
  182. () => ({
  183. referenceKeys,
  184. indexAllReferences,
  185. searchLocalReferences,
  186. }),
  187. [indexAllReferences, referenceKeys, searchLocalReferences]
  188. )
  189. return (
  190. <ReferencesContext.Provider value={value}>
  191. {children}
  192. </ReferencesContext.Provider>
  193. )
  194. }
  195. export function useReferencesContext() {
  196. const context = useContext(ReferencesContext)
  197. if (!context) {
  198. throw new Error(
  199. 'useReferencesContext is only available inside ReferencesProvider'
  200. )
  201. }
  202. return context
  203. }