first commit
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { HistoryContextValue } from './types/history-context-value'
|
||||
import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
|
||||
import { renamePathnameKey } from '../utils/file-tree'
|
||||
import { isFileRenamed } from '../utils/file-diff'
|
||||
import { loadLabels } from '../utils/label'
|
||||
import { autoSelectFile } from '../utils/auto-select-file'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
import moment from 'moment'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import {
|
||||
FetchUpdatesResponse,
|
||||
LoadedUpdate,
|
||||
Update,
|
||||
} from '../services/types/update'
|
||||
import { Selection } from '../services/types/selection'
|
||||
import { useErrorHandler } from 'react-error-boundary'
|
||||
import { getUpdateForVersion } from '../utils/history-details'
|
||||
import { getHueForUserId } from '@/shared/utils/colors'
|
||||
|
||||
// Allow testing of infinite scrolling by providing query string parameters to
|
||||
// limit the number of updates returned in a batch and apply a delay
|
||||
function limitUpdates(
|
||||
promise: Promise<FetchUpdatesResponse>
|
||||
): Promise<FetchUpdatesResponse> {
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
const maxBatchSizeParam = queryParams.get('history-max-updates')
|
||||
const delayParam = queryParams.get('history-updates-delay')
|
||||
if (delayParam === null && maxBatchSizeParam === null) {
|
||||
return promise
|
||||
}
|
||||
return promise.then(response => {
|
||||
let { updates, nextBeforeTimestamp } = response
|
||||
const maxBatchSize = maxBatchSizeParam ? parseInt(maxBatchSizeParam, 10) : 0
|
||||
const delay = delayParam ? parseInt(delayParam, 10) : 0
|
||||
if (maxBatchSize > 0 && updates.length > maxBatchSize) {
|
||||
updates = updates.slice(0, maxBatchSize)
|
||||
nextBeforeTimestamp = updates[updates.length - 1].fromV
|
||||
}
|
||||
const limitedResponse = { updates, nextBeforeTimestamp }
|
||||
if (delay > 0) {
|
||||
return new Promise(resolve => {
|
||||
window.setTimeout(() => resolve(limitedResponse), delay)
|
||||
})
|
||||
} else {
|
||||
return limitedResponse
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectionInitialState: Selection = {
|
||||
updateRange: null,
|
||||
comparing: false,
|
||||
files: [],
|
||||
previouslySelectedPathname: null,
|
||||
}
|
||||
|
||||
const updatesInfoInitialState: HistoryContextValue['updatesInfo'] = {
|
||||
updates: [],
|
||||
visibleUpdateCount: null,
|
||||
atEnd: false,
|
||||
freeHistoryLimitHit: false,
|
||||
nextBeforeTimestamp: undefined,
|
||||
loadingState: 'loadingInitial',
|
||||
}
|
||||
|
||||
function useHistory() {
|
||||
const { view } = useLayoutContext()
|
||||
const user = useUserContext()
|
||||
const project = useProjectContext()
|
||||
const userId = user.id
|
||||
const projectId = project._id
|
||||
const projectOwnerId = project.owner?._id
|
||||
const userHasFullFeature = Boolean(
|
||||
project.features?.versioning || user.isAdmin
|
||||
)
|
||||
const currentUserIsOwner = projectOwnerId === userId
|
||||
|
||||
const [selection, setSelection] = useState<Selection>(selectionInitialState)
|
||||
|
||||
const [updatesInfo, setUpdatesInfo] = useState<
|
||||
HistoryContextValue['updatesInfo']
|
||||
>(updatesInfoInitialState)
|
||||
const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
|
||||
const [labelsOnly, setLabelsOnly] = usePersistedState(
|
||||
`history.userPrefs.showOnlyLabels.${projectId}`,
|
||||
false
|
||||
)
|
||||
|
||||
const updatesAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const handleError = useErrorHandler()
|
||||
|
||||
const fetchNextBatchOfUpdates = useCallback(() => {
|
||||
// If there is an in-flight request for updates, just let it complete, by
|
||||
// bailing out
|
||||
if (updatesAbortControllerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatesLoadingState = updatesInfo.loadingState
|
||||
|
||||
const loadUpdates = (updatesData: Update[]) => {
|
||||
const dateTimeNow = new Date()
|
||||
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
|
||||
let { updates, freeHistoryLimitHit, visibleUpdateCount } = updatesInfo
|
||||
let previousUpdate = updates[updates.length - 1]
|
||||
|
||||
const loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
|
||||
for (const [index, update] of loadedUpdates.entries()) {
|
||||
for (const user of update.meta.users) {
|
||||
if (user) {
|
||||
user.hue = getHueForUserId(user.id)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!previousUpdate ||
|
||||
!moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
|
||||
) {
|
||||
update.meta.first_in_day = true
|
||||
}
|
||||
|
||||
previousUpdate = update
|
||||
|
||||
// the free tier cutoff is 24 hours, so show one extra update
|
||||
// after which will become the fade teaser above the paywall
|
||||
if (
|
||||
!userHasFullFeature &&
|
||||
visibleUpdateCount === null &&
|
||||
update.meta.end_ts < timestamp24hoursAgo
|
||||
) {
|
||||
// Make sure that we show at least one entry fully (to allow labelling), and one extra for fading
|
||||
// Since the index for the first free tier cutoff will be at 0 if all versions were updated the day before (all version in the past),
|
||||
// we need to +2 instead of +1. this gives us one which is selected and one which is faded
|
||||
visibleUpdateCount = index > 0 ? index + 1 : 2
|
||||
freeHistoryLimitHit = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updates: updates.concat(loadedUpdates),
|
||||
visibleUpdateCount,
|
||||
freeHistoryLimitHit,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updatesInfo.atEnd ||
|
||||
!(
|
||||
updatesLoadingState === 'loadingInitial' ||
|
||||
updatesLoadingState === 'ready'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
updatesAbortControllerRef.current = new AbortController()
|
||||
const signal = updatesAbortControllerRef.current.signal
|
||||
|
||||
const updatesPromise = limitUpdates(
|
||||
fetchUpdates(projectId, updatesInfo.nextBeforeTimestamp, signal)
|
||||
)
|
||||
const labelsPromise = labels == null ? fetchLabels(projectId, signal) : null
|
||||
|
||||
setUpdatesInfo({
|
||||
...updatesInfo,
|
||||
loadingState:
|
||||
updatesLoadingState === 'ready' ? 'loadingUpdates' : 'loadingInitial',
|
||||
})
|
||||
|
||||
Promise.all([updatesPromise, labelsPromise])
|
||||
.then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
|
||||
if (labels) {
|
||||
setLabels(loadLabels(labels, updatesData))
|
||||
}
|
||||
|
||||
const { updates, visibleUpdateCount, freeHistoryLimitHit } =
|
||||
loadUpdates(updatesData)
|
||||
|
||||
const atEnd =
|
||||
nextBeforeTimestamp == null || freeHistoryLimitHit || !updates.length
|
||||
|
||||
setUpdatesInfo({
|
||||
updates,
|
||||
visibleUpdateCount,
|
||||
freeHistoryLimitHit,
|
||||
atEnd,
|
||||
nextBeforeTimestamp,
|
||||
loadingState: 'ready',
|
||||
})
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
updatesAbortControllerRef.current = null
|
||||
})
|
||||
}, [updatesInfo, projectId, labels, handleError, userHasFullFeature])
|
||||
|
||||
// Abort in-flight updates request on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (updatesAbortControllerRef.current) {
|
||||
updatesAbortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load on first render
|
||||
const initialFetch = useRef(false)
|
||||
useEffect(() => {
|
||||
if (view === 'history' && !initialFetch.current) {
|
||||
initialFetch.current = true
|
||||
return fetchNextBatchOfUpdates()
|
||||
}
|
||||
}, [view, fetchNextBatchOfUpdates])
|
||||
|
||||
useEffect(() => {
|
||||
// Reset some parts of the state
|
||||
if (view !== 'history') {
|
||||
initialFetch.current = false
|
||||
setSelection(prevSelection => ({
|
||||
...selectionInitialState,
|
||||
// retain the previously selected pathname
|
||||
previouslySelectedPathname: prevSelection.previouslySelectedPathname,
|
||||
}))
|
||||
setUpdatesInfo(updatesInfoInitialState)
|
||||
setLabels(null)
|
||||
}
|
||||
}, [view])
|
||||
|
||||
const resetSelection = useCallback(() => {
|
||||
setSelection(selectionInitialState)
|
||||
}, [])
|
||||
|
||||
const { updateRange } = selection
|
||||
const { fromV, toV } = updateRange || {}
|
||||
const { updates } = updatesInfo
|
||||
|
||||
const updateForToV =
|
||||
toV === undefined ? undefined : getUpdateForVersion(toV, updates)
|
||||
|
||||
// Load files when the update selection changes
|
||||
const [loadingFileDiffs, setLoadingFileDiffs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fromV === undefined || toV === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = new AbortController()
|
||||
setLoadingFileDiffs(true)
|
||||
|
||||
diffFiles(projectId, fromV, toV, abortController.signal)
|
||||
.then(({ diff: files }) => {
|
||||
setSelection(previousSelection => {
|
||||
const selectedFile = autoSelectFile(
|
||||
files,
|
||||
toV,
|
||||
previousSelection.comparing,
|
||||
updateForToV,
|
||||
previousSelection.previouslySelectedPathname
|
||||
)
|
||||
const newFiles = files.map(file => {
|
||||
if (isFileRenamed(file) && file.newPathname) {
|
||||
return renamePathnameKey(file)
|
||||
}
|
||||
|
||||
return file
|
||||
})
|
||||
return {
|
||||
...previousSelection,
|
||||
files: newFiles,
|
||||
selectedFile,
|
||||
previouslySelectedPathname: selectedFile.pathname,
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
setLoadingFileDiffs(false)
|
||||
abortController = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
}, [projectId, fromV, toV, updateForToV, handleError])
|
||||
|
||||
useEffect(() => {
|
||||
// Set update range if there isn't one and updates have loaded
|
||||
if (updates.length && !updateRange) {
|
||||
setSelection(prevSelection => ({
|
||||
...prevSelection,
|
||||
updateRange: {
|
||||
fromV: updates[0].fromV,
|
||||
toV: updates[0].toV,
|
||||
fromVTimestamp: updates[0].meta.end_ts,
|
||||
toVTimestamp: updates[0].meta.end_ts,
|
||||
},
|
||||
comparing: false,
|
||||
files: [],
|
||||
}))
|
||||
}
|
||||
}, [updateRange, updates])
|
||||
|
||||
const value = useMemo<HistoryContextValue>(
|
||||
() => ({
|
||||
loadingFileDiffs,
|
||||
updatesInfo,
|
||||
setUpdatesInfo,
|
||||
labels,
|
||||
setLabels,
|
||||
labelsOnly,
|
||||
setLabelsOnly,
|
||||
userHasFullFeature,
|
||||
currentUserIsOwner,
|
||||
projectId,
|
||||
selection,
|
||||
setSelection,
|
||||
fetchNextBatchOfUpdates,
|
||||
resetSelection,
|
||||
}),
|
||||
[
|
||||
loadingFileDiffs,
|
||||
updatesInfo,
|
||||
setUpdatesInfo,
|
||||
labels,
|
||||
setLabels,
|
||||
labelsOnly,
|
||||
setLabelsOnly,
|
||||
userHasFullFeature,
|
||||
currentUserIsOwner,
|
||||
projectId,
|
||||
selection,
|
||||
setSelection,
|
||||
fetchNextBatchOfUpdates,
|
||||
resetSelection,
|
||||
]
|
||||
)
|
||||
|
||||
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,106 @@
|
||||
import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
import { useLayoutContext } from '../../../../shared/context/layout-context'
|
||||
import { restoreFile } from '../../services/api'
|
||||
import { isFileRemoved } from '../../utils/file-diff'
|
||||
import { useHistoryContext } from '../history-context'
|
||||
import type { HistoryContextValue } from '../types/history-context-value'
|
||||
import { useErrorHandler } from 'react-error-boundary'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { findInTree } from '@/features/file-tree/util/find-in-tree'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { RestoreFileResponse } from '@/features/history/services/types/restore-file'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
|
||||
type RestorationState =
|
||||
| 'idle'
|
||||
| 'restoring'
|
||||
| 'waitingForFileTree'
|
||||
| 'complete'
|
||||
| 'error'
|
||||
| 'timedOut'
|
||||
|
||||
export function useRestoreDeletedFile() {
|
||||
const { projectId } = useHistoryContext()
|
||||
const { setView } = useLayoutContext()
|
||||
const { openDocWithId, openFileWithId } = useEditorManagerContext()
|
||||
const handleError = useErrorHandler()
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const [state, setState] = useState<RestorationState>('idle')
|
||||
const [restoredFileMetadata, setRestoredFileMetadata] =
|
||||
useState<RestoreFileResponse | null>(null)
|
||||
|
||||
const isLoading = state === 'restoring' || state === 'waitingForFileTree'
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'waitingForFileTree' && restoredFileMetadata) {
|
||||
const result = findInTree(fileTreeData, restoredFileMetadata.id)
|
||||
if (result) {
|
||||
setState('complete')
|
||||
const { _id: id } = result.entity
|
||||
setView('editor')
|
||||
|
||||
if (restoredFileMetadata.type === 'doc') {
|
||||
openDocWithId(id)
|
||||
} else {
|
||||
openFileWithId(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
state,
|
||||
fileTreeData,
|
||||
restoredFileMetadata,
|
||||
openDocWithId,
|
||||
openFileWithId,
|
||||
setView,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'waitingForFileTree') {
|
||||
const timer = window.setTimeout(() => {
|
||||
setState('timedOut')
|
||||
handleError(new Error('timed out'))
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [handleError, state])
|
||||
|
||||
const restoreDeletedFile = useCallback(
|
||||
(selection: HistoryContextValue['selection']) => {
|
||||
const { selectedFile, files } = selection
|
||||
|
||||
if (
|
||||
selectedFile &&
|
||||
selectedFile.pathname &&
|
||||
isFileRemoved(selectedFile)
|
||||
) {
|
||||
const file = files.find(file => file.pathname === selectedFile.pathname)
|
||||
if (file && isFileRemoved(file)) {
|
||||
sendMB('history-v2-restore-deleted')
|
||||
|
||||
setState('restoring')
|
||||
|
||||
restoreFile(projectId, {
|
||||
...selectedFile,
|
||||
pathname: file.newPathname ?? file.pathname,
|
||||
}).then(
|
||||
(data: RestoreFileResponse) => {
|
||||
setRestoredFileMetadata(data)
|
||||
setState('waitingForFileTree')
|
||||
},
|
||||
error => {
|
||||
setState('error')
|
||||
handleError(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleError, projectId]
|
||||
)
|
||||
|
||||
return { restoreDeletedFile, isLoading }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useErrorHandler } from 'react-error-boundary'
|
||||
import { restoreProjectToVersion } from '../../services/api'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
|
||||
type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
|
||||
|
||||
export const useRestoreProject = () => {
|
||||
const handleError = useErrorHandler()
|
||||
const { setView } = useLayoutContext()
|
||||
|
||||
const [restorationState, setRestorationState] =
|
||||
useState<RestorationState>('initial')
|
||||
|
||||
const restoreProject = useCallback(
|
||||
(projectId: string, version: number) => {
|
||||
setRestorationState('restoring')
|
||||
restoreProjectToVersion(projectId, version)
|
||||
.then(() => {
|
||||
setRestorationState('restored')
|
||||
setView('editor')
|
||||
})
|
||||
.catch(err => {
|
||||
setRestorationState('error')
|
||||
handleError(err)
|
||||
})
|
||||
},
|
||||
[handleError, setView]
|
||||
)
|
||||
|
||||
return {
|
||||
restorationState,
|
||||
restoreProject,
|
||||
isRestoring: restorationState === 'restoring',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useLayoutContext } from '../../../../shared/context/layout-context'
|
||||
import { restoreFileToVersion } from '../../services/api'
|
||||
import { isFileRemoved } from '../../utils/file-diff'
|
||||
import { useHistoryContext } from '../history-context'
|
||||
import type { HistoryContextValue } from '../types/history-context-value'
|
||||
import { useErrorHandler } from 'react-error-boundary'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { findInTree } from '@/features/file-tree/util/find-in-tree'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { RestoreFileResponse } from '../../services/types/restore-file'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
|
||||
const RESTORE_FILE_TIMEOUT = 3000
|
||||
|
||||
type RestoreState =
|
||||
| 'idle'
|
||||
| 'restoring'
|
||||
| 'waitingForFileTree'
|
||||
| 'complete'
|
||||
| 'error'
|
||||
| 'timedOut'
|
||||
|
||||
export function useRestoreSelectedFile() {
|
||||
const { projectId } = useHistoryContext()
|
||||
const { setView } = useLayoutContext()
|
||||
const { openDocWithId, openFileWithId } = useEditorManagerContext()
|
||||
const handleError = useErrorHandler()
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const [state, setState] = useState<RestoreState>('idle')
|
||||
const [restoredFileMetadata, setRestoredFileMetadata] =
|
||||
useState<RestoreFileResponse | null>(null)
|
||||
|
||||
const isLoading = state === 'restoring' || state === 'waitingForFileTree'
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'waitingForFileTree' && restoredFileMetadata) {
|
||||
const result = findInTree(fileTreeData, restoredFileMetadata.id)
|
||||
if (result) {
|
||||
setState('complete')
|
||||
const { _id: id } = result.entity
|
||||
setView('editor')
|
||||
|
||||
if (restoredFileMetadata.type === 'doc') {
|
||||
openDocWithId(id)
|
||||
} else {
|
||||
openFileWithId(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
state,
|
||||
fileTreeData,
|
||||
restoredFileMetadata,
|
||||
openDocWithId,
|
||||
openFileWithId,
|
||||
setView,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'waitingForFileTree') {
|
||||
const timer = window.setTimeout(() => {
|
||||
setState('timedOut')
|
||||
handleError(new Error('timed out'))
|
||||
}, RESTORE_FILE_TIMEOUT)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [handleError, state])
|
||||
|
||||
const restoreSelectedFile = useCallback(
|
||||
(selection: HistoryContextValue['selection']) => {
|
||||
const { selectedFile, files } = selection
|
||||
|
||||
if (selectedFile && selectedFile.pathname) {
|
||||
const file = files.find(file => file.pathname === selectedFile.pathname)
|
||||
|
||||
if (file) {
|
||||
const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
|
||||
const toVersion = deletedAtV ?? selection.updateRange?.toV
|
||||
if (!toVersion) {
|
||||
return
|
||||
}
|
||||
setState('restoring')
|
||||
|
||||
restoreFileToVersion(projectId, file.pathname, toVersion).then(
|
||||
(data: RestoreFileResponse) => {
|
||||
setRestoredFileMetadata(data)
|
||||
setState('waitingForFileTree')
|
||||
},
|
||||
error => {
|
||||
setState('error')
|
||||
handleError(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleError, projectId]
|
||||
)
|
||||
|
||||
return { restoreSelectedFile, isLoading }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Nullable } from '../../../../../../types/utils'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
import { LoadedLabel } from '../../services/types/label'
|
||||
import { Selection } from '../../services/types/selection'
|
||||
|
||||
type UpdatesLoadingState = 'loadingInitial' | 'loadingUpdates' | 'ready'
|
||||
|
||||
export type HistoryContextValue = {
|
||||
updatesInfo: {
|
||||
updates: LoadedUpdate[]
|
||||
visibleUpdateCount: Nullable<number>
|
||||
atEnd: boolean
|
||||
nextBeforeTimestamp: number | undefined
|
||||
freeHistoryLimitHit: boolean
|
||||
loadingState: UpdatesLoadingState
|
||||
}
|
||||
setUpdatesInfo: React.Dispatch<
|
||||
React.SetStateAction<HistoryContextValue['updatesInfo']>
|
||||
>
|
||||
userHasFullFeature: boolean
|
||||
currentUserIsOwner: boolean
|
||||
loadingFileDiffs: boolean
|
||||
labels: Nullable<LoadedLabel[]>
|
||||
setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
|
||||
labelsOnly: boolean
|
||||
setLabelsOnly: React.Dispatch<React.SetStateAction<boolean>>
|
||||
projectId: string
|
||||
selection: Selection
|
||||
setSelection: React.Dispatch<
|
||||
React.SetStateAction<HistoryContextValue['selection']>
|
||||
>
|
||||
fetchNextBatchOfUpdates: () => (() => void) | void
|
||||
resetSelection: () => void
|
||||
}
|
||||
Reference in New Issue
Block a user