Przeglądaj źródła

Add default pathname logic on history react file tree (#12505)

On history react, when the initial screen has been loaded, no default pathname is selected. This PR adds logic for selecting default pathname and getting the diff for that pathname.

Also, add some other small changes, the notable ones are:

- Refactor some type naming and file structure related to the history file tree
- Refactor file tree selectable hooks (merge selectable context provider into the main provider)
- prevent clicking on the same file tree item by checking the current pathname before invoking the handler function

GitOrigin-RevId: 73c36e9ed918ae3d92dd47108fbe8542a7571bdd
M Fahru 3 lat temu
rodzic
commit
fb6746a887

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

@@ -1,26 +1,30 @@
 import HistoryFileTreeItem from './history-file-tree-item'
 import HistoryFileTreeItem from './history-file-tree-item'
 import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
 import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
 import Icon from '../../../../shared/components/icon'
 import Icon from '../../../../shared/components/icon'
-import { useSelectableEntity } from '../../context/history-file-tree-selectable'
-import { DiffOperation } from '../../services/types/file-tree'
+import { useFileTreeItemSelection } from '../../context/hooks/use-file-tree-item-selection'
+import { DiffOperation } from '../../services/types/diff-operation'
+import classNames from 'classnames'
 
 
 type HistoryFileTreeDocProps = {
 type HistoryFileTreeDocProps = {
   name: string
   name: string
-  id: string
+  pathname: string
   operation?: DiffOperation
   operation?: DiffOperation
 }
 }
 
 
 export default function HistoryFileTreeDoc({
 export default function HistoryFileTreeDoc({
   name,
   name,
-  id,
+  pathname,
   operation,
   operation,
 }: HistoryFileTreeDocProps) {
 }: HistoryFileTreeDocProps) {
-  const { props: selectableEntityProps } = useSelectableEntity(id)
+  const { isSelected, onClick } = useFileTreeItemSelection(pathname)
 
 
   return (
   return (
     <li
     <li
       role="treeitem"
       role="treeitem"
-      {...selectableEntityProps}
+      className={classNames({ selected: isSelected })}
+      onClick={onClick}
+      onKeyDown={onClick}
+      aria-selected={isSelected}
       aria-label={name}
       aria-label={name}
       tabIndex={0}
       tabIndex={0}
     >
     >

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

@@ -24,7 +24,7 @@ export default function HistoryFileTreeFolderList({
       {folders.sort(compareFunction).map(folder => {
       {folders.sort(compareFunction).map(folder => {
         return (
         return (
           <HistoryFileTreeFolder
           <HistoryFileTreeFolder
-            key={folder._id}
+            key={folder.name}
             name={folder.name}
             name={folder.name}
             folders={folder.folders}
             folders={folder.folders}
             docs={folder.docs ?? []}
             docs={folder.docs ?? []}
@@ -34,9 +34,9 @@ export default function HistoryFileTreeFolderList({
       {docs.sort(compareFunction).map(doc => {
       {docs.sort(compareFunction).map(doc => {
         return (
         return (
           <HistoryFileTreeDoc
           <HistoryFileTreeDoc
-            key={doc._id}
+            key={doc.pathname}
             name={doc.name}
             name={doc.name}
-            id={doc._id}
+            pathname={doc.pathname}
             operation={doc.operation}
             operation={doc.operation}
           />
           />
         )
         )

+ 2 - 3
services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx

@@ -5,13 +5,12 @@ import HistoryFileTreeItem from './history-file-tree-item'
 import HistoryFileTreeFolderList from './history-file-tree-folder-list'
 import HistoryFileTreeFolderList from './history-file-tree-folder-list'
 
 
 import Icon from '../../../../shared/components/icon'
 import Icon from '../../../../shared/components/icon'
-import type { Doc } from '../../../../../../types/doc'
-import type { HistoryFileTree } from '../../utils/file-tree'
+import type { HistoryDoc, HistoryFileTree } from '../../utils/file-tree'
 
 
 type HistoryFileTreeFolderProps = {
 type HistoryFileTreeFolderProps = {
   name: string
   name: string
   folders: HistoryFileTree[]
   folders: HistoryFileTree[]
-  docs: Doc[]
+  docs: HistoryDoc[]
 }
 }
 
 
 export default function HistoryFileTreeFolder({
 export default function HistoryFileTreeFolder({

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

@@ -1,5 +1,5 @@
 import type { ReactNode } from 'react'
 import type { ReactNode } from 'react'
-import { DiffOperation } from '../../services/types/file-tree'
+import { DiffOperation } from '../../services/types/diff-operation'
 
 
 type FileTreeItemProps = {
 type FileTreeItemProps = {
   name: string
   name: string

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

@@ -1,7 +1,5 @@
 import _ from 'lodash'
 import _ from 'lodash'
-import { useCallback } from 'react'
 import { useHistoryContext } from '../context/history-context'
 import { useHistoryContext } from '../context/history-context'
-import { HistoryFileTreeSelectableProvider } from '../context/history-file-tree-selectable'
 import {
 import {
   fileTreeDiffToFileTreeData,
   fileTreeDiffToFileTreeData,
   reducePathsToTree,
   reducePathsToTree,
@@ -9,16 +7,7 @@ import {
 import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list'
 import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list'
 
 
 export default function HistoryFileTree() {
 export default function HistoryFileTree() {
-  const { fileSelection, setFileSelection } = useHistoryContext()
-
-  const handleSelectFile = useCallback(
-    (pathname: string) => {
-      if (fileSelection) {
-        setFileSelection({ files: fileSelection.files, pathname })
-      }
-    },
-    [fileSelection, setFileSelection]
-  )
+  const { fileSelection } = useHistoryContext()
 
 
   if (!fileSelection) {
   if (!fileSelection) {
     return null
     return null
@@ -29,14 +18,12 @@ export default function HistoryFileTree() {
   const mappedFileTree = fileTreeDiffToFileTreeData(fileTree)
   const mappedFileTree = fileTreeDiffToFileTreeData(fileTree)
 
 
   return (
   return (
-    <HistoryFileTreeSelectableProvider onSelectFile={handleSelectFile}>
-      <HistoryFileTreeFolderList
-        folders={mappedFileTree.folders}
-        docs={mappedFileTree.docs ?? []}
-        rootClassName="file-tree-list"
-      >
-        <li className="bottom-buffer" />
-      </HistoryFileTreeFolderList>
-    </HistoryFileTreeSelectableProvider>
+    <HistoryFileTreeFolderList
+      folders={mappedFileTree.folders}
+      docs={mappedFileTree.docs ?? []}
+      rootClassName="file-tree-list"
+    >
+      <li className="bottom-buffer" />
+    </HistoryFileTreeFolderList>
   )
   )
 }
 }

+ 35 - 7
services/web/frontend/js/features/history/context/history-context.tsx

@@ -14,6 +14,7 @@ import { HistoryContextValue } from './types/history-context-value'
 import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
 import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
 import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
 import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
 import { loadLabels } from '../utils/label'
 import { loadLabels } from '../utils/label'
+import { autoSelectFile } from '../utils/auto-select-file'
 import ColorManager from '../../../ide/colors/ColorManager'
 import ColorManager from '../../../ide/colors/ColorManager'
 import moment from 'moment'
 import moment from 'moment'
 import * as eventTracking from '../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../infrastructure/event-tracking'
@@ -35,6 +36,9 @@ function useHistory() {
   const [updates, setUpdates] = useState<LoadedUpdate[]>([])
   const [updates, setUpdates] = useState<LoadedUpdate[]>([])
   const [loadingFileTree, setLoadingFileTree] =
   const [loadingFileTree, setLoadingFileTree] =
     useState<HistoryContextValue['loadingFileTree']>(true)
     useState<HistoryContextValue['loadingFileTree']>(true)
+  // eslint-disable-next-line no-unused-vars
+  const [viewMode, setViewMode] =
+    useState<HistoryContextValue['viewMode']>('point_in_time')
   const [nextBeforeTimestamp, setNextBeforeTimestamp] =
   const [nextBeforeTimestamp, setNextBeforeTimestamp] =
     useState<HistoryContextValue['nextBeforeTimestamp']>()
     useState<HistoryContextValue['nextBeforeTimestamp']>()
   const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
   const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
@@ -43,8 +47,7 @@ function useHistory() {
   const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
   const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
   const [isLoading, setIsLoading] = useState(false)
   const [isLoading, setIsLoading] = useState(false)
   const [error, setError] = useState(null)
   const [error, setError] = useState(null)
-  /* eslint-disable no-unused-vars */
-  const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
+  // eslint-disable-next-line no-unused-vars
   const [userHasFullFeature, setUserHasFullFeature] =
   const [userHasFullFeature, setUserHasFullFeature] =
     useState<HistoryContextValue['userHasFullFeature']>(undefined)
     useState<HistoryContextValue['userHasFullFeature']>(undefined)
   const [selection, setSelection] = useState<HistoryContextValue['selection']>({
   const [selection, setSelection] = useState<HistoryContextValue['selection']>({
@@ -177,8 +180,14 @@ function useHistory() {
     const { fromV, toV } = updateSelection.update
     const { fromV, toV } = updateSelection.update
 
 
     diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
     diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
       // TODO Infer default file sensibly
       // TODO Infer default file sensibly
-      const pathname = null
+      const pathname = defaultSelection.pathname
       const newFiles = files.map(file => {
       const newFiles = files.map(file => {
         if (isFileRenamed(file) && file.newPathname) {
         if (isFileRenamed(file) && file.newPathname) {
           return renamePathnameKey(file)
           return renamePathnameKey(file)
@@ -188,14 +197,33 @@ function useHistory() {
       })
       })
       setFileSelection({ files: newFiles, pathname })
       setFileSelection({ files: newFiles, pathname })
     })
     })
-  }, [updateSelection, projectId])
+  }, [updateSelection, projectId, selection, updates, viewMode])
 
 
-  // Set update selection if there isn't one
   useEffect(() => {
   useEffect(() => {
+    // Set update selection if there isn't one
     if (updates.length && !updateSelection) {
     if (updates.length && !updateSelection) {
-      setUpdateSelection({ update: updates[0], comparing: false })
+      setUpdateSelection({
+        update: {
+          ...updates[0],
+          // Set fromV and toV for initial load as the latest version
+          // This is to mimic angular history behaviour when selecting default pathname on initial history load
+          fromV: updates[0].toV,
+        },
+        comparing: false,
+      })
+    }
+
+    // Set default selection if there isn't one
+    if (updates.length && selection.range.fromV === null) {
+      setSelection({
+        ...selection,
+        range: {
+          fromV: updates[0].toV,
+          toV: updates[0].toV,
+        },
+      })
     }
     }
-  }, [setUpdateSelection, updateSelection, updates])
+  }, [setUpdateSelection, updateSelection, updates, selection])
 
 
   const value = useMemo<HistoryContextValue>(
   const value = useMemo<HistoryContextValue>(
     () => ({
     () => ({

+ 0 - 87
services/web/frontend/js/features/history/context/history-file-tree-selectable.tsx

@@ -1,87 +0,0 @@
-import {
-  createContext,
-  type ReactNode,
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useState,
-} from 'react'
-import classNames from 'classnames'
-import _ from 'lodash'
-
-import usePreviousValue from '../../../shared/hooks/use-previous-value'
-import { Nullable } from '../../../../../types/utils'
-
-type Context = {
-  select: (id: string) => void
-  selectedFile: Nullable<string>
-}
-
-const FileTreeSelectableContext = createContext<Context>({
-  select: () => {},
-  selectedFile: null,
-})
-
-type HistoryFileTreeSelectableProviderProps = {
-  onSelectFile: (id: string) => void
-  children: ReactNode
-}
-
-export function HistoryFileTreeSelectableProvider({
-  onSelectFile,
-  children,
-}: HistoryFileTreeSelectableProviderProps) {
-  const [selectedFile, setSelectedFile] =
-    useState<Context['selectedFile']>(null)
-
-  const previousSelectedFile = usePreviousValue(selectedFile)
-
-  useEffect(() => {
-    if (!selectedFile) {
-      return
-    }
-
-    if (_.isEqual(selectedFile, previousSelectedFile)) {
-      return
-    }
-
-    onSelectFile(selectedFile)
-  }, [selectedFile, previousSelectedFile, onSelectFile])
-
-  const select = useCallback(id => {
-    setSelectedFile(id)
-  }, [])
-
-  const value = {
-    selectedFile,
-    select,
-  }
-
-  return (
-    <FileTreeSelectableContext.Provider value={value}>
-      {children}
-    </FileTreeSelectableContext.Provider>
-  )
-}
-
-export function useSelectableEntity(id: string) {
-  const { selectedFile, select } = useContext(FileTreeSelectableContext)
-
-  const handleClick = useCallback(() => {
-    select(id)
-  }, [id, select])
-
-  const isSelected = selectedFile === id
-
-  const props = useMemo(
-    () => ({
-      className: classNames({ selected: isSelected }),
-      'aria-selected': isSelected,
-      onClick: handleClick,
-    }),
-    [handleClick, isSelected]
-  )
-
-  return { isSelected, props }
-}

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

@@ -0,0 +1,22 @@
+import { useCallback, useMemo } from 'react'
+import { useHistoryContext } from '../history-context'
+
+export function useFileTreeItemSelection(pathname: string) {
+  const { fileSelection, setFileSelection, selection } = useHistoryContext()
+
+  const handleClick = useCallback(() => {
+    if (pathname !== fileSelection?.pathname) {
+      setFileSelection({
+        files: fileSelection?.files || selection.files,
+        pathname,
+      })
+    }
+  }, [fileSelection, pathname, selection, setFileSelection])
+
+  const isSelected = useMemo(
+    () => fileSelection?.pathname === pathname,
+    [fileSelection, pathname]
+  )
+
+  return { isSelected, onClick: handleClick }
+}

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

@@ -7,10 +7,11 @@ import {
 } from '../../services/types/update'
 } from '../../services/types/update'
 import { Selection } from '../../../../../../types/history/selection'
 import { Selection } from '../../../../../../types/history/selection'
 import { FileSelection } from '../../services/types/file'
 import { FileSelection } from '../../services/types/file'
+import { ViewMode } from '../../services/types/view-mode'
 
 
 export type HistoryContextValue = {
 export type HistoryContextValue = {
   updates: LoadedUpdate[]
   updates: LoadedUpdate[]
-  viewMode: string
+  viewMode: ViewMode
   nextBeforeTimestamp: number | undefined
   nextBeforeTimestamp: number | undefined
   atEnd: boolean
   atEnd: boolean
   userHasFullFeature: boolean | undefined
   userHasFullFeature: boolean | undefined

+ 0 - 0
services/web/frontend/js/features/history/services/types/file-tree.ts → services/web/frontend/js/features/history/services/types/diff-operation.ts


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

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

+ 1 - 0
services/web/frontend/js/features/history/services/types/view-mode.ts

@@ -0,0 +1 @@
+export type ViewMode = 'compare' | 'point_in_time'

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

@@ -0,0 +1,152 @@
+import _ from 'lodash'
+import type { Nullable } from '../../../../../types/utils'
+import type { HistoryContextValue } from '../context/types/history-context-value'
+import type { FileDiff } from '../services/types/file'
+import type { DiffOperation } from '../services/types/diff-operation'
+import type { Update } from '../services/types/update'
+
+function selectFile(
+  file: Nullable<FileDiff>,
+  selection: HistoryContextValue['selection']
+) {
+  if (file === null) {
+    return selection
+  }
+
+  const newSelection = {
+    ...selection,
+    pathname: file.pathname,
+    file,
+  }
+
+  return newSelection
+}
+
+function getUpdateForVersion(
+  version: Update['toV'],
+  updates: HistoryContextValue['updates']
+): Nullable<Update> {
+  return updates.filter(update => update.toV === version)?.[0] ?? null
+}
+
+type FileWithOps = {
+  pathname: FileDiff['pathname']
+  operation: DiffOperation
+}
+
+function getFilesWithOps(
+  viewMode: HistoryContextValue['viewMode'],
+  selection: HistoryContextValue['selection'],
+  updates: HistoryContextValue['updates']
+): FileWithOps[] {
+  if (selection.range.toV && viewMode === 'point_in_time') {
+    const filesWithOps: FileWithOps[] = []
+    const currentUpdate = getUpdateForVersion(selection.range.toV, updates)
+
+    if (currentUpdate !== null) {
+      for (const pathname of currentUpdate.pathnames) {
+        filesWithOps.push({
+          pathname,
+          operation: 'edited',
+        })
+      }
+
+      for (const op of currentUpdate.project_ops) {
+        let fileWithOps: Nullable<FileWithOps> = null
+
+        if (op.add) {
+          fileWithOps = {
+            pathname: op.add.pathname,
+            operation: 'added',
+          }
+        } else if (op.remove) {
+          fileWithOps = {
+            pathname: op.remove.pathname,
+            operation: 'removed',
+          }
+        } else if (op.rename) {
+          fileWithOps = {
+            pathname: op.rename.newPathname,
+            operation: 'renamed',
+          }
+        }
+
+        if (fileWithOps !== null) {
+          filesWithOps.push(fileWithOps)
+        }
+      }
+    }
+
+    return filesWithOps
+  } else {
+    const filesWithOps = _.reduce(
+      selection.files,
+      (curFilesWithOps, file) => {
+        if ('operation' in file) {
+          curFilesWithOps.push({
+            pathname: file.pathname,
+            operation: file.operation,
+          })
+        }
+        return curFilesWithOps
+      },
+      <FileWithOps[]>[]
+    )
+
+    return filesWithOps
+  }
+}
+
+const orderedOpTypes: DiffOperation[] = [
+  'edited',
+  'added',
+  'renamed',
+  'removed',
+]
+
+export function autoSelectFile(
+  files: FileDiff[],
+  selection: HistoryContextValue['selection'],
+  viewMode: HistoryContextValue['viewMode'],
+  updates: HistoryContextValue['updates']
+) {
+  let fileToSelect: Nullable<FileDiff> = null
+
+  const filesWithOps = getFilesWithOps(viewMode, selection, updates)
+  for (const opType of orderedOpTypes) {
+    const fileWithMatchingOpType = _.find(filesWithOps, {
+      operation: opType,
+    })
+
+    if (fileWithMatchingOpType != null) {
+      fileToSelect =
+        _.find(files, {
+          pathname: fileWithMatchingOpType.pathname,
+        }) ?? null
+
+      break
+    }
+  }
+
+  if (!fileToSelect) {
+    const mainFile = _.find(files, function (file) {
+      return /main\.tex$/.test(file.pathname)
+    })
+
+    if (mainFile) {
+      fileToSelect = mainFile
+    } else {
+      const anyTeXFile = _.find(files, function (file) {
+        return /\.tex$/.test(file.pathname)
+      })
+
+      if (anyTeXFile) {
+        fileToSelect = anyTeXFile
+      } else {
+        fileToSelect = files[0]
+      }
+    }
+  }
+
+  return selectFile(fileToSelect, selection)
+}

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

@@ -1,7 +1,6 @@
 import _ from 'lodash'
 import _ from 'lodash'
-import type { Doc } from '../../../../../types/doc'
 import type { FileDiff, FileRenamed } from '../services/types/file'
 import type { FileDiff, FileRenamed } from '../services/types/file'
-import type { DiffOperation } from '../services/types/file-tree'
+import type { DiffOperation } from '../services/types/diff-operation'
 
 
 // `Partial` because the `reducePathsToTree` function was copied directly
 // `Partial` because the `reducePathsToTree` function was copied directly
 // from a javascript file without proper type system and the logic is not typescript-friendly.
 // from a javascript file without proper type system and the logic is not typescript-friendly.
@@ -49,13 +48,15 @@ export function reducePathsToTree(
   return currentFileTree
   return currentFileTree
 }
 }
 
 
-export type HistoryDoc = Doc & Pick<FileTreeEntity, 'operation'>
+export type HistoryDoc = {
+  pathname: string
+  name: string
+} & Pick<FileTreeEntity, 'operation'>
 
 
 export type HistoryFileTree = {
 export type HistoryFileTree = {
   docs?: HistoryDoc[]
   docs?: HistoryDoc[]
   folders: HistoryFileTree[]
   folders: HistoryFileTree[]
   name: string
   name: string
-  _id: string
 }
 }
 
 
 export function fileTreeDiffToFileTreeData(
 export function fileTreeDiffToFileTreeData(
@@ -68,7 +69,7 @@ export function fileTreeDiffToFileTreeData(
   for (const file of fileTreeDiff) {
   for (const file of fileTreeDiff) {
     if (file.type === 'file') {
     if (file.type === 'file') {
       docs.push({
       docs.push({
-        _id: file.pathname ?? '',
+        pathname: file.pathname ?? '',
         name: file.name ?? '',
         name: file.name ?? '',
         operation: file.operation,
         operation: file.operation,
       })
       })
@@ -84,7 +85,6 @@ export function fileTreeDiffToFileTreeData(
     docs,
     docs,
     folders,
     folders,
     name: currentFolderName,
     name: currentFolderName,
-    _id: currentFolderName,
   }
   }
 }
 }
 
 

+ 777 - 0
services/web/test/frontend/features/history/utils/auto-select-file.test.ts

@@ -0,0 +1,777 @@
+import { expect } from 'chai'
+import type { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
+import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file'
+import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file'
+import type { User } from '../../../../../frontend/js/features/history/services/types/shared'
+
+describe('autoSelectFile', function () {
+  const historyUsers: User[] = [
+    {
+      first_name: 'first_name',
+      last_name: 'last_name',
+      email: 'email@overleaf.com',
+      id: '6266xb6b7a366460a66186xx',
+    },
+  ]
+
+  const emptySelection: HistoryContextValue['selection'] = {
+    docs: {},
+    pathname: null,
+    range: {
+      fromV: null,
+      toV: null,
+    },
+    hoveredRange: {
+      fromV: null,
+      toV: null,
+    },
+    diff: null,
+    files: [],
+    file: null,
+  }
+
+  describe('for `point_in_time` view mode', function () {
+    const viewMode: HistoryContextValue['viewMode'] = 'point_in_time'
+
+    it('return the file with `edited` as the last operation', function () {
+      const files: FileDiff[] = [
+        {
+          pathname: 'main.tex',
+        },
+        {
+          pathname: 'sample.bib',
+        },
+        {
+          pathname: 'frog.jpg',
+        },
+        {
+          pathname: 'newfile5.tex',
+        },
+        {
+          pathname: 'newfolder1/newfolder2/newfile2.tex',
+        },
+        {
+          pathname: 'newfolder1/newfile10.tex',
+          operation: 'edited',
+        },
+      ]
+
+      const selection: HistoryContextValue['selection'] = {
+        ...emptySelection,
+        range: {
+          fromV: 26,
+          toV: 26,
+        },
+      }
+
+      const updates: HistoryContextValue['updates'] = [
+        {
+          fromV: 25,
+          toV: 26,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888731881,
+            end_ts: 1680888731881,
+          },
+          labels: [],
+          pathnames: ['newfolder1/newfile10.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 23,
+          toV: 25,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888725098,
+            end_ts: 1680888729123,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              rename: {
+                pathname: 'newfolder1/newfile3.tex',
+                newPathname: 'newfolder1/newfile10.tex',
+              },
+              atV: 24,
+            },
+            {
+              rename: {
+                pathname: 'newfile3.tex',
+                newPathname: 'newfolder1/newfile3.tex',
+              },
+              atV: 23,
+            },
+          ],
+        },
+        {
+          fromV: 22,
+          toV: 23,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888721015,
+            end_ts: 1680888721015,
+          },
+          labels: [],
+          pathnames: ['newfile3.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 19,
+          toV: 22,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888715364,
+            end_ts: 1680888718726,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              rename: {
+                pathname: 'newfolder1/newfolder2/newfile3.tex',
+                newPathname: 'newfile3.tex',
+              },
+              atV: 21,
+            },
+            {
+              rename: {
+                pathname: 'newfolder1/newfile2.tex',
+                newPathname: 'newfolder1/newfolder2/newfile2.tex',
+              },
+              atV: 20,
+            },
+            {
+              rename: {
+                pathname: 'newfolder1/newfile5.tex',
+                newPathname: 'newfile5.tex',
+              },
+              atV: 19,
+            },
+          ],
+        },
+        {
+          fromV: 16,
+          toV: 19,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888705042,
+            end_ts: 1680888712662,
+          },
+          labels: [],
+          pathnames: [
+            'main.tex',
+            'newfolder1/newfile2.tex',
+            'newfolder1/newfile5.tex',
+          ],
+          project_ops: [],
+        },
+        {
+          fromV: 0,
+          toV: 16,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680888456499,
+            end_ts: 1680888640774,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              add: {
+                pathname: 'newfolder1/newfile2.tex',
+              },
+              atV: 15,
+            },
+            {
+              remove: {
+                pathname: 'newfile2.tex',
+              },
+              atV: 14,
+            },
+            {
+              rename: {
+                pathname: 'newfolder1/frog.jpg',
+                newPathname: 'frog.jpg',
+              },
+              atV: 13,
+            },
+            {
+              rename: {
+                pathname: 'newfolder1/newfile2.tex',
+                newPathname: 'newfile2.tex',
+              },
+              atV: 12,
+            },
+            {
+              rename: {
+                pathname: 'newfile5.tex',
+                newPathname: 'newfolder1/newfile5.tex',
+              },
+              atV: 11,
+            },
+            {
+              rename: {
+                pathname: 'newfile4.tex',
+                newPathname: 'newfile5.tex',
+              },
+              atV: 10,
+            },
+            {
+              add: {
+                pathname: 'newfile4.tex',
+              },
+              atV: 9,
+            },
+            {
+              remove: {
+                pathname: 'newfolder1/newfolder2/newfile1.tex',
+              },
+              atV: 8,
+            },
+            {
+              rename: {
+                pathname: 'frog.jpg',
+                newPathname: 'newfolder1/frog.jpg',
+              },
+              atV: 7,
+            },
+            {
+              add: {
+                pathname: 'newfolder1/newfolder2/newfile3.tex',
+              },
+              atV: 6,
+            },
+            {
+              add: {
+                pathname: 'newfolder1/newfile2.tex',
+              },
+              atV: 5,
+            },
+            {
+              rename: {
+                pathname: 'newfolder1/newfile1.tex',
+                newPathname: 'newfolder1/newfolder2/newfile1.tex',
+              },
+              atV: 4,
+            },
+            {
+              add: {
+                pathname: 'newfolder1/newfile1.tex',
+              },
+              atV: 3,
+            },
+            {
+              add: {
+                pathname: 'frog.jpg',
+              },
+              atV: 2,
+            },
+            {
+              add: {
+                pathname: 'sample.bib',
+              },
+              atV: 1,
+            },
+            {
+              add: {
+                pathname: 'main.tex',
+              },
+              atV: 0,
+            },
+          ],
+        },
+      ]
+
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
+
+      expect(defaultSelection.pathname).to.equal('newfolder1/newfile10.tex')
+    })
+    it('return file with `added` operation on highest `atV` value if no other operation is available on the latest `updates` entry', function () {
+      const files: FileDiff[] = [
+        {
+          pathname: 'main.tex',
+          operation: 'added',
+        },
+        {
+          pathname: 'sample.bib',
+          operation: 'added',
+        },
+        {
+          pathname: 'frog.jpg',
+          operation: 'added',
+        },
+        {
+          pathname: 'newfile1.tex',
+          operation: 'added',
+        },
+      ]
+
+      const selection: HistoryContextValue['selection'] = {
+        ...emptySelection,
+        range: {
+          fromV: 4,
+          toV: 4,
+        },
+      }
+
+      const updates: HistoryContextValue['updates'] = [
+        {
+          fromV: 0,
+          toV: 4,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680861468999,
+            end_ts: 1680861491861,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              add: {
+                pathname: 'newfile1.tex',
+              },
+              atV: 3,
+            },
+            {
+              add: {
+                pathname: 'frog.jpg',
+              },
+              atV: 2,
+            },
+            {
+              add: {
+                pathname: 'sample.bib',
+              },
+              atV: 1,
+            },
+            {
+              add: {
+                pathname: 'main.tex',
+              },
+              atV: 0,
+            },
+          ],
+        },
+      ]
+
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
+
+      expect(defaultSelection.pathname).to.equal('newfile1.tex')
+    })
+
+    it('return the last non-`removed` operation with the highest `atV` value', function () {
+      const files: FileDiff[] = [
+        {
+          pathname: 'main.tex',
+          operation: 'removed',
+          deletedAtV: 6,
+        },
+        {
+          pathname: 'sample.bib',
+        },
+        {
+          pathname: 'main2.tex',
+          operation: 'added',
+        },
+        {
+          pathname: 'main3.tex',
+          operation: 'added',
+        },
+      ]
+
+      const selection: HistoryContextValue['selection'] = {
+        ...emptySelection,
+        range: {
+          fromV: 7,
+          toV: 7,
+        },
+      }
+
+      const updates: HistoryContextValue['updates'] = [
+        {
+          fromV: 4,
+          toV: 7,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680874742389,
+            end_ts: 1680874755552,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              remove: {
+                pathname: 'main.tex',
+              },
+              atV: 6,
+            },
+            {
+              add: {
+                pathname: 'main3.tex',
+              },
+              atV: 5,
+            },
+            {
+              add: {
+                pathname: 'main2.tex',
+              },
+              atV: 4,
+            },
+          ],
+        },
+        {
+          fromV: 0,
+          toV: 4,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680861975947,
+            end_ts: 1680861988442,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              remove: {
+                pathname: 'frog.jpg',
+              },
+              atV: 3,
+            },
+            {
+              add: {
+                pathname: 'frog.jpg',
+              },
+              atV: 2,
+            },
+            {
+              add: {
+                pathname: 'sample.bib',
+              },
+              atV: 1,
+            },
+            {
+              add: {
+                pathname: 'main.tex',
+              },
+              atV: 0,
+            },
+          ],
+        },
+      ]
+
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
+
+      expect(defaultSelection.pathname).to.equal('main3.tex')
+    })
+
+    it('if `removed` is the last operation, and no other operation is available on the latest `updates` entry, with `main.tex` available as a file name somewhere in the file tree, return `main.tex`', function () {
+      const files: FileDiff[] = [
+        {
+          pathname: 'main.tex',
+        },
+        {
+          pathname: 'sample.bib',
+        },
+        {
+          pathname: 'frog.jpg',
+        },
+        {
+          pathname: 'newfolder/maybewillbedeleted.tex',
+          newPathname: 'newfolder2/maybewillbedeleted.tex',
+          operation: 'removed',
+          deletedAtV: 10,
+        },
+      ]
+
+      const selection: HistoryContextValue['selection'] = {
+        ...emptySelection,
+        range: {
+          fromV: 11,
+          toV: 11,
+        },
+      }
+
+      const updates: HistoryContextValue['updates'] = [
+        {
+          fromV: 9,
+          toV: 11,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904414419,
+            end_ts: 1680904417538,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              remove: {
+                pathname: 'newfolder2/maybewillbedeleted.tex',
+              },
+              atV: 10,
+            },
+            {
+              rename: {
+                pathname: 'newfolder/maybewillbedeleted.tex',
+                newPathname: 'newfolder2/maybewillbedeleted.tex',
+              },
+              atV: 9,
+            },
+          ],
+        },
+        {
+          fromV: 8,
+          toV: 9,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904410333,
+            end_ts: 1680904410333,
+          },
+          labels: [],
+          pathnames: ['newfolder/maybewillbedeleted.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 7,
+          toV: 8,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904407448,
+            end_ts: 1680904407448,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              rename: {
+                pathname: 'newfolder/tobedeleted.tex',
+                newPathname: 'newfolder/maybewillbedeleted.tex',
+              },
+              atV: 7,
+            },
+          ],
+        },
+        {
+          fromV: 6,
+          toV: 7,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904400839,
+            end_ts: 1680904400839,
+          },
+          labels: [],
+          pathnames: ['newfolder/tobedeleted.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 5,
+          toV: 6,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904398544,
+            end_ts: 1680904398544,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              rename: {
+                pathname: 'tobedeleted.tex',
+                newPathname: 'newfolder/tobedeleted.tex',
+              },
+              atV: 5,
+            },
+          ],
+        },
+        {
+          fromV: 4,
+          toV: 5,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904389891,
+            end_ts: 1680904389891,
+          },
+          labels: [],
+          pathnames: ['tobedeleted.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 0,
+          toV: 4,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680904363778,
+            end_ts: 1680904385308,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              add: {
+                pathname: 'tobedeleted.tex',
+              },
+              atV: 3,
+            },
+            {
+              add: {
+                pathname: 'frog.jpg',
+              },
+              atV: 2,
+            },
+            {
+              add: {
+                pathname: 'sample.bib',
+              },
+              atV: 1,
+            },
+            {
+              add: {
+                pathname: 'main.tex',
+              },
+              atV: 0,
+            },
+          ],
+        },
+      ]
+
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
+
+      expect(defaultSelection.pathname).to.equal('main.tex')
+    })
+
+    it('if `removed` is the last operation, and no other operation is available on the latest `updates` entry, with `main.tex` is not available as a file name somewhere in the file tree, return any tex file based on ascending alphabetical order', function () {
+      const files: FileDiff[] = [
+        {
+          pathname: 'certainly_not_main.tex',
+        },
+        {
+          pathname: 'newfile.tex',
+        },
+        {
+          pathname: 'file2.tex',
+        },
+      ]
+
+      const selection: HistoryContextValue['selection'] = {
+        ...emptySelection,
+        range: {
+          fromV: 8,
+          toV: 8,
+        },
+      }
+
+      const updates: HistoryContextValue['updates'] = [
+        {
+          fromV: 7,
+          toV: 8,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680905536168,
+            end_ts: 1680905536168,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              remove: {
+                pathname: 'newfolder/tobedeleted.txt',
+              },
+              atV: 7,
+            },
+          ],
+        },
+        {
+          fromV: 6,
+          toV: 7,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680905531816,
+            end_ts: 1680905531816,
+          },
+          labels: [],
+          pathnames: ['newfolder/tobedeleted.txt'],
+          project_ops: [],
+        },
+        {
+          fromV: 0,
+          toV: 6,
+          meta: {
+            users: historyUsers,
+            start_ts: 1680905492130,
+            end_ts: 1680905529186,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [
+            {
+              rename: {
+                pathname: 'tobedeleted.txt',
+                newPathname: 'newfolder/tobedeleted.txt',
+              },
+              atV: 5,
+            },
+            {
+              add: {
+                pathname: 'file2.tex',
+              },
+              atV: 4,
+            },
+            {
+              add: {
+                pathname: 'newfile.tex',
+              },
+              atV: 3,
+            },
+            {
+              add: {
+                pathname: 'tobedeleted.txt',
+              },
+              atV: 2,
+            },
+            {
+              rename: {
+                pathname: 'main.tex',
+                newPathname: 'certainly_not_main.tex',
+              },
+              atV: 1,
+            },
+            {
+              add: {
+                pathname: 'main.tex',
+              },
+              atV: 0,
+            },
+          ],
+        },
+      ]
+
+      const defaultSelection = autoSelectFile(
+        files,
+        selection,
+        viewMode,
+        updates
+      )
+
+      expect(defaultSelection.pathname).to.equal('certainly_not_main.tex')
+    })
+  })
+})

+ 0 - 10
services/web/types/file-tree.ts

@@ -1,10 +0,0 @@
-import type { FileRef } from './file-ref'
-import type { Doc } from './doc'
-
-export type FileTree = {
-  _id: string
-  name: string
-  folders: FileTree[]
-  fileRefs: FileRef[]
-  docs: Doc[]
-}

+ 4 - 3
services/web/types/history/selection.ts

@@ -1,10 +1,11 @@
+import { FileDiff } from '../../frontend/js/features/history/services/types/file'
 import { Nullable } from '../utils'
 import { Nullable } from '../utils'
 
 
 type Docs = Record<string, unknown>
 type Docs = Record<string, unknown>
 
 
 interface Range {
 interface Range {
-  fromV: Nullable<unknown>
-  toV: Nullable<unknown>
+  fromV: Nullable<number>
+  toV: Nullable<number>
 }
 }
 
 
 interface HoveredRange {
 interface HoveredRange {
@@ -18,6 +19,6 @@ export interface Selection {
   range: Range
   range: Range
   hoveredRange: HoveredRange
   hoveredRange: HoveredRange
   diff: Nullable<unknown>
   diff: Nullable<unknown>
-  files: unknown[]
+  files: FileDiff[]
   file: Nullable<unknown>
   file: Nullable<unknown>
 }
 }