metadata-context.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import {
  2. createContext,
  3. useContext,
  4. useEffect,
  5. FC,
  6. useCallback,
  7. useMemo,
  8. useState,
  9. useRef,
  10. } from 'react'
  11. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  12. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  13. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  14. import _ from 'lodash'
  15. import { getJSON, postJSON } from '@/infrastructure/fetch-json'
  16. import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
  17. import { useEditorContext } from '@/shared/context/editor-context'
  18. import { useIdeContext } from '@/shared/context/ide-context'
  19. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  20. import useEventListener from '@/shared/hooks/use-event-listener'
  21. import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
  22. import { Project } from '../../../../../types/project'
  23. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  24. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  25. import { useTranslation } from 'react-i18next'
  26. type DocumentMetadata = {
  27. labels: string[]
  28. packages: Record<string, any>
  29. }
  30. type DocumentsMetadata = Record<string, DocumentMetadata>
  31. type MetadataContextValue = {
  32. metadata: {
  33. state: {
  34. documents: DocumentsMetadata
  35. }
  36. getAllLabels: () => DocumentMetadata['labels']
  37. getAllPackages: () => DocumentMetadata['packages']
  38. }
  39. }
  40. type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
  41. const MetadataContext = createContext<MetadataContextValue | undefined>(
  42. undefined
  43. )
  44. export const MetadataProvider: FC = ({ children }) => {
  45. const { t } = useTranslation()
  46. const ide = useIdeContext()
  47. const { eventEmitter, projectId } = useIdeReactContext()
  48. const { socket } = useConnectionContext()
  49. const { onlineUsersCount } = useOnlineUsersContext()
  50. const { permissionsLevel } = useEditorContext()
  51. const { permissions } = usePermissionsContext()
  52. const { currentDocument } = useEditorManagerContext()
  53. const { showGenericMessageModal } = useModalsContext()
  54. const [documents, setDocuments] = useState<DocumentsMetadata>({})
  55. const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
  56. useEffect(() => {
  57. const handleEntityDeleted = (entity: FileTreeFindResult) => {
  58. if (entity.type === 'doc') {
  59. setDocuments(documents => {
  60. return _.omit(documents, entity.entity._id)
  61. })
  62. }
  63. }
  64. eventEmitter.on('entity:deleted', handleEntityDeleted)
  65. return () => {
  66. eventEmitter.off('entity:deleted', handleEntityDeleted)
  67. }
  68. }, [eventEmitter])
  69. useEffect(() => {
  70. window.dispatchEvent(
  71. new CustomEvent('project:metadata', { detail: documents })
  72. )
  73. }, [documents])
  74. const onBroadcastDocMeta = useCallback((data: DocMetadataResponse) => {
  75. const { docId, meta } = data
  76. if (docId != null && meta != null) {
  77. setDocuments(documents => ({ ...documents, [docId]: meta }))
  78. }
  79. }, [])
  80. const getAllLabels = useCallback(
  81. () => _.flattenDeep(Object.values(documents).map(meta => meta.labels)),
  82. [documents]
  83. )
  84. const getAllPackages = useCallback(() => {
  85. const packageCommandMapping: Record<string, any> = {}
  86. for (const meta of Object.values(documents)) {
  87. for (const [packageName, commandSnippets] of Object.entries(
  88. meta.packages
  89. )) {
  90. packageCommandMapping[packageName] = commandSnippets
  91. }
  92. }
  93. return packageCommandMapping
  94. }, [documents])
  95. const loadProjectMetaFromServer = useCallback(() => {
  96. getJSON(`/project/${projectId}/metadata`).then(
  97. (response: { projectMeta: DocumentsMetadata }) => {
  98. const { projectMeta } = response
  99. if (projectMeta) {
  100. setDocuments(projectMeta)
  101. }
  102. }
  103. )
  104. }, [projectId])
  105. const loadDocMetaFromServer = useCallback(
  106. (docId: string) => {
  107. // Don't broadcast metadata when there are no other users in the
  108. // project.
  109. const broadcast = onlineUsersCount > 0
  110. postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
  111. body: {
  112. broadcast,
  113. },
  114. }).then((response: DocMetadataResponse) => {
  115. if (!broadcast && response) {
  116. // handle the POST response like a broadcast event when there are no
  117. // other users in the project.
  118. onBroadcastDocMeta(response)
  119. }
  120. })
  121. },
  122. [onBroadcastDocMeta, onlineUsersCount, projectId]
  123. )
  124. const scheduleLoadDocMetaFromServer = useCallback(
  125. (docId: string) => {
  126. if (permissionsLevel === 'readOnly') {
  127. // The POST request is blocked for users without write permission.
  128. // The user will not be able to consume the metadata for edits anyway.
  129. return
  130. }
  131. // Debounce loading labels with a timeout
  132. const existingTimeout = debouncerRef.current.get(docId)
  133. if (existingTimeout != null) {
  134. window.clearTimeout(existingTimeout)
  135. debouncerRef.current.delete(docId)
  136. }
  137. debouncerRef.current.set(
  138. docId,
  139. window.setTimeout(() => {
  140. // TODO: wait for the document to be saved?
  141. loadDocMetaFromServer(docId)
  142. debouncerRef.current.delete(docId)
  143. }, 2000)
  144. )
  145. },
  146. [loadDocMetaFromServer, permissionsLevel]
  147. )
  148. const handleBroadcastDocMeta = useCallback(
  149. (data: DocMetadataResponse) => {
  150. onBroadcastDocMeta(data)
  151. },
  152. [onBroadcastDocMeta]
  153. )
  154. useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
  155. const handleMetadataOutdated = useCallback(() => {
  156. if (currentDocument) {
  157. scheduleLoadDocMetaFromServer(currentDocument.doc_id)
  158. }
  159. }, [currentDocument, scheduleLoadDocMetaFromServer])
  160. useEventListener('editor:metadata-outdated', handleMetadataOutdated)
  161. useEffect(() => {
  162. const handleProjectJoined = ({ project }: { project: Project }) => {
  163. if (project.deletedByExternalDataSource) {
  164. showGenericMessageModal(
  165. t('project_renamed_or_deleted'),
  166. t('project_renamed_or_deleted_detail')
  167. )
  168. }
  169. window.setTimeout(() => {
  170. if (permissions.write) {
  171. loadProjectMetaFromServer()
  172. }
  173. }, 200)
  174. }
  175. eventEmitter.once('project:joined', handleProjectJoined)
  176. return () => {
  177. eventEmitter.off('project:joined', handleProjectJoined)
  178. }
  179. }, [
  180. eventEmitter,
  181. loadProjectMetaFromServer,
  182. permissions,
  183. showGenericMessageModal,
  184. t,
  185. ])
  186. const value = useMemo<MetadataContextValue>(
  187. () => ({
  188. metadata: {
  189. state: { documents },
  190. getAllLabels,
  191. getAllPackages,
  192. },
  193. }),
  194. [documents, getAllLabels, getAllPackages]
  195. )
  196. // Expose metadataManager via ide object because useCodeMirrorScope relies on
  197. // it, for now
  198. ide.metadataManager = value
  199. return (
  200. <MetadataContext.Provider value={value}>
  201. {children}
  202. </MetadataContext.Provider>
  203. )
  204. }
  205. export function useMetadataContext(): MetadataContextValue {
  206. const context = useContext(MetadataContext)
  207. if (!context) {
  208. throw new Error(
  209. 'useMetadataContext is only available inside MetadataProvider'
  210. )
  211. }
  212. return context
  213. }