editor-manager-context.tsx 20 KB

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