editor-manager-context.tsx 20 KB

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