mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #12366 from overleaf/ii-react-history-context
[web] Add context to history GitOrigin-RevId: bc504994c50c0e7abe8181a671357d5db59a3343
This commit is contained in:
parent
0227f186c5
commit
95c8a1aeea
11 changed files with 345 additions and 12 deletions
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
{view === 'history' && updates.length > 0 && (
|
||||
<div className="history-react">
|
||||
<Editor />
|
||||
<ChangeList />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
services/web/frontend/js/features/history/services/api.ts
Normal file
29
services/web/frontend/js/features/history/services/api.ts
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
<HistoryProvider>
|
||||
<LocalCompileProvider>
|
||||
<DetachCompileProvider>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</DetachCompileProvider>
|
||||
</LocalCompileProvider>
|
||||
</HistoryProvider>
|
||||
</LayoutProvider>
|
||||
</ProjectSettingsProvider>
|
||||
</EditorProvider>
|
||||
|
|
23
services/web/types/history/selection.ts
Normal file
23
services/web/types/history/selection.ts
Normal 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>
|
||||
}
|
Loading…
Reference in a new issue