editor-manager-context.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  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. openInitialDoc: (docId: string) => void
  54. jumpToLine: (options: GotoLineOptions) => void
  55. }
  56. function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
  57. return typeof options.gotoLine === 'number'
  58. }
  59. function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
  60. return typeof options.gotoOffset === 'number'
  61. }
  62. export type EditorScopeValue = {
  63. showSymbolPalette: false
  64. toggleSymbolPalette: () => void
  65. sharejs_doc: Document | null
  66. open_doc_id: string | null
  67. open_doc_name: string | null
  68. opening: boolean
  69. trackChanges: boolean
  70. wantTrackChanges: boolean
  71. showVisual: boolean
  72. newSourceEditor: boolean
  73. error_state: boolean
  74. }
  75. const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
  76. export const EditorManagerProvider: FC = ({ children }) => {
  77. const { t } = useTranslation()
  78. const ide = useIdeContext()
  79. const { projectId } = useIdeReactContext()
  80. const { reportError, eventEmitter, eventLog } = useIdeReactContext()
  81. const { socket, disconnect, connectionState } = useConnectionContext()
  82. const { view, setView } = useLayoutContext()
  83. const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
  84. useModalsContext()
  85. const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
  86. 'editor.showSymbolPalette'
  87. )
  88. const [showVisual] = useScopeValue<boolean>('editor.showVisual')
  89. const [currentDocument, setCurrentDocument] =
  90. useScopeValue<Document>('editor.sharejs_doc')
  91. const [openDocId, setOpenDocId] = useScopeValue<DocId | null>(
  92. 'editor.open_doc_id'
  93. )
  94. const [, setOpenDocName] = useScopeValue<string | null>(
  95. 'editor.open_doc_name'
  96. )
  97. const [, setOpening] = useScopeValue<boolean>('editor.opening')
  98. const [, setIsInErrorState] = useScopeValue<boolean>('editor.error_state')
  99. const [, setTrackChanges] = useScopeValue<boolean>('editor.trackChanges')
  100. const [wantTrackChanges] = useScopeValue<boolean>('editor.wantTrackChanges')
  101. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine')
  102. const { fileTreeData } = useFileTreeData()
  103. const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
  104. const [globalEditorWatchdogManager] = useState(
  105. () =>
  106. new EditorWatchdogManager({
  107. onTimeoutHandler: (meta: Record<string, any>) => {
  108. sendMB('losing-edits', meta)
  109. reportError('losing-edits', meta)
  110. },
  111. })
  112. )
  113. // Store the most recent document error and consume it in an effect, which
  114. // prevents circular dependencies in useCallbacks
  115. const [docError, setDocError] = useState<{
  116. doc: Doc
  117. document: Document
  118. error: Error | string
  119. meta?: Record<string, any>
  120. editorContent?: string
  121. } | null>(null)
  122. const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
  123. useEffect(() => {
  124. if (!genericModalVisible) {
  125. setDocTooLongErrorShown(false)
  126. }
  127. }, [genericModalVisible])
  128. const [openDocs] = useState(
  129. () =>
  130. new OpenDocuments(
  131. socket,
  132. globalEditorWatchdogManager,
  133. eventEmitter,
  134. eventLog
  135. )
  136. )
  137. const openDocIdStorageKey = `doc.open_id.${projectId}`
  138. // Persist the open document ID to local storage
  139. useEffect(() => {
  140. if (openDocId) {
  141. customLocalStorage.setItem(openDocIdStorageKey, openDocId)
  142. }
  143. }, [openDocId, openDocIdStorageKey])
  144. const editorOpenDocEpochRef = useRef(0)
  145. // TODO: This looks dodgy because it wraps a state setter and is itself
  146. // stored in React state in the scope store. The problem is that it needs to
  147. // be exposed via the scope store because some components access it that way;
  148. // it would be better to simply access it from a context, but the current
  149. // implementation in EditorManager interacts with Angular scope to update
  150. // the layout. Once Angular is gone, this can become a context method.
  151. useEffect(() => {
  152. ide.scopeStore.set('editor.toggleSymbolPalette', () => {
  153. setShowSymbolPalette(show => {
  154. const newValue = !show
  155. sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide')
  156. return newValue
  157. })
  158. })
  159. }, [ide.scopeStore, setShowSymbolPalette])
  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 getCurrentDocId = useCallback(() => openDocId, [openDocId])
  170. const startIgnoringExternalUpdates = useCallback(
  171. () => setIgnoringExternalUpdates(true),
  172. []
  173. )
  174. const stopIgnoringExternalUpdates = useCallback(
  175. () => setIgnoringExternalUpdates(false),
  176. []
  177. )
  178. const jumpToLine = useCallback(
  179. (options: GotoLineOptions) => {
  180. goToLineEmitter(
  181. options.gotoLine,
  182. options.gotoColumn ?? 0,
  183. options.syncToPdf ?? false
  184. )
  185. },
  186. [goToLineEmitter]
  187. )
  188. const unbindFromDocumentEvents = (document: Document) => {
  189. document.off()
  190. }
  191. const attachErrorHandlerToDocument = useCallback(
  192. (doc: Doc, document: Document) => {
  193. document.on(
  194. 'error',
  195. (
  196. error: Error | string,
  197. meta?: Record<string, any>,
  198. editorContent?: string
  199. ) => {
  200. setDocError({ doc, document, error, meta, editorContent })
  201. }
  202. )
  203. },
  204. []
  205. )
  206. const bindToDocumentEvents = useCallback(
  207. (doc: Doc, document: Document) => {
  208. attachErrorHandlerToDocument(doc, document)
  209. document.on('externalUpdate', (update: Update) => {
  210. if (ignoringExternalUpdates) {
  211. return
  212. }
  213. if (
  214. update.meta.type === 'external' &&
  215. update.meta.source === '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. window.setTimeout(() => jumpToLine(options))
  349. // Jump to the line again after a stored scroll position has been restored
  350. if (isNewDoc) {
  351. window.addEventListener(
  352. 'editor:scroll-position-restored',
  353. () => jumpToLine(options),
  354. { once: true }
  355. )
  356. }
  357. } else if (hasGotoOffset(options)) {
  358. window.setTimeout(() => {
  359. eventEmitter.emit('editor:gotoOffset', options.gotoOffset)
  360. })
  361. }
  362. }
  363. // If we already have the document open we can return at this point.
  364. // Note: only use forceReopen:true to override this when the document is
  365. // out of sync and needs to be reloaded from the server.
  366. if (doc._id === openDocId && !options.forceReopen) {
  367. done(false)
  368. return
  369. }
  370. // We're now either opening a new document or reloading a broken one.
  371. setOpenDocId(doc._id as DocId)
  372. setOpenDocName(doc.name)
  373. setOpening(true)
  374. try {
  375. const document = await openNewDocument(doc)
  376. syncTrackChangesState(document)
  377. eventEmitter.emit('doc:opened')
  378. setOpening(false)
  379. setCurrentDocument(document)
  380. done(true)
  381. } catch (error: any) {
  382. if (error?.message === 'another document was loaded') {
  383. debugConsole.log(
  384. `[openDoc] another document was loaded while ${doc._id} was loading`
  385. )
  386. return
  387. }
  388. debugConsole.error('Error opening document', error)
  389. showGenericMessageModal(
  390. t('error_opening_document'),
  391. t('error_opening_document_detail')
  392. )
  393. }
  394. },
  395. [
  396. eventEmitter,
  397. jumpToLine,
  398. openDocId,
  399. openNewDocument,
  400. setCurrentDocument,
  401. setOpenDocId,
  402. setOpenDocName,
  403. setOpening,
  404. setView,
  405. showGenericMessageModal,
  406. syncTrackChangesState,
  407. t,
  408. view,
  409. ]
  410. )
  411. const openDocWithId = useCallback(
  412. (docId: string, options: OpenDocOptions = {}) => {
  413. const doc = findDocEntityById(fileTreeData, docId)
  414. if (!doc) {
  415. return
  416. }
  417. openDoc(doc, options)
  418. },
  419. [fileTreeData, openDoc]
  420. )
  421. const openInitialDoc = useCallback(
  422. (fallbackDocId: string) => {
  423. const docId =
  424. customLocalStorage.getItem(openDocIdStorageKey) || fallbackDocId
  425. if (docId) {
  426. openDocWithId(docId)
  427. }
  428. },
  429. [openDocIdStorageKey, openDocWithId]
  430. )
  431. useEffect(() => {
  432. if (docError) {
  433. const { doc, document, error, meta } = docError
  434. let { editorContent } = docError
  435. const message = typeof error === 'string' ? error : error?.message ?? ''
  436. // Clear document error so that it's only handled once
  437. setDocError(null)
  438. if (message.includes('maxDocLength')) {
  439. openDoc(doc, { forceReopen: true })
  440. showGenericMessageModal(
  441. t('document_too_long'),
  442. t('document_too_long_detail')
  443. )
  444. setDocTooLongErrorShown(true)
  445. } else if (/too many comments or tracked changes/.test(message)) {
  446. showGenericMessageModal(
  447. t('too_many_comments_or_tracked_changes'),
  448. t('too_many_comments_or_tracked_changes_detail')
  449. )
  450. } else if (!docTooLongErrorShown) {
  451. // Do not allow this doc to open another error modal.
  452. document.off('error')
  453. // Preserve the sharejs contents before the teardown.
  454. // eslint-disable-next-line no-unused-vars
  455. editorContent =
  456. typeof editorContent === 'string'
  457. ? editorContent
  458. : document.doc?._doc.snapshot
  459. // Tear down the ShareJsDoc.
  460. if (document.doc) document.doc.clearInflightAndPendingOps()
  461. // Do not re-join after re-connecting.
  462. document.leaveAndCleanUp()
  463. disconnect()
  464. reportError(error, meta)
  465. // Tell the user about the error state.
  466. setIsInErrorState(true)
  467. showOutOfSyncModal(editorContent || '')
  468. // Do not forceReopen the document.
  469. return
  470. }
  471. const handleProjectJoined = () => {
  472. openDoc(doc, { forceReopen: true })
  473. }
  474. eventEmitter.once('project:joined', handleProjectJoined)
  475. return () => {
  476. eventEmitter.off('project:joined', handleProjectJoined)
  477. }
  478. }
  479. }, [
  480. disconnect,
  481. docError,
  482. docTooLongErrorShown,
  483. eventEmitter,
  484. openDoc,
  485. reportError,
  486. setIsInErrorState,
  487. showGenericMessageModal,
  488. showOutOfSyncModal,
  489. t,
  490. ])
  491. useEventListener('editor:insert-symbol', () => {
  492. sendMB('symbol-palette-insert')
  493. })
  494. useEventListener('flush-changes', () => {
  495. openDocs.flushAll()
  496. })
  497. useEventListener('blur', () => {
  498. openDocs.flushAll()
  499. })
  500. // Flush changes before disconnecting
  501. useEffect(() => {
  502. if (connectionState.forceDisconnected) {
  503. openDocs.flushAll()
  504. }
  505. }, [connectionState.forceDisconnected, openDocs])
  506. // Watch for changes in wantTrackChanges
  507. const previousWantTrackChangesRef = useRef(wantTrackChanges)
  508. useEffect(() => {
  509. if (wantTrackChanges !== previousWantTrackChangesRef.current) {
  510. previousWantTrackChangesRef.current = wantTrackChanges
  511. syncTrackChangesState(currentDocument)
  512. }
  513. }, [currentDocument, syncTrackChangesState, wantTrackChanges])
  514. const editorManager = useMemo(
  515. () => ({
  516. getEditorType,
  517. showSymbolPalette,
  518. currentDocument,
  519. currentDocumentId: openDocId,
  520. getCurrentDocValue,
  521. getCurrentDocId,
  522. startIgnoringExternalUpdates,
  523. stopIgnoringExternalUpdates,
  524. openDocId: openDocWithId,
  525. openDoc,
  526. openInitialDoc,
  527. jumpToLine,
  528. }),
  529. [
  530. getEditorType,
  531. showSymbolPalette,
  532. currentDocument,
  533. openDocId,
  534. getCurrentDocValue,
  535. getCurrentDocId,
  536. startIgnoringExternalUpdates,
  537. stopIgnoringExternalUpdates,
  538. openDocWithId,
  539. openDoc,
  540. openInitialDoc,
  541. jumpToLine,
  542. ]
  543. )
  544. // Expose editorManager via ide object because some React code relies on it,
  545. // for now
  546. ide.editorManager = editorManager
  547. return (
  548. <EditorManagerContext.Provider value={editorManager}>
  549. {children}
  550. </EditorManagerContext.Provider>
  551. )
  552. }
  553. export function useEditorManagerContext(): EditorManager {
  554. const context = useContext(EditorManagerContext)
  555. if (!context) {
  556. throw new Error(
  557. 'useEditorManagerContext is only available inside EditorManagerProvider'
  558. )
  559. }
  560. return context
  561. }