editor-manager-context.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import {
  2. createContext,
  3. FC,
  4. useCallback,
  5. useContext,
  6. useEffect,
  7. useMemo,
  8. useRef,
  9. useState,
  10. } from 'react'
  11. import { sendMB } from '@/infrastructure/event-tracking'
  12. import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
  13. import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
  14. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  15. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  16. import { debugConsole } from '@/utils/debugging'
  17. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  18. import { useLayoutContext } from '@/shared/context/layout-context'
  19. import { useUserContext } from '@/shared/context/user-context'
  20. import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
  21. import { Doc } from '../../../../../types/doc'
  22. import { useFileTreeData } from '@/shared/context/file-tree-data-context'
  23. import {
  24. findDocEntityById,
  25. findFileRefEntityById,
  26. } from '@/features/ide-react/util/find-doc-entity-by-id'
  27. import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
  28. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  29. import { useTranslation } from 'react-i18next'
  30. import customLocalStorage from '@/infrastructure/local-storage'
  31. import useEventListener from '@/shared/hooks/use-event-listener'
  32. import { EditorType } from '@/features/ide-react/editor/types/editor-type'
  33. import { DocId } from '../../../../../types/project-settings'
  34. import { Update } from '@/features/history/services/types/update'
  35. import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker'
  36. import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
  37. import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
  38. import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
  39. export interface GotoOffsetOptions {
  40. gotoOffset: number
  41. }
  42. interface OpenDocOptions
  43. extends Partial<GotoLineOptions>,
  44. Partial<GotoOffsetOptions> {
  45. gotoOffset?: number
  46. forceReopen?: boolean
  47. keepCurrentView?: boolean
  48. }
  49. export type EditorManager = {
  50. getEditorType: () => EditorType | null
  51. getCurrentDocValue: () => string | null
  52. getCurrentDocumentId: () => DocId | null
  53. setIgnoringExternalUpdates: (value: boolean) => void
  54. openDocWithId: (
  55. docId: string,
  56. options?: OpenDocOptions
  57. ) => Promise<Doc | undefined>
  58. openDoc: (document: Doc, options?: OpenDocOptions) => Promise<Doc | undefined>
  59. openDocs: OpenDocuments
  60. openFileWithId: (fileId: string) => void
  61. openInitialDoc: (docId?: string) => Promise<Doc | undefined>
  62. isLoading: boolean
  63. jumpToLine: (options: GotoLineOptions) => void
  64. debugTimers: React.MutableRefObject<Record<string, number>>
  65. }
  66. function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
  67. return typeof options.gotoLine === 'number'
  68. }
  69. function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
  70. return typeof options.gotoOffset === 'number'
  71. }
  72. export const EditorManagerContext = createContext<EditorManager | undefined>(
  73. undefined
  74. )
  75. export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
  76. children,
  77. }) => {
  78. const { t } = useTranslation()
  79. const { reportError, eventEmitter, projectId, setOutOfSync } =
  80. useIdeReactContext()
  81. const { socket, closeConnection, connectionState } = useConnectionContext()
  82. const { view, setView, setOpenFile } = useLayoutContext()
  83. const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
  84. useModalsContext()
  85. const { id: userId } = useUserContext()
  86. const {
  87. showVisual,
  88. opening,
  89. setOpening,
  90. errorState,
  91. setErrorState,
  92. setTrackChanges,
  93. wantTrackChanges,
  94. } = useEditorPropertiesContext()
  95. const {
  96. currentDocumentId,
  97. setCurrentDocumentId,
  98. setOpenDocName,
  99. currentDocument,
  100. setCurrentDocument,
  101. } = useEditorOpenDocContext()
  102. const wantTrackChangesRef = useRef(wantTrackChanges)
  103. useEffect(() => {
  104. wantTrackChangesRef.current = wantTrackChanges
  105. }, [wantTrackChanges])
  106. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine')
  107. const { fileTreeData } = useFileTreeData()
  108. const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
  109. const { createDebugDiff, debugTimers } = useDebugDiffTracker(
  110. projectId,
  111. currentDocument
  112. )
  113. const [globalEditorWatchdogManager] = useState(
  114. () =>
  115. new EditorWatchdogManager({
  116. onTimeoutHandler: (meta: Record<string, any>) => {
  117. let diffSize: number | null = null
  118. createDebugDiff()
  119. .then(calculatedDiffSize => {
  120. diffSize = calculatedDiffSize
  121. })
  122. .finally(() => {
  123. sendMB('losing-edits', {
  124. ...meta,
  125. diffSize,
  126. timers: debugTimers.current,
  127. })
  128. reportError('losing-edits', {
  129. ...meta,
  130. diffSize,
  131. timers: debugTimers.current,
  132. })
  133. })
  134. },
  135. })
  136. )
  137. // Store the most recent document error and consume it in an effect, which
  138. // prevents circular dependencies in useCallbacks
  139. const [docError, setDocError] = useState<{
  140. doc: Doc
  141. document: DocumentContainer
  142. error: Error | string
  143. meta?: Record<string, any>
  144. editorContent?: string
  145. } | null>(null)
  146. const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
  147. useEffect(() => {
  148. if (!genericModalVisible) {
  149. setDocTooLongErrorShown(false)
  150. }
  151. }, [genericModalVisible])
  152. const [openDocs] = useState(
  153. () => new OpenDocuments(socket, globalEditorWatchdogManager, eventEmitter)
  154. )
  155. const currentDocumentIdStorageKey = `doc.open_id.${projectId}`
  156. // Persist the open document ID to local storage
  157. useEffect(() => {
  158. if (currentDocumentId) {
  159. customLocalStorage.setItem(currentDocumentIdStorageKey, currentDocumentId)
  160. }
  161. }, [currentDocumentId, currentDocumentIdStorageKey])
  162. const editorOpenDocEpochRef = useRef(0)
  163. const getEditorType = useCallback((): EditorType | null => {
  164. if (!currentDocument) {
  165. return null
  166. }
  167. return showVisual ? 'cm6-rich-text' : 'cm6'
  168. }, [currentDocument, showVisual])
  169. const getCurrentDocValue = useCallback(() => {
  170. return currentDocument?.getSnapshot() ?? null
  171. }, [currentDocument])
  172. const getCurrentDocumentId = useCallback(
  173. () => currentDocumentId,
  174. [currentDocumentId]
  175. )
  176. const jumpToLine = useCallback(
  177. (options: GotoLineOptions) => {
  178. goToLineEmitter(options)
  179. },
  180. [goToLineEmitter]
  181. )
  182. const attachErrorHandlerToDocument = useCallback(
  183. (doc: Doc, document: DocumentContainer) => {
  184. document.on(
  185. 'error',
  186. (
  187. error: Error | string,
  188. meta?: Record<string, any>,
  189. editorContent?: string
  190. ) => {
  191. setDocError({ doc, document, error, meta, editorContent })
  192. }
  193. )
  194. },
  195. []
  196. )
  197. const ignoringExternalUpdatesRef = useRef<boolean>(ignoringExternalUpdates)
  198. useEffect(() => {
  199. ignoringExternalUpdatesRef.current = ignoringExternalUpdates
  200. }, [ignoringExternalUpdates])
  201. const bindToDocumentEvents = useCallback(
  202. (doc: Doc, document: DocumentContainer) => {
  203. attachErrorHandlerToDocument(doc, document)
  204. document.on('externalUpdate', (update: Update) => {
  205. if (ignoringExternalUpdatesRef.current) {
  206. return
  207. }
  208. if (
  209. update.meta.type === 'external' &&
  210. update.meta.source === 'git-bridge'
  211. ) {
  212. return
  213. }
  214. if (
  215. update.meta.origin?.kind === 'file-restore' ||
  216. update.meta.origin?.kind === 'project-restore'
  217. ) {
  218. return
  219. }
  220. showGenericMessageModal(
  221. t('document_updated_externally'),
  222. t('document_updated_externally_detail')
  223. )
  224. })
  225. },
  226. [attachErrorHandlerToDocument, showGenericMessageModal, t]
  227. )
  228. const syncTimeoutRef = useRef<number | null>(null)
  229. const syncTrackChangesState = useCallback(
  230. (doc: DocumentContainer) => {
  231. if (!doc) {
  232. return
  233. }
  234. if (syncTimeoutRef.current) {
  235. window.clearTimeout(syncTimeoutRef.current)
  236. syncTimeoutRef.current = null
  237. }
  238. const want = wantTrackChangesRef.current
  239. const have = doc.getTrackingChanges()
  240. if (want === have) {
  241. setTrackChanges(want)
  242. return
  243. }
  244. const tryToggle = () => {
  245. const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
  246. if (saved) {
  247. doc.setTrackChangesUserId(want ? userId : null)
  248. setTrackChanges(want)
  249. } else {
  250. syncTimeoutRef.current = window.setTimeout(tryToggle, 100)
  251. }
  252. }
  253. tryToggle()
  254. },
  255. [setTrackChanges, userId]
  256. )
  257. const doOpenNewDocument = useCallback(
  258. (doc: Doc) =>
  259. new Promise<DocumentContainer>((resolve, reject) => {
  260. debugConsole.log('[doOpenNewDocument] Opening...')
  261. const newDocument = openDocs.getDocument(doc._id)
  262. if (!newDocument) {
  263. debugConsole.error(`No open document with ID '${doc._id}' found`)
  264. reject(new Error('no open document found'))
  265. return
  266. }
  267. const preJoinEpoch = ++editorOpenDocEpochRef.current
  268. newDocument.join(error => {
  269. if (error) {
  270. debugConsole.log(
  271. `[doOpenNewDocument] error joining doc ${doc._id}`,
  272. error
  273. )
  274. reject(error)
  275. return
  276. }
  277. if (editorOpenDocEpochRef.current !== preJoinEpoch) {
  278. debugConsole.log(
  279. `[doOpenNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
  280. )
  281. newDocument.leaveAndCleanUp()
  282. reject(new Error('another document was loaded'))
  283. return
  284. }
  285. bindToDocumentEvents(doc, newDocument)
  286. resolve(newDocument)
  287. })
  288. }),
  289. [bindToDocumentEvents, openDocs]
  290. )
  291. const openNewDocument = useCallback(
  292. async (doc: Doc): Promise<DocumentContainer> => {
  293. // Leave the current document
  294. // - when we are opening a different new one, to avoid race conditions
  295. // between leaving and joining the same document
  296. // - when the current one has pending ops that need flushing, to avoid
  297. // race conditions from cleanup
  298. const currentDocumentId = currentDocument?.doc_id
  299. const hasBufferedOps = currentDocument && currentDocument.hasBufferedOps()
  300. const changingDoc = currentDocument && currentDocumentId !== doc._id
  301. if (changingDoc || hasBufferedOps) {
  302. debugConsole.log('[openNewDocument] Leaving existing open doc...')
  303. // Do not trigger any UI changes from remote operations
  304. currentDocument.off()
  305. // Keep listening for out-of-sync and similar errors.
  306. attachErrorHandlerToDocument(doc, currentDocument)
  307. // Teardown the Document -> ShareJsDoc -> sharejs doc
  308. // By the time this completes, the Document instance is no longer
  309. // registered in OpenDocuments and doOpenNewDocument can start
  310. // from scratch -- read: no corrupted internal state.
  311. const preLeaveEpoch = ++editorOpenDocEpochRef.current
  312. try {
  313. await currentDocument.leaveAndCleanUpPromise()
  314. } catch (error) {
  315. debugConsole.log(
  316. `[openNewDocument] error leaving doc ${currentDocumentId}`,
  317. error
  318. )
  319. throw error
  320. }
  321. if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
  322. debugConsole.log(
  323. `[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
  324. )
  325. throw new Error('another document was loaded')
  326. }
  327. }
  328. return doOpenNewDocument(doc)
  329. },
  330. [attachErrorHandlerToDocument, doOpenNewDocument, currentDocument]
  331. )
  332. const currentDocumentIdRef = useRef(currentDocumentId)
  333. useEffect(() => {
  334. currentDocumentIdRef.current = currentDocumentId
  335. }, [currentDocumentId])
  336. const openDoc = useCallback(
  337. async (doc: Doc, options: OpenDocOptions = {}) => {
  338. debugConsole.log(`[openDoc] Opening ${doc._id}`)
  339. const { promise, resolve, reject } = Promise.withResolvers<Doc>()
  340. if (view === 'editor') {
  341. // store position of previous doc before switching docs
  342. eventEmitter.emit('store-doc-position')
  343. }
  344. if (!options.keepCurrentView) {
  345. setView('editor')
  346. }
  347. const done = (isNewDoc: boolean) => {
  348. window.dispatchEvent(
  349. new CustomEvent('doc:after-opened', {
  350. detail: { isNewDoc, docId: doc._id },
  351. })
  352. )
  353. window.dispatchEvent(
  354. new CustomEvent('entity:opened', {
  355. detail: doc._id,
  356. })
  357. )
  358. if (hasGotoLine(options)) {
  359. const jump = () => jumpToLine(options)
  360. if (isNewDoc) {
  361. // Jump to the line after a stored scroll position has been restored
  362. window.addEventListener('editor:scroll-position-restored', jump, {
  363. once: true,
  364. })
  365. } else {
  366. // Jump directly to the line
  367. jump()
  368. }
  369. } else if (hasGotoOffset(options)) {
  370. const jump = () => {
  371. eventEmitter.emit('editor:gotoOffset', options)
  372. }
  373. if (isNewDoc) {
  374. // Jump to the offset after a stored scroll position has been restored
  375. window.addEventListener('editor:scroll-position-restored', jump, {
  376. once: true,
  377. })
  378. } else {
  379. // Jump directly to the offset
  380. jump()
  381. }
  382. }
  383. resolve(doc)
  384. }
  385. // If we already have the document open, or are opening the document, we can return at this point.
  386. // Note: only use forceReopen:true to override this when the document is
  387. // out of sync and needs to be reloaded from the server.
  388. if (doc._id === currentDocumentIdRef.current && !options.forceReopen) {
  389. done(false)
  390. return
  391. }
  392. // We're now either opening a new document or reloading a broken one.
  393. currentDocumentIdRef.current = doc._id as DocId
  394. setCurrentDocumentId(doc._id as DocId)
  395. setOpenDocName(doc.name)
  396. setOpening(true)
  397. try {
  398. const document = await openNewDocument(doc)
  399. syncTrackChangesState(document)
  400. setOpening(false)
  401. setCurrentDocument(document)
  402. done(true)
  403. } catch (error: any) {
  404. if (error?.message === 'another document was loaded') {
  405. debugConsole.log(
  406. `[openDoc] another document was loaded while ${doc._id} was loading`
  407. )
  408. return
  409. }
  410. debugConsole.error('Error opening document', error)
  411. showGenericMessageModal(
  412. t('error_opening_document'),
  413. t('error_opening_document_detail')
  414. )
  415. reject(error)
  416. }
  417. return promise
  418. },
  419. [
  420. eventEmitter,
  421. jumpToLine,
  422. openNewDocument,
  423. setCurrentDocument,
  424. setCurrentDocumentId,
  425. setOpenDocName,
  426. setOpening,
  427. setView,
  428. showGenericMessageModal,
  429. syncTrackChangesState,
  430. t,
  431. view,
  432. ]
  433. )
  434. const openDocWithId = useCallback(
  435. async (docId: string, options: OpenDocOptions = {}) => {
  436. const doc = findDocEntityById(fileTreeData, docId)
  437. if (!doc) {
  438. return
  439. }
  440. return await openDoc(doc, options)
  441. },
  442. [fileTreeData, openDoc]
  443. )
  444. const openFileWithId = useCallback(
  445. (fileRefId: string) => {
  446. const fileRef = findFileRefEntityById(fileTreeData, fileRefId)
  447. if (!fileRef) {
  448. return
  449. }
  450. setOpenFile(convertFileRefToBinaryFile(fileRef))
  451. window.dispatchEvent(
  452. new CustomEvent('entity:opened', {
  453. detail: fileRef._id,
  454. })
  455. )
  456. },
  457. [fileTreeData, setOpenFile]
  458. )
  459. const openInitialDoc = useCallback(
  460. async (fallbackDocId?: string) => {
  461. const docId =
  462. customLocalStorage.getItem(currentDocumentIdStorageKey) || fallbackDocId
  463. if (docId) {
  464. return await openDocWithId(docId)
  465. }
  466. },
  467. [currentDocumentIdStorageKey, openDocWithId]
  468. )
  469. useEffect(() => {
  470. if (docError) {
  471. const { doc, document, error, meta } = docError
  472. let { editorContent } = docError
  473. const message = typeof error === 'string' ? error : (error?.message ?? '')
  474. // Clear document error so that it's only handled once
  475. setDocError(null)
  476. if (message.includes('maxDocLength')) {
  477. openDoc(doc, { forceReopen: true })
  478. const hasTrackedDeletes =
  479. document.ranges != null &&
  480. document.ranges.changes.some(change => 'd' in change.op)
  481. const explanation = hasTrackedDeletes
  482. ? `${t('document_too_long_detail')} ${t('document_too_long_tracked_deletes')}`
  483. : t('document_too_long_detail')
  484. showGenericMessageModal(t('document_too_long'), explanation)
  485. setDocTooLongErrorShown(true)
  486. } else if (/too many comments or tracked changes/.test(message)) {
  487. showGenericMessageModal(
  488. t('too_many_comments_or_tracked_changes'),
  489. t('too_many_comments_or_tracked_changes_detail')
  490. )
  491. } else if (!docTooLongErrorShown) {
  492. // Do not allow this doc to open another error modal.
  493. document.off('error')
  494. // Preserve the sharejs contents before the teardown.
  495. // eslint-disable-next-line no-unused-vars
  496. editorContent =
  497. typeof editorContent === 'string'
  498. ? editorContent
  499. : document.getSnapshot()
  500. // Tear down the ShareJsDoc.
  501. if (document.doc) document.doc.clearInflightAndPendingOps()
  502. // Do not re-join after re-connecting.
  503. document.leaveAndCleanUp()
  504. closeConnection('out-of-sync')
  505. reportError(error, meta)
  506. // Tell the user about the error state.
  507. setErrorState(true)
  508. // Ensure that the editor is locked
  509. setOutOfSync(true)
  510. // Display the "out of sync" modal
  511. showOutOfSyncModal(editorContent || '')
  512. // Do not forceReopen the document.
  513. return
  514. }
  515. const handleProjectJoined = () => {
  516. return openDoc(doc, { forceReopen: true })
  517. }
  518. eventEmitter.once('project:joined', handleProjectJoined)
  519. return () => {
  520. eventEmitter.off('project:joined', handleProjectJoined)
  521. }
  522. }
  523. }, [
  524. closeConnection,
  525. docError,
  526. docTooLongErrorShown,
  527. eventEmitter,
  528. openDoc,
  529. reportError,
  530. setErrorState,
  531. showGenericMessageModal,
  532. showOutOfSyncModal,
  533. setOutOfSync,
  534. t,
  535. ])
  536. useEventListener(
  537. 'editor:insert-symbol',
  538. useCallback(() => {
  539. sendMB('symbol-palette-insert')
  540. }, [])
  541. )
  542. useEventListener(
  543. 'blur',
  544. useCallback(() => {
  545. openDocs.flushAll()
  546. }, [openDocs])
  547. )
  548. // Flush changes before disconnecting
  549. useEffect(() => {
  550. if (connectionState.forceDisconnected) {
  551. openDocs.flushAll()
  552. }
  553. }, [connectionState.forceDisconnected, openDocs])
  554. // Watch for changes in wantTrackChanges
  555. const previousWantTrackChangesRef = useRef(wantTrackChanges)
  556. useEffect(() => {
  557. if (
  558. currentDocument &&
  559. wantTrackChanges !== previousWantTrackChangesRef.current
  560. ) {
  561. previousWantTrackChangesRef.current = wantTrackChanges
  562. syncTrackChangesState(currentDocument)
  563. }
  564. }, [currentDocument, syncTrackChangesState, wantTrackChanges])
  565. const isLoading = Boolean(
  566. (!currentDocument || opening) && !errorState && currentDocumentId
  567. )
  568. const value: EditorManager = useMemo(
  569. () => ({
  570. getEditorType,
  571. getCurrentDocValue,
  572. getCurrentDocumentId,
  573. setIgnoringExternalUpdates,
  574. openDocWithId,
  575. openDoc,
  576. openDocs,
  577. isLoading,
  578. openFileWithId,
  579. openInitialDoc,
  580. jumpToLine,
  581. debugTimers,
  582. }),
  583. [
  584. getEditorType,
  585. getCurrentDocValue,
  586. getCurrentDocumentId,
  587. setIgnoringExternalUpdates,
  588. openDocWithId,
  589. openDoc,
  590. openDocs,
  591. openFileWithId,
  592. openInitialDoc,
  593. isLoading,
  594. jumpToLine,
  595. debugTimers,
  596. ]
  597. )
  598. return (
  599. <EditorManagerContext.Provider value={value}>
  600. {children}
  601. </EditorManagerContext.Provider>
  602. )
  603. }
  604. export function useEditorManagerContext(): EditorManager {
  605. const context = useContext(EditorManagerContext)
  606. if (!context) {
  607. throw new Error(
  608. 'useEditorManagerContext is only available inside EditorManagerProvider'
  609. )
  610. }
  611. return context
  612. }