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 ToggleSwitch from './toggle-switch'
|
||||||
import Main from './main'
|
import Main from './main'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
function ChangeList() {
|
function ChangeList() {
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const [labelsOnly, setLabelsOnly] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="change-list">
|
<aside className="change-list">
|
||||||
<div className="history-header toggle-switch-container">
|
<div className="history-header toggle-switch-container">
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
import { useHistoryContext } from '../../context/history-context'
|
||||||
|
|
||||||
function Main() {
|
function Main() {
|
||||||
return <div>Main (editor)</div>
|
const { fileSelection } = useHistoryContext()
|
||||||
|
|
||||||
|
return <div>Main (editor). File: {fileSelection?.pathname || 'not set'}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Main
|
export default Main
|
||||||
|
|
|
@ -1,3 +1,18 @@
|
||||||
export default function HistoryFileTree() {
|
import { useHistoryContext } from '../context/history-context'
|
||||||
return <div>History file tree</div>
|
|
||||||
|
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 HistoryFileTree from './history-file-tree'
|
||||||
import ChangeList from './change-list/change-list'
|
import ChangeList from './change-list/change-list'
|
||||||
import Editor from './editor/editor'
|
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')
|
const fileTreeContainer = document.getElementById('history-file-tree')
|
||||||
|
|
||||||
export default function HistoryRoot() {
|
export default function HistoryRoot() {
|
||||||
|
const { view } = useLayoutContext()
|
||||||
|
const { updates } = useHistoryContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{fileTreeContainer
|
{fileTreeContainer
|
||||||
? createPortal(<HistoryFileTree />, fileTreeContainer)
|
? createPortal(<HistoryFileTree />, fileTreeContainer)
|
||||||
: null}
|
: null}
|
||||||
|
{view === 'history' && updates.length > 0 && (
|
||||||
<div className="history-react">
|
<div className="history-react">
|
||||||
<Editor />
|
<Editor />
|
||||||
<ChangeList />
|
<ChangeList />
|
||||||
</div>
|
</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 { SplitTestProvider } from './split-test-context'
|
||||||
import { FileTreeDataProvider } from './file-tree-data-context'
|
import { FileTreeDataProvider } from './file-tree-data-context'
|
||||||
import { ProjectSettingsProvider } from '../../features/editor-left-menu/context/project-settings-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 }) {
|
export function ContextRoot({ children, ide, settings }) {
|
||||||
return (
|
return (
|
||||||
|
@ -25,11 +26,13 @@ export function ContextRoot({ children, ide, settings }) {
|
||||||
<EditorProvider settings={settings}>
|
<EditorProvider settings={settings}>
|
||||||
<ProjectSettingsProvider>
|
<ProjectSettingsProvider>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
|
<HistoryProvider>
|
||||||
<LocalCompileProvider>
|
<LocalCompileProvider>
|
||||||
<DetachCompileProvider>
|
<DetachCompileProvider>
|
||||||
<ChatProvider>{children}</ChatProvider>
|
<ChatProvider>{children}</ChatProvider>
|
||||||
</DetachCompileProvider>
|
</DetachCompileProvider>
|
||||||
</LocalCompileProvider>
|
</LocalCompileProvider>
|
||||||
|
</HistoryProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</ProjectSettingsProvider>
|
</ProjectSettingsProvider>
|
||||||
</EditorProvider>
|
</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