diff --git a/services/web/frontend/js/features/history/components/editor/editor.tsx b/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx similarity index 85% rename from services/web/frontend/js/features/history/components/editor/editor.tsx rename to services/web/frontend/js/features/history/components/diff-view/diff-view.tsx index be73a44c48..c50fa654d1 100644 --- a/services/web/frontend/js/features/history/components/editor/editor.tsx +++ b/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx @@ -1,7 +1,7 @@ import Toolbar from './toolbar' import Main from './main' -function Editor() { +function DiffView() { return (
@@ -14,4 +14,4 @@ function Editor() { ) } -export default Editor +export default DiffView diff --git a/services/web/frontend/js/features/history/components/editor/document-diff-viewer.tsx b/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx similarity index 66% rename from services/web/frontend/js/features/history/components/editor/document-diff-viewer.tsx rename to services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx index 4399a5828f..a49bf5b86c 100644 --- a/services/web/frontend/js/features/history/components/editor/document-diff-viewer.tsx +++ b/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx @@ -1,33 +1,20 @@ -import { FC, useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import withErrorBoundary from '../../../../infrastructure/error-boundary' import { ErrorBoundaryFallback } from '../../../../shared/components/error-boundary-fallback' import { EditorState, Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { lineNumbers } from '../../../../../../modules/source-editor/frontend/js/extensions/line-numbers' import { indentationMarkers } from '@replit/codemirror-indentation-markers' -import { highlights, setHighlightsEffect } from '../extensions/highlights' +import { highlights, setHighlightsEffect } from '../../extensions/highlights' import useScopeValue from '../../../../shared/hooks/use-scope-value' import { FontFamily, LineHeight, OverallTheme, } from '../../../../../../modules/source-editor/frontend/js/extensions/theme' -import { theme, Options } from '../extensions/theme' +import { theme, Options } from '../../extensions/theme' import { indentUnit } from '@codemirror/language' - -interface Range { - from: number - to: number -} - -type HighlightType = 'addition' | 'deletion' - -export interface Highlight { - label: string - hue: number - range: Range - type: HighlightType -} +import { Highlight } from '../../services/types/doc' function extensions(themeOptions: Options): Extension[] { return [ @@ -41,10 +28,13 @@ function extensions(themeOptions: Options): Extension[] { ] } -const DocumentDiffViewer: FC<{ doc: string; highlights: Highlight[] }> = ({ +function DocumentDiffViewer({ doc, highlights, -}) => { +}: { + doc: string + highlights: Highlight[] +}) { const [fontFamily] = useScopeValue('settings.fontFamily') const [fontSize] = useScopeValue('settings.fontSize') const [lineHeight] = useScopeValue('settings.lineHeight') @@ -62,16 +52,13 @@ const DocumentDiffViewer: FC<{ doc: string; highlights: Highlight[] }> = ({ }) }) - const viewRef = useRef(null) - if (viewRef.current === null) { - viewRef.current = new EditorView({ + const view = useRef( + new EditorView({ state, }) - } + ).current - const view = viewRef.current - - // Append the editor view dom to the container node when mounted + // Append the editor view DOM to the container node when mounted const containerRef = useCallback( node => { if (node) { @@ -81,12 +68,14 @@ const DocumentDiffViewer: FC<{ doc: string; highlights: Highlight[] }> = ({ [view] ) - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: doc }, - effects: setHighlightsEffect.of(highlights), - }) + useEffect(() => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, + effects: setHighlightsEffect.of(highlights), + }) + }, [doc, highlights, view]) - return
+ return
} export default withErrorBoundary(DocumentDiffViewer, ErrorBoundaryFallback) diff --git a/services/web/frontend/js/features/history/components/diff-view/main.tsx b/services/web/frontend/js/features/history/components/diff-view/main.tsx new file mode 100644 index 0000000000..52248eae3e --- /dev/null +++ b/services/web/frontend/js/features/history/components/diff-view/main.tsx @@ -0,0 +1,68 @@ +import { useHistoryContext } from '../../context/history-context' +import { diffDoc } from '../../services/api' +import { useEffect } from 'react' +import { DocDiffResponse, Highlight } from '../../services/types/doc' +import { highlightsFromDiffResponse } from '../../util/highlights-from-diff-response' +import DocumentDiffViewer from './document-diff-viewer' +import useAsync from '../../../../shared/hooks/use-async' +import { useTranslation } from 'react-i18next' + +type Diff = { + binary: boolean + docDiff?: { + doc: string + highlights: Highlight[] + } +} + +function Main() { + const { t } = useTranslation() + const { projectId, updateSelection, fileSelection } = useHistoryContext() + const { isLoading, runAsync, data } = useAsync() + let diff: Diff | undefined + if (data?.diff) { + if ('binary' in data.diff) { + diff = { binary: true } + } else { + diff = { binary: false, docDiff: highlightsFromDiffResponse(data.diff) } + } + } + + useEffect(() => { + if (!updateSelection || !fileSelection || !fileSelection.pathname) { + return + } + + const { fromV, toV } = updateSelection.update + + // TODO: Error handling + runAsync(diffDoc(projectId, fromV, toV, fileSelection.pathname)) + }, [fileSelection, projectId, runAsync, updateSelection]) + + if (isLoading) { + return ( +
+ +    + {t('loading')}… +
+ ) + } + + if (!diff) { + return
No document
+ } + + if (diff.binary) { + return
Binary file
+ } + + if (diff.docDiff) { + const { doc, highlights } = diff.docDiff + return + } + + return
No document
+} + +export default Main diff --git a/services/web/frontend/js/features/history/components/editor/toolbar.tsx b/services/web/frontend/js/features/history/components/diff-view/toolbar.tsx similarity index 100% rename from services/web/frontend/js/features/history/components/editor/toolbar.tsx rename to services/web/frontend/js/features/history/components/diff-view/toolbar.tsx diff --git a/services/web/frontend/js/features/history/components/editor/main.tsx b/services/web/frontend/js/features/history/components/editor/main.tsx deleted file mode 100644 index 77adc93c26..0000000000 --- a/services/web/frontend/js/features/history/components/editor/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useHistoryContext } from '../../context/history-context' - -function Main() { - const { fileSelection } = useHistoryContext() - - return
Main (editor). File: {fileSelection?.pathname || 'not set'}
-} - -export default Main diff --git a/services/web/frontend/js/features/history/components/history-root.tsx b/services/web/frontend/js/features/history/components/history-root.tsx index 02755b7cf7..09484c9dc5 100644 --- a/services/web/frontend/js/features/history/components/history-root.tsx +++ b/services/web/frontend/js/features/history/components/history-root.tsx @@ -1,5 +1,5 @@ import ChangeList from './change-list/change-list' -import Editor from './editor/editor' +import DiffView from './diff-view/diff-view' import { useLayoutContext } from '../../../shared/context/layout-context' import { useHistoryContext } from '../context/history-context' @@ -13,7 +13,7 @@ export default function HistoryRoot() { return (
- +
) diff --git a/services/web/frontend/js/features/history/components/extensions/highlights.ts b/services/web/frontend/js/features/history/extensions/highlights.ts similarity index 97% rename from services/web/frontend/js/features/history/components/extensions/highlights.ts rename to services/web/frontend/js/features/history/extensions/highlights.ts index 00d5a0321b..b4ee5a33e8 100644 --- a/services/web/frontend/js/features/history/components/extensions/highlights.ts +++ b/services/web/frontend/js/features/history/extensions/highlights.ts @@ -1,6 +1,6 @@ import { StateEffect, StateField } from '@codemirror/state' import { Decoration, EditorView, hoverTooltip, Tooltip } from '@codemirror/view' -import { Highlight } from '../editor/document-diff-viewer' +import { Highlight } from '../services/types/doc' export const setHighlightsEffect = StateEffect.define() diff --git a/services/web/frontend/js/features/history/components/extensions/theme.ts b/services/web/frontend/js/features/history/extensions/theme.ts similarity index 100% rename from services/web/frontend/js/features/history/components/extensions/theme.ts rename to services/web/frontend/js/features/history/extensions/theme.ts diff --git a/services/web/frontend/js/features/history/services/api.ts b/services/web/frontend/js/features/history/services/api.ts index 20b66a1c4c..8ad132f617 100644 --- a/services/web/frontend/js/features/history/services/api.ts +++ b/services/web/frontend/js/features/history/services/api.ts @@ -1,6 +1,7 @@ import { getJSON } from '../../../infrastructure/fetch-json' import { FileDiff } from './types/file' import { Update } from './types/update' +import { DocDiffResponse } from './types/doc' const BATCH_SIZE = 10 @@ -27,3 +28,19 @@ export function diffFiles(projectId: string, fromV: number, toV: number) { const diffUrl = `/project/${projectId}/filetree/diff?${queryParamsSerialized}` return getJSON<{ diff: FileDiff[] }>(diffUrl) } + +export function diffDoc( + projectId: string, + fromV: number, + toV: number, + pathname: string +) { + const queryParams: Record = { + from: fromV.toString(), + to: toV.toString(), + pathname, + } + const queryParamsSerialized = new URLSearchParams(queryParams).toString() + const diffUrl = `/project/${projectId}/diff?${queryParamsSerialized}` + return getJSON(diffUrl) +} diff --git a/services/web/frontend/js/features/history/services/types/doc.ts b/services/web/frontend/js/features/history/services/types/doc.ts new file mode 100644 index 0000000000..7fd3c51bc4 --- /dev/null +++ b/services/web/frontend/js/features/history/services/types/doc.ts @@ -0,0 +1,26 @@ +import { Meta } from './shared' + +export interface DocDiffChunk { + u?: string + i?: string + d?: string + meta?: Meta +} + +export interface BinaryDiffResponse { + binary: true +} + +export type DocDiffResponse = { diff: DocDiffChunk[] | BinaryDiffResponse } + +interface Range { + from: number + to: number +} + +export interface Highlight { + label: string + hue: number + range: Range + type: 'addition' | 'deletion' +} diff --git a/services/web/frontend/js/features/history/services/types/shared.ts b/services/web/frontend/js/features/history/services/types/shared.ts new file mode 100644 index 0000000000..f16696bf94 --- /dev/null +++ b/services/web/frontend/js/features/history/services/types/shared.ts @@ -0,0 +1,12 @@ +export interface User { + first_name: string + last_name: string + email: string + id: string +} + +export interface Meta { + users: User[] + start_ts: number + end_ts: number +} diff --git a/services/web/frontend/js/features/history/services/types/update.ts b/services/web/frontend/js/features/history/services/types/update.ts index 9e82a22b3e..5f6f693b14 100644 --- a/services/web/frontend/js/features/history/services/types/update.ts +++ b/services/web/frontend/js/features/history/services/types/update.ts @@ -1,15 +1,4 @@ -interface User { - first_name: string - last_name: string - email: string - id: string -} - -interface UpdateMeta { - users: User[] - start_ts: number - end_ts: number -} +import { Meta } from './shared' interface UpdateLabel { id: string @@ -33,7 +22,7 @@ interface ProjectOp { export interface Update { fromV: number toV: number - meta: UpdateMeta + meta: Meta labels?: Label[] pathnames: string[] project_ops: ProjectOp[] diff --git a/services/web/frontend/js/features/history/util/highlights-from-diff-response.ts b/services/web/frontend/js/features/history/util/highlights-from-diff-response.ts new file mode 100644 index 0000000000..ebfb50b4e6 --- /dev/null +++ b/services/web/frontend/js/features/history/util/highlights-from-diff-response.ts @@ -0,0 +1,51 @@ +import displayNameForUser from '../../../ide/history/util/displayNameForUser' +import moment from 'moment/moment' +import ColorManager from '../../../ide/colors/ColorManager' +import { DocDiffChunk, Highlight } from '../services/types/doc' + +export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) { + let pos = 0 + const highlights: Highlight[] = [] + let doc = '' + + for (const entry of chunks) { + const content = entry.u || entry.i || entry.d || '' + doc += content + const from = pos + const to = doc.length + pos = to + const range = { from, to } + + const isInsertion = typeof entry.i === 'string' + const isDeletion = typeof entry.d === 'string' + + if (isInsertion || isDeletion) { + const meta = entry.meta + if (!meta) { + throw new Error('No meta found') + } + const user = meta.users?.[0] + const name = displayNameForUser(user) + const date = moment(meta.end_ts).format('Do MMM YYYY, h:mm a') + if (isInsertion) { + highlights.push({ + type: 'addition', + // There doesn't seem to be a convenient way to make this translatable + label: `Added by ${name} on ${date}`, + range, + hue: ColorManager.getHueForUserId(user.id), + }) + } else if (isDeletion) { + highlights.push({ + type: 'deletion', + // There doesn't seem to be a convenient way to make this translatable + label: `Deleted by ${name} on ${date}`, + range, + hue: ColorManager.getHueForUserId(user.id), + }) + } + } + } + + return { doc, highlights } +} diff --git a/services/web/frontend/stories/history/document-diff-viewer.stories.tsx b/services/web/frontend/stories/history/document-diff-viewer.stories.tsx index 991b056b1a..18df844d4a 100644 --- a/services/web/frontend/stories/history/document-diff-viewer.stories.tsx +++ b/services/web/frontend/stories/history/document-diff-viewer.stories.tsx @@ -1,8 +1,7 @@ import { ScopeDecorator } from '../decorators/scope' -import DocumentDiffViewer, { - Highlight, -} from '../../js/features/history/components/editor/document-diff-viewer' +import DocumentDiffViewer from '../../js/features/history/components/diff-view/document-diff-viewer' import React from 'react' +import { Highlight } from '../../js/features/history/services/types/doc' export default { title: 'History / Document Diff Viewer', diff --git a/services/web/frontend/stylesheets/app/editor/history-react.less b/services/web/frontend/stylesheets/app/editor/history-react.less index 96eeba4440..c616cd3088 100644 --- a/services/web/frontend/stylesheets/app/editor/history-react.less +++ b/services/web/frontend/stylesheets/app/editor/history-react.less @@ -34,6 +34,17 @@ history-root { .doc-container { flex: 1; + overflow-y: auto; + + .document-diff-container { + height: 100%; + display: flex; + flex-direction: column; + + .cm-editor { + height: 100%; + } + } } } @@ -54,4 +65,10 @@ history-root { display: block; } } + + .history-loading-panel { + padding-top: 10rem; + font-family: @font-family-serif; + text-align: center; + } }