references-context.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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 { useFeatureFlag } from '@/shared/context/split-test-context'
  25. import type { ReferenceIndexer } from '../references/reference-indexer'
  26. import { AdvancedReferenceSearchResult } from '@/features/ide-react/references/types'
  27. import clientId from '@/utils/client-id'
  28. import { sendMBOnce } from '@/infrastructure/event-tracking'
  29. export const ReferencesContext = createContext<
  30. | {
  31. referenceKeys: Set<string>
  32. indexAllReferences: (shouldBroadcast: boolean) => Promise<void>
  33. searchLocalReferences: (
  34. query: string
  35. ) => Promise<AdvancedReferenceSearchResult>
  36. }
  37. | undefined
  38. >(undefined)
  39. export const ReferencesProvider: FC<React.PropsWithChildren> = ({
  40. children,
  41. }) => {
  42. const { fileTreeData } = useFileTreeData()
  43. const { eventEmitter, projectId } = 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 clientSideReferences = useFeatureFlag('client-side-references')
  50. const [existingIndexHash, setExistingIndexHash] = useState<
  51. Record<string, { hash: string; timestamp: number }>
  52. >({})
  53. const indexAllReferencesServerside = useCallback(
  54. async (shouldBroadcast: boolean) => {
  55. return postJSON(`/project/${projectId}/references/indexAll`, {
  56. body: {
  57. shouldBroadcast,
  58. },
  59. })
  60. .then((response: { keys: string[] }) => {
  61. setReferenceKeys(new Set(response.keys))
  62. })
  63. .catch(error => {
  64. // allow the request to fail
  65. debugConsole.error(error)
  66. })
  67. },
  68. [projectId]
  69. )
  70. const indexerRef = useRef<Promise<ReferenceIndexer> | null>(null)
  71. if (clientSideReferences && indexerRef.current === null) {
  72. indexerRef.current = import('../references/reference-indexer').then(
  73. m => new m.ReferenceIndexer()
  74. )
  75. }
  76. const indexAllReferencesLocally = useCallback(
  77. async (shouldBroadcast: boolean) => {
  78. sendMBOnce('client-side-references-index')
  79. abortControllerRef.current?.abort()
  80. if (!indexerRef.current) {
  81. return
  82. }
  83. abortControllerRef.current = new AbortController()
  84. const signal = abortControllerRef.current.signal
  85. await openDocs.awaitBufferedOps(signalWithTimeout(signal, 5000))
  86. await projectSnapshot.refresh()
  87. if (signal.aborted) {
  88. return
  89. }
  90. const indexer = await indexerRef.current
  91. const keys = await indexer.updateFromSnapshot(projectSnapshot, { signal })
  92. if (signal.aborted) {
  93. return
  94. }
  95. setReferenceKeys(keys)
  96. if (shouldBroadcast) {
  97. // Inform other clients about change in keys
  98. await postJSON(`/project/${projectId}/references/indexAll`, {
  99. body: { shouldBroadcast: true, clientId: clientId.get() },
  100. }).catch(error => {
  101. // allow the request to fail
  102. debugConsole.error(error)
  103. })
  104. }
  105. },
  106. [projectSnapshot, openDocs, projectId]
  107. )
  108. const indexAllReferences = clientSideReferences
  109. ? indexAllReferencesLocally
  110. : indexAllReferencesServerside
  111. const indexReferencesIfDocModified = useCallback(
  112. (doc: ShareJsDoc, shouldBroadcast: boolean) => {
  113. // avoid reindexing references if the bib file has not changed since the
  114. // last time they were indexed
  115. const docId = doc.doc_id
  116. const snapshot = doc.getSnapshot()
  117. const now = Date.now()
  118. const sha1 = generateSHA1Hash(
  119. 'blob ' + snapshot.length + '\x00' + snapshot
  120. )
  121. const CACHE_LIFETIME = 6 * 3600 * 1000 // allow reindexing every 6 hours
  122. const cacheEntry = existingIndexHash[docId]
  123. const isCached =
  124. cacheEntry &&
  125. cacheEntry.timestamp > now - CACHE_LIFETIME &&
  126. cacheEntry.hash === sha1
  127. if (!isCached) {
  128. indexAllReferences(shouldBroadcast)
  129. setExistingIndexHash(existingIndexHash => ({
  130. ...existingIndexHash,
  131. [docId]: { hash: sha1, timestamp: now },
  132. }))
  133. }
  134. },
  135. [existingIndexHash, indexAllReferences]
  136. )
  137. useEffect(() => {
  138. const handleDocClosed = ({
  139. detail: [doc],
  140. }: CustomEvent<IdeEvents['document:closed']>) => {
  141. if (
  142. doc.doc_id &&
  143. findDocEntityById(fileTreeData, doc.doc_id)?.name?.endsWith('.bib')
  144. ) {
  145. indexReferencesIfDocModified(doc, true)
  146. }
  147. }
  148. eventEmitter.on('document:closed', handleDocClosed)
  149. return () => {
  150. eventEmitter.off('document:closed', handleDocClosed)
  151. }
  152. }, [eventEmitter, fileTreeData, indexReferencesIfDocModified])
  153. useEventListener(
  154. 'reference:added',
  155. useCallback(() => {
  156. indexAllReferences(true)
  157. }, [indexAllReferences])
  158. )
  159. useEffect(() => {
  160. const handleProjectJoined = () => {
  161. // We only need to grab the references when the editor first loads,
  162. // not on every reconnect
  163. socket.on('references:keys:updated', (keys, allDocs, refresherId) => {
  164. if (clientSideReferences) {
  165. if (refresherId === clientId.get()) {
  166. // We asked for this broadcast, so we must have already done the indexing
  167. return
  168. }
  169. indexAllReferences(false)
  170. } else {
  171. setReferenceKeys(oldDocs =>
  172. allDocs ? new Set(keys) : new Set([...oldDocs, ...keys])
  173. )
  174. }
  175. })
  176. indexAllReferences(false)
  177. }
  178. eventEmitter.once('project:joined', handleProjectJoined)
  179. return () => {
  180. eventEmitter.off('project:joined', handleProjectJoined)
  181. }
  182. }, [eventEmitter, indexAllReferences, socket, clientSideReferences])
  183. const searchLocalReferences = useCallback(
  184. async (query: string): Promise<AdvancedReferenceSearchResult> => {
  185. if (!indexerRef.current) {
  186. return { hits: [] }
  187. }
  188. const indexer = await indexerRef.current
  189. return await indexer.search(query)
  190. },
  191. []
  192. )
  193. const value = useMemo(
  194. () => ({
  195. referenceKeys,
  196. indexAllReferences,
  197. searchLocalReferences,
  198. }),
  199. [indexAllReferences, referenceKeys, searchLocalReferences]
  200. )
  201. return (
  202. <ReferencesContext.Provider value={value}>
  203. {children}
  204. </ReferencesContext.Provider>
  205. )
  206. }
  207. export function useReferencesContext() {
  208. const context = useContext(ReferencesContext)
  209. if (!context) {
  210. throw new Error(
  211. 'useReferencesContext is only available inside ReferencesProvider'
  212. )
  213. }
  214. return context
  215. }