mirror of
https://github.com/overleaf/overleaf.git
synced 2024-09-16 02:52:31 -04:00
Implement deleted file restore for history react (#12753)
* Add strikethrough to deleted file tree item in history file tree * Add "restore file" button on toolbar if the selected file have a `removed` operation. * Implement "restore file" functionality on removed file: - Refactor the `Selection` object in history context value since we need the `deletedAtV` data which currently is not passed through the state. - Refactor and clean up file tree type system to support passing through the whole `FileDiff` object for getting the `deletedAtV` data which only appear on `removed` operation - Implement `postJSON` with file restoration API and pass it on restore file onClick handler at toolbar * Implement loading behaviour while restoring file is inflight: - Add `loadingRestoreFile` to `LoadingState` - Change restore file button to `Restoring...` when in loading state. * Refactor: - Rename `DiffOperation` to `FileOperation` - Extract `isFileRemoved` and `isFileRenamed` to its own file - Extract `Toolbar` components into small files GitOrigin-RevId: 2e32ebd2165f73fc6533ff282a9c084162efd682
This commit is contained in:
parent
0de648eecd
commit
0648b8aa6c
29 changed files with 394 additions and 182 deletions
|
@ -715,6 +715,8 @@
|
|||
"resend": "",
|
||||
"resend_confirmation_email": "",
|
||||
"resending_confirmation_email": "",
|
||||
"restore_file": "",
|
||||
"restoring": "",
|
||||
"reverse_x_sort_order": "",
|
||||
"revert_pending_plan_change": "",
|
||||
"review": "",
|
||||
|
|
|
@ -44,7 +44,6 @@ function Compare({
|
|||
},
|
||||
comparing: true,
|
||||
files: [],
|
||||
pathname: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ function HistoryVersionDetails({
|
|||
updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
|
||||
comparing: false,
|
||||
files: [],
|
||||
pathname: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Toolbar from './toolbar'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Toolbar from './toolbar/toolbar'
|
||||
import Main from './main'
|
||||
import { Diff, DocDiffResponse } from '../../services/types/doc'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Nullable } from '../../../../../../types/utils'
|
||||
import { useHistoryContext } from '../../context/history-context'
|
||||
import { diffDoc } from '../../services/api'
|
||||
|
@ -14,35 +14,37 @@ function DiffView() {
|
|||
|
||||
const { isLoading, runAsync } = useAsync<DocDiffResponse>()
|
||||
|
||||
const { updateRange, pathname } = selection
|
||||
const { updateRange, selectedFile } = selection
|
||||
|
||||
useEffect(() => {
|
||||
if (!updateRange || !pathname) {
|
||||
if (!updateRange || !selectedFile?.pathname) {
|
||||
return
|
||||
}
|
||||
|
||||
const { fromV, toV } = updateRange
|
||||
|
||||
// TODO: Error handling
|
||||
runAsync(diffDoc(projectId, fromV, toV, pathname)).then(data => {
|
||||
let diff: Diff | undefined
|
||||
runAsync(diffDoc(projectId, fromV, toV, selectedFile.pathname)).then(
|
||||
data => {
|
||||
let diff: Diff | undefined
|
||||
|
||||
if (!data?.diff) {
|
||||
setDiff(null)
|
||||
}
|
||||
|
||||
if ('binary' in data.diff) {
|
||||
diff = { binary: true }
|
||||
} else {
|
||||
diff = {
|
||||
binary: false,
|
||||
docDiff: highlightsFromDiffResponse(data.diff),
|
||||
if (!data?.diff) {
|
||||
setDiff(null)
|
||||
}
|
||||
}
|
||||
|
||||
setDiff(diff)
|
||||
})
|
||||
}, [projectId, runAsync, updateRange, pathname])
|
||||
if ('binary' in data.diff) {
|
||||
diff = { binary: true }
|
||||
} else {
|
||||
diff = {
|
||||
binary: false,
|
||||
docDiff: highlightsFromDiffResponse(data.diff),
|
||||
}
|
||||
}
|
||||
|
||||
setDiff(diff)
|
||||
}
|
||||
)
|
||||
}, [projectId, runAsync, updateRange, selectedFile])
|
||||
|
||||
return (
|
||||
<div className="doc-panel">
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { formatTime } from '../../../utils/format-date'
|
||||
import type { Nullable } from '../../../../../../types/utils'
|
||||
import type { Diff } from '../../services/types/doc'
|
||||
import type { HistoryContextValue } from '../../context/types/history-context-value'
|
||||
|
||||
type ToolbarProps = {
|
||||
diff: Nullable<Diff>
|
||||
selection: HistoryContextValue['selection']
|
||||
}
|
||||
|
||||
function Toolbar({ diff, selection }: ToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!selection) return null
|
||||
|
||||
return (
|
||||
<div className="history-react-toolbar">
|
||||
<div>
|
||||
{selection.comparing ? (
|
||||
<Trans
|
||||
i18nKey="comparing_x_to_y"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<time className="history-react-toolbar-time" />]}
|
||||
values={{
|
||||
startTime: formatTime(
|
||||
selection.updateRange?.fromVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
endTime: formatTime(
|
||||
selection.updateRange?.toVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="viewing_x"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<time className="history-react-toolbar-time" />]}
|
||||
values={{
|
||||
endTime: formatTime(
|
||||
selection.updateRange?.toVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{selection.pathname ? (
|
||||
<div className="history-react-toolbar-changes">
|
||||
{t('x_changes_in', {
|
||||
count: diff?.docDiff?.highlights.length ?? 0,
|
||||
})}
|
||||
|
||||
<strong>{getFileName(selection)}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getFileName(selection: HistoryContextValue['selection']) {
|
||||
const filePathParts = selection?.pathname?.split('/')
|
||||
let fileName
|
||||
if (filePathParts) {
|
||||
fileName = filePathParts[filePathParts.length - 1]
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
export default Toolbar
|
|
@ -0,0 +1,43 @@
|
|||
import { Trans } from 'react-i18next'
|
||||
import { formatTime } from '../../../../utils/format-date'
|
||||
import type { HistoryContextValue } from '../../../context/types/history-context-value'
|
||||
|
||||
type ToolbarDatetimeProps = {
|
||||
selection: HistoryContextValue['selection']
|
||||
}
|
||||
|
||||
export default function ToolbarDatetime({ selection }: ToolbarDatetimeProps) {
|
||||
return (
|
||||
<div className="history-react-toolbar-datetime">
|
||||
{selection.comparing ? (
|
||||
<Trans
|
||||
i18nKey="comparing_x_to_y"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<time className="history-react-toolbar-time" />]}
|
||||
values={{
|
||||
startTime: formatTime(
|
||||
selection.updateRange?.fromVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
endTime: formatTime(
|
||||
selection.updateRange?.toVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="viewing_x"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<time className="history-react-toolbar-time" />]}
|
||||
values={{
|
||||
endTime: formatTime(
|
||||
selection.updateRange?.toVTimestamp,
|
||||
'Do MMMM · h:mm a'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { HistoryContextValue } from '../../../context/types/history-context-value'
|
||||
import type { Diff } from '../../../services/types/doc'
|
||||
import type { Nullable } from '../../../../../../../types/utils'
|
||||
|
||||
type ToolbarFileInfoProps = {
|
||||
diff: Nullable<Diff>
|
||||
selection: HistoryContextValue['selection']
|
||||
}
|
||||
|
||||
export default function ToolbarFileInfo({
|
||||
diff,
|
||||
selection,
|
||||
}: ToolbarFileInfoProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="history-react-toolbar-file-info">
|
||||
{t('x_changes_in', {
|
||||
count: diff?.docDiff?.highlights?.length ?? 0,
|
||||
})}
|
||||
|
||||
<strong>{getFileName(selection)}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getFileName(selection: HistoryContextValue['selection']) {
|
||||
const filePathParts = selection?.selectedFile?.pathname?.split('/')
|
||||
let fileName
|
||||
if (filePathParts) {
|
||||
fileName = filePathParts[filePathParts.length - 1]
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistoryContext } from '../../../context/history-context'
|
||||
import { useRestoreDeletedFile } from '../../../context/hooks/use-restore-deleted-file'
|
||||
import type { HistoryContextValue } from '../../../context/types/history-context-value'
|
||||
|
||||
type ToolbarRestoreFileButtonProps = {
|
||||
selection: HistoryContextValue['selection']
|
||||
}
|
||||
|
||||
export default function ToolbarRestoreFileButton({
|
||||
selection,
|
||||
}: ToolbarRestoreFileButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { loadingState } = useHistoryContext()
|
||||
|
||||
const onRestoreFile = useRestoreDeletedFile()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="btn-secondary history-react-toolbar-restore-file-button"
|
||||
bsSize="xs"
|
||||
bsStyle={null}
|
||||
onClick={() => onRestoreFile(selection)}
|
||||
disabled={loadingState === 'restoringFile'}
|
||||
>
|
||||
{loadingState === 'restoringFile'
|
||||
? `${t('restoring')}…`
|
||||
: t('restore_file')}
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import type { Nullable } from '../../../../../../../types/utils'
|
||||
import type { Diff } from '../../../services/types/doc'
|
||||
import type { HistoryContextValue } from '../../../context/types/history-context-value'
|
||||
import ToolbarDatetime from './toolbar-datetime'
|
||||
import ToolbarFileInfo from './toolbar-file-info'
|
||||
import ToolbarRestoreFileButton from './toolbar-restore-file-button'
|
||||
import { isFileRemoved } from '../../../utils/file-diff'
|
||||
|
||||
type ToolbarProps = {
|
||||
diff: Nullable<Diff>
|
||||
selection: HistoryContextValue['selection']
|
||||
}
|
||||
|
||||
export default function Toolbar({ diff, selection }: ToolbarProps) {
|
||||
const showRestoreFileButton =
|
||||
selection.selectedFile && isFileRemoved(selection.selectedFile)
|
||||
|
||||
return (
|
||||
<div className="history-react-toolbar">
|
||||
<ToolbarDatetime selection={selection} />
|
||||
{selection.selectedFile?.pathname ? (
|
||||
<ToolbarFileInfo diff={diff} selection={selection} />
|
||||
) : null}
|
||||
{showRestoreFileButton ? (
|
||||
<ToolbarRestoreFileButton selection={selection} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,22 +1,20 @@
|
|||
import classNames from 'classnames'
|
||||
import HistoryFileTreeItem from './history-file-tree-item'
|
||||
import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { useFileTreeItemSelection } from '../../context/hooks/use-file-tree-item-selection'
|
||||
import { DiffOperation } from '../../services/types/diff-operation'
|
||||
import classNames from 'classnames'
|
||||
import type { FileDiff } from '../../services/types/file'
|
||||
|
||||
type HistoryFileTreeDocProps = {
|
||||
file: FileDiff
|
||||
name: string
|
||||
pathname: string
|
||||
operation?: DiffOperation
|
||||
}
|
||||
|
||||
export default function HistoryFileTreeDoc({
|
||||
file,
|
||||
name,
|
||||
pathname,
|
||||
operation,
|
||||
}: HistoryFileTreeDocProps) {
|
||||
const { isSelected, onClick } = useFileTreeItemSelection(pathname)
|
||||
const { isSelected, onClick } = useFileTreeItemSelection(file)
|
||||
|
||||
return (
|
||||
<li
|
||||
|
@ -30,7 +28,7 @@ export default function HistoryFileTreeDoc({
|
|||
>
|
||||
<HistoryFileTreeItem
|
||||
name={name}
|
||||
operation={operation}
|
||||
operation={'operation' in file ? file.operation : undefined}
|
||||
icons={
|
||||
<Icon
|
||||
type={iconTypeFromName(name)}
|
||||
|
|
|
@ -33,12 +33,7 @@ export default function HistoryFileTreeFolderList({
|
|||
})}
|
||||
{docs.sort(compareFunction).map(doc => {
|
||||
return (
|
||||
<HistoryFileTreeDoc
|
||||
key={doc.pathname}
|
||||
name={doc.name}
|
||||
pathname={doc.pathname}
|
||||
operation={doc.operation}
|
||||
/>
|
||||
<HistoryFileTreeDoc key={doc.pathname} name={doc.name} file={doc} />
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import classNames from 'classnames'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DiffOperation } from '../../services/types/diff-operation'
|
||||
import type { FileOperation } from '../../services/types/file-operation'
|
||||
import Badge from '../../../../shared/components/badge'
|
||||
|
||||
type FileTreeItemProps = {
|
||||
name: string
|
||||
operation?: DiffOperation
|
||||
operation?: FileOperation
|
||||
icons: ReactNode
|
||||
}
|
||||
|
||||
|
@ -17,7 +18,13 @@ export default function HistoryFileTreeItem({
|
|||
<div className="history-file-tree-item" role="presentation">
|
||||
{icons}
|
||||
<button className="history-file-tree-item-button">
|
||||
<span className="history-file-tree-item-button-text">{name}</span>
|
||||
<span
|
||||
className={classNames('history-file-tree-item-button-text', {
|
||||
strikethrough: operation === 'removed',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{operation ? (
|
||||
<Badge className="history-file-tree-item-button-badge" size="sm">
|
||||
{operation}
|
||||
|
|
|
@ -12,7 +12,8 @@ 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, isFileRenamed } from '../utils/file-tree'
|
||||
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 ColorManager from '../../../ide/colors/ColorManager'
|
||||
|
@ -61,7 +62,6 @@ const selectionInitialState: Selection = {
|
|||
updateRange: null,
|
||||
comparing: false,
|
||||
files: [],
|
||||
pathname: null,
|
||||
}
|
||||
|
||||
function useHistory() {
|
||||
|
@ -209,7 +209,7 @@ function useHistory() {
|
|||
const { fromV, toV } = updateRange
|
||||
|
||||
diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
|
||||
const pathname = autoSelectFile(
|
||||
const selectedFile = autoSelectFile(
|
||||
files,
|
||||
updateRange.toV,
|
||||
comparing,
|
||||
|
@ -222,7 +222,12 @@ function useHistory() {
|
|||
|
||||
return file
|
||||
})
|
||||
setSelection({ updateRange, comparing, files: newFiles, pathname })
|
||||
setSelection({
|
||||
updateRange,
|
||||
comparing,
|
||||
files: newFiles,
|
||||
selectedFile,
|
||||
})
|
||||
})
|
||||
}, [updateRange, projectId, updates, comparing])
|
||||
|
||||
|
@ -238,7 +243,6 @@ function useHistory() {
|
|||
},
|
||||
comparing: false,
|
||||
files: [],
|
||||
pathname: null,
|
||||
})
|
||||
}
|
||||
}, [updateRange, updates])
|
||||
|
@ -247,6 +251,7 @@ function useHistory() {
|
|||
() => ({
|
||||
error,
|
||||
loadingState,
|
||||
setLoadingState,
|
||||
updatesInfo,
|
||||
setUpdatesInfo,
|
||||
labels,
|
||||
|
@ -261,6 +266,7 @@ function useHistory() {
|
|||
[
|
||||
error,
|
||||
loadingState,
|
||||
setLoadingState,
|
||||
updatesInfo,
|
||||
setUpdatesInfo,
|
||||
labels,
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useHistoryContext } from '../history-context'
|
||||
import type { FileDiff } from '../../services/types/file'
|
||||
|
||||
export function useFileTreeItemSelection(pathname: string) {
|
||||
export function useFileTreeItemSelection(file: FileDiff) {
|
||||
const { selection, setSelection } = useHistoryContext()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (pathname !== selection.pathname) {
|
||||
if (file.pathname !== selection.selectedFile?.pathname) {
|
||||
setSelection({
|
||||
...selection,
|
||||
pathname,
|
||||
selectedFile: file,
|
||||
})
|
||||
}
|
||||
}, [pathname, selection, setSelection])
|
||||
}, [file, selection, setSelection])
|
||||
|
||||
const isSelected = selection.pathname === pathname
|
||||
const isSelected = selection.selectedFile?.pathname === file.pathname
|
||||
|
||||
return { isSelected, onClick: handleClick }
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
import { useIdeContext } from '../../../../shared/context/ide-context'
|
||||
import { useLayoutContext } from '../../../../shared/context/layout-context'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { restoreFile } from '../../services/api'
|
||||
import { isFileRemoved } from '../../utils/file-diff'
|
||||
import { useHistoryContext } from '../history-context'
|
||||
import type { HistoryContextValue } from '../types/history-context-value'
|
||||
|
||||
export function useRestoreDeletedFile() {
|
||||
const { runAsync } = useAsync()
|
||||
const { setLoadingState, projectId } = useHistoryContext()
|
||||
const ide = useIdeContext()
|
||||
const { setView } = useLayoutContext()
|
||||
|
||||
return async (selection: HistoryContextValue['selection']) => {
|
||||
const { selectedFile } = selection
|
||||
|
||||
if (selectedFile && selectedFile.pathname && isFileRemoved(selectedFile)) {
|
||||
sendMB('history-v2-restore-deleted')
|
||||
setLoadingState('restoringFile')
|
||||
|
||||
await runAsync(
|
||||
restoreFile(projectId, selectedFile)
|
||||
.then(data => {
|
||||
const { id, type } = data
|
||||
|
||||
const entity = ide.fileTreeManager.findEntityById(id)
|
||||
|
||||
if (type === 'doc') {
|
||||
ide.editorManager.openDoc(entity)
|
||||
} else {
|
||||
ide.binaryFilesManager.openFile(entity)
|
||||
}
|
||||
|
||||
setView('editor')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingState('ready')
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,12 @@ import { LoadedUpdate } from '../../services/types/update'
|
|||
import { LoadedLabel } from '../../services/types/label'
|
||||
import { Selection } from '../../services/types/selection'
|
||||
|
||||
type LoadingState =
|
||||
| 'loadingInitial'
|
||||
| 'loadingUpdates'
|
||||
| 'restoringFile'
|
||||
| 'ready'
|
||||
|
||||
export type HistoryContextValue = {
|
||||
updatesInfo: {
|
||||
updates: LoadedUpdate[]
|
||||
|
@ -14,7 +20,10 @@ export type HistoryContextValue = {
|
|||
React.SetStateAction<HistoryContextValue['updatesInfo']>
|
||||
>
|
||||
userHasFullFeature: boolean | undefined
|
||||
loadingState: 'loadingInitial' | 'loadingUpdates' | 'ready'
|
||||
loadingState: LoadingState
|
||||
setLoadingState: React.Dispatch<
|
||||
React.SetStateAction<HistoryContextValue['loadingState']>
|
||||
>
|
||||
error: Nullable<unknown>
|
||||
labels: Nullable<LoadedLabel[]>
|
||||
setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
|
||||
|
|
|
@ -3,10 +3,11 @@ import {
|
|||
getJSON,
|
||||
postJSON,
|
||||
} from '../../../infrastructure/fetch-json'
|
||||
import { FileDiff } from './types/file'
|
||||
import { FileDiff, FileRemoved } from './types/file'
|
||||
import { FetchUpdatesResponse } from './types/update'
|
||||
import { Label } from './types/label'
|
||||
import { DocDiffResponse } from './types/doc'
|
||||
import { RestoreFileResponse } from './types/restore-file'
|
||||
|
||||
const BATCH_SIZE = 10
|
||||
|
||||
|
@ -70,3 +71,12 @@ export function diffDoc(
|
|||
const diffUrl = `/project/${projectId}/diff?${queryParamsSerialized}`
|
||||
return getJSON<DocDiffResponse>(diffUrl)
|
||||
}
|
||||
|
||||
export function restoreFile(projectId: string, selectedFile: FileRemoved) {
|
||||
return postJSON<RestoreFileResponse>(`/project/${projectId}/restore_file`, {
|
||||
body: {
|
||||
version: selectedFile.deletedAtV,
|
||||
pathname: selectedFile.pathname,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export type DiffOperation = 'edited' | 'added' | 'renamed' | 'removed'
|
|
@ -0,0 +1 @@
|
|||
export type FileOperation = 'edited' | 'added' | 'renamed' | 'removed'
|
|
@ -1,27 +1,27 @@
|
|||
import { DiffOperation } from './diff-operation'
|
||||
import { FileOperation } from './file-operation'
|
||||
|
||||
export interface FileUnchanged {
|
||||
pathname: string
|
||||
}
|
||||
|
||||
export interface FileAdded extends FileUnchanged {
|
||||
operation: Extract<DiffOperation, 'added'>
|
||||
operation: Extract<FileOperation, 'added'>
|
||||
}
|
||||
|
||||
export interface FileRemoved extends FileUnchanged {
|
||||
operation: Extract<DiffOperation, 'removed'>
|
||||
operation: Extract<FileOperation, 'removed'>
|
||||
newPathname?: string
|
||||
deletedAtV: number
|
||||
}
|
||||
|
||||
export interface FileEdited extends FileUnchanged {
|
||||
operation: Extract<DiffOperation, 'edited'>
|
||||
operation: Extract<FileOperation, 'edited'>
|
||||
}
|
||||
|
||||
export interface FileRenamed extends FileUnchanged {
|
||||
newPathname?: string
|
||||
oldPathname?: string
|
||||
operation: Extract<DiffOperation, 'renamed'>
|
||||
operation: Extract<FileOperation, 'renamed'>
|
||||
}
|
||||
|
||||
export type FileDiff =
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export type RestoreFileResponse = {
|
||||
id: string
|
||||
type: 'doc' | 'file'
|
||||
}
|
|
@ -5,5 +5,5 @@ export interface Selection {
|
|||
updateRange: UpdateRange | null
|
||||
comparing: boolean
|
||||
files: FileDiff[]
|
||||
pathname: string | null
|
||||
selectedFile?: FileDiff
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'lodash'
|
||||
import type { Nullable } from '../../../../../types/utils'
|
||||
import type { FileDiff } from '../services/types/file'
|
||||
import type { DiffOperation } from '../services/types/diff-operation'
|
||||
import type { FileOperation } from '../services/types/file-operation'
|
||||
import type { LoadedUpdate, Version } from '../services/types/update'
|
||||
|
||||
function getUpdateForVersion(
|
||||
|
@ -13,7 +13,7 @@ function getUpdateForVersion(
|
|||
|
||||
type FileWithOps = {
|
||||
pathname: FileDiff['pathname']
|
||||
operation: DiffOperation
|
||||
operation: FileOperation
|
||||
}
|
||||
|
||||
function getFilesWithOps(
|
||||
|
@ -80,7 +80,7 @@ function getFilesWithOps(
|
|||
}
|
||||
}
|
||||
|
||||
const orderedOpTypes: DiffOperation[] = [
|
||||
const orderedOpTypes: FileOperation[] = [
|
||||
'edited',
|
||||
'added',
|
||||
'renamed',
|
||||
|
@ -92,7 +92,7 @@ export function autoSelectFile(
|
|||
toV: Version,
|
||||
comparing: boolean,
|
||||
updates: LoadedUpdate[]
|
||||
) {
|
||||
): FileDiff {
|
||||
let fileToSelect: Nullable<FileDiff> = null
|
||||
|
||||
const filesWithOps = getFilesWithOps(files, toV, comparing, updates)
|
||||
|
@ -131,5 +131,5 @@ export function autoSelectFile(
|
|||
}
|
||||
}
|
||||
|
||||
return fileToSelect.pathname
|
||||
return fileToSelect
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import type { FileDiff, FileRemoved, FileRenamed } from '../services/types/file'
|
||||
|
||||
export function isFileRenamed(fileDiff: FileDiff): fileDiff is FileRenamed {
|
||||
return (fileDiff as FileRenamed).operation === 'renamed'
|
||||
}
|
||||
|
||||
export function isFileRemoved(fileDiff: FileDiff): fileDiff is FileRemoved {
|
||||
return (fileDiff as FileRemoved).operation === 'removed'
|
||||
}
|
|
@ -1,19 +1,12 @@
|
|||
import _ from 'lodash'
|
||||
import type { FileDiff, FileRenamed } from '../services/types/file'
|
||||
import type { DiffOperation } from '../services/types/diff-operation'
|
||||
import { isFileRemoved } from './file-diff'
|
||||
|
||||
// `Partial` because the `reducePathsToTree` function was copied directly
|
||||
// from a javascript file without proper type system and the logic is not typescript-friendly.
|
||||
// TODO: refactor the function to have a proper type system
|
||||
type FileTreeEntity = Partial<{
|
||||
name: string
|
||||
type: 'file' | 'folder'
|
||||
oldPathname: string
|
||||
newPathname: string
|
||||
pathname: string
|
||||
children: FileTreeEntity[]
|
||||
operation: DiffOperation
|
||||
}>
|
||||
export type FileTreeEntity = {
|
||||
name?: string
|
||||
type?: 'file' | 'folder'
|
||||
children?: FileTreeEntity[]
|
||||
} & FileDiff
|
||||
|
||||
export function reducePathsToTree(
|
||||
currentFileTree: FileTreeEntity[],
|
||||
|
@ -21,25 +14,31 @@ export function reducePathsToTree(
|
|||
) {
|
||||
const filePathParts = fileObject?.pathname?.split('/') ?? ''
|
||||
let currentFileTreeLocation = currentFileTree
|
||||
|
||||
for (let index = 0; index < filePathParts.length; index++) {
|
||||
let fileTreeEntity: FileTreeEntity | null = {}
|
||||
const pathPart = filePathParts[index]
|
||||
const isFile = index === filePathParts.length - 1
|
||||
|
||||
if (isFile) {
|
||||
fileTreeEntity = _.clone(fileObject)
|
||||
const fileTreeEntity: FileTreeEntity = _.clone(fileObject)
|
||||
fileTreeEntity.name = pathPart
|
||||
fileTreeEntity.type = 'file'
|
||||
|
||||
currentFileTreeLocation.push(fileTreeEntity)
|
||||
} else {
|
||||
fileTreeEntity =
|
||||
_.find(currentFileTreeLocation, entity => entity.name === pathPart) ??
|
||||
null
|
||||
if (fileTreeEntity == null) {
|
||||
let fileTreeEntity: FileTreeEntity | undefined = _.find(
|
||||
currentFileTreeLocation,
|
||||
entity => entity.name === pathPart
|
||||
)
|
||||
|
||||
if (fileTreeEntity === undefined) {
|
||||
fileTreeEntity = {
|
||||
name: pathPart,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
children: <FileTreeEntity[]>[],
|
||||
pathname: pathPart,
|
||||
}
|
||||
|
||||
currentFileTreeLocation.push(fileTreeEntity)
|
||||
}
|
||||
currentFileTreeLocation = fileTreeEntity.children ?? []
|
||||
|
@ -49,9 +48,8 @@ export function reducePathsToTree(
|
|||
}
|
||||
|
||||
export type HistoryDoc = {
|
||||
pathname: string
|
||||
name: string
|
||||
} & Pick<FileTreeEntity, 'operation'>
|
||||
} & FileDiff
|
||||
|
||||
export type HistoryFileTree = {
|
||||
docs?: HistoryDoc[]
|
||||
|
@ -68,11 +66,22 @@ export function fileTreeDiffToFileTreeData(
|
|||
|
||||
for (const file of fileTreeDiff) {
|
||||
if (file.type === 'file') {
|
||||
docs.push({
|
||||
const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
|
||||
|
||||
let newDoc: HistoryDoc = {
|
||||
pathname: file.pathname ?? '',
|
||||
name: file.name ?? '',
|
||||
operation: file.operation,
|
||||
})
|
||||
deletedAtV,
|
||||
}
|
||||
|
||||
if ('operation' in file) {
|
||||
newDoc = {
|
||||
...newDoc,
|
||||
operation: file.operation,
|
||||
}
|
||||
}
|
||||
|
||||
docs.push(newDoc)
|
||||
} else if (file.type === 'folder') {
|
||||
if (file.children) {
|
||||
const folder = fileTreeDiffToFileTreeData(file.children, file.name)
|
||||
|
@ -108,7 +117,3 @@ export function renamePathnameKey(file: FileRenamed): FileRenamed {
|
|||
operation: file.operation,
|
||||
}
|
||||
}
|
||||
|
||||
export function isFileRenamed(fileDiff: FileDiff): fileDiff is FileRenamed {
|
||||
return (fileDiff as FileRenamed).operation === 'renamed'
|
||||
}
|
||||
|
|
|
@ -252,13 +252,20 @@ history-root {
|
|||
|
||||
.history-react-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.history-react-toolbar-file-info {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.history-react-toolbar-time {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-react-toolbar-restore-file-button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -391,6 +398,10 @@ history-root {
|
|||
.history-file-tree-item-button-text {
|
||||
display: inline-flex;
|
||||
margin-right: 5px;
|
||||
|
||||
&.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.history-file-tree-item-button-badge {
|
||||
|
|
|
@ -1252,6 +1252,7 @@
|
|||
"resolve": "Resolve",
|
||||
"resolved_comments": "Resolved comments",
|
||||
"restore": "Restore",
|
||||
"restore_file": "Restore file",
|
||||
"restore_to_any_older_version": "Restore to any older version",
|
||||
"restore_to_before_these_changes": "Restore to before these changes",
|
||||
"restoring": "Restoring",
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar'
|
||||
import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar/toolbar'
|
||||
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
|
||||
import { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
|
||||
import { Diff } from '../../../../../frontend/js/features/history/services/types/doc'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('history toolbar', function () {
|
||||
const editorProvidersScope = {
|
||||
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
|
||||
}
|
||||
|
||||
const diff: Diff = {
|
||||
binary: false,
|
||||
docDiff: {
|
||||
|
@ -42,20 +48,26 @@ describe('history toolbar', function () {
|
|||
pathname: 'frog.jpg',
|
||||
},
|
||||
],
|
||||
pathname: 'main.tex',
|
||||
selectedFile: {
|
||||
pathname: 'main.tex',
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(
|
||||
<div className="history-react">
|
||||
<Toolbar diff={diff} selection={selection} />
|
||||
</div>
|
||||
<EditorProviders scope={editorProvidersScope}>
|
||||
<HistoryProvider>
|
||||
<div className="history-react">
|
||||
<Toolbar diff={diff} selection={selection} />
|
||||
</div>
|
||||
</HistoryProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.history-react-toolbar').within(() => {
|
||||
cy.get('div:first-child').contains('Viewing 13th April')
|
||||
})
|
||||
|
||||
cy.get('.history-react-toolbar-changes').contains('1 change in main.tex')
|
||||
cy.get('.history-react-toolbar-file-info').contains('1 change in main.tex')
|
||||
})
|
||||
|
||||
it('renders comparing mode', function () {
|
||||
|
@ -81,13 +93,19 @@ describe('history toolbar', function () {
|
|||
operation: 'added',
|
||||
},
|
||||
],
|
||||
pathname: 'main.tex',
|
||||
selectedFile: {
|
||||
pathname: 'main.tex',
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(
|
||||
<div className="history-react">
|
||||
<Toolbar diff={diff} selection={selection} />
|
||||
</div>
|
||||
<EditorProviders scope={editorProvidersScope}>
|
||||
<HistoryProvider>
|
||||
<div className="history-react">
|
||||
<Toolbar diff={diff} selection={selection} />
|
||||
</div>
|
||||
</HistoryProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.history-react-toolbar').within(() => {
|
||||
|
|
|
@ -259,7 +259,12 @@ describe('autoSelectFile', function () {
|
|||
},
|
||||
]
|
||||
|
||||
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
|
||||
const { pathname } = autoSelectFile(
|
||||
files,
|
||||
updates[0].toV,
|
||||
comparing,
|
||||
updates
|
||||
)
|
||||
|
||||
expect(pathname).to.equal('newfolder1/newfile10.tex')
|
||||
})
|
||||
|
@ -324,7 +329,12 @@ describe('autoSelectFile', function () {
|
|||
},
|
||||
]
|
||||
|
||||
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
|
||||
const { pathname } = autoSelectFile(
|
||||
files,
|
||||
updates[0].toV,
|
||||
comparing,
|
||||
updates
|
||||
)
|
||||
|
||||
expect(pathname).to.equal('newfile1.tex')
|
||||
})
|
||||
|
@ -420,7 +430,12 @@ describe('autoSelectFile', function () {
|
|||
},
|
||||
]
|
||||
|
||||
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
|
||||
const { pathname } = autoSelectFile(
|
||||
files,
|
||||
updates[0].toV,
|
||||
comparing,
|
||||
updates
|
||||
)
|
||||
|
||||
expect(pathname).to.equal('main3.tex')
|
||||
})
|
||||
|
@ -586,7 +601,12 @@ describe('autoSelectFile', function () {
|
|||
},
|
||||
]
|
||||
|
||||
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
|
||||
const { pathname } = autoSelectFile(
|
||||
files,
|
||||
updates[0].toV,
|
||||
comparing,
|
||||
updates
|
||||
)
|
||||
|
||||
expect(pathname).to.equal('main.tex')
|
||||
})
|
||||
|
@ -689,7 +709,12 @@ describe('autoSelectFile', function () {
|
|||
},
|
||||
]
|
||||
|
||||
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
|
||||
const { pathname } = autoSelectFile(
|
||||
files,
|
||||
updates[0].toV,
|
||||
comparing,
|
||||
updates
|
||||
)
|
||||
|
||||
expect(pathname).to.equal('certainly_not_main.tex')
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue