editor-manager-context.tsx 19 KB

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