editor-manager-context.tsx 19 KB

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