mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-25 04:24:43 -04:00
Use precalculated diff in revision viewer (#2179)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
f55b590777
commit
649ebe48f1
8 changed files with 530 additions and 89 deletions
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { invertUnifiedPatch } from './invert-unified-patch'
|
||||
import { parsePatch } from 'diff'
|
||||
|
||||
describe('invert unified patch', () => {
|
||||
it('inverts a patch correctly', () => {
|
||||
const parsedPatch = parsePatch(`--- a\t2022-07-03 21:21:07.499933337 +0200
|
||||
+++ b\t2022-07-03 21:22:28.650972217 +0200
|
||||
@@ -1,5 +1,4 @@
|
||||
-a
|
||||
-b
|
||||
c
|
||||
d
|
||||
+d
|
||||
e`)[0]
|
||||
const result = invertUnifiedPatch(parsedPatch)
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"hunks": Array [
|
||||
Object {
|
||||
"linedelimiters": Array [
|
||||
"
|
||||
",
|
||||
"
|
||||
",
|
||||
"
|
||||
",
|
||||
"
|
||||
",
|
||||
"
|
||||
",
|
||||
"
|
||||
",
|
||||
],
|
||||
"lines": Array [
|
||||
"+a",
|
||||
"+b",
|
||||
" c",
|
||||
" d",
|
||||
"-d",
|
||||
" e",
|
||||
],
|
||||
"newLines": 5,
|
||||
"newStart": 1,
|
||||
"oldLines": 4,
|
||||
"oldStart": 1,
|
||||
},
|
||||
],
|
||||
"index": undefined,
|
||||
"newFileName": "a",
|
||||
"newHeader": "2022-07-03 21:21:07.499933337 +0200",
|
||||
"oldFileName": "b",
|
||||
"oldHeader": "2022-07-03 21:22:28.650972217 +0200",
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Hunk, ParsedDiff } from 'diff'
|
||||
|
||||
/**
|
||||
* Inverts a given unified patch.
|
||||
* A patch that e.g. adds a line, will remove it then.
|
||||
*
|
||||
* @param parsedDiff The patch to invert
|
||||
* @return The inverted patch
|
||||
*/
|
||||
export const invertUnifiedPatch = (parsedDiff: ParsedDiff): ParsedDiff => {
|
||||
const { oldFileName, newFileName, oldHeader, newHeader, hunks, index } = parsedDiff
|
||||
|
||||
const newHunks: Hunk[] = hunks.map((hunk) => {
|
||||
const { oldLines, oldStart, newLines, newStart, lines, linedelimiters } = hunk
|
||||
return {
|
||||
oldLines: newLines,
|
||||
oldStart: newStart,
|
||||
newLines: oldLines,
|
||||
newStart: oldStart,
|
||||
linedelimiters: linedelimiters,
|
||||
lines: lines.map((line) => {
|
||||
if (line.startsWith('-')) {
|
||||
return `+${line.slice(1)}`
|
||||
} else if (line.startsWith('+')) {
|
||||
return `-${line.slice(1)}`
|
||||
} else {
|
||||
return line
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hunks: newHunks,
|
||||
index: index,
|
||||
newFileName: oldFileName,
|
||||
newHeader: oldHeader,
|
||||
oldFileName: newFileName,
|
||||
oldHeader: newHeader
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { DateTime } from 'luxon'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { ListGroup } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
|
@ -20,12 +20,13 @@ import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
|
|||
|
||||
export interface RevisionListEntryProps {
|
||||
active: boolean
|
||||
onSelect: (selectedId: number) => void
|
||||
onSelect: () => void
|
||||
revision: RevisionMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an entry in the revision list.
|
||||
*
|
||||
* @param active true if this is the currently selected revision entry.
|
||||
* @param onSelect Callback that is fired when this revision entry is selected.
|
||||
* @param revision The metadata for this revision entry.
|
||||
|
@ -33,10 +34,6 @@ export interface RevisionListEntryProps {
|
|||
export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, onSelect, revision }) => {
|
||||
useTranslation()
|
||||
|
||||
const onSelectRevision = useCallback(() => {
|
||||
onSelect(revision.id)
|
||||
}, [revision, onSelect])
|
||||
|
||||
const revisionCreationTime = useMemo(() => {
|
||||
return DateTime.fromISO(revision.createdAt).toFormat('DDDD T')
|
||||
}, [revision.createdAt])
|
||||
|
@ -55,9 +52,8 @@ export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, on
|
|||
|
||||
return (
|
||||
<ListGroup.Item
|
||||
as='li'
|
||||
active={active}
|
||||
onClick={onSelectRevision}
|
||||
onClick={onSelect}
|
||||
className={`user-select-none ${styles['revision-item']} d-flex flex-column`}>
|
||||
<span>
|
||||
<ForkAwesomeIcon icon={'clock-o'} className='mx-2' />
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { RevisionListEntry } from './revision-list-entry'
|
||||
import { useAsync } from 'react-use'
|
||||
import { getAllRevisions } from '../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import { ListGroup } from 'react-bootstrap'
|
||||
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
|
||||
|
||||
export interface RevisionListProps {
|
||||
selectedRevisionId?: number
|
||||
onRevisionSelect: (selectedRevisionId: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of selectable revisions of the current note.
|
||||
*
|
||||
* @param selectedRevisionId The currently selected revision
|
||||
* @param onRevisionSelect Callback that is executed when a list entry is selected
|
||||
*/
|
||||
export const RevisionList: React.FC<RevisionListProps> = ({ selectedRevisionId, onRevisionSelect }) => {
|
||||
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||
|
||||
const {
|
||||
value: revisions,
|
||||
error,
|
||||
loading
|
||||
} = useAsync(() => {
|
||||
return getAllRevisions(noteIdentifier)
|
||||
}, [noteIdentifier])
|
||||
|
||||
const revisionList = useMemo(() => {
|
||||
if (loading || !revisions) {
|
||||
return null
|
||||
}
|
||||
return revisions.map((revisionListEntry) => (
|
||||
<RevisionListEntry
|
||||
active={selectedRevisionId === revisionListEntry.id}
|
||||
onSelect={() => onRevisionSelect(revisionListEntry.id)}
|
||||
revision={revisionListEntry}
|
||||
key={revisionListEntry.id}
|
||||
/>
|
||||
))
|
||||
}, [loading, onRevisionSelect, revisions, selectedRevisionId])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading} error={error} componentName={'revision list'}>
|
||||
<ListGroup>{revisionList}</ListGroup>
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
|
@ -4,52 +4,26 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Col, ListGroup, Modal, Row } from 'react-bootstrap'
|
||||
import React, { useState } from 'react'
|
||||
import { Col, Modal, Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAllRevisions } from '../../../../api/revisions'
|
||||
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../common/modals/common-modal'
|
||||
import { RevisionListEntry } from './revision-list-entry'
|
||||
import styles from './revision-modal.module.scss'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import { useAsync } from 'react-use'
|
||||
import { RevisionModalFooter } from './revision-modal-footer'
|
||||
import { RevisionViewer } from './revision-viewer'
|
||||
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
|
||||
import { RevisionList } from './revision-list'
|
||||
|
||||
/**
|
||||
* Modal that shows the available revisions and allows for comparison between them.
|
||||
*
|
||||
* @param show true to show the modal, false otherwise.
|
||||
* @param onHide Callback that is fired when the modal is requested to close.
|
||||
*/
|
||||
export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
||||
useTranslation()
|
||||
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
|
||||
|
||||
const { value, error, loading } = useAsync(() => {
|
||||
return getAllRevisions(noteIdentifier)
|
||||
}, [noteIdentifier])
|
||||
|
||||
const selectRevision = useCallback((revisionId: number) => {
|
||||
setSelectedRevisionId(revisionId)
|
||||
}, [])
|
||||
|
||||
const revisionList = useMemo(() => {
|
||||
if (loading || !value) {
|
||||
return null
|
||||
}
|
||||
return value.map((revisionListEntry) => (
|
||||
<RevisionListEntry
|
||||
active={selectedRevisionId === revisionListEntry.id}
|
||||
onSelect={selectRevision}
|
||||
revision={revisionListEntry}
|
||||
key={revisionListEntry.id}
|
||||
/>
|
||||
))
|
||||
}, [loading, value, selectedRevisionId, selectRevision])
|
||||
|
||||
return (
|
||||
<CommonModal
|
||||
show={show}
|
||||
|
@ -62,12 +36,10 @@ export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide })
|
|||
<Modal.Body>
|
||||
<Row>
|
||||
<Col lg={4} className={styles['scroll-col']}>
|
||||
<ListGroup as='ul'>{revisionList}</ListGroup>
|
||||
<RevisionList onRevisionSelect={setSelectedRevisionId} selectedRevisionId={selectedRevisionId} />
|
||||
</Col>
|
||||
<Col lg={8} className={styles['scroll-col']}>
|
||||
<AsyncLoadingBoundary loading={loading} componentName={'RevisionModal'} error={error}>
|
||||
<RevisionViewer selectedRevisionId={selectedRevisionId} allRevisions={value} />
|
||||
</AsyncLoadingBoundary>
|
||||
<RevisionViewer selectedRevisionId={selectedRevisionId} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
|
|
|
@ -3,74 +3,58 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'
|
||||
import { useAsync } from 'react-use'
|
||||
import type { RevisionDetails, RevisionMetadata } from '../../../../api/revisions/types'
|
||||
import { getRevision } from '../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content'
|
||||
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated'
|
||||
import type { AsyncState } from 'react-use/lib/useAsyncFn'
|
||||
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
|
||||
import { applyPatch, parsePatch } from 'diff'
|
||||
import { invertUnifiedPatch } from './invert-unified-patch'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
|
||||
export interface RevisionViewerProps {
|
||||
selectedRevisionId?: number
|
||||
allRevisions?: RevisionMetadata[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the diff viewer for a given revision and its previous one.
|
||||
*
|
||||
* @param selectedRevisionId The id of the currently selected revision.
|
||||
* @param allRevisions List of metadata for all available revisions.
|
||||
*/
|
||||
export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId, allRevisions }) => {
|
||||
export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId }) => {
|
||||
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||
const markdownContent = useNoteMarkdownContent()
|
||||
const darkModeEnabled = useIsDarkModeActivated()
|
||||
|
||||
const previousRevisionContent = useAsync(async () => {
|
||||
if (!allRevisions || selectedRevisionId === undefined) {
|
||||
return Promise.reject()
|
||||
const { value, error, loading } = useAsync(async () => {
|
||||
if (selectedRevisionId === undefined) {
|
||||
throw new Error('No revision selected')
|
||||
} else {
|
||||
return await getRevision(noteIdentifier, selectedRevisionId)
|
||||
}
|
||||
const revisionIds = allRevisions.map((revisionMetadata) => revisionMetadata.id)
|
||||
const largestId = Math.max(...revisionIds)
|
||||
if (selectedRevisionId === largestId) {
|
||||
return Promise.resolve(markdownContent)
|
||||
}
|
||||
const nextSmallerId = revisionIds
|
||||
.sort()
|
||||
.reverse()
|
||||
.find((id) => id < selectedRevisionId)
|
||||
if (!nextSmallerId) {
|
||||
return Promise.resolve('')
|
||||
}
|
||||
const revision = await getRevision(noteIdentifier, nextSmallerId)
|
||||
return revision.content
|
||||
}, [selectedRevisionId, allRevisions])
|
||||
|
||||
const selectedRevision = useAsync(() => {
|
||||
if (!allRevisions || selectedRevisionId === undefined) {
|
||||
return Promise.reject()
|
||||
}
|
||||
return getRevision(noteIdentifier, selectedRevisionId)
|
||||
}, [selectedRevisionId, noteIdentifier])
|
||||
|
||||
if (selectedRevisionId === undefined || !allRevisions) {
|
||||
const previousRevisionContent = useMemo(() => {
|
||||
return Optional.ofNullable(value)
|
||||
.flatMap((revision) =>
|
||||
Optional.ofNullable(parsePatch(revision.patch)[0])
|
||||
.map((patch) => invertUnifiedPatch(patch))
|
||||
.map((patch) => applyPatch(revision.content, patch))
|
||||
)
|
||||
.orElse('')
|
||||
}, [value])
|
||||
|
||||
if (selectedRevisionId === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO Rework the revision viewer to use pre-calculated diffs
|
||||
// see https://github.com/hedgedoc/react-client/issues/1989
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary
|
||||
loading={selectedRevision.loading || previousRevisionContent.loading}
|
||||
componentName={'RevisionViewer'}
|
||||
error={selectedRevision.error || previousRevisionContent.error}>
|
||||
<AsyncLoadingBoundary loading={loading} componentName={'RevisionViewer'} error={error}>
|
||||
<ReactDiffViewer
|
||||
oldValue={previousRevisionContent.value ?? ''}
|
||||
newValue={(selectedRevision as AsyncState<RevisionDetails>).value?.content}
|
||||
oldValue={previousRevisionContent ?? ''}
|
||||
newValue={value?.content ?? ''}
|
||||
splitView={false}
|
||||
compareMethod={DiffMethod.WORDS}
|
||||
useDarkTheme={darkModeEnabled}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue