Merge pull request #12644 from overleaf/td-history-compare

Initial implementation of comparing history versions

GitOrigin-RevId: 890e270d6e41856a79689ab41ccfbde25c4703ba
This commit is contained in:
Tim Down 2023-04-18 16:15:01 +02:00 committed by Copybot
parent df58f6720e
commit 99e1ff0804
16 changed files with 147 additions and 119 deletions

View file

@ -19,7 +19,7 @@ function ChangeList() {
)} )}
</div> </div>
{!error && ( {!error && (
<div className="version-list-container"> <div className="history-version-list-container">
{labelsOnly ? <LabelsList /> : <AllHistoryList />} {labelsOnly ? <LabelsList /> : <AllHistoryList />}
</div> </div>
)} )}

View file

@ -6,6 +6,9 @@ import { useUserContext } from '../../../../shared/context/user-context'
import { relativeDate, formatTime } from '../../../utils/format-date' import { relativeDate, formatTime } from '../../../utils/format-date'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import { LoadedUpdate } from '../../services/types/update' import { LoadedUpdate } from '../../services/types/update'
import { useHistoryContext } from '../../context/history-context'
import classNames from 'classnames'
import { updateIsSelected } from '../../utils/history-details'
type HistoryEntryProps = { type HistoryEntryProps = {
update: LoadedUpdate update: LoadedUpdate
@ -15,16 +18,46 @@ function HistoryVersion({ update }: HistoryEntryProps) {
const { id: currentUserId } = useUserContext() const { id: currentUserId } = useUserContext()
const orderedLabels = orderBy(update.labels, ['created_at'], ['desc']) const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
const { selection, setSelection } = useHistoryContext()
const selected = updateIsSelected(update, selection)
function compare() {
const { updateRange } = selection
if (!updateRange) {
return
}
const fromV = Math.min(update.fromV, updateRange.fromV)
const toV = Math.max(update.toV, updateRange.toV)
setSelection({
updateRange: { fromV, toV },
comparing: true,
files: [],
pathname: null,
})
}
return ( return (
<div> <div className={classNames({ 'history-version-selected': selected })}>
{update.meta.first_in_day && ( {update.meta.first_in_day && (
<time className="history-version-day"> <time className="history-version-day">
{relativeDate(update.meta.end_ts)} {relativeDate(update.meta.end_ts)}
</time> </time>
)} )}
{/* TODO: Sort out accessibility for this */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div <div
className="history-version-details" className="history-version-details"
data-testid="history-version-details" data-testid="history-version-details"
onClick={() =>
setSelection({
updateRange: update,
comparing: false,
files: [],
pathname: null,
})
}
> >
<time className="history-version-metadata-time"> <time className="history-version-metadata-time">
<b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b> <b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b>
@ -44,6 +77,18 @@ function HistoryVersion({ update }: HistoryEntryProps) {
currentUserId={currentUserId} currentUserId={currentUserId}
/> />
<Origin origin={update.meta.origin} /> <Origin origin={update.meta.origin} />
{selection.comparing ? null : (
<div>
<button
onClick={event => {
event.stopPropagation()
compare()
}}
>
Compare
</button>
</div>
)}
</div> </div>
</div> </div>
) )

View file

@ -17,7 +17,7 @@ type Diff = {
function Main() { function Main() {
const { t } = useTranslation() const { t } = useTranslation()
const { projectId, updateSelection, fileSelection } = useHistoryContext() const { projectId, selection } = useHistoryContext()
const { isLoading, runAsync, data } = useAsync<DocDiffResponse>() const { isLoading, runAsync, data } = useAsync<DocDiffResponse>()
let diff: Diff | undefined let diff: Diff | undefined
if (data?.diff) { if (data?.diff) {
@ -28,16 +28,18 @@ function Main() {
} }
} }
const { updateRange, pathname } = selection
useEffect(() => { useEffect(() => {
if (!updateSelection || !fileSelection || !fileSelection.pathname) { if (!updateRange || !pathname) {
return return
} }
const { fromV, toV } = updateSelection.update const { fromV, toV } = updateRange
// TODO: Error handling // TODO: Error handling
runAsync(diffDoc(projectId, fromV, toV, fileSelection.pathname)) runAsync(diffDoc(projectId, fromV, toV, pathname))
}, [fileSelection, projectId, runAsync, updateSelection]) }, [projectId, runAsync, pathname, updateRange])
if (isLoading) { if (isLoading) {
return ( return (

View file

@ -7,13 +7,9 @@ import {
import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list' import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list'
export default function HistoryFileTree() { export default function HistoryFileTree() {
const { fileSelection } = useHistoryContext() const { files } = useHistoryContext().selection
if (!fileSelection) { const fileTree = _.reduce(files, reducePathsToTree, [])
return null
}
const fileTree = _.reduce(fileSelection.files, reducePathsToTree, [])
const mappedFileTree = fileTreeDiffToFileTreeData(fileTree) const mappedFileTree = fileTreeDiffToFileTreeData(fileTree)

View file

@ -19,9 +19,9 @@ import ColorManager from '../../../ide/colors/ColorManager'
import moment from 'moment' import moment from 'moment'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { LoadedUpdate, Update, UpdateSelection } from '../services/types/update' import { LoadedUpdate, Update } from '../services/types/update'
import { FileSelection } from '../services/types/file'
import { Nullable } from '../../../../../types/utils' import { Nullable } from '../../../../../types/utils'
import { Selection } from '../services/types/selection'
function useHistory() { function useHistory() {
const { view } = useLayoutContext() const { view } = useLayoutContext()
@ -30,9 +30,14 @@ function useHistory() {
const userId = user.id const userId = user.id
const projectId = project._id const projectId = project._id
const projectOwnerId = project.owner?._id const projectOwnerId = project.owner?._id
const [updateSelection, setUpdateSelection] =
useState<UpdateSelection | null>(null) const [selection, setSelection] = useState<Selection>({
const [fileSelection, setFileSelection] = useState<FileSelection | null>(null) updateRange: null,
comparing: false,
files: [],
pathname: null,
})
const [updates, setUpdates] = useState<LoadedUpdate[]>([]) const [updates, setUpdates] = useState<LoadedUpdate[]>([])
const [loadingFileTree, setLoadingFileTree] = const [loadingFileTree, setLoadingFileTree] =
useState<HistoryContextValue['loadingFileTree']>(true) useState<HistoryContextValue['loadingFileTree']>(true)
@ -158,15 +163,17 @@ function useHistory() {
} }
}, [view, fetchNextBatchOfUpdates]) }, [view, fetchNextBatchOfUpdates])
const { updateRange, comparing } = selection
// Load files when the update selection changes // Load files when the update selection changes
useEffect(() => { useEffect(() => {
if (!updateSelection) { if (!updateRange) {
return return
} }
const { fromV, toV } = updateSelection.update const { fromV, toV } = updateRange
diffFiles(projectId, fromV, toV).then(({ diff: files }) => { diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
const pathname = autoSelectFile(files, updateSelection, updates) const pathname = autoSelectFile(files, updateRange, comparing, updates)
const newFiles = files.map(file => { const newFiles = files.map(file => {
if (isFileRenamed(file) && file.newPathname) { if (isFileRenamed(file) && file.newPathname) {
return renamePathnameKey(file) return renamePathnameKey(file)
@ -174,19 +181,21 @@ function useHistory() {
return file return file
}) })
setFileSelection({ files: newFiles, pathname }) setSelection({ updateRange, comparing, files: newFiles, pathname })
}) })
}, [updateSelection, projectId, updates]) }, [updateRange, projectId, updates, comparing])
useEffect(() => { useEffect(() => {
// Set update selection if there isn't one // Set update selection if there isn't one
if (updates.length && !updateSelection) { if (updates.length && !updateRange) {
setUpdateSelection({ setSelection({
update: updates[0], updateRange: updates[0],
comparing: false, comparing: false,
files: [],
pathname: null,
}) })
} }
}, [setUpdateSelection, updateSelection, updates]) }, [updateRange, updates])
const value = useMemo<HistoryContextValue>( const value = useMemo<HistoryContextValue>(
() => ({ () => ({
@ -202,10 +211,8 @@ function useHistory() {
setUpdates, setUpdates,
userHasFullFeature, userHasFullFeature,
projectId, projectId,
fileSelection, selection,
setFileSelection, setSelection,
updateSelection,
setUpdateSelection,
}), }),
[ [
atEnd, atEnd,
@ -220,10 +227,8 @@ function useHistory() {
setUpdates, setUpdates,
userHasFullFeature, userHasFullFeature,
projectId, projectId,
fileSelection, selection,
setFileSelection, setSelection,
updateSelection,
setUpdateSelection,
] ]
) )

View file

@ -1,25 +1,19 @@
import { useCallback, useMemo } from 'react' import { useCallback } from 'react'
import { useHistoryContext } from '../history-context' import { useHistoryContext } from '../history-context'
export function useFileTreeItemSelection(pathname: string) { export function useFileTreeItemSelection(pathname: string) {
const { fileSelection, setFileSelection } = useHistoryContext() const { selection, setSelection } = useHistoryContext()
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (!fileSelection) { if (pathname !== selection.pathname) {
return setSelection({
} ...selection,
if (pathname !== fileSelection.pathname) {
setFileSelection({
files: fileSelection.files,
pathname, pathname,
}) })
} }
}, [fileSelection, pathname, setFileSelection]) }, [pathname, selection, setSelection])
const isSelected = useMemo( const isSelected = selection.pathname === pathname
() => fileSelection?.pathname === pathname,
[fileSelection, pathname]
)
return { isSelected, onClick: handleClick } return { isSelected, onClick: handleClick }
} }

View file

@ -1,7 +1,7 @@
import { Nullable } from '../../../../../../types/utils' import { Nullable } from '../../../../../../types/utils'
import { LoadedUpdate, UpdateSelection } from '../../services/types/update' import { LoadedUpdate } from '../../services/types/update'
import { LoadedLabel } from '../../services/types/label' import { LoadedLabel } from '../../services/types/label'
import { FileSelection } from '../../services/types/file' import { Selection } from '../../services/types/selection'
export type HistoryContextValue = { export type HistoryContextValue = {
updates: LoadedUpdate[] updates: LoadedUpdate[]
@ -18,8 +18,6 @@ export type HistoryContextValue = {
setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>> setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
loadingFileTree: boolean loadingFileTree: boolean
projectId: string projectId: string
fileSelection: FileSelection | null selection: Selection
setFileSelection: (fileSelection: FileSelection) => void setSelection: (selection: Selection) => void
updateSelection: UpdateSelection | null
setUpdateSelection: (updateSelection: UpdateSelection) => void
} }

View file

@ -73,11 +73,6 @@ const plugin = ViewPlugin.fromClass(
const oldLocations = this.view.state.field(highlightLocationsField) const oldLocations = this.view.state.field(highlightLocationsField)
const newLocations = calculateHighlightLocations(this.view) const newLocations = calculateHighlightLocations(this.view)
console.log(
'dispatchIfChanged, changed is',
!isEqual(oldLocations, newLocations)
)
if (!isEqual(oldLocations, newLocations)) { if (!isEqual(oldLocations, newLocations)) {
this.view.dispatch({ this.view.dispatch({
effects: setHighlightLocationsEffect.of(newLocations), effects: setHighlightLocationsEffect.of(newLocations),

View file

@ -17,9 +17,10 @@ export function fetchUpdates(projectId: string, before?: number) {
const queryParamsSerialized = new URLSearchParams(queryParams).toString() const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}` const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
return getJSON<{ updates: Update[]; nextBeforeTimestamp?: number }>( return getJSON<{
updatesURL updates: Update[]
) nextBeforeTimestamp?: number
}>(updatesURL)
} }
export function fetchLabels(projectId: string) { export function fetchLabels(projectId: string) {

View file

@ -30,8 +30,3 @@ export type FileDiff =
| FileEdited | FileEdited
| FileRenamed | FileRenamed
| FileUnchanged | FileUnchanged
export interface FileSelection {
files: FileDiff[]
pathname: string | null
}

View file

@ -0,0 +1,9 @@
import { FileDiff } from './file'
import { UpdateRange } from './update'
export interface Selection {
updateRange: UpdateRange | null
comparing: boolean
files: FileDiff[]
pathname: string | null
}

View file

@ -9,9 +9,12 @@ export interface ProjectOp {
atV: number atV: number
} }
export interface Update { export interface UpdateRange {
fromV: number fromV: number
toV: number toV: number
}
export interface Update extends UpdateRange {
meta: Meta meta: Meta
labels: Label[] labels: Label[]
pathnames: string[] pathnames: string[]
@ -32,8 +35,3 @@ interface LoadedUpdateMeta extends Meta {
export interface LoadedUpdate extends Update { export interface LoadedUpdate extends Update {
meta: LoadedUpdateMeta meta: LoadedUpdateMeta
} }
export interface UpdateSelection {
update: Update
comparing: boolean
}

View file

@ -3,12 +3,12 @@ import type { Nullable } from '../../../../../types/utils'
import type { HistoryContextValue } from '../context/types/history-context-value' import type { HistoryContextValue } from '../context/types/history-context-value'
import type { FileDiff } from '../services/types/file' import type { FileDiff } from '../services/types/file'
import type { DiffOperation } from '../services/types/diff-operation' import type { DiffOperation } from '../services/types/diff-operation'
import type { Update } from '../services/types/update' import type { LoadedUpdate, UpdateRange } from '../services/types/update'
function getUpdateForVersion( function getUpdateForVersion(
version: Update['toV'], version: LoadedUpdate['toV'],
updates: HistoryContextValue['updates'] updates: HistoryContextValue['updates']
): Nullable<Update> { ): Nullable<LoadedUpdate> {
return updates.filter(update => update.toV === version)?.[0] ?? null return updates.filter(update => update.toV === version)?.[0] ?? null
} }
@ -19,18 +19,13 @@ type FileWithOps = {
function getFilesWithOps( function getFilesWithOps(
files: FileDiff[], files: FileDiff[],
updateSelection: HistoryContextValue['updateSelection'], updateRange: UpdateRange,
comparing: boolean,
updates: HistoryContextValue['updates'] updates: HistoryContextValue['updates']
): FileWithOps[] { ): FileWithOps[] {
if (!updateSelection) { if (updateRange.toV && !comparing) {
return []
}
if (updateSelection.update.toV && !updateSelection.comparing) {
const filesWithOps: FileWithOps[] = [] const filesWithOps: FileWithOps[] = []
const currentUpdate = getUpdateForVersion( const currentUpdate = getUpdateForVersion(updateRange.toV, updates)
updateSelection.update.toV,
updates
)
if (currentUpdate !== null) { if (currentUpdate !== null) {
for (const pathname of currentUpdate.pathnames) { for (const pathname of currentUpdate.pathnames) {
@ -95,12 +90,13 @@ const orderedOpTypes: DiffOperation[] = [
export function autoSelectFile( export function autoSelectFile(
files: FileDiff[], files: FileDiff[],
updateSelection: HistoryContextValue['updateSelection'], updateRange: UpdateRange,
comparing: boolean,
updates: HistoryContextValue['updates'] updates: HistoryContextValue['updates']
) { ) {
let fileToSelect: Nullable<FileDiff> = null let fileToSelect: Nullable<FileDiff> = null
const filesWithOps = getFilesWithOps(files, updateSelection, updates) const filesWithOps = getFilesWithOps(files, updateRange, comparing, updates)
for (const opType of orderedOpTypes) { for (const opType of orderedOpTypes) {
const fileWithMatchingOpType = _.find(filesWithOps, { const fileWithMatchingOpType = _.find(filesWithOps, {
operation: opType, operation: opType,

View file

@ -1,7 +1,8 @@
import ColorManager from '../../../ide/colors/ColorManager' import ColorManager from '../../../ide/colors/ColorManager'
import { Nullable } from '../../../../../types/utils' import { Nullable } from '../../../../../types/utils'
import { User } from '../services/types/shared' import { User } from '../services/types/shared'
import { ProjectOp } from '../services/types/update' import { ProjectOp, UpdateRange } from '../services/types/update'
import { Selection } from '../services/types/selection'
export const getUserColor = (user?: Nullable<{ id: string }>) => { export const getUserColor = (user?: Nullable<{ id: string }>) => {
const hue = ColorManager.getHueForUserId(user?.id) || 100 const hue = ColorManager.getHueForUserId(user?.id) || 100
@ -35,3 +36,11 @@ export const getProjectOpDoc = (projectOp: ProjectOp) => {
} }
return '' return ''
} }
export const updateIsSelected = (update: UpdateRange, selection: Selection) => {
return (
selection.updateRange &&
update.fromV >= selection.updateRange.fromV &&
update.toV <= selection.updateRange.toV
)
}

View file

@ -39,6 +39,8 @@ history-root {
} }
.change-list { .change-list {
display: flex;
flex-direction: column;
width: @versions-list-width; width: @versions-list-width;
font-size: @font-size-small; font-size: @font-size-small;
border-left: 1px solid @history-react-separator-color; border-left: 1px solid @history-react-separator-color;
@ -53,6 +55,11 @@ history-root {
} }
} }
.history-version-list-container {
flex: 1;
overflow-y: auto;
}
.history-toggle-switch-container, .history-toggle-switch-container,
.history-version-day, .history-version-day,
.history-version-details { .history-version-details {
@ -79,6 +86,10 @@ history-root {
} }
} }
.history-version-selected {
background-color: @green-10;
}
.history-version-metadata-time { .history-version-metadata-time {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;

View file

@ -3,7 +3,6 @@ import type { HistoryContextValue } from '../../../../../frontend/js/features/hi
import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file' import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file'
import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file' import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file'
import type { User } from '../../../../../frontend/js/features/history/services/types/shared' import type { User } from '../../../../../frontend/js/features/history/services/types/shared'
import { UpdateSelection } from '../../../../../frontend/js/features/history/services/types/update'
describe('autoSelectFile', function () { describe('autoSelectFile', function () {
const historyUsers: User[] = [ const historyUsers: User[] = [
@ -260,12 +259,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updateSelection: UpdateSelection = { const pathname = autoSelectFile(files, updates[0], comparing, updates)
update: updates[0],
comparing,
}
const pathname = autoSelectFile(files, updateSelection, updates)
expect(pathname).to.equal('newfolder1/newfile10.tex') expect(pathname).to.equal('newfolder1/newfile10.tex')
}) })
@ -330,12 +324,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updateSelection: UpdateSelection = { const pathname = autoSelectFile(files, updates[0], comparing, updates)
update: updates[0],
comparing,
}
const pathname = autoSelectFile(files, updateSelection, updates)
expect(pathname).to.equal('newfile1.tex') expect(pathname).to.equal('newfile1.tex')
}) })
@ -431,12 +420,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updateSelection: UpdateSelection = { const pathname = autoSelectFile(files, updates[0], comparing, updates)
update: updates[0],
comparing,
}
const pathname = autoSelectFile(files, updateSelection, updates)
expect(pathname).to.equal('main3.tex') expect(pathname).to.equal('main3.tex')
}) })
@ -602,12 +586,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updateSelection: UpdateSelection = { const pathname = autoSelectFile(files, updates[0], comparing, updates)
update: updates[0],
comparing,
}
const pathname = autoSelectFile(files, updateSelection, updates)
expect(pathname).to.equal('main.tex') expect(pathname).to.equal('main.tex')
}) })
@ -710,12 +689,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updateSelection: UpdateSelection = { const pathname = autoSelectFile(files, updates[0], comparing, updates)
update: updates[0],
comparing,
}
const pathname = autoSelectFile(files, updateSelection, updates)
expect(pathname).to.equal('certainly_not_main.tex') expect(pathname).to.equal('certainly_not_main.tex')
}) })