editor-manager-context.tsx 20 KB

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