unsaved-docs.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  2. import { useEditorContext } from '@/shared/context/editor-context'
  3. import { FC, useCallback, useEffect, useRef, useState } from 'react'
  4. import { PermissionsLevel } from '@/features/ide-react/types/permissions'
  5. import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
  6. import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-alert'
  7. import useEventListener from '@/shared/hooks/use-event-listener'
  8. import { createPortal } from 'react-dom'
  9. import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
  10. const MAX_UNSAVED_SECONDS = 30 // lock the editor after this time if unsaved
  11. export const UnsavedDocs: FC = () => {
  12. const { openDocs, debugTimers } = useEditorManagerContext()
  13. const { permissionsLevel, setPermissionsLevel } = useEditorContext()
  14. const [isLocked, setIsLocked] = useState(false)
  15. const [unsavedDocs, setUnsavedDocs] = useState(new Map<string, number>())
  16. const globalAlertsContainer = useGlobalAlertsContainer()
  17. // always contains the latest value
  18. const previousUnsavedDocsRef = useRef(unsavedDocs)
  19. // always contains the latest value
  20. const permissionsLevelRef = useRef(permissionsLevel)
  21. useEffect(() => {
  22. permissionsLevelRef.current = permissionsLevel
  23. }, [permissionsLevel])
  24. // warn if the window is being closed with unsaved changes
  25. useEventListener(
  26. 'beforeunload',
  27. useCallback(
  28. (event: BeforeUnloadEvent) => {
  29. if (openDocs.hasUnsavedChanges()) {
  30. // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
  31. event.preventDefault()
  32. }
  33. },
  34. [openDocs]
  35. )
  36. )
  37. // keep track of which docs are currently unsaved, and how long they've been unsaved for
  38. // NOTE: openDocs should never change, so it's safe to use as a dependency here
  39. useEffect(() => {
  40. const interval = window.setInterval(() => {
  41. debugTimers.current.CheckUnsavedDocs = Date.now()
  42. const unsavedDocs = new Map()
  43. const docs = openDocs.unsavedDocs()
  44. for (const doc of docs) {
  45. const oldestOpCreatedAt =
  46. doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
  47. if (oldestOpCreatedAt) {
  48. const unsavedSeconds = Math.floor(
  49. (performance.now() - oldestOpCreatedAt) / 1000
  50. )
  51. unsavedDocs.set(doc.doc_id, unsavedSeconds)
  52. }
  53. }
  54. // avoid setting the unsavedDocs state to a new empty Map every second
  55. if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
  56. previousUnsavedDocsRef.current = unsavedDocs
  57. setUnsavedDocs(unsavedDocs)
  58. }
  59. }, 1000)
  60. return () => {
  61. window.clearInterval(interval)
  62. }
  63. }, [openDocs, debugTimers])
  64. const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
  65. // lock the editor if at least one doc has been unsaved for too long
  66. useEffect(() => {
  67. setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
  68. }, [maxUnsavedSeconds])
  69. // display a modal and set the permissions level to readOnly if docs have been unsaved for too long
  70. const originalPermissionsLevelRef = useRef<PermissionsLevel | null>(null)
  71. useEffect(() => {
  72. if (isLocked) {
  73. originalPermissionsLevelRef.current = permissionsLevelRef.current
  74. // TODO: what if the real permissions level changes in the meantime?
  75. // TODO: perhaps the "locked" state should be stored in the editor context instead?
  76. setPermissionsLevel('readOnly')
  77. setIsLocked(true)
  78. } else {
  79. if (originalPermissionsLevelRef.current) {
  80. setPermissionsLevel(originalPermissionsLevelRef.current)
  81. }
  82. }
  83. }, [isLocked, setPermissionsLevel])
  84. // remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
  85. useEffect(() => {
  86. if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
  87. setIsLocked(false)
  88. }
  89. }, [unsavedDocs])
  90. if (!globalAlertsContainer) {
  91. return null
  92. }
  93. return (
  94. <>
  95. {isLocked &&
  96. createPortal(<UnsavedDocsLockedAlert />, globalAlertsContainer)}
  97. {unsavedDocs.size > 0 &&
  98. createPortal(
  99. <UnsavedDocsAlert unsavedDocs={unsavedDocs} />,
  100. globalAlertsContainer
  101. )}
  102. </>
  103. )
  104. }