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

Merge pull request #12366 from overleaf/ii-react-history-context

[web] Add context to history

GitOrigin-RevId: bc504994c50c0e7abe8181a671357d5db59a3343
ilkin-overleaf 3 лет назад
Родитель
Сommit
95c8a1aeea

+ 4 - 0
services/web/frontend/js/features/history/components/change-list/change-list.tsx

@@ -1,7 +1,11 @@
 import ToggleSwitch from './toggle-switch'
 import Main from './main'
+import { useState } from 'react'
 
 function ChangeList() {
+  // eslint-disable-next-line no-unused-vars
+  const [labelsOnly, setLabelsOnly] = useState(false)
+
   return (
     <aside className="change-list">
       <div className="history-header toggle-switch-container">

+ 5 - 1
services/web/frontend/js/features/history/components/editor/main.tsx

@@ -1,5 +1,9 @@
+import { useHistoryContext } from '../../context/history-context'
+
 function Main() {
-  return <div>Main (editor)</div>
+  const { fileSelection } = useHistoryContext()
+
+  return <div>Main (editor). File: {fileSelection?.pathname || 'not set'}</div>
 }
 
 export default Main

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

@@ -1,3 +1,18 @@
-export default function HistoryFileTree() {
-  return <div>History file tree</div>
+import { useHistoryContext } from '../context/history-context'
+
+function HistoryFileTree() {
+  // eslint-disable-next-line no-unused-vars
+  const { fileSelection, setFileSelection } = useHistoryContext()
+
+  return fileSelection ? (
+    <ol>
+      {fileSelection.files.map(file => (
+        <li key={file.pathname}>{file.pathname}</li>
+      ))}
+    </ol>
+  ) : (
+    <div>No files</div>
+  )
 }
+
+export default HistoryFileTree

+ 11 - 4
services/web/frontend/js/features/history/components/history-root.tsx

@@ -2,19 +2,26 @@ import { createPortal } from 'react-dom'
 import HistoryFileTree from './history-file-tree'
 import ChangeList from './change-list/change-list'
 import Editor from './editor/editor'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import { useHistoryContext } from '../context/history-context'
 
 const fileTreeContainer = document.getElementById('history-file-tree')
 
 export default function HistoryRoot() {
+  const { view } = useLayoutContext()
+  const { updates } = useHistoryContext()
+
   return (
     <>
       {fileTreeContainer
         ? createPortal(<HistoryFileTree />, fileTreeContainer)
         : null}
-      <div className="history-react">
-        <Editor />
-        <ChangeList />
-      </div>
+      {view === 'history' && updates.length > 0 && (
+        <div className="history-react">
+          <Editor />
+          <ChangeList />
+        </div>
+      )}
     </>
   )
 }

+ 155 - 0
services/web/frontend/js/features/history/context/history-context.tsx

@@ -0,0 +1,155 @@
+import { createContext, useContext, useEffect, useState, useMemo } from 'react'
+import { useIdeContext } from '../../../shared/context/ide-context'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import useAsync from '../../../shared/hooks/use-async'
+import { HistoryContextValue } from './types/history-context-value'
+import { Update, UpdateSelection } from '../services/types/update'
+import { FileSelection } from '../services/types/file'
+import { diffFiles, fetchUpdates } from '../services/api'
+
+function useHistory() {
+  const { view } = useLayoutContext()
+  const ide = useIdeContext()
+  const projectId = ide.project_id
+  const [updateSelection, setUpdateSelection] =
+    useState<UpdateSelection | null>(null)
+  const [fileSelection, setFileSelection] = useState<FileSelection | null>(null)
+  /* eslint-disable no-unused-vars */
+  const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
+  const [nextBeforeTimestamp, setNextBeforeTimestamp] =
+    useState<HistoryContextValue['nextBeforeTimestamp']>(null)
+  const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
+  const [showOnlyLabels, setShowOnlyLabels] =
+    useState<HistoryContextValue['showOnlyLabels']>(false)
+  const [userHasFullFeature, setUserHasFullFeature] =
+    useState<HistoryContextValue['userHasFullFeature']>(undefined)
+  const [freeHistoryLimitHit, setFreeHistoryLimitHit] =
+    useState<HistoryContextValue['freeHistoryLimitHit']>(false)
+  const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
+  const [selection, setSelection] = useState<HistoryContextValue['selection']>({
+    docs: {},
+    pathname: null,
+    range: {
+      fromV: null,
+      toV: null,
+    },
+    hoveredRange: {
+      fromV: null,
+      toV: null,
+    },
+    diff: null,
+    files: [],
+    file: null,
+  })
+  /* eslint-enable no-unused-vars */
+
+  const {
+    isLoading: loading,
+    error,
+    data,
+    runAsync,
+  } = useAsync<{ updates: Update[] }>()
+  const updates = useMemo(() => data?.updates ?? [], [data?.updates])
+  const loadingFileTree = true
+
+  // Initial load when the History tab is active
+  useEffect(() => {
+    if (view === 'history') {
+      runAsync(fetchUpdates(projectId)).catch(console.error)
+    }
+  }, [view, projectId, runAsync])
+
+  // Load files when the update selection changes
+  useEffect(() => {
+    if (!updateSelection) {
+      return
+    }
+    const { fromV, toV } = updateSelection.update
+
+    diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
+      // TODO Infer default file sensibly
+      let pathname = null
+      for (const file of files) {
+        if (file.pathname.endsWith('.tex')) {
+          pathname = file.pathname
+          break
+        } else if (!pathname) {
+          pathname = file.pathname
+        }
+      }
+      setFileSelection({ files, pathname })
+    })
+  }, [updateSelection, projectId])
+
+  // Set update selection if there isn't one
+  useEffect(() => {
+    if (updates.length && !updateSelection) {
+      setUpdateSelection({ update: updates[0], comparing: false })
+    }
+  }, [setUpdateSelection, updateSelection, updates])
+
+  const value = useMemo<HistoryContextValue>(
+    () => ({
+      atEnd,
+      error,
+      freeHistoryLimitHit,
+      loading,
+      labels,
+      loadingFileTree,
+      nextBeforeTimestamp,
+      selection,
+      showOnlyLabels,
+      updates,
+      userHasFullFeature,
+      viewMode,
+      projectId,
+      fileSelection,
+      setFileSelection,
+      updateSelection,
+      setUpdateSelection,
+    }),
+    [
+      atEnd,
+      error,
+      freeHistoryLimitHit,
+      loading,
+      labels,
+      loadingFileTree,
+      nextBeforeTimestamp,
+      selection,
+      showOnlyLabels,
+      updates,
+      userHasFullFeature,
+      viewMode,
+      projectId,
+      fileSelection,
+      setFileSelection,
+      updateSelection,
+      setUpdateSelection,
+    ]
+  )
+
+  return { value }
+}
+
+export const HistoryContext = createContext<HistoryContextValue | undefined>(
+  undefined
+)
+
+type HistoryProviderProps = {
+  children?: React.ReactNode
+}
+
+export function HistoryProvider({ ...props }: HistoryProviderProps) {
+  const { value } = useHistory()
+
+  return <HistoryContext.Provider value={value} {...props} />
+}
+
+export function useHistoryContext() {
+  const context = useContext(HistoryContext)
+  if (!context) {
+    throw new Error('HistoryContext is only available inside HistoryProvider')
+  }
+  return context
+}

+ 24 - 0
services/web/frontend/js/features/history/context/types/history-context-value.ts

@@ -0,0 +1,24 @@
+import { Nullable } from '../../../../../../types/utils'
+import { Update, UpdateSelection } from '../../services/types/update'
+import { Selection } from '../../../../../../types/history/selection'
+import { FileSelection } from '../../services/types/file'
+
+export type HistoryContextValue = {
+  updates: Update[]
+  viewMode: string
+  nextBeforeTimestamp: Nullable<number>
+  loading: boolean
+  atEnd: boolean
+  userHasFullFeature: boolean | undefined
+  freeHistoryLimitHit: boolean
+  selection: Selection
+  error: Nullable<unknown>
+  showOnlyLabels: boolean
+  labels: Nullable<unknown>
+  loadingFileTree: boolean
+  projectId: string
+  fileSelection: FileSelection | null
+  setFileSelection: (fileSelection: FileSelection) => void
+  updateSelection: UpdateSelection | null
+  setUpdateSelection: (updateSelection: UpdateSelection) => void
+}

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

@@ -0,0 +1,29 @@
+import { getJSON } from '../../../infrastructure/fetch-json'
+import { FileDiff } from './types/file'
+import { Update } from './types/update'
+
+const BATCH_SIZE = 10
+
+export function fetchUpdates(projectId: string, before?: number) {
+  const queryParams: Record<string, string> = {
+    min_count: BATCH_SIZE.toString(),
+  }
+
+  if (before != null) {
+    queryParams.before = before.toString()
+  }
+
+  const queryParamsSerialized = new URLSearchParams(queryParams).toString()
+  const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
+  return getJSON<{ updates: Update[] }>(updatesURL)
+}
+
+export function diffFiles(projectId: string, fromV: number, toV: number) {
+  const queryParams: Record<string, string> = {
+    from: fromV.toString(),
+    to: toV.toString(),
+  }
+  const queryParamsSerialized = new URLSearchParams(queryParams).toString()
+  const diffUrl = `/project/${projectId}/filetree/diff?${queryParamsSerialized}`
+  return getJSON<{ diff: FileDiff[] }>(diffUrl)
+}

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

@@ -0,0 +1,24 @@
+export interface FileUnchanged {
+  pathname: string
+}
+
+export interface FileAdded extends FileUnchanged {
+  operation: 'added'
+}
+
+export interface FileRemoved extends FileUnchanged {
+  operation: 'removed'
+  deletedAtV: number
+}
+
+export interface FileRenamed extends FileUnchanged {
+  newPathname: string
+  operation: 'renamed'
+}
+
+export type FileDiff = FileAdded | FileRemoved | FileRenamed | FileUnchanged
+
+export interface FileSelection {
+  files: FileDiff[]
+  pathname: string | null
+}

+ 45 - 0
services/web/frontend/js/features/history/services/types/update.ts

@@ -0,0 +1,45 @@
+interface User {
+  first_name: string
+  last_name: string
+  email: string
+  id: string
+}
+
+interface UpdateMeta {
+  users: User[]
+  start_ts: number
+  end_ts: number
+}
+
+interface UpdateLabel {
+  id: string
+  comment: string
+  version: number
+  user_id: string
+  created_at: string
+}
+
+interface Label extends UpdateLabel {
+  user_display_name: string
+}
+
+interface ProjectOp {
+  add?: { pathname: string }
+  rename?: { pathname: string; newPathname: string }
+  remove?: { pathname: string }
+  atV: number
+}
+
+export interface Update {
+  fromV: number
+  toV: number
+  meta: UpdateMeta
+  labels?: Label[]
+  pathnames: string[]
+  project_ops: ProjectOp[]
+}
+
+export interface UpdateSelection {
+  update: Update
+  comparing: boolean
+}

+ 8 - 5
services/web/frontend/js/shared/context/root-context.js

@@ -13,6 +13,7 @@ import { ProjectProvider } from './project-context'
 import { SplitTestProvider } from './split-test-context'
 import { FileTreeDataProvider } from './file-tree-data-context'
 import { ProjectSettingsProvider } from '../../features/editor-left-menu/context/project-settings-context'
+import { HistoryProvider } from '../../features/history/context/history-context'
 
 export function ContextRoot({ children, ide, settings }) {
   return (
@@ -25,11 +26,13 @@ export function ContextRoot({ children, ide, settings }) {
                 <EditorProvider settings={settings}>
                   <ProjectSettingsProvider>
                     <LayoutProvider>
-                      <LocalCompileProvider>
-                        <DetachCompileProvider>
-                          <ChatProvider>{children}</ChatProvider>
-                        </DetachCompileProvider>
-                      </LocalCompileProvider>
+                      <HistoryProvider>
+                        <LocalCompileProvider>
+                          <DetachCompileProvider>
+                            <ChatProvider>{children}</ChatProvider>
+                          </DetachCompileProvider>
+                        </LocalCompileProvider>
+                      </HistoryProvider>
                     </LayoutProvider>
                   </ProjectSettingsProvider>
                 </EditorProvider>

+ 23 - 0
services/web/types/history/selection.ts

@@ -0,0 +1,23 @@
+import { Nullable } from '../utils'
+
+type Docs = Record<string, unknown>
+
+interface Range {
+  fromV: Nullable<unknown>
+  toV: Nullable<unknown>
+}
+
+interface HoveredRange {
+  fromV: Nullable<unknown>
+  toV: Nullable<unknown>
+}
+
+export interface Selection {
+  docs: Docs
+  pathname: Nullable<string>
+  range: Range
+  hoveredRange: HoveredRange
+  diff: Nullable<unknown>
+  files: unknown[]
+  file: Nullable<unknown>
+}