editor-manager-context.tsx 20 KB

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