references-context.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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, permissionsLevel, projectJoined } =
  44. useIdeReactContext()
  45. const { socket } = useConnectionContext()
  46. const { projectSnapshot } = useProjectContext()
  47. const { openDocs } = useEditorManagerContext()
  48. const abortControllerRef = useRef<AbortController | null>(null)
  49. const [referenceKeys, setReferenceKeys] = useState(new Set<string>())
  50. const clientSideReferences = useFeatureFlag('client-side-references')
  51. const [existingIndexHash, setExistingIndexHash] = useState<
  52. Record<string, { hash: string; timestamp: number }>
  53. >({})
  54. const indexAllReferencesServerside = useCallback(
  55. async (shouldBroadcast: boolean) => {
  56. return postJSON(`/project/${projectId}/references/indexAll`, {
  57. body: {
  58. shouldBroadcast,
  59. },
  60. })
  61. .then((response: { keys: string[] }) => {
  62. setReferenceKeys(new Set(response.keys))
  63. })
  64. .catch(error => {
  65. // allow the request to fail
  66. debugConsole.error(error)
  67. })
  68. },
  69. [projectId]
  70. )
  71. const indexerRef = useRef<Promise<ReferenceIndexer> | null>(null)
  72. if (clientSideReferences && indexerRef.current === null) {
  73. indexerRef.current = import('../references/reference-indexer').then(
  74. m => new m.ReferenceIndexer()
  75. )
  76. }
  77. const indexAllReferencesLocally = useCallback(
  78. async (shouldBroadcast: boolean) => {
  79. if (permissionsLevel === 'readOnly') {
  80. // Not going to search the references, so let's not index them.
  81. return
  82. }
  83. sendMBOnce('client-side-references-index')
  84. abortControllerRef.current?.abort()
  85. if (!indexerRef.current) {
  86. return
  87. }
  88. abortControllerRef.current = new AbortController()
  89. const signal = abortControllerRef.current.signal
  90. await openDocs.awaitBufferedOps(signalWithTimeout(signal, 5000))
  91. await projectSnapshot.refresh()
  92. if (signal.aborted) {
  93. return
  94. }
  95. const indexer = await indexerRef.current
  96. const keys = await indexer.updateFromSnapshot(projectSnapshot, { signal })
  97. if (signal.aborted) {
  98. return
  99. }
  100. setReferenceKeys(keys)
  101. if (shouldBroadcast) {
  102. // Inform other clients about change in keys
  103. await postJSON(`/project/${projectId}/references/indexAll`, {
  104. body: { shouldBroadcast: true, clientId: clientId.get() },
  105. }).catch(error => {
  106. // allow the request to fail
  107. debugConsole.error(error)
  108. })
  109. }
  110. },
  111. [projectSnapshot, openDocs, projectId, permissionsLevel]
  112. )
  113. const indexAllReferences = clientSideReferences
  114. ? indexAllReferencesLocally
  115. : indexAllReferencesServerside
  116. const indexReferencesIfDocModified = useCallback(
  117. (doc: ShareJsDoc, shouldBroadcast: boolean) => {
  118. // avoid reindexing references if the bib file has not changed since the
  119. // last time they were indexed
  120. const docId = doc.doc_id
  121. const snapshot = doc.getSnapshot()
  122. const now = Date.now()
  123. const sha1 = generateSHA1Hash(
  124. 'blob ' + snapshot.length + '\x00' + snapshot
  125. )
  126. const CACHE_LIFETIME = 6 * 3600 * 1000 // allow reindexing every 6 hours
  127. const cacheEntry = existingIndexHash[docId]
  128. const isCached =
  129. cacheEntry &&
  130. cacheEntry.timestamp > now - CACHE_LIFETIME &&
  131. cacheEntry.hash === sha1
  132. if (!isCached) {
  133. indexAllReferences(shouldBroadcast)
  134. setExistingIndexHash(existingIndexHash => ({
  135. ...existingIndexHash,
  136. [docId]: { hash: sha1, timestamp: now },
  137. }))
  138. }
  139. },
  140. [existingIndexHash, indexAllReferences]
  141. )
  142. useEffect(() => {
  143. const handleDocClosed = ({
  144. detail: [doc],
  145. }: CustomEvent<IdeEvents['document:closed']>) => {
  146. if (
  147. doc.doc_id &&
  148. findDocEntityById(fileTreeData, doc.doc_id)?.name?.endsWith('.bib')
  149. ) {
  150. indexReferencesIfDocModified(doc, true)
  151. }
  152. }
  153. eventEmitter.on('document:closed', handleDocClosed)
  154. return () => {
  155. eventEmitter.off('document:closed', handleDocClosed)
  156. }
  157. }, [eventEmitter, fileTreeData, indexReferencesIfDocModified])
  158. useEventListener(
  159. 'reference:added',
  160. useCallback(() => {
  161. indexAllReferences(true)
  162. }, [indexAllReferences])
  163. )
  164. const doneInitialIndex = useRef(false)
  165. useEffect(() => {
  166. // We wait for projectJoined to ensure that the correct permission level
  167. // has been received and stored on the client.
  168. if (projectJoined && !doneInitialIndex.current) {
  169. doneInitialIndex.current = true
  170. indexAllReferences(false)
  171. }
  172. if (projectJoined && socket) {
  173. const processUpdatedReferenceKeys = (
  174. keys: string[],
  175. allDocs: boolean,
  176. refresherId: string
  177. ) => {
  178. if (clientSideReferences) {
  179. if (refresherId === clientId.get()) {
  180. // We asked for this broadcast, so we must have already done the indexing
  181. return
  182. }
  183. indexAllReferences(false)
  184. } else {
  185. setReferenceKeys(oldDocs =>
  186. allDocs ? new Set(keys) : new Set([...oldDocs, ...keys])
  187. )
  188. }
  189. }
  190. socket.on('references:keys:updated', processUpdatedReferenceKeys)
  191. return () => {
  192. socket.removeListener(
  193. 'references:keys:updated',
  194. processUpdatedReferenceKeys
  195. )
  196. }
  197. }
  198. }, [projectJoined, indexAllReferences, socket, clientSideReferences])
  199. const searchLocalReferences = useCallback(
  200. async (query: string): Promise<AdvancedReferenceSearchResult> => {
  201. if (!indexerRef.current) {
  202. return { hits: [] }
  203. }
  204. const indexer = await indexerRef.current
  205. return await indexer.search(query)
  206. },
  207. []
  208. )
  209. const value = useMemo(
  210. () => ({
  211. referenceKeys,
  212. indexAllReferences,
  213. searchLocalReferences,
  214. }),
  215. [indexAllReferences, referenceKeys, searchLocalReferences]
  216. )
  217. return (
  218. <ReferencesContext.Provider value={value}>
  219. {children}
  220. </ReferencesContext.Provider>
  221. )
  222. }
  223. export function useReferencesContext() {
  224. const context = useContext(ReferencesContext)
  225. if (!context) {
  226. throw new Error(
  227. 'useReferencesContext is only available inside ReferencesProvider'
  228. )
  229. }
  230. return context
  231. }