references-context.tsx 7.3 KB

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