Просмотр исходного кода

Implement deleted file restore for history react (#12753)

* Add strikethrough to deleted file tree item in history file tree

* Add "restore file" button on toolbar if the selected file have a `removed` operation.

* Implement "restore file" functionality on removed file:

- Refactor the `Selection` object in history context value since we need the `deletedAtV` data which currently is not passed through the state.
- Refactor and clean up file tree type system to support passing through the whole `FileDiff` object for getting the `deletedAtV` data which only appear on `removed` operation
- Implement `postJSON` with file restoration API and pass it on restore file onClick handler at toolbar

* Implement loading behaviour while restoring file is inflight:

- Add `loadingRestoreFile` to `LoadingState`
- Change restore file button to `Restoring...` when in loading state.

* Refactor:

- Rename `DiffOperation` to `FileOperation`
- Extract `isFileRemoved` and `isFileRenamed` to its own file
- Extract `Toolbar` components into small files

GitOrigin-RevId: 2e32ebd2165f73fc6533ff282a9c084162efd682
M Fahru 3 лет назад
Родитель
Сommit
0648b8aa6c
29 измененных файлов с 393 добавлено и 181 удалено
  1. 2 0
      services/web/frontend/extracted-translations.json
  2. 0 1
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare.tsx
  3. 0 1
      services/web/frontend/js/features/history/components/change-list/history-version-details.tsx
  4. 21 19
      services/web/frontend/js/features/history/components/diff-view/diff-view.tsx
  5. 0 73
      services/web/frontend/js/features/history/components/diff-view/toolbar.tsx
  6. 43 0
      services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-datetime.tsx
  7. 36 0
      services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-file-info.tsx
  8. 32 0
      services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-restore-file-button.tsx
  9. 29 0
      services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar.tsx
  10. 6 8
      services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx
  11. 1 6
      services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx
  12. 10 3
      services/web/frontend/js/features/history/components/file-tree/history-file-tree-item.tsx
  13. 11 5
      services/web/frontend/js/features/history/context/history-context.tsx
  14. 6 5
      services/web/frontend/js/features/history/context/hooks/use-file-tree-item-selection.tsx
  15. 44 0
      services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts
  16. 10 1
      services/web/frontend/js/features/history/context/types/history-context-value.ts
  17. 11 1
      services/web/frontend/js/features/history/services/api.ts
  18. 0 1
      services/web/frontend/js/features/history/services/types/diff-operation.ts
  19. 1 0
      services/web/frontend/js/features/history/services/types/file-operation.ts
  20. 5 5
      services/web/frontend/js/features/history/services/types/file.ts
  21. 4 0
      services/web/frontend/js/features/history/services/types/restore-file.ts
  22. 1 1
      services/web/frontend/js/features/history/services/types/selection.ts
  23. 5 5
      services/web/frontend/js/features/history/utils/auto-select-file.ts
  24. 9 0
      services/web/frontend/js/features/history/utils/file-diff.ts
  25. 34 29
      services/web/frontend/js/features/history/utils/file-tree.ts
  26. 13 2
      services/web/frontend/stylesheets/app/editor/history-react.less
  27. 1 0
      services/web/locales/en.json
  28. 28 10
      services/web/test/frontend/features/history/components/toolbar.spec.tsx
  29. 30 5
      services/web/test/frontend/features/history/utils/auto-select-file.test.ts

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

@@ -715,6 +715,8 @@
   "resend": "",
   "resend_confirmation_email": "",
   "resending_confirmation_email": "",
+  "restore_file": "",
+  "restoring": "",
   "reverse_x_sort_order": "",
   "revert_pending_plan_change": "",
   "review": "",

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

@@ -44,7 +44,6 @@ function Compare({
       },
       comparing: true,
       files: [],
-      pathname: null,
     })
   }
 

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

@@ -24,7 +24,6 @@ function HistoryVersionDetails({
         updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
         comparing: false,
         files: [],
-        pathname: null,
       })
     }
   }

+ 21 - 19
services/web/frontend/js/features/history/components/diff-view/diff-view.tsx

@@ -1,7 +1,7 @@
-import Toolbar from './toolbar'
+import { useEffect, useState } from 'react'
+import Toolbar from './toolbar/toolbar'
 import Main from './main'
 import { Diff, DocDiffResponse } from '../../services/types/doc'
-import { useEffect, useState } from 'react'
 import { Nullable } from '../../../../../../types/utils'
 import { useHistoryContext } from '../../context/history-context'
 import { diffDoc } from '../../services/api'
@@ -14,35 +14,37 @@ function DiffView() {
 
   const { isLoading, runAsync } = useAsync<DocDiffResponse>()
 
-  const { updateRange, pathname } = selection
+  const { updateRange, selectedFile } = selection
 
   useEffect(() => {
-    if (!updateRange || !pathname) {
+    if (!updateRange || !selectedFile?.pathname) {
       return
     }
 
     const { fromV, toV } = updateRange
 
     // TODO: Error handling
-    runAsync(diffDoc(projectId, fromV, toV, pathname)).then(data => {
-      let diff: Diff | undefined
+    runAsync(diffDoc(projectId, fromV, toV, selectedFile.pathname)).then(
+      data => {
+        let diff: Diff | undefined
 
-      if (!data?.diff) {
-        setDiff(null)
-      }
+        if (!data?.diff) {
+          setDiff(null)
+        }
 
-      if ('binary' in data.diff) {
-        diff = { binary: true }
-      } else {
-        diff = {
-          binary: false,
-          docDiff: highlightsFromDiffResponse(data.diff),
+        if ('binary' in data.diff) {
+          diff = { binary: true }
+        } else {
+          diff = {
+            binary: false,
+            docDiff: highlightsFromDiffResponse(data.diff),
+          }
         }
-      }
 
-      setDiff(diff)
-    })
-  }, [projectId, runAsync, updateRange, pathname])
+        setDiff(diff)
+      }
+    )
+  }, [projectId, runAsync, updateRange, selectedFile])
 
   return (
     <div className="doc-panel">

+ 0 - 73
services/web/frontend/js/features/history/components/diff-view/toolbar.tsx

@@ -1,73 +0,0 @@
-import { Trans, useTranslation } from 'react-i18next'
-import { formatTime } from '../../../utils/format-date'
-import type { Nullable } from '../../../../../../types/utils'
-import type { Diff } from '../../services/types/doc'
-import type { HistoryContextValue } from '../../context/types/history-context-value'
-
-type ToolbarProps = {
-  diff: Nullable<Diff>
-  selection: HistoryContextValue['selection']
-}
-
-function Toolbar({ diff, selection }: ToolbarProps) {
-  const { t } = useTranslation()
-
-  if (!selection) return null
-
-  return (
-    <div className="history-react-toolbar">
-      <div>
-        {selection.comparing ? (
-          <Trans
-            i18nKey="comparing_x_to_y"
-            // eslint-disable-next-line react/jsx-key
-            components={[<time className="history-react-toolbar-time" />]}
-            values={{
-              startTime: formatTime(
-                selection.updateRange?.fromVTimestamp,
-                'Do MMMM · h:mm a'
-              ),
-              endTime: formatTime(
-                selection.updateRange?.toVTimestamp,
-                'Do MMMM · h:mm a'
-              ),
-            }}
-          />
-        ) : (
-          <Trans
-            i18nKey="viewing_x"
-            // eslint-disable-next-line react/jsx-key
-            components={[<time className="history-react-toolbar-time" />]}
-            values={{
-              endTime: formatTime(
-                selection.updateRange?.toVTimestamp,
-                'Do MMMM · h:mm a'
-              ),
-            }}
-          />
-        )}
-      </div>
-      {selection.pathname ? (
-        <div className="history-react-toolbar-changes">
-          {t('x_changes_in', {
-            count: diff?.docDiff?.highlights.length ?? 0,
-          })}
-          &nbsp;
-          <strong>{getFileName(selection)}</strong>
-        </div>
-      ) : null}
-    </div>
-  )
-}
-
-function getFileName(selection: HistoryContextValue['selection']) {
-  const filePathParts = selection?.pathname?.split('/')
-  let fileName
-  if (filePathParts) {
-    fileName = filePathParts[filePathParts.length - 1]
-  }
-
-  return fileName
-}
-
-export default Toolbar

+ 43 - 0
services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-datetime.tsx

@@ -0,0 +1,43 @@
+import { Trans } from 'react-i18next'
+import { formatTime } from '../../../../utils/format-date'
+import type { HistoryContextValue } from '../../../context/types/history-context-value'
+
+type ToolbarDatetimeProps = {
+  selection: HistoryContextValue['selection']
+}
+
+export default function ToolbarDatetime({ selection }: ToolbarDatetimeProps) {
+  return (
+    <div className="history-react-toolbar-datetime">
+      {selection.comparing ? (
+        <Trans
+          i18nKey="comparing_x_to_y"
+          // eslint-disable-next-line react/jsx-key
+          components={[<time className="history-react-toolbar-time" />]}
+          values={{
+            startTime: formatTime(
+              selection.updateRange?.fromVTimestamp,
+              'Do MMMM · h:mm a'
+            ),
+            endTime: formatTime(
+              selection.updateRange?.toVTimestamp,
+              'Do MMMM · h:mm a'
+            ),
+          }}
+        />
+      ) : (
+        <Trans
+          i18nKey="viewing_x"
+          // eslint-disable-next-line react/jsx-key
+          components={[<time className="history-react-toolbar-time" />]}
+          values={{
+            endTime: formatTime(
+              selection.updateRange?.toVTimestamp,
+              'Do MMMM · h:mm a'
+            ),
+          }}
+        />
+      )}
+    </div>
+  )
+}

+ 36 - 0
services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-file-info.tsx

@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next'
+import type { HistoryContextValue } from '../../../context/types/history-context-value'
+import type { Diff } from '../../../services/types/doc'
+import type { Nullable } from '../../../../../../../types/utils'
+
+type ToolbarFileInfoProps = {
+  diff: Nullable<Diff>
+  selection: HistoryContextValue['selection']
+}
+
+export default function ToolbarFileInfo({
+  diff,
+  selection,
+}: ToolbarFileInfoProps) {
+  const { t } = useTranslation()
+
+  return (
+    <div className="history-react-toolbar-file-info">
+      {t('x_changes_in', {
+        count: diff?.docDiff?.highlights?.length ?? 0,
+      })}
+      &nbsp;
+      <strong>{getFileName(selection)}</strong>
+    </div>
+  )
+}
+
+function getFileName(selection: HistoryContextValue['selection']) {
+  const filePathParts = selection?.selectedFile?.pathname?.split('/')
+  let fileName
+  if (filePathParts) {
+    fileName = filePathParts[filePathParts.length - 1]
+  }
+
+  return fileName
+}

+ 32 - 0
services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-restore-file-button.tsx

@@ -0,0 +1,32 @@
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useHistoryContext } from '../../../context/history-context'
+import { useRestoreDeletedFile } from '../../../context/hooks/use-restore-deleted-file'
+import type { HistoryContextValue } from '../../../context/types/history-context-value'
+
+type ToolbarRestoreFileButtonProps = {
+  selection: HistoryContextValue['selection']
+}
+
+export default function ToolbarRestoreFileButton({
+  selection,
+}: ToolbarRestoreFileButtonProps) {
+  const { t } = useTranslation()
+  const { loadingState } = useHistoryContext()
+
+  const onRestoreFile = useRestoreDeletedFile()
+
+  return (
+    <Button
+      className="btn-secondary history-react-toolbar-restore-file-button"
+      bsSize="xs"
+      bsStyle={null}
+      onClick={() => onRestoreFile(selection)}
+      disabled={loadingState === 'restoringFile'}
+    >
+      {loadingState === 'restoringFile'
+        ? `${t('restoring')}…`
+        : t('restore_file')}
+    </Button>
+  )
+}

+ 29 - 0
services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar.tsx

@@ -0,0 +1,29 @@
+import type { Nullable } from '../../../../../../../types/utils'
+import type { Diff } from '../../../services/types/doc'
+import type { HistoryContextValue } from '../../../context/types/history-context-value'
+import ToolbarDatetime from './toolbar-datetime'
+import ToolbarFileInfo from './toolbar-file-info'
+import ToolbarRestoreFileButton from './toolbar-restore-file-button'
+import { isFileRemoved } from '../../../utils/file-diff'
+
+type ToolbarProps = {
+  diff: Nullable<Diff>
+  selection: HistoryContextValue['selection']
+}
+
+export default function Toolbar({ diff, selection }: ToolbarProps) {
+  const showRestoreFileButton =
+    selection.selectedFile && isFileRemoved(selection.selectedFile)
+
+  return (
+    <div className="history-react-toolbar">
+      <ToolbarDatetime selection={selection} />
+      {selection.selectedFile?.pathname ? (
+        <ToolbarFileInfo diff={diff} selection={selection} />
+      ) : null}
+      {showRestoreFileButton ? (
+        <ToolbarRestoreFileButton selection={selection} />
+      ) : null}
+    </div>
+  )
+}

+ 6 - 8
services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx

@@ -1,22 +1,20 @@
+import classNames from 'classnames'
 import HistoryFileTreeItem from './history-file-tree-item'
 import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
 import Icon from '../../../../shared/components/icon'
 import { useFileTreeItemSelection } from '../../context/hooks/use-file-tree-item-selection'
-import { DiffOperation } from '../../services/types/diff-operation'
-import classNames from 'classnames'
+import type { FileDiff } from '../../services/types/file'
 
 type HistoryFileTreeDocProps = {
+  file: FileDiff
   name: string
-  pathname: string
-  operation?: DiffOperation
 }
 
 export default function HistoryFileTreeDoc({
+  file,
   name,
-  pathname,
-  operation,
 }: HistoryFileTreeDocProps) {
-  const { isSelected, onClick } = useFileTreeItemSelection(pathname)
+  const { isSelected, onClick } = useFileTreeItemSelection(file)
 
   return (
     <li
@@ -30,7 +28,7 @@ export default function HistoryFileTreeDoc({
     >
       <HistoryFileTreeItem
         name={name}
-        operation={operation}
+        operation={'operation' in file ? file.operation : undefined}
         icons={
           <Icon
             type={iconTypeFromName(name)}

+ 1 - 6
services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx

@@ -33,12 +33,7 @@ export default function HistoryFileTreeFolderList({
       })}
       {docs.sort(compareFunction).map(doc => {
         return (
-          <HistoryFileTreeDoc
-            key={doc.pathname}
-            name={doc.name}
-            pathname={doc.pathname}
-            operation={doc.operation}
-          />
+          <HistoryFileTreeDoc key={doc.pathname} name={doc.name} file={doc} />
         )
       })}
       {children}

+ 10 - 3
services/web/frontend/js/features/history/components/file-tree/history-file-tree-item.tsx

@@ -1,10 +1,11 @@
+import classNames from 'classnames'
 import type { ReactNode } from 'react'
-import type { DiffOperation } from '../../services/types/diff-operation'
+import type { FileOperation } from '../../services/types/file-operation'
 import Badge from '../../../../shared/components/badge'
 
 type FileTreeItemProps = {
   name: string
-  operation?: DiffOperation
+  operation?: FileOperation
   icons: ReactNode
 }
 
@@ -17,7 +18,13 @@ export default function HistoryFileTreeItem({
     <div className="history-file-tree-item" role="presentation">
       {icons}
       <button className="history-file-tree-item-button">
-        <span className="history-file-tree-item-button-text">{name}</span>
+        <span
+          className={classNames('history-file-tree-item-button-text', {
+            strikethrough: operation === 'removed',
+          })}
+        >
+          {name}
+        </span>
         {operation ? (
           <Badge className="history-file-tree-item-button-badge" size="sm">
             {operation}

+ 11 - 5
services/web/frontend/js/features/history/context/history-context.tsx

@@ -12,7 +12,8 @@ import { useUserContext } from '../../../shared/context/user-context'
 import { useProjectContext } from '../../../shared/context/project-context'
 import { HistoryContextValue } from './types/history-context-value'
 import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
-import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
+import { renamePathnameKey } from '../utils/file-tree'
+import { isFileRenamed } from '../utils/file-diff'
 import { loadLabels } from '../utils/label'
 import { autoSelectFile } from '../utils/auto-select-file'
 import ColorManager from '../../../ide/colors/ColorManager'
@@ -61,7 +62,6 @@ const selectionInitialState: Selection = {
   updateRange: null,
   comparing: false,
   files: [],
-  pathname: null,
 }
 
 function useHistory() {
@@ -209,7 +209,7 @@ function useHistory() {
     const { fromV, toV } = updateRange
 
     diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
-      const pathname = autoSelectFile(
+      const selectedFile = autoSelectFile(
         files,
         updateRange.toV,
         comparing,
@@ -222,7 +222,12 @@ function useHistory() {
 
         return file
       })
-      setSelection({ updateRange, comparing, files: newFiles, pathname })
+      setSelection({
+        updateRange,
+        comparing,
+        files: newFiles,
+        selectedFile,
+      })
     })
   }, [updateRange, projectId, updates, comparing])
 
@@ -238,7 +243,6 @@ function useHistory() {
         },
         comparing: false,
         files: [],
-        pathname: null,
       })
     }
   }, [updateRange, updates])
@@ -247,6 +251,7 @@ function useHistory() {
     () => ({
       error,
       loadingState,
+      setLoadingState,
       updatesInfo,
       setUpdatesInfo,
       labels,
@@ -261,6 +266,7 @@ function useHistory() {
     [
       error,
       loadingState,
+      setLoadingState,
       updatesInfo,
       setUpdatesInfo,
       labels,

+ 6 - 5
services/web/frontend/js/features/history/context/hooks/use-file-tree-item-selection.tsx

@@ -1,19 +1,20 @@
 import { useCallback } from 'react'
 import { useHistoryContext } from '../history-context'
+import type { FileDiff } from '../../services/types/file'
 
-export function useFileTreeItemSelection(pathname: string) {
+export function useFileTreeItemSelection(file: FileDiff) {
   const { selection, setSelection } = useHistoryContext()
 
   const handleClick = useCallback(() => {
-    if (pathname !== selection.pathname) {
+    if (file.pathname !== selection.selectedFile?.pathname) {
       setSelection({
         ...selection,
-        pathname,
+        selectedFile: file,
       })
     }
-  }, [pathname, selection, setSelection])
+  }, [file, selection, setSelection])
 
-  const isSelected = selection.pathname === pathname
+  const isSelected = selection.selectedFile?.pathname === file.pathname
 
   return { isSelected, onClick: handleClick }
 }

+ 44 - 0
services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts

@@ -0,0 +1,44 @@
+import { sendMB } from '../../../../infrastructure/event-tracking'
+import { useIdeContext } from '../../../../shared/context/ide-context'
+import { useLayoutContext } from '../../../../shared/context/layout-context'
+import useAsync from '../../../../shared/hooks/use-async'
+import { restoreFile } from '../../services/api'
+import { isFileRemoved } from '../../utils/file-diff'
+import { useHistoryContext } from '../history-context'
+import type { HistoryContextValue } from '../types/history-context-value'
+
+export function useRestoreDeletedFile() {
+  const { runAsync } = useAsync()
+  const { setLoadingState, projectId } = useHistoryContext()
+  const ide = useIdeContext()
+  const { setView } = useLayoutContext()
+
+  return async (selection: HistoryContextValue['selection']) => {
+    const { selectedFile } = selection
+
+    if (selectedFile && selectedFile.pathname && isFileRemoved(selectedFile)) {
+      sendMB('history-v2-restore-deleted')
+      setLoadingState('restoringFile')
+
+      await runAsync(
+        restoreFile(projectId, selectedFile)
+          .then(data => {
+            const { id, type } = data
+
+            const entity = ide.fileTreeManager.findEntityById(id)
+
+            if (type === 'doc') {
+              ide.editorManager.openDoc(entity)
+            } else {
+              ide.binaryFilesManager.openFile(entity)
+            }
+
+            setView('editor')
+          })
+          .finally(() => {
+            setLoadingState('ready')
+          })
+      )
+    }
+  }
+}

+ 10 - 1
services/web/frontend/js/features/history/context/types/history-context-value.ts

@@ -3,6 +3,12 @@ import { LoadedUpdate } from '../../services/types/update'
 import { LoadedLabel } from '../../services/types/label'
 import { Selection } from '../../services/types/selection'
 
+type LoadingState =
+  | 'loadingInitial'
+  | 'loadingUpdates'
+  | 'restoringFile'
+  | 'ready'
+
 export type HistoryContextValue = {
   updatesInfo: {
     updates: LoadedUpdate[]
@@ -14,7 +20,10 @@ export type HistoryContextValue = {
     React.SetStateAction<HistoryContextValue['updatesInfo']>
   >
   userHasFullFeature: boolean | undefined
-  loadingState: 'loadingInitial' | 'loadingUpdates' | 'ready'
+  loadingState: LoadingState
+  setLoadingState: React.Dispatch<
+    React.SetStateAction<HistoryContextValue['loadingState']>
+  >
   error: Nullable<unknown>
   labels: Nullable<LoadedLabel[]>
   setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>

+ 11 - 1
services/web/frontend/js/features/history/services/api.ts

@@ -3,10 +3,11 @@ import {
   getJSON,
   postJSON,
 } from '../../../infrastructure/fetch-json'
-import { FileDiff } from './types/file'
+import { FileDiff, FileRemoved } from './types/file'
 import { FetchUpdatesResponse } from './types/update'
 import { Label } from './types/label'
 import { DocDiffResponse } from './types/doc'
+import { RestoreFileResponse } from './types/restore-file'
 
 const BATCH_SIZE = 10
 
@@ -70,3 +71,12 @@ export function diffDoc(
   const diffUrl = `/project/${projectId}/diff?${queryParamsSerialized}`
   return getJSON<DocDiffResponse>(diffUrl)
 }
+
+export function restoreFile(projectId: string, selectedFile: FileRemoved) {
+  return postJSON<RestoreFileResponse>(`/project/${projectId}/restore_file`, {
+    body: {
+      version: selectedFile.deletedAtV,
+      pathname: selectedFile.pathname,
+    },
+  })
+}

+ 0 - 1
services/web/frontend/js/features/history/services/types/diff-operation.ts

@@ -1 +0,0 @@
-export type DiffOperation = 'edited' | 'added' | 'renamed' | 'removed'

+ 1 - 0
services/web/frontend/js/features/history/services/types/file-operation.ts

@@ -0,0 +1 @@
+export type FileOperation = 'edited' | 'added' | 'renamed' | 'removed'

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

@@ -1,27 +1,27 @@
-import { DiffOperation } from './diff-operation'
+import { FileOperation } from './file-operation'
 
 export interface FileUnchanged {
   pathname: string
 }
 
 export interface FileAdded extends FileUnchanged {
-  operation: Extract<DiffOperation, 'added'>
+  operation: Extract<FileOperation, 'added'>
 }
 
 export interface FileRemoved extends FileUnchanged {
-  operation: Extract<DiffOperation, 'removed'>
+  operation: Extract<FileOperation, 'removed'>
   newPathname?: string
   deletedAtV: number
 }
 
 export interface FileEdited extends FileUnchanged {
-  operation: Extract<DiffOperation, 'edited'>
+  operation: Extract<FileOperation, 'edited'>
 }
 
 export interface FileRenamed extends FileUnchanged {
   newPathname?: string
   oldPathname?: string
-  operation: Extract<DiffOperation, 'renamed'>
+  operation: Extract<FileOperation, 'renamed'>
 }
 
 export type FileDiff =

+ 4 - 0
services/web/frontend/js/features/history/services/types/restore-file.ts

@@ -0,0 +1,4 @@
+export type RestoreFileResponse = {
+  id: string
+  type: 'doc' | 'file'
+}

+ 1 - 1
services/web/frontend/js/features/history/services/types/selection.ts

@@ -5,5 +5,5 @@ export interface Selection {
   updateRange: UpdateRange | null
   comparing: boolean
   files: FileDiff[]
-  pathname: string | null
+  selectedFile?: FileDiff
 }

+ 5 - 5
services/web/frontend/js/features/history/utils/auto-select-file.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash'
 import type { Nullable } from '../../../../../types/utils'
 import type { FileDiff } from '../services/types/file'
-import type { DiffOperation } from '../services/types/diff-operation'
+import type { FileOperation } from '../services/types/file-operation'
 import type { LoadedUpdate, Version } from '../services/types/update'
 
 function getUpdateForVersion(
@@ -13,7 +13,7 @@ function getUpdateForVersion(
 
 type FileWithOps = {
   pathname: FileDiff['pathname']
-  operation: DiffOperation
+  operation: FileOperation
 }
 
 function getFilesWithOps(
@@ -80,7 +80,7 @@ function getFilesWithOps(
   }
 }
 
-const orderedOpTypes: DiffOperation[] = [
+const orderedOpTypes: FileOperation[] = [
   'edited',
   'added',
   'renamed',
@@ -92,7 +92,7 @@ export function autoSelectFile(
   toV: Version,
   comparing: boolean,
   updates: LoadedUpdate[]
-) {
+): FileDiff {
   let fileToSelect: Nullable<FileDiff> = null
 
   const filesWithOps = getFilesWithOps(files, toV, comparing, updates)
@@ -131,5 +131,5 @@ export function autoSelectFile(
     }
   }
 
-  return fileToSelect.pathname
+  return fileToSelect
 }

+ 9 - 0
services/web/frontend/js/features/history/utils/file-diff.ts

@@ -0,0 +1,9 @@
+import type { FileDiff, FileRemoved, FileRenamed } from '../services/types/file'
+
+export function isFileRenamed(fileDiff: FileDiff): fileDiff is FileRenamed {
+  return (fileDiff as FileRenamed).operation === 'renamed'
+}
+
+export function isFileRemoved(fileDiff: FileDiff): fileDiff is FileRemoved {
+  return (fileDiff as FileRemoved).operation === 'removed'
+}

+ 34 - 29
services/web/frontend/js/features/history/utils/file-tree.ts

@@ -1,19 +1,12 @@
 import _ from 'lodash'
 import type { FileDiff, FileRenamed } from '../services/types/file'
-import type { DiffOperation } from '../services/types/diff-operation'
+import { isFileRemoved } from './file-diff'
 
-// `Partial` because the `reducePathsToTree` function was copied directly
-// from a javascript file without proper type system and the logic is not typescript-friendly.
-// TODO: refactor the function to have a proper type system
-type FileTreeEntity = Partial<{
-  name: string
-  type: 'file' | 'folder'
-  oldPathname: string
-  newPathname: string
-  pathname: string
-  children: FileTreeEntity[]
-  operation: DiffOperation
-}>
+export type FileTreeEntity = {
+  name?: string
+  type?: 'file' | 'folder'
+  children?: FileTreeEntity[]
+} & FileDiff
 
 export function reducePathsToTree(
   currentFileTree: FileTreeEntity[],
@@ -21,25 +14,31 @@ export function reducePathsToTree(
 ) {
   const filePathParts = fileObject?.pathname?.split('/') ?? ''
   let currentFileTreeLocation = currentFileTree
+
   for (let index = 0; index < filePathParts.length; index++) {
-    let fileTreeEntity: FileTreeEntity | null = {}
     const pathPart = filePathParts[index]
     const isFile = index === filePathParts.length - 1
+
     if (isFile) {
-      fileTreeEntity = _.clone(fileObject)
+      const fileTreeEntity: FileTreeEntity = _.clone(fileObject)
       fileTreeEntity.name = pathPart
       fileTreeEntity.type = 'file'
+
       currentFileTreeLocation.push(fileTreeEntity)
     } else {
-      fileTreeEntity =
-        _.find(currentFileTreeLocation, entity => entity.name === pathPart) ??
-        null
-      if (fileTreeEntity == null) {
+      let fileTreeEntity: FileTreeEntity | undefined = _.find(
+        currentFileTreeLocation,
+        entity => entity.name === pathPart
+      )
+
+      if (fileTreeEntity === undefined) {
         fileTreeEntity = {
           name: pathPart,
           type: 'folder',
-          children: [],
+          children: <FileTreeEntity[]>[],
+          pathname: pathPart,
         }
+
         currentFileTreeLocation.push(fileTreeEntity)
       }
       currentFileTreeLocation = fileTreeEntity.children ?? []
@@ -49,9 +48,8 @@ export function reducePathsToTree(
 }
 
 export type HistoryDoc = {
-  pathname: string
   name: string
-} & Pick<FileTreeEntity, 'operation'>
+} & FileDiff
 
 export type HistoryFileTree = {
   docs?: HistoryDoc[]
@@ -68,11 +66,22 @@ export function fileTreeDiffToFileTreeData(
 
   for (const file of fileTreeDiff) {
     if (file.type === 'file') {
-      docs.push({
+      const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
+
+      let newDoc: HistoryDoc = {
         pathname: file.pathname ?? '',
         name: file.name ?? '',
-        operation: file.operation,
-      })
+        deletedAtV,
+      }
+
+      if ('operation' in file) {
+        newDoc = {
+          ...newDoc,
+          operation: file.operation,
+        }
+      }
+
+      docs.push(newDoc)
     } else if (file.type === 'folder') {
       if (file.children) {
         const folder = fileTreeDiffToFileTreeData(file.children, file.name)
@@ -108,7 +117,3 @@ export function renamePathnameKey(file: FileRenamed): FileRenamed {
     operation: file.operation,
   }
 }
-
-export function isFileRenamed(fileDiff: FileDiff): fileDiff is FileRenamed {
-  return (fileDiff as FileRenamed).operation === 'renamed'
-}

+ 13 - 2
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -252,13 +252,20 @@ history-root {
 
   .history-react-toolbar {
     display: flex;
-    justify-content: space-between;
     align-items: center;
-    width: 100%;
+
+    .history-react-toolbar-file-info {
+      flex: 1;
+      text-align: right;
+    }
 
     .history-react-toolbar-time {
       font-weight: 700;
     }
+
+    .history-react-toolbar-restore-file-button {
+      margin-left: 5px;
+    }
   }
 }
 
@@ -391,6 +398,10 @@ history-root {
           .history-file-tree-item-button-text {
             display: inline-flex;
             margin-right: 5px;
+
+            &.strikethrough {
+              text-decoration: line-through;
+            }
           }
 
           .history-file-tree-item-button-badge {

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

@@ -1252,6 +1252,7 @@
   "resolve": "Resolve",
   "resolved_comments": "Resolved comments",
   "restore": "Restore",
+  "restore_file": "Restore file",
   "restore_to_any_older_version": "Restore to any older version",
   "restore_to_before_these_changes": "Restore to before these changes",
   "restoring": "Restoring",

+ 28 - 10
services/web/test/frontend/features/history/components/toolbar.spec.tsx

@@ -1,8 +1,14 @@
-import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar'
+import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar/toolbar'
+import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
 import { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
 import { Diff } from '../../../../../frontend/js/features/history/services/types/doc'
+import { EditorProviders } from '../../../helpers/editor-providers'
 
 describe('history toolbar', function () {
+  const editorProvidersScope = {
+    ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
+  }
+
   const diff: Diff = {
     binary: false,
     docDiff: {
@@ -42,20 +48,26 @@ describe('history toolbar', function () {
           pathname: 'frog.jpg',
         },
       ],
-      pathname: 'main.tex',
+      selectedFile: {
+        pathname: 'main.tex',
+      },
     }
 
     cy.mount(
-      <div className="history-react">
-        <Toolbar diff={diff} selection={selection} />
-      </div>
+      <EditorProviders scope={editorProvidersScope}>
+        <HistoryProvider>
+          <div className="history-react">
+            <Toolbar diff={diff} selection={selection} />
+          </div>
+        </HistoryProvider>
+      </EditorProviders>
     )
 
     cy.get('.history-react-toolbar').within(() => {
       cy.get('div:first-child').contains('Viewing 13th April')
     })
 
-    cy.get('.history-react-toolbar-changes').contains('1 change in main.tex')
+    cy.get('.history-react-toolbar-file-info').contains('1 change in main.tex')
   })
 
   it('renders comparing mode', function () {
@@ -81,13 +93,19 @@ describe('history toolbar', function () {
           operation: 'added',
         },
       ],
-      pathname: 'main.tex',
+      selectedFile: {
+        pathname: 'main.tex',
+      },
     }
 
     cy.mount(
-      <div className="history-react">
-        <Toolbar diff={diff} selection={selection} />
-      </div>
+      <EditorProviders scope={editorProvidersScope}>
+        <HistoryProvider>
+          <div className="history-react">
+            <Toolbar diff={diff} selection={selection} />
+          </div>
+        </HistoryProvider>
+      </EditorProviders>
     )
 
     cy.get('.history-react-toolbar').within(() => {

+ 30 - 5
services/web/test/frontend/features/history/utils/auto-select-file.test.ts

@@ -259,7 +259,12 @@ describe('autoSelectFile', function () {
         },
       ]
 
-      const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
+      const { pathname } = autoSelectFile(
+        files,
+        updates[0].toV,
+        comparing,
+        updates
+      )
 
       expect(pathname).to.equal('newfolder1/newfile10.tex')
     })
@@ -324,7 +329,12 @@ describe('autoSelectFile', function () {
         },
       ]
 
-      const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
+      const { pathname } = autoSelectFile(
+        files,
+        updates[0].toV,
+        comparing,
+        updates
+      )
 
       expect(pathname).to.equal('newfile1.tex')
     })
@@ -420,7 +430,12 @@ describe('autoSelectFile', function () {
         },
       ]
 
-      const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
+      const { pathname } = autoSelectFile(
+        files,
+        updates[0].toV,
+        comparing,
+        updates
+      )
 
       expect(pathname).to.equal('main3.tex')
     })
@@ -586,7 +601,12 @@ describe('autoSelectFile', function () {
         },
       ]
 
-      const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
+      const { pathname } = autoSelectFile(
+        files,
+        updates[0].toV,
+        comparing,
+        updates
+      )
 
       expect(pathname).to.equal('main.tex')
     })
@@ -689,7 +709,12 @@ describe('autoSelectFile', function () {
         },
       ]
 
-      const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
+      const { pathname } = autoSelectFile(
+        files,
+        updates[0].toV,
+        comparing,
+        updates
+      )
 
       expect(pathname).to.equal('certainly_not_main.tex')
     })