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

[web] Add context to history

GitOrigin-RevId: bc504994c50c0e7abe8181a671357d5db59a3343
This commit is contained in:
ilkin-overleaf 2023-03-24 15:58:55 +02:00 committed by Copybot
parent 0227f186c5
commit 95c8a1aeea
11 changed files with 345 additions and 12 deletions

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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>
)}
</>
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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>

View file

@ -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>
}