first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import { useEffect, useMemo } from 'react'
import Toolbar from './toolbar/toolbar'
import Main from './main'
import { Diff, DocDiffResponse } from '../../services/types/doc'
import { useHistoryContext } from '../../context/history-context'
import { diffDoc } from '../../services/api'
import { highlightsFromDiffResponse } from '../../utils/highlights-from-diff-response'
import { useErrorHandler } from 'react-error-boundary'
import useAsync from '../../../../shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
function DiffView() {
const { selection, projectId, loadingFileDiffs } = useHistoryContext()
const { isLoading, data, runAsync } = useAsync<DocDiffResponse>()
const { t } = useTranslation()
const { updateRange, selectedFile } = selection
const handleError = useErrorHandler()
useEffect(() => {
if (!updateRange || !selectedFile?.pathname || loadingFileDiffs) {
return
}
const { fromV, toV } = updateRange
let abortController: AbortController | null = new AbortController()
runAsync(
diffDoc(
projectId,
fromV,
toV,
selectedFile.pathname,
abortController.signal
)
)
.catch(handleError)
.finally(() => {
abortController = null
})
// Abort an existing request before starting a new one or on unmount
return () => {
if (abortController) {
abortController.abort()
}
}
}, [
projectId,
runAsync,
updateRange,
selectedFile,
loadingFileDiffs,
handleError,
])
const diff = useMemo(() => {
let diff: Diff | null
if (!data?.diff) {
diff = null
} else if ('binary' in data.diff) {
diff = { binary: true }
} else {
diff = {
binary: false,
docDiff: highlightsFromDiffResponse(data.diff, t),
}
}
return diff
}, [data, t])
return (
<div className="doc-panel">
<div className="history-header toolbar-container">
<Toolbar diff={diff} selection={selection} />
</div>
<div className="doc-container">
<Main diff={diff} isLoading={isLoading || loadingFileDiffs} />
</div>
</div>
)
}
export default DiffView

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react'
import {
EditorSelection,
EditorState,
Extension,
StateEffect,
} from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { indentationMarkers } from '@replit/codemirror-indentation-markers'
import { highlights, setHighlightsEffect } from '../../extensions/highlights'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { theme, Options, setOptionsTheme } from '../../extensions/theme'
import { indentUnit } from '@codemirror/language'
import { Highlight } from '../../services/types/doc'
import useIsMounted from '../../../../shared/hooks/use-is-mounted'
import {
highlightLocations,
highlightLocationsField,
scrollToHighlight,
} from '../../extensions/highlight-locations'
import { useTranslation } from 'react-i18next'
import { inlineBackground } from '../../../source-editor/extensions/inline-background'
import OLButton from '@/features/ui/components/ol/ol-button'
function extensions(themeOptions: Options): Extension[] {
return [
EditorView.editable.of(false),
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '0' }),
lineNumbers(),
EditorView.lineWrapping,
indentUnit.of(' '), // TODO: Vary this by file type
indentationMarkers({ hideFirstIndent: true, highlightActiveBlock: false }),
highlights(),
highlightLocations(),
theme(themeOptions),
inlineBackground(false),
]
}
function DocumentDiffViewer({
doc,
highlights,
}: {
doc: string
highlights: Highlight[]
}) {
const { userSettings } = useUserSettingsContext()
const { fontFamily, fontSize, lineHeight } = userSettings
const isMounted = useIsMounted()
const { t } = useTranslation()
const [state, setState] = useState(() => {
return EditorState.create({
doc,
extensions: extensions({
fontSize,
fontFamily,
lineHeight,
}),
})
})
const [view] = useState<EditorView>(() => {
return new EditorView({
state,
dispatch: tr => {
view.update([tr])
if (isMounted.current) {
setState(view.state)
}
},
})
})
const highlightLocations = state.field(highlightLocationsField)
// Append the editor view DOM to the container node when mounted
const containerRef = useCallback(
node => {
if (node) {
node.appendChild(view.dom)
}
},
[view]
)
const scrollToPrevious = useCallback(() => {
if (highlightLocations.previous) {
scrollToHighlight(view, highlightLocations.previous)
}
}, [highlightLocations.previous, view])
const scrollToNext = useCallback(() => {
if (highlightLocations.next) {
scrollToHighlight(view, highlightLocations.next)
}
}, [highlightLocations.next, view])
const { before, after } = highlightLocations
useEffect(() => {
const effects: StateEffect<unknown>[] = [setHighlightsEffect.of(highlights)]
if (highlights.length > 0) {
const { from, to } = highlights[0].range
effects.push(
EditorView.scrollIntoView(EditorSelection.range(from, to), {
y: 'center',
})
)
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc },
effects,
})
}, [doc, highlights, view])
// Update the document diff viewer theme whenever the font size, font family
// or line height user setting changes
useEffect(() => {
view.dispatch(
setOptionsTheme({
fontSize,
fontFamily,
lineHeight,
})
)
}, [view, fontSize, fontFamily, lineHeight])
return (
<div className="document-diff-container">
<div ref={containerRef} className="cm-viewer-container" />
{before > 0 ? (
<OLButton
variant="secondary"
leadingIcon="arrow_upward"
onClick={scrollToPrevious}
className="previous-highlight-button"
>
{t('n_more_updates_above', { count: before })}
</OLButton>
) : null}
{after > 0 ? (
<OLButton
variant="secondary"
leadingIcon="arrow_downward"
onClick={scrollToNext}
className="next-highlight-button"
>
{t('n_more_updates_below', { count: after })}
</OLButton>
) : null}
</div>
)
}
export default DocumentDiffViewer

View File

@@ -0,0 +1,40 @@
import { Nullable } from '../../../../../../types/utils'
import { Diff } from '../../services/types/doc'
import DocumentDiffViewer from './document-diff-viewer'
import LoadingSpinner from '../../../../shared/components/loading-spinner'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type MainProps = {
diff: Nullable<Diff>
isLoading: boolean
}
function Main({ diff, isLoading }: MainProps) {
const { t } = useTranslation()
if (isLoading) {
return <LoadingSpinner />
}
if (!diff) {
return <div className="history-content">No document</div>
}
if (diff.binary) {
return (
<div className="history-content">
<OLNotification content={t('binary_history_error')} type="info" />
</div>
)
}
if (diff.docDiff) {
const { doc, highlights } = diff.docDiff
return <DocumentDiffViewer doc={doc} highlights={highlights} />
}
return null
}
export default Main

View File

@@ -0,0 +1,47 @@
import { formatTime } from '@/features/utils/format-date'
import { useMemo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
type RestoreFileConfirmModalProps = {
show: boolean
timestamp: number
onConfirm: () => void
onHide: () => void
}
export function RestoreFileConfirmModal({
show,
timestamp,
onConfirm,
onHide,
}: RestoreFileConfirmModalProps) {
const { t } = useTranslation()
const date = useMemo(() => formatTime(timestamp, 'Do MMMM'), [timestamp])
const time = useMemo(() => formatTime(timestamp, 'h:mm a'), [timestamp])
return (
<OLModal show={show} onHide={onHide}>
<OLModalHeader closeButton>
<OLModalTitle>{t('restore_file_confirmation_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('restore_file_confirmation_message', { date, time })}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>
{t('cancel')}
</OLButton>
<OLButton variant="primary" onClick={onConfirm}>
{t('restore')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,30 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
export function RestoreFileErrorModal({
resetErrorBoundary,
}: {
resetErrorBoundary: VoidFunction
}) {
const { t } = useTranslation()
return (
<OLModal show onHide={resetErrorBoundary}>
<OLModalHeader closeButton>
<OLModalTitle>{t('restore_file_error_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{t('restore_file_error_message')}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={resetErrorBoundary}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,36 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
export function RestoreProjectErrorModal({
resetErrorBoundary,
}: {
resetErrorBoundary: VoidFunction
}) {
const { t } = useTranslation()
return (
<OLModal show onHide={resetErrorBoundary}>
<OLModalHeader closeButton>
<OLModalTitle>
{t('an_error_occured_while_restoring_project')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t(
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={resetErrorBoundary}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,60 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { formatDate } from '@/utils/dates'
import { useCallback } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
type RestoreProjectModalProps = {
setShow: React.Dispatch<React.SetStateAction<boolean>>
show: boolean
isRestoring: boolean
endTimestamp: number
onRestore: () => void
}
export const RestoreProjectModal = ({
setShow,
show,
endTimestamp,
isRestoring,
onRestore,
}: RestoreProjectModalProps) => {
const { t } = useTranslation()
const onCancel = useCallback(() => {
setShow(false)
}, [setShow])
return (
<OLModal onHide={() => setShow(false)} show={show}>
<OLModalHeader>
<OLModalTitle>{t('restore_this_version')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('your_current_project_will_revert_to_the_version_from_time', {
timestamp: formatDate(endTimestamp),
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel} disabled={isRestoring}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={onRestore}
disabled={isRestoring}
isLoading={isRestoring}
>
{t('restore')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,47 @@
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_from_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'
),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
) : (
<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'
),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)}
</div>
)
}

View File

@@ -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,
})}
&nbsp;
<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
}

View File

@@ -0,0 +1,28 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
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 { restoreDeletedFile, isLoading } = useRestoreDeletedFile()
return (
<OLButton
variant="secondary"
size="sm"
className="history-react-toolbar-restore-file-button"
isLoading={isLoading}
onClick={() => restoreDeletedFile(selection)}
>
{t('restore_file')}
</OLButton>
)
}

View File

@@ -0,0 +1,51 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { RestoreFileConfirmModal } from '../modals/restore-file-confirm-modal'
import { useState } from 'react'
import { RestoreFileErrorModal } from '../modals/restore-file-error-modal'
import { useRestoreSelectedFile } from '@/features/history/context/hooks/use-restore-selected-file'
type ToolbarRevertingFileButtonProps = {
selection: HistoryContextValue['selection']
}
function ToolbarRestoreFileToVersionButton({
selection,
}: ToolbarRevertingFileButtonProps) {
const { t } = useTranslation()
const { restoreSelectedFile, isLoading } = useRestoreSelectedFile()
const [showConfirmModal, setShowConfirmModal] = useState(false)
if (!selection.updateRange || !selection.selectedFile) {
return null
}
return (
<>
<RestoreFileConfirmModal
show={showConfirmModal}
timestamp={selection.updateRange.toVTimestamp}
onConfirm={() => {
setShowConfirmModal(false)
restoreSelectedFile(selection)
}}
onHide={() => setShowConfirmModal(false)}
/>
<OLButton
variant="secondary"
size="sm"
isLoading={isLoading}
onClick={() => setShowConfirmModal(true)}
>
{t('restore_file_version')}
</OLButton>
</>
)
}
export default withErrorBoundary(
ToolbarRestoreFileToVersionButton,
RestoreFileErrorModal
)

View File

@@ -0,0 +1,51 @@
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'
import ToolbarRestoreFileToVersionButton from './toolbar-restore-file-to-version-button'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import SplitTestBadge from '@/shared/components/split-test-badge'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
type ToolbarProps = {
diff: Nullable<Diff>
selection: HistoryContextValue['selection']
}
export default function Toolbar({ diff, selection }: ToolbarProps) {
const { write } = usePermissionsContext()
const hasRestoreFileToVersion = useFeatureFlag('revert-file')
const showRestoreFileToVersionButton =
hasRestoreFileToVersion && selection.selectedFile && write
const showRestoreFileButton =
selection.selectedFile &&
isFileRemoved(selection.selectedFile) &&
!showRestoreFileToVersionButton &&
write
return (
<div className="history-react-toolbar">
<ToolbarDatetime selection={selection} />
{selection.selectedFile?.pathname ? (
<ToolbarFileInfo diff={diff} selection={selection} />
) : null}
{showRestoreFileButton ? (
<ToolbarRestoreFileButton selection={selection} />
) : null}
{showRestoreFileToVersionButton ? (
<>
<ToolbarRestoreFileToVersionButton selection={selection} />
<SplitTestBadge
splitTestName="revert-file"
displayOnVariants={['enabled']}
/>
</>
) : null}
</div>
)
}