Pārlūkot izejas kodu

Merge pull request #19743 from overleaf/mj-restore-project

[web+project-history] Add project version reverting

GitOrigin-RevId: 0f77cec730393187d531c0c6561faaa652bebf29
Mathias Jakobsen 2 gadi atpakaļ
vecāks
revīzija
654d96ace6
25 mainītis faili ar 619 papildinājumiem un 13 dzēšanām
  1. 4 0
      libraries/overleaf-editor-core/lib/origin/index.js
  2. 51 0
      libraries/overleaf-editor-core/lib/origin/restore_project_origin.js
  3. 10 0
      services/project-history/app/js/HttpController.js
  4. 5 0
      services/project-history/app/js/Router.js
  5. 10 0
      services/project-history/app/js/SnapshotManager.js
  6. 6 0
      services/project-history/app/js/SummarizedUpdatesManager.js
  7. 46 0
      services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js
  8. 13 0
      services/web/app/src/Features/History/HistoryController.js
  9. 64 2
      services/web/app/src/Features/History/RestoreManager.js
  10. 1 0
      services/web/app/src/Features/Project/ProjectController.js
  11. 5 0
      services/web/app/src/router.js
  12. 6 0
      services/web/frontend/extracted-translations.json
  13. 9 0
      services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx
  14. 61 0
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx
  15. 18 10
      services/web/frontend/js/features/history/components/change-list/history-version.tsx
  16. 1 0
      services/web/frontend/js/features/history/components/change-list/label-list-item.tsx
  17. 23 0
      services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx
  18. 34 0
      services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx
  19. 60 0
      services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx
  20. 36 0
      services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
  21. 6 0
      services/web/frontend/js/features/history/services/api.ts
  22. 5 0
      services/web/frontend/js/features/history/services/types/shared.ts
  23. 4 1
      services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
  24. 6 0
      services/web/locales/en.json
  25. 135 0
      services/web/test/unit/src/History/RestoreManagerTests.js

+ 4 - 0
libraries/overleaf-editor-core/lib/origin/index.js

@@ -6,6 +6,7 @@ const assert = require('check-types').assert
 // dependency
 let RestoreOrigin = null
 let RestoreFileOrigin = null
+let RestoreProjectOrigin = null
 
 /**
  * An Origin records where a {@link Change} came from. The Origin class handles
@@ -34,6 +35,8 @@ class Origin {
     if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
     if (raw.kind === RestoreFileOrigin.KIND)
       return RestoreFileOrigin.fromRaw(raw)
+    if (raw.kind === RestoreProjectOrigin.KIND)
+      return RestoreProjectOrigin.fromRaw(raw)
     return new Origin(raw.kind)
   }
 
@@ -58,3 +61,4 @@ module.exports = Origin
 
 RestoreOrigin = require('./restore_origin')
 RestoreFileOrigin = require('./restore_file_origin')
+RestoreProjectOrigin = require('./restore_project_origin')

+ 51 - 0
libraries/overleaf-editor-core/lib/origin/restore_project_origin.js

@@ -0,0 +1,51 @@
+'use strict'
+
+const assert = require('check-types').assert
+
+const Origin = require('.')
+
+class RestoreProjectOrigin extends Origin {
+  /**
+   * @param {number} version that was restored
+   * @param {Date} timestamp from the restored version
+   */
+  constructor(version, timestamp) {
+    assert.integer(version, 'RestoreProjectOrigin: bad version')
+    assert.date(timestamp, 'RestoreProjectOrigin: bad timestamp')
+
+    super(RestoreProjectOrigin.KIND)
+    this.version = version
+    this.timestamp = timestamp
+  }
+
+  static fromRaw(raw) {
+    return new RestoreProjectOrigin(raw.version, new Date(raw.timestamp))
+  }
+
+  /** @inheritdoc */
+  toRaw() {
+    return {
+      kind: RestoreProjectOrigin.KIND,
+      version: this.version,
+      timestamp: this.timestamp.toISOString(),
+    }
+  }
+
+  /**
+   * @return {number}
+   */
+  getVersion() {
+    return this.version
+  }
+
+  /**
+   * @return {Date}
+   */
+  getTimestamp() {
+    return this.timestamp
+  }
+}
+
+RestoreProjectOrigin.KIND = 'project-restore'
+
+module.exports = RestoreProjectOrigin

+ 10 - 0
services/project-history/app/js/HttpController.js

@@ -230,6 +230,16 @@ export function getProjectSnapshot(req, res, next) {
   )
 }
 
+export function getPathsAtVersion(req, res, next) {
+  const { project_id: projectId, version } = req.params
+  SnapshotManager.getPathsAtVersion(projectId, version, (error, result) => {
+    if (error != null) {
+      return next(error)
+    }
+    res.json(result)
+  })
+}
+
 export function healthCheck(req, res) {
   HealthChecker.check(err => {
     if (err != null) {

+ 5 - 0
services/project-history/app/js/Router.js

@@ -156,6 +156,11 @@ export function initialize(app) {
     HttpController.getProjectSnapshot
   )
 
+  app.get(
+    '/project/:project_id/paths/version/:version',
+    HttpController.getPathsAtVersion
+  )
+
   app.post(
     '/project/:project_id/force',
     validate({

+ 10 - 0
services/project-history/app/js/SnapshotManager.js

@@ -237,6 +237,13 @@ async function getProjectSnapshot(projectId, version) {
   }
 }
 
+async function getPathsAtVersion(projectId, version) {
+  const snapshot = await _getSnapshotAtVersion(projectId, version)
+  return {
+    paths: snapshot.getFilePathnames(),
+  }
+}
+
 /**
  *
  * @param {string} projectId
@@ -295,12 +302,14 @@ const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
 const getProjectSnapshotCb = callbackify(getProjectSnapshot)
 const getLatestSnapshotCb = callbackify(getLatestSnapshot)
 const getRangesSnapshotCb = callbackify(getRangesSnapshot)
+const getPathsAtVersionCb = callbackify(getPathsAtVersion)
 
 export {
   getFileSnapshotStreamCb as getFileSnapshotStream,
   getProjectSnapshotCb as getProjectSnapshot,
   getLatestSnapshotCb as getLatestSnapshot,
   getRangesSnapshotCb as getRangesSnapshot,
+  getPathsAtVersionCb as getPathsAtVersion,
 }
 
 export const promises = {
@@ -308,4 +317,5 @@ export const promises = {
   getProjectSnapshot,
   getLatestSnapshot,
   getRangesSnapshot,
+  getPathsAtVersion,
 }

+ 6 - 0
services/project-history/app/js/SummarizedUpdatesManager.js

@@ -261,6 +261,12 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) {
       ) {
         return false
       }
+      if (
+        update.meta.origin.kind === 'project-restore' &&
+        update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp
+      ) {
+        return false
+      }
     } else {
       return false
     }

+ 46 - 0
services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js

@@ -998,4 +998,50 @@ Four five six\
       })
     })
   })
+
+  describe('getPathsAtVersion', function () {
+    beforeEach(function () {
+      this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
+      this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
+        chunk: (this.chunk = {
+          history: {
+            snapshot: {
+              files: {
+                'main.tex': {
+                  hash: (this.fileHash =
+                    '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
+                  rangesHash: (this.rangesHash =
+                    '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
+                  stringLength: 41,
+                },
+                'other.tex': {
+                  hash: (this.fileHash =
+                    'f572d396fae9206628714fb2ce00f72e94f2258f'),
+                  stringLength: 6,
+                },
+              },
+            },
+            changes: [],
+          },
+          startVersion: 4,
+          authors: [
+            {
+              id: 31,
+              email: 'author@example.com',
+              name: 'Author',
+            },
+          ],
+        }),
+      })
+    })
+
+    it('should return an array of paths', async function () {
+      const result = await this.SnapshotManager.promises.getPathsAtVersion(
+        this.projectId,
+        4
+      )
+      expect(result.paths).to.have.length(2)
+      expect(result.paths).to.include.members(['main.tex', 'other.tex'])
+    })
+  })
 })

+ 13 - 0
services/web/app/src/Features/History/HistoryController.js

@@ -118,6 +118,7 @@ module.exports = HistoryController = {
       projectId,
       version,
       pathname,
+      {},
       function (err, entity) {
         if (err) {
           return next(err)
@@ -130,6 +131,18 @@ module.exports = HistoryController = {
     )
   },
 
+  revertProject(req, res, next) {
+    const { project_id: projectId } = req.params
+    const { version } = req.body
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    RestoreManager.revertProject(userId, projectId, version, function (err) {
+      if (err) {
+        return next(err)
+      }
+      res.sendStatus(200)
+    })
+  },
+
   getLabels(req, res, next) {
     const projectId = req.params.Project_id
     HistoryController._makeRequest(

+ 64 - 2
services/web/app/src/Features/History/RestoreManager.js

@@ -16,6 +16,7 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
 const ChatManager = require('../Chat/ChatManager')
 const OError = require('@overleaf/o-error')
 const ProjectGetter = require('../Project/ProjectGetter')
+const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 
 const RestoreManager = {
   async restoreFileFromV2(userId, projectId, version, pathname) {
@@ -49,7 +50,7 @@ const RestoreManager = {
     )
   },
 
-  async revertFile(userId, projectId, version, pathname) {
+  async revertFile(userId, projectId, version, pathname, options = {}) {
     const project = await ProjectGetter.promises.getProject(projectId, {
       overleaf: true,
     })
@@ -85,7 +86,7 @@ const RestoreManager = {
     )
     const updateAtVersion = updates.find(update => update.toV === version)
 
-    const origin = {
+    const origin = options.origin || {
       kind: 'file-restore',
       path: pathname,
       version,
@@ -239,6 +240,61 @@ const RestoreManager = {
     }
   },
 
+  async revertProject(userId, projectId, version) {
+    const project = await ProjectGetter.promises.getProject(projectId, {
+      overleaf: true,
+    })
+    if (!project?.overleaf?.history?.rangesSupportEnabled) {
+      throw new OError('project does not have ranges support', { projectId })
+    }
+
+    // Get project paths at version
+    const pathsAtPastVersion = await RestoreManager._getProjectPathsAtVersion(
+      projectId,
+      version
+    )
+
+    const updates = await RestoreManager._getUpdatesFromHistory(
+      projectId,
+      version
+    )
+    const updateAtVersion = updates.find(update => update.toV === version)
+
+    const origin = {
+      kind: 'project-restore',
+      version,
+      timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
+    }
+
+    for (const pathname of pathsAtPastVersion) {
+      await RestoreManager.revertFile(userId, projectId, version, pathname, {
+        origin,
+      })
+    }
+
+    const entitiesAtLiveVersion =
+      await ProjectEntityHandler.promises.getAllEntities(projectId)
+
+    const trimLeadingSlash = path => path.replace(/^\//, '')
+
+    const pathsAtLiveVersion = entitiesAtLiveVersion.docs
+      .map(doc => doc.path)
+      .concat(entitiesAtLiveVersion.files.map(file => file.path))
+      .map(trimLeadingSlash)
+
+    // Delete files that were not present at the reverted version
+    for (const path of pathsAtLiveVersion) {
+      if (!pathsAtPastVersion.includes(path)) {
+        await EditorController.promises.deleteEntityWithPath(
+          projectId,
+          path,
+          origin,
+          userId
+        )
+      }
+    }
+  },
+
   async _writeFileVersionToDisk(projectId, version, pathname) {
     const url = `${
       Settings.apis.project_history.url
@@ -258,6 +314,12 @@ const RestoreManager = {
     const res = await fetchJson(url)
     return res.updates
   },
+
+  async _getProjectPathsAtVersion(projectId, version) {
+    const url = `${Settings.apis.project_history.url}/project/${projectId}/paths/version/${version}`
+    const res = await fetchJson(url)
+    return res.paths
+  },
 }
 
 module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -335,6 +335,7 @@ const _ProjectController = {
       'pdfjs-40',
       'personal-access-token',
       'revert-file',
+      'revert-project',
       'review-panel-redesign',
       'track-pdf-download',
       !anonymous && 'writefull-oauth-promotion',

+ 5 - 0
services/web/app/src/router.js

@@ -784,6 +784,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     AuthorizationMiddleware.ensureUserCanWriteProjectContent,
     HistoryController.revertFile
   )
+  webRouter.post(
+    '/project/:project_id/revert-project',
+    AuthorizationMiddleware.ensureUserCanWriteProjectContent,
+    HistoryController.revertProject
+  )
   webRouter.get(
     '/project/:project_id/version/:version/zip',
     RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision),

+ 6 - 0
services/web/frontend/extracted-translations.json

@@ -87,6 +87,7 @@
   "already_subscribed_try_refreshing_the_page": "",
   "also": "",
   "an_email_has_already_been_sent_to": "",
+  "an_error_occured_while_restoring_project": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
   "anonymous": "",
   "anyone_with_link_can_edit": "",
@@ -430,6 +431,7 @@
   "file_action_edited": "",
   "file_action_renamed": "",
   "file_action_restored": "",
+  "file_action_restored_project": "",
   "file_already_exists": "",
   "file_already_exists_in_this_location": "",
   "file_name": "",
@@ -1144,6 +1146,8 @@
   "restore_file_error_message": "",
   "restore_file_error_title": "",
   "restore_file_version": "",
+  "restore_project_to_this_version": "",
+  "restore_this_version": "",
   "restoring": "",
   "resync_completed": "",
   "resync_message": "",
@@ -1420,6 +1424,7 @@
   "then_x_price_per_month": "",
   "then_x_price_per_year": "",
   "there_are_lots_of_options_to_edit_and_customize_your_figures": "",
+  "there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "",
   "they_lose_access_to_account": "",
   "this_action_cannot_be_reversed": "",
   "this_action_cannot_be_undone": "",
@@ -1712,6 +1717,7 @@
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_compile_timed_out": "",
+  "your_current_project_will_revert_to_the_version_from_time": "",
   "your_git_access_info": "",
   "your_git_access_info_bullet_1": "",
   "your_git_access_info_bullet_2": "",

+ 9 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx

@@ -3,17 +3,20 @@ import Download from './menu-item/download'
 import { Version } from '../../../services/types/update'
 import { useCallback } from 'react'
 import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
+import RestoreProject from './menu-item/restore-project'
 
 type VersionDropdownContentProps = {
   projectId: string
   version: Version
   closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
+  endTimestamp: number
 }
 
 function HistoryDropdownContent({
   projectId,
   version,
   closeDropdownForItem,
+  endTimestamp,
 }: VersionDropdownContentProps) {
   const closeDropdown = useCallback(() => {
     closeDropdownForItem(version, 'moreOptions')
@@ -31,6 +34,12 @@ function HistoryDropdownContent({
         version={version}
         closeDropdown={closeDropdown}
       />
+      <RestoreProject
+        projectId={projectId}
+        version={version}
+        closeDropdown={closeDropdown}
+        endTimestamp={endTimestamp}
+      />
     </>
   )
 }

+ 61 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx

@@ -0,0 +1,61 @@
+import Icon from '@/shared/components/icon'
+import { useCallback, useState } from 'react'
+import { MenuItem } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
+import { useSplitTestContext } from '@/shared/context/split-test-context'
+import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
+
+type RestoreProjectProps = {
+  projectId: string
+  version: number
+  closeDropdown: () => void
+  endTimestamp: number
+}
+
+const RestoreProject = ({
+  projectId,
+  version,
+  closeDropdown,
+  endTimestamp,
+}: RestoreProjectProps) => {
+  const { t } = useTranslation()
+  const [showModal, setShowModal] = useState(false)
+  const { splitTestVariants } = useSplitTestContext()
+  const { restoreProject, isRestoring } = useRestoreProject()
+
+  const handleClick = useCallback(() => {
+    closeDropdown()
+    setShowModal(true)
+  }, [closeDropdown])
+
+  const onRestore = useCallback(() => {
+    restoreProject(projectId, version)
+  }, [restoreProject, version, projectId])
+
+  if (
+    splitTestVariants['revert-file'] !== 'enabled' ||
+    splitTestVariants['revert-project'] !== 'enabled'
+  ) {
+    return null
+  }
+
+  return (
+    <>
+      <MenuItem onClick={handleClick}>
+        <Icon type="undo" fw /> {t('restore_project_to_this_version')}
+      </MenuItem>
+      <RestoreProjectModal
+        setShow={setShowModal}
+        show={showModal}
+        endTimestamp={endTimestamp}
+        isRestoring={isRestoring}
+        onRestore={onRestore}
+      />
+    </>
+  )
+}
+
+export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal)

+ 18 - 10
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -21,6 +21,7 @@ import CompareVersionDropdown from './dropdown/compare-version-dropdown'
 import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
 import FileRestoreChange from './file-restore-change'
 import HistoryResyncChange from './history-resync-change'
+import ProjectRestoreChange from './project-restore-change'
 
 type HistoryVersionProps = {
   update: LoadedUpdate
@@ -113,6 +114,7 @@ function HistoryVersion({
               {dropdownActive ? (
                 <HistoryDropdownContent
                   version={update.toV}
+                  endTimestamp={update.meta.end_ts}
                   projectId={projectId}
                   closeDropdownForItem={closeDropdownForItem}
                 />
@@ -166,16 +168,7 @@ function HistoryVersion({
                 label={label}
               />
             ))}
-            {update.meta.origin?.kind === 'file-restore' ? (
-              <FileRestoreChange origin={update.meta.origin} />
-            ) : update.meta.origin?.kind === 'history-resync' ? (
-              <HistoryResyncChange />
-            ) : (
-              <Changes
-                pathnames={update.pathnames}
-                projectOps={update.project_ops}
-              />
-            )}
+            <ChangeEntry update={update} />
             {update.meta.origin?.kind !== 'history-resync' ? (
               <>
                 <MetadataUsersList
@@ -193,4 +186,19 @@ function HistoryVersion({
   )
 }
 
+function ChangeEntry({ update }: { update: LoadedUpdate }) {
+  switch (update.meta.origin?.kind) {
+    case 'file-restore':
+      return <FileRestoreChange origin={update.meta.origin} />
+    case 'history-resync':
+      return <HistoryResyncChange />
+    case 'project-restore':
+      return <ProjectRestoreChange origin={update.meta.origin} />
+    default:
+      return (
+        <Changes pathnames={update.pathnames} projectOps={update.project_ops} />
+      )
+  }
+}
+
 export default memo(HistoryVersion)

+ 1 - 0
services/web/frontend/js/features/history/components/change-list/label-list-item.tsx

@@ -91,6 +91,7 @@ function LabelListItem({
             version={version}
             projectId={projectId}
             closeDropdownForItem={closeDropdownForItem}
+            endTimestamp={toVTimestamp * 1000}
           />
         ) : null}
       </HistoryDropdown>

+ 23 - 0
services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx

@@ -0,0 +1,23 @@
+import { formatTime } from '@/features/utils/format-date'
+import { useTranslation } from 'react-i18next'
+import { LoadedUpdate } from '../../services/types/update'
+
+function ProjectRestoreChange({
+  origin,
+}: Pick<LoadedUpdate['meta'], 'origin'>) {
+  const { t } = useTranslation()
+
+  if (!origin || origin.kind !== 'project-restore') {
+    return null
+  }
+
+  return (
+    <div className="history-version-restore-project">
+      {t('file_action_restored_project', {
+        date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
+      })}
+    </div>
+  )
+}
+
+export default ProjectRestoreChange

+ 34 - 0
services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx

@@ -0,0 +1,34 @@
+import { Button, Modal } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+export function RestoreProjectErrorModal({
+  resetErrorBoundary,
+}: {
+  resetErrorBoundary: VoidFunction
+}) {
+  const { t } = useTranslation()
+
+  return (
+    <Modal show onHide={resetErrorBoundary}>
+      <Modal.Header closeButton>
+        <Modal.Title>
+          {t('an_error_occured_while_restoring_project')}
+        </Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        {t(
+          'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
+        )}
+      </Modal.Body>
+      <Modal.Footer>
+        <Button
+          bsStyle={null}
+          className="btn-secondary"
+          onClick={resetErrorBoundary}
+        >
+          {t('close')}
+        </Button>
+      </Modal.Footer>
+    </Modal>
+  )
+}

+ 60 - 0
services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx

@@ -0,0 +1,60 @@
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { formatDate } from '@/utils/dates'
+import { useCallback } from 'react'
+import { Button, Modal } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+type RestoreProjectModalProps = {
+  setShow: React.Dispatch<React.SetStateAction<boolean>>
+  show: boolean
+  isRestoring: boolean
+  endTimestamp: number
+  onRestore: () => void
+}
+
+export const RestoreProjectModal = ({
+  setShow,
+  show,
+  endTimestamp,
+  isRestoring,
+  onRestore,
+}: RestoreProjectModalProps) => {
+  const { t } = useTranslation()
+
+  const onCancel = useCallback(() => {
+    setShow(false)
+  }, [setShow])
+
+  return (
+    <AccessibleModal onHide={() => setShow(false)} show={show}>
+      <Modal.Header>
+        <Modal.Title>{t('restore_this_version')}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        <p>
+          {t('your_current_project_will_revert_to_the_version_from_time', {
+            timestamp: formatDate(endTimestamp),
+          })}
+        </p>
+      </Modal.Body>
+      <Modal.Footer>
+        <Button
+          className="btn btn-secondary"
+          bsStyle={null}
+          onClick={onCancel}
+          disabled={isRestoring}
+        >
+          {t('cancel')}
+        </Button>
+        <Button
+          className="btn btn-primary"
+          bsStyle={null}
+          onClick={onRestore}
+          disabled={isRestoring}
+        >
+          {isRestoring ? t('restoring') : t('restore')}
+        </Button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 36 - 0
services/web/frontend/js/features/history/context/hooks/use-restore-project.ts

@@ -0,0 +1,36 @@
+import { useCallback, useState } from 'react'
+import { useErrorHandler } from 'react-error-boundary'
+import { restoreProjectToVersion } from '../../services/api'
+import { useLayoutContext } from '@/shared/context/layout-context'
+
+type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
+
+export const useRestoreProject = () => {
+  const handleError = useErrorHandler()
+  const { setView } = useLayoutContext()
+
+  const [restorationState, setRestorationState] =
+    useState<RestorationState>('initial')
+
+  const restoreProject = useCallback(
+    (projectId: string, version: number) => {
+      setRestorationState('restoring')
+      restoreProjectToVersion(projectId, version)
+        .then(() => {
+          setRestorationState('restored')
+          setView('editor')
+        })
+        .catch(err => {
+          setRestorationState('error')
+          handleError(err)
+        })
+    },
+    [handleError, setView]
+  )
+
+  return {
+    restorationState,
+    restoreProject,
+    isRestoring: restorationState === 'restoring',
+  }
+}

+ 6 - 0
services/web/frontend/js/features/history/services/api.ts

@@ -107,3 +107,9 @@ export function restoreFileToVersion(
     },
   })
 }
+
+export function restoreProjectToVersion(projectId: string, version: number) {
+  return postJSON(`/project/${projectId}/revert-project`, {
+    body: { version },
+  })
+}

+ 5 - 0
services/web/frontend/js/features/history/services/types/shared.ts

@@ -29,4 +29,9 @@ export interface Meta {
         timestamp: number
         version: number
       }
+    | {
+        kind: 'project-restore'
+        timestamp: number
+        version: number
+      }
 }

+ 4 - 1
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -286,7 +286,10 @@ export const EditorManagerProvider: FC = ({ children }) => {
         ) {
           return
         }
-        if (update.meta.origin?.kind === 'file-restore') {
+        if (
+          update.meta.origin?.kind === 'file-restore' ||
+          update.meta.origin?.kind === 'project-restore'
+        ) {
           return
         }
         showGenericMessageModal(

+ 6 - 0
services/web/locales/en.json

@@ -124,6 +124,7 @@
   "also_available_as_on_premises": "Also available as On-Premises",
   "alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
   "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
+  "an_error_occured_while_restoring_project": "An error occured while restoring the project",
   "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
   "and": "and",
   "annual": "Annual",
@@ -638,6 +639,7 @@
   "file_action_edited": "Edited",
   "file_action_renamed": "Renamed",
   "file_action_restored": "Restored __fileName__ from: __date__",
+  "file_action_restored_project": "Restored project from __date__",
   "file_already_exists": "A file or folder with this name already exists",
   "file_already_exists_in_this_location": "An item named <0>__fileName__</0> already exists in this location. If you wish to move this file, rename or remove the conflicting file and try again.",
   "file_name": "File Name",
@@ -1673,6 +1675,8 @@
   "restore_file_error_message": "There was a problem restoring the file version. Please try again in a few moments. If the problem continues please contact us.",
   "restore_file_error_title": "Restore File Error",
   "restore_file_version": "Restore this version",
+  "restore_project_to_this_version": "Restore project to this version",
+  "restore_this_version": "Restore this version",
   "restoring": "Restoring",
   "restricted": "Restricted",
   "restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.",
@@ -2034,6 +2038,7 @@
   "then_x_price_per_month": "Then __price__ per month",
   "then_x_price_per_year": "Then __price__ per year",
   "there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how</0>",
+  "there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "There was a problem restoring the project. Please try again in a few moments. Contact us of the problem persists.",
   "there_was_an_error_opening_your_content": "There was an error creating your project",
   "thesis": "Thesis",
   "they_lose_access_to_account": "They lose all access to this Overleaf account immediately",
@@ -2393,6 +2398,7 @@
   "your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
   "your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
   "your_compile_timed_out": "Your compile timed out",
+  "your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
   "your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
   "your_git_access_info_bullet_1": "You can have up to 10 tokens.",
   "your_git_access_info_bullet_2": "If you reach the maximum limit, you’ll need to delete a token before you can generate a new one.",

+ 135 - 0
services/web/test/unit/src/History/RestoreManagerTests.js

@@ -35,6 +35,9 @@ describe('RestoreManager', function () {
         '../Editor/EditorRealTimeController': (this.EditorRealTimeController =
           {}),
         '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
+        '../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
+          promises: {},
+        }),
       },
     })
     this.user_id = 'mock-user-id'
@@ -488,4 +491,136 @@ describe('RestoreManager', function () {
       })
     })
   })
+
+  describe('revertProject', function () {
+    beforeEach(function () {
+      this.ProjectGetter.promises.getProject = sinon.stub()
+      this.ProjectGetter.promises.getProject
+        .withArgs(this.project_id)
+        .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
+      this.RestoreManager.promises.revertFile = sinon.stub().resolves()
+      this.RestoreManager.promises._getProjectPathsAtVersion = sinon
+        .stub()
+        .resolves([])
+      this.ProjectEntityHandler.promises.getAllEntities = sinon
+        .stub()
+        .resolves({ docs: [], files: [] })
+      this.EditorController.promises.deleteEntityWithPath = sinon
+        .stub()
+        .resolves()
+      this.RestoreManager.promises._getUpdatesFromHistory = sinon
+        .stub()
+        .resolves([
+          { toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
+        ])
+    })
+
+    describe('reverting a project without ranges support', function () {
+      beforeEach(function () {
+        this.ProjectGetter.promises.getProject = sinon.stub().resolves({
+          overleaf: { history: { rangesSupportEnabled: false } },
+        })
+      })
+
+      it('should throw an error', async function () {
+        await expect(
+          this.RestoreManager.promises.revertProject(
+            this.user_id,
+            this.project_id,
+            this.version
+          )
+        ).to.eventually.be.rejectedWith('project does not have ranges support')
+      })
+    })
+
+    describe('for a project with overlap in current files and old files', function () {
+      beforeEach(async function () {
+        this.ProjectEntityHandler.promises.getAllEntities = sinon
+          .stub()
+          .resolves({
+            docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
+            files: [{ path: '/figures/image.png' }],
+          })
+        this.RestoreManager.promises._getProjectPathsAtVersion = sinon
+          .stub()
+          .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
+
+        await this.RestoreManager.promises.revertProject(
+          this.user_id,
+          this.project_id,
+          this.version
+        )
+        this.origin = {
+          kind: 'project-restore',
+          version: this.version,
+          timestamp: new Date(this.end_ts).toISOString(),
+        }
+      })
+
+      it('should delete the old files', function () {
+        expect(
+          this.EditorController.promises.deleteEntityWithPath
+        ).to.have.been.calledWith(
+          this.project_id,
+          'new-file.tex',
+          this.origin,
+          this.user_id
+        )
+      })
+
+      it('should not delete the current files', function () {
+        expect(
+          this.EditorController.promises.deleteEntityWithPath
+        ).to.not.have.been.calledWith(
+          this.project_id,
+          'main.tex',
+          this.origin,
+          this.user_id
+        )
+
+        expect(
+          this.EditorController.promises.deleteEntityWithPath
+        ).to.not.have.been.calledWith(
+          this.project_id,
+          'figures/image.png',
+          this.origin,
+          this.user_id
+        )
+      })
+
+      it('should revert the old files', function () {
+        expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
+          this.user_id,
+          this.project_id,
+          this.version,
+          'main.tex'
+        )
+
+        expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
+          this.user_id,
+          this.project_id,
+          this.version,
+          'figures/image.png'
+        )
+
+        expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
+          this.user_id,
+          this.project_id,
+          this.version,
+          'since-deleted.tex'
+        )
+      })
+
+      it('should not revert the current files', function () {
+        expect(
+          this.RestoreManager.promises.revertFile
+        ).to.not.have.been.calledWith(
+          this.user_id,
+          this.project_id,
+          this.version,
+          'new-file.tex'
+        )
+      })
+    })
+  })
 })