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,129 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import ModalError from './modal-error'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import useAsync from '../../../../shared/hooks/use-async'
import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
import { useHistoryContext } from '../../context/history-context'
import { addLabel } from '../../services/api'
import { Label } from '../../services/types/label'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { debugConsole } from '@/utils/debugging'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
type AddLabelModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
version: number
}
function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
const { t } = useTranslation()
const [comment, setComment] = useState('')
const {
isLoading,
isSuccess,
isError,
error,
data: label,
reset,
runAsync,
} = useAsync<Label>()
const { projectId } = useHistoryContext()
const { signal } = useAbortController()
const { addUpdateLabel } = useAddOrRemoveLabels()
const { autoFocusedRef, resetAutoFocus } =
useRefWithAutoFocus<HTMLInputElement>()
// Reset the autofocus when `show` changes so that autofocus still happens if
// the dialog is shown, hidden and then shown again
useEffect(() => {
if (show) {
resetAutoFocus()
}
}, [resetAutoFocus, show])
const handleModalExited = () => {
setComment('')
if (!isSuccess || !label) return
addUpdateLabel(label)
reset()
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
runAsync(addLabel(projectId, { comment, version }, signal))
.then(() => setShow(false))
.catch(debugConsole.error)
}
const responseError = error as unknown as {
response: Response
data?: {
message?: string
}
}
return (
<OLModal
show={show}
onExited={handleModalExited}
onHide={() => setShow(false)}
id="add-history-label"
>
<OLModalHeader>
<OLModalTitle>{t('history_add_label')}</OLModalTitle>
</OLModalHeader>
<OLForm onSubmit={handleSubmit}>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<OLFormGroup>
<OLFormControl
ref={autoFocusedRef}
type="text"
placeholder={t('history_new_label_name')}
required
value={comment}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setComment(e.target.value)
}
/>
</OLFormGroup>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShow(false)}
>
{t('cancel')}
</OLButton>
<OLButton
type="submit"
variant="primary"
disabled={isLoading || !comment.length}
isLoading={isLoading}
>
{t('history_add_label')}
</OLButton>
</OLModalFooter>
</OLForm>
</OLModal>
)
}
export default AddLabelModal

View File

@@ -0,0 +1,348 @@
import { useEffect, useRef, useState } from 'react'
import HistoryVersion from './history-version'
import LoadingSpinner from '../../../../shared/components/loading-spinner'
import { OwnerPaywallPrompt } from './owner-paywall-prompt'
import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
import { isVersionSelected } from '../../utils/history-details'
import { useUserContext } from '../../../../shared/context/user-context'
import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
import { useHistoryContext } from '../../context/history-context'
import { useEditorContext } from '../../../../shared/context/editor-context'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import Close from '@/shared/components/close'
import { Trans, useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import useTutorial from '@/shared/hooks/promotions/use-tutorial'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function AllHistoryList() {
const { id: currentUserId } = useUserContext()
const {
projectId,
updatesInfo,
fetchNextBatchOfUpdates,
currentUserIsOwner,
selection,
setSelection,
} = useHistoryContext()
const {
visibleUpdateCount,
updates,
atEnd,
loadingState: updatesLoadingState,
} = updatesInfo
const scrollerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
const [bottomVisible, setBottomVisible] = useState(false)
const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
useDropdownActiveItem()
const showPaywall =
updatesLoadingState === 'ready' && updatesInfo.freeHistoryLimitHit
const showOwnerPaywall = showPaywall && currentUserIsOwner
const showNonOwnerPaywall = showPaywall && !currentUserIsOwner
const visibleUpdates =
visibleUpdateCount === null ? updates : updates.slice(0, visibleUpdateCount)
// Create an intersection observer that watches for any part of an element
// positioned at the bottom of the list to be visible
useEffect(() => {
if (updatesLoadingState === 'ready' && !intersectionObserverRef.current) {
const scroller = scrollerRef.current
const bottom = bottomRef.current
if (scroller && bottom) {
intersectionObserverRef.current = new IntersectionObserver(
entries => {
setBottomVisible(entries[0].isIntersecting)
},
{ root: scroller }
)
intersectionObserverRef.current.observe(bottom)
return () => {
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect()
}
}
}
}
}, [updatesLoadingState])
useEffect(() => {
if (!atEnd && updatesLoadingState === 'ready' && bottomVisible) {
fetchNextBatchOfUpdates()
}
}, [atEnd, bottomVisible, fetchNextBatchOfUpdates, updatesLoadingState])
// While updates are loading, remove the intersection observer and set
// bottomVisible to false. This is to avoid loading more updates immediately
// after rendering the pending updates, which would happen otherwise, because
// the intersection observer is asynchronous and won't have noticed that the
// bottom is no longer visible
useEffect(() => {
if (updatesLoadingState !== 'ready' && intersectionObserverRef.current) {
setBottomVisible(false)
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect()
intersectionObserverRef.current = null
}
}
}, [updatesLoadingState])
const { inactiveTutorials } = useEditorContext()
const {
showPopup: showHistoryTutorial,
tryShowingPopup: tryShowingHistoryTutorial,
hideUntilReload: hideHistoryTutorialUntilReload,
completeTutorial: completeHistoryTutorial,
} = useTutorial('react-history-buttons-tutorial', {
name: 'react-history-buttons-tutorial',
})
const {
showPopup: showRestorePromo,
tryShowingPopup: tryShowingRestorePromo,
hideUntilReload: hideRestorePromoUntilReload,
completeTutorial: completeRestorePromo,
} = useTutorial('history-restore-promo', {
name: 'history-restore-promo',
})
const inFileRestoreSplitTest = useFeatureFlag('revert-file')
const inProjectRestoreSplitTest = useFeatureFlag('revert-project')
const hasVisibleUpdates = visibleUpdates.length > 0
const isMoreThanOneVersion = visibleUpdates.length > 1
const [layoutSettled, setLayoutSettled] = useState(false)
// When there is a paywall and only two version's to compare,
// they are not comparable because the one that has a paywall will not have the compare button
// so we should not display on-boarding popover in that case
const isPaywallAndNonComparable =
visibleUpdates.length === 2 && updatesInfo.freeHistoryLimitHit
useEffect(() => {
const hasCompletedHistoryTutorial = inactiveTutorials.includes(
'react-history-buttons-tutorial'
)
const hasCompletedRestorePromotion = inactiveTutorials.includes(
'history-restore-promo'
)
// wait for the layout to settle before showing popover, to avoid a flash/ instant move
if (!layoutSettled) {
return
}
if (
!hasCompletedHistoryTutorial &&
isMoreThanOneVersion &&
!isPaywallAndNonComparable
) {
tryShowingHistoryTutorial()
} else if (
!hasCompletedRestorePromotion &&
inFileRestoreSplitTest &&
inProjectRestoreSplitTest &&
hasVisibleUpdates
) {
tryShowingRestorePromo()
}
}, [
hasVisibleUpdates,
inFileRestoreSplitTest,
inProjectRestoreSplitTest,
tryShowingRestorePromo,
inactiveTutorials,
isMoreThanOneVersion,
isPaywallAndNonComparable,
layoutSettled,
tryShowingHistoryTutorial,
])
const { t } = useTranslation()
let popover = null
// hiding is different from dismissing, as we wont save a full dismissal to the user
// meaning the tutorial will show on page reload/ re-navigation
const hidePopover = () => {
hideHistoryTutorialUntilReload()
hideRestorePromoUntilReload()
}
if (showHistoryTutorial) {
popover = (
<OLOverlay
placement="left-start"
show={showHistoryTutorial}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current}
popperConfig={{
modifiers: [
{
name: 'offset',
options: {
offset: [10, 10],
},
},
],
}}
>
<OLPopover
id="popover-react-history-tutorial"
title={
<span>
{t('react_history_tutorial_title')}{' '}
<Close
variant="dark"
onDismiss={() =>
completeHistoryTutorial({
event: 'promo-click',
action: 'complete',
})
}
/>
</span>
}
className="dark-themed history-popover"
>
<Trans
i18nKey="react_history_tutorial_content"
components={[
// eslint-disable-next-line react/jsx-key
<MaterialIcon
type="align_end"
className="history-dropdown-icon-inverted"
/>,
<a href="https://www.overleaf.com/learn/latex/Using_the_History_feature" />, // eslint-disable-line jsx-a11y/anchor-has-content, react/jsx-key
]}
/>
</OLPopover>
</OLOverlay>
)
} else if (showRestorePromo) {
popover = (
<OLOverlay
placement="left-start"
show={showRestorePromo}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current}
>
<OLPopover
id="popover-history-restore-promo"
title={
<span>
{t('history_restore_promo_title')}
<Close
variant="dark"
onDismiss={() =>
completeRestorePromo({
event: 'promo-click',
action: 'complete',
})
}
/>
</span>
}
className="dark-themed history-popover"
>
<Trans
i18nKey="history_restore_promo_content"
components={[
// eslint-disable-next-line react/jsx-key
<MaterialIcon
type="more_vert"
className="history-restore-promo-icon"
/>,
]}
/>
</OLPopover>
</OLOverlay>
)
}
// give the components time to position before showing popover so we don't get an instant position change
useEffect(() => {
const timer = window.setTimeout(() => {
setLayoutSettled(true)
}, 500)
return () => clearTimeout(timer)
}, [setLayoutSettled])
// resizes can cause the popover to point to the wrong thing, since it changes the horizontal layout of the page
useEffect(() => {
window.addEventListener('resize', hidePopover)
return () => window.removeEventListener('resize', hidePopover)
})
return (
<div ref={scrollerRef} className="history-all-versions-scroller">
{popover}
<div className="history-all-versions-container">
<div ref={bottomRef} className="history-versions-bottom" />
{visibleUpdates.map((update, index) => {
const selectionState = isVersionSelected(
selection,
update.fromV,
update.toV
)
const dropdownActive =
update.toV === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'moreOptions'
const compareDropdownActive =
update === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'compare'
const showDivider = Boolean(update.meta.first_in_day && index > 0)
const faded =
updatesInfo.freeHistoryLimitHit &&
index === visibleUpdates.length - 1 &&
visibleUpdates.length > 1
const selectable =
!faded &&
(selection.comparing ||
selectionState === 'aboveSelected' ||
selectionState === 'belowSelected')
return (
<HistoryVersion
key={`${update.fromV}_${update.toV}`}
update={update}
faded={faded}
showDivider={showDivider}
setSelection={setSelection}
selectionState={selectionState}
currentUserId={currentUserId!}
selectable={selectable}
projectId={projectId}
setActiveDropdownItem={setActiveDropdownItem}
closeDropdownForItem={closeDropdownForItem}
dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
compareDropdownActive={compareDropdownActive}
compareDropdownOpen={
activeDropdownItem.isOpened && compareDropdownActive
}
dropdownActive={dropdownActive}
/>
)
})}
</div>
{showOwnerPaywall ? <OwnerPaywallPrompt /> : null}
{showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
{updatesLoadingState === 'loadingInitial' ||
updatesLoadingState === 'loadingUpdates' ? (
<div className="history-all-versions-loading">
<LoadingSpinner />
</div>
) : null}
</div>
)
}
export default AllHistoryList

View File

@@ -0,0 +1,21 @@
import ToggleSwitch from './toggle-switch'
import AllHistoryList from './all-history-list'
import LabelsList from './labels-list'
import { useHistoryContext } from '../../context/history-context'
function ChangeList() {
const { labelsOnly, setLabelsOnly } = useHistoryContext()
return (
<aside className="change-list">
<div className="history-header history-toggle-switch-container">
<ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
</div>
<div className="history-version-list-container">
{labelsOnly ? <LabelsList /> : <AllHistoryList />}
</div>
</aside>
)
}
export default ChangeList

View File

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { getProjectOpDoc } from '../../utils/history-details'
import { LoadedUpdate } from '../../services/types/update'
type ChangesProps = {
pathnames: LoadedUpdate['pathnames']
projectOps: LoadedUpdate['project_ops']
}
function Changes({ pathnames, projectOps }: ChangesProps) {
const { t } = useTranslation()
return (
<ol className="history-version-changes">
{pathnames.map(pathname => (
<li key={pathname}>
<div
className="history-version-change-action"
data-testid="history-version-change-action"
>
{t('file_action_edited')}
</div>
<div
className="history-version-change-doc"
data-testid="history-version-change-doc"
>
{pathname}
</div>
</li>
))}
{projectOps.map((op, index) => (
<li key={index}>
<div
className="history-version-change-action"
data-testid="history-version-change-action"
>
{op.rename && t('file_action_renamed')}
{op.add && t('file_action_created')}
{op.remove && t('file_action_deleted')}
</div>
<div
className="history-version-change-doc"
data-testid="history-version-change-doc"
>
{getProjectOpDoc(op)}
</div>
</li>
))}
</ol>
)
}
export default Changes

View File

@@ -0,0 +1,44 @@
import React, { ReactNode } from 'react'
import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
type ActionDropdownProps = {
id: string
children: React.ReactNode
isOpened: boolean
iconTag: ReactNode
toolTipDescription: string
setIsOpened: (isOpened: boolean) => void
}
function ActionsDropdown(props: ActionDropdownProps) {
const { id, children, isOpened, iconTag, setIsOpened, toolTipDescription } =
props
return (
<Dropdown
align="end"
className="float-end"
show={isOpened}
onToggle={open => setIsOpened(open)}
>
<DropdownToggleWithTooltip
id={`history-version-dropdown-${id}`}
className="history-version-dropdown-menu-btn"
aria-label={toolTipDescription}
toolTipDescription={toolTipDescription}
overlayTriggerProps={{ placement: 'bottom' }}
tooltipProps={{ hidden: isOpened }}
>
{iconTag}
</DropdownToggleWithTooltip>
<DropdownMenu className="history-version-dropdown-menu">
{children}
</DropdownMenu>
</Dropdown>
)
}
export default ActionsDropdown

View File

@@ -0,0 +1,120 @@
import { LoadedUpdate, Version } from '../../../services/types/update'
import { useCallback } from 'react'
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
import { useTranslation } from 'react-i18next'
import { updateRangeForUpdate } from '../../../utils/history-details'
import CompareDropDownItem from './menu-item/compare-dropdown-item'
import { useHistoryContext } from '../../../context/history-context'
import MaterialIcon from '../../../../../shared/components/material-icon'
type VersionDropdownContentAllHistoryProps = {
update: LoadedUpdate
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function CompareVersionDropdownContentAllHistory({
update,
closeDropdownForItem,
}: VersionDropdownContentAllHistoryProps) {
const updateRange = updateRangeForUpdate(update)
const closeDropdown = useCallback(() => {
closeDropdownForItem(update, 'compare')
}, [closeDropdownForItem, update])
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
<CompareDropDownItem
comparisonRange={{
fromV: selRange.fromV,
toV: updateRange.toV,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: updateRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
text={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon type="align_start" className="history-dropdown-icon" />
}
/>
<CompareDropDownItem
comparisonRange={{
fromV: updateRange.fromV,
toV: selRange.toV,
fromVTimestamp: updateRange.fromVTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
text={t('history_compare_from_this_version')}
icon={
<MaterialIcon type="align_end" className="history-dropdown-icon" />
}
/>
</>
)
}
type VersionDropdownContentLabelsListProps = {
version: Version
versionTimestamp: number
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function CompareVersionDropdownContentLabelsList({
version,
versionTimestamp,
closeDropdownForItem,
}: VersionDropdownContentLabelsListProps) {
const closeDropdownLabels = useCallback(() => {
closeDropdownForItem(version, 'compare')
}, [closeDropdownForItem, version])
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
<CompareDropDownItem
comparisonRange={{
fromV: selRange.fromV,
toV: version,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: versionTimestamp,
}}
closeDropdown={closeDropdownLabels}
text={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon type="align_start" className="history-dropdown-icon" />
}
/>
<CompareDropDownItem
comparisonRange={{
fromV: version,
toV: selRange.toV,
fromVTimestamp: versionTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdownLabels}
text={t('history_compare_from_this_version')}
icon={
<MaterialIcon type="align_end" className="history-dropdown-icon" />
}
/>
</>
)
}
export {
CompareVersionDropdownContentAllHistory,
CompareVersionDropdownContentLabelsList,
}

View File

@@ -0,0 +1,38 @@
import ActionsDropdown from './actions-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTranslation } from 'react-i18next'
type CompareVersionDropdownProps = {
children: React.ReactNode
id: string
isOpened: boolean
setIsOpened: (isOpened: boolean) => void
}
function CompareVersionDropdown({
children,
id,
isOpened,
setIsOpened,
}: CompareVersionDropdownProps) {
const { t } = useTranslation()
return (
<ActionsDropdown
id={id}
isOpened={isOpened}
setIsOpened={setIsOpened}
toolTipDescription={t('compare')}
iconTag={
<MaterialIcon
type="align_space_even"
className="history-dropdown-icon"
accessibilityLabel={t('compare')}
/>
}
>
{children}
</ActionsDropdown>
)
}
export default CompareVersionDropdown

View File

@@ -0,0 +1,54 @@
import AddLabel from './menu-item/add-label'
import Download from './menu-item/download'
import { Version } from '../../../services/types/update'
import { useCallback } from 'react'
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
import RestoreProject from './menu-item/restore-project'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
type VersionDropdownContentProps = {
projectId: string
version: Version
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
endTimestamp: number
}
function HistoryDropdownContent({
projectId,
version,
closeDropdownForItem,
endTimestamp,
}: VersionDropdownContentProps) {
const closeDropdown = useCallback(() => {
closeDropdownForItem(version, 'moreOptions')
}, [closeDropdownForItem, version])
const permissions = usePermissionsContext()
return (
<>
{permissions.labelVersion && (
<AddLabel
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
/>
)}
<Download
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
/>
{permissions.write && (
<RestoreProject
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
endTimestamp={endTimestamp}
/>
)}
</>
)
}
export default HistoryDropdownContent

View File

@@ -0,0 +1,34 @@
import ActionsDropdown from './actions-dropdown'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryDropdownProps = {
children: React.ReactNode
id: string
isOpened: boolean
setIsOpened: (isOpened: boolean) => void
}
function HistoryDropdown({
children,
id,
isOpened,
setIsOpened,
}: HistoryDropdownProps) {
const { t } = useTranslation()
return (
<ActionsDropdown
id={id}
isOpened={isOpened}
toolTipDescription={t('more_actions')}
setIsOpened={setIsOpened}
iconTag={
<MaterialIcon type="more_vert" accessibilityLabel={t('more_actions')} />
}
>
{children}
</ActionsDropdown>
)
}
export default HistoryDropdown

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import AddLabelModal from '../../add-label-modal'
type DownloadProps = {
projectId: string
version: number
closeDropdown: () => void
}
function AddLabel({
version,
projectId,
closeDropdown,
...props
}: DownloadProps) {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const handleClick = () => {
closeDropdown()
setShowModal(true)
}
return (
<>
<OLDropdownMenuItem
onClick={handleClick}
leadingIcon={<OLTagIcon />}
as="button"
className="dropdown-item-material-icon-small"
{...props}
>
{t('history_label_this_version')}
</OLDropdownMenuItem>
<AddLabelModal
show={showModal}
setShow={setShowModal}
version={version}
/>
</>
)
}
export default AddLabel

View File

@@ -0,0 +1,47 @@
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type CompareProps = {
comparisonRange: UpdateRange
icon: ReactNode
text: string
closeDropdown: () => void
}
function CompareDropDownItem({
comparisonRange,
text,
closeDropdown,
icon,
...props
}: CompareProps) {
const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: React.MouseEvent) => {
e.stopPropagation()
closeDropdown()
setSelection(({ previouslySelectedPathname }) => ({
updateRange: comparisonRange,
comparing: true,
files: [],
previouslySelectedPathname,
}))
}
return (
<OLDropdownMenuItem
{...props}
leadingIcon={icon}
as="button"
onClick={handleCompareVersion}
className="dropdown-item-material-icon-small"
>
{text}
</OLDropdownMenuItem>
)
}
export default CompareDropDownItem

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next'
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import Compare from './compare'
import MaterialIcon from '../../../../../../shared/components/material-icon'
import { ItemSelectionState } from '../../../../utils/history-details'
type CompareItemsProps = {
updateRange: UpdateRange
selectionState: ItemSelectionState
closeDropdown: () => void
}
function CompareItems({
updateRange,
selectionState,
closeDropdown,
}: CompareItemsProps) {
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
{selectionState === 'belowSelected' ? (
<Compare
comparisonRange={{
fromV: updateRange.fromV,
toV: selRange.toV,
fromVTimestamp: updateRange.fromVTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
toolTipDescription={t('history_compare_from_this_version')}
icon={
<MaterialIcon
type="align_end"
className="history-dropdown-icon pb-1"
/>
}
/>
) : null}
{selectionState === 'aboveSelected' ? (
<Compare
comparisonRange={{
fromV: selRange.fromV,
toV: updateRange.toV,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: updateRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
toolTipDescription={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon
type="align_start"
className="history-dropdown-icon pt-1"
/>
}
/>
) : null}
</>
)
}
export default CompareItems

View File

@@ -0,0 +1,47 @@
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type CompareProps = {
comparisonRange: UpdateRange
icon: ReactNode
toolTipDescription?: string
closeDropdown: () => void
}
function Compare({
comparisonRange,
closeDropdown,
toolTipDescription,
icon,
}: CompareProps) {
const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: { stopPropagation: () => void }) => {
e.stopPropagation()
closeDropdown()
setSelection(({ previouslySelectedPathname }) => ({
updateRange: comparisonRange,
comparing: true,
files: [],
previouslySelectedPathname,
}))
}
return (
<OLTooltip
description={toolTipDescription}
id="compare-btn"
overlayProps={{ placement: 'left' }}
>
<button className="history-compare-btn" onClick={handleCompareVersion}>
<span className="visually-hidden">{toolTipDescription}</span>
{icon}
</button>
</OLTooltip>
)
}
export default Compare

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
type DownloadProps = {
projectId: string
version: number
closeDropdown: () => void
}
function Download({
version,
projectId,
closeDropdown,
...props
}: DownloadProps) {
const { t } = useTranslation()
return (
<OLDropdownMenuItem
href={`/project/${projectId}/version/${version}/zip`}
download={`${projectId}_v${version}.zip`}
rel="noreferrer"
onClick={closeDropdown}
leadingIcon={<MaterialIcon type="download" />}
{...props}
>
{t('history_download_this_version')}
</OLDropdownMenuItem>
)
}
export default Download

View File

@@ -0,0 +1,65 @@
import { useCallback, useState } from 'react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import { useTranslation } from 'react-i18next'
import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
import MaterialIcon from '@/shared/components/material-icon'
type RestoreProjectProps = {
projectId: string
version: number
closeDropdown: () => void
endTimestamp: number
}
const RestoreProject = ({
projectId,
version,
closeDropdown,
endTimestamp,
}: RestoreProjectProps) => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const { splitTestVariants } = useSplitTestContext()
const { restoreProject, isRestoring } = useRestoreProject()
const handleClick = useCallback(() => {
closeDropdown()
setShowModal(true)
}, [closeDropdown])
const onRestore = useCallback(() => {
restoreProject(projectId, version)
}, [restoreProject, version, projectId])
if (
splitTestVariants['revert-file'] !== 'enabled' ||
splitTestVariants['revert-project'] !== 'enabled'
) {
return null
}
return (
<>
<OLDropdownMenuItem
as="button"
leadingIcon={<MaterialIcon type="undo" />}
onClick={handleClick}
>
{t('restore_project_to_this_version')}
</OLDropdownMenuItem>
<RestoreProjectModal
setShow={setShowModal}
show={showModal}
endTimestamp={endTimestamp}
isRestoring={isRestoring}
onRestore={onRestore}
/>
</>
)
}
export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal)

View File

@@ -0,0 +1,22 @@
import { formatTime } from '@/features/utils/format-date'
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function FileRestoreChange({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
if (!origin || origin.kind !== 'file-restore') {
return null
}
return (
<div className="history-version-restore-file">
{t('file_action_restored', {
fileName: origin.path,
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
})}
</div>
)
}
export default FileRestoreChange

View File

@@ -0,0 +1,9 @@
import { useTranslation } from 'react-i18next'
function HistoryResyncChange() {
const { t } = useTranslation()
return <div>{t('history_resync')}</div>
}
export default HistoryResyncChange

View File

@@ -0,0 +1,55 @@
import classnames from 'classnames'
import { HistoryContextValue } from '../../context/types/history-context-value'
import { UpdateRange } from '../../services/types/update'
import { ReactNode, MouseEvent } from 'react'
import { ItemSelectionState } from '../../utils/history-details'
type HistoryVersionDetailsProps = {
children: ReactNode
updateRange: UpdateRange
selectionState: ItemSelectionState
selectable: boolean
setSelection: HistoryContextValue['setSelection']
}
function HistoryVersionDetails({
children,
selectionState,
updateRange,
selectable,
setSelection,
}: HistoryVersionDetailsProps) {
const handleSelect = (e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (!target.closest('.dropdown') && e.currentTarget.contains(target)) {
setSelection(({ previouslySelectedPathname }) => ({
updateRange,
comparing: false,
files: [],
previouslySelectedPathname,
}))
}
}
return (
// TODO: Sort out accessibility for this
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={classnames('history-version-details', {
'history-version-selected':
selectionState === 'upperSelected' ||
selectionState === 'lowerSelected' ||
selectionState === 'selected',
'history-version-within-selected': selectionState === 'withinSelected',
'history-version-selectable': selectable,
})}
data-testid="history-version-details"
data-selected={selectionState}
onClick={selectable ? handleSelect : undefined}
>
{children}
</div>
)
}
export default HistoryVersionDetails

View File

@@ -0,0 +1,204 @@
import { useCallback, memo } from 'react'
import HistoryVersionDetails from './history-version-details'
import TagTooltip from './tag-tooltip'
import Changes from './changes'
import MetadataUsersList from './metadata-users-list'
import Origin from './origin'
import HistoryDropdown from './dropdown/history-dropdown'
import { formatTime, relativeDate } from '../../../utils/format-date'
import { orderBy } from 'lodash'
import { LoadedUpdate } from '../../services/types/update'
import classNames from 'classnames'
import {
updateRangeForUpdate,
ItemSelectionState,
} from '../../utils/history-details'
import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
import { HistoryContextValue } from '../../context/types/history-context-value'
import HistoryDropdownContent from './dropdown/history-dropdown-content'
import CompareItems from './dropdown/menu-item/compare-items'
import CompareVersionDropdown from './dropdown/compare-version-dropdown'
import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
import FileRestoreChange from './file-restore-change'
import HistoryResyncChange from './history-resync-change'
import ProjectRestoreChange from './project-restore-change'
type HistoryVersionProps = {
update: LoadedUpdate
currentUserId: string
projectId: string
selectable: boolean
faded: boolean
showDivider: boolean
selectionState: ItemSelectionState
setSelection: HistoryContextValue['setSelection']
dropdownOpen: boolean
dropdownActive: boolean
compareDropdownOpen: boolean
compareDropdownActive: boolean
setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function HistoryVersion({
update,
currentUserId,
projectId,
selectable,
faded,
showDivider,
selectionState,
setSelection,
dropdownOpen,
dropdownActive,
compareDropdownOpen,
compareDropdownActive,
setActiveDropdownItem,
closeDropdownForItem,
}: HistoryVersionProps) {
const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
const closeDropdown = useCallback(() => {
closeDropdownForItem(update.toV, 'moreOptions')
}, [closeDropdownForItem, update])
const updateRange = updateRangeForUpdate(update)
return (
<>
{showDivider ? (
<div
className={classNames({
'history-version-divider-container': true,
'version-element-within-selected ':
selectionState === 'withinSelected' ||
selectionState === 'lowerSelected',
})}
>
<hr className="history-version-divider" />
</div>
) : null}
{update.meta.first_in_day ? (
<div
className={classNames('history-version-day', {
'version-element-within-selected':
selectionState === 'withinSelected' ||
selectionState === 'lowerSelected',
})}
>
<time>{relativeDate(update.meta.end_ts)}</time>
</div>
) : null}
<div
data-testid="history-version"
className={classNames({
'history-version-faded': faded,
})}
>
<HistoryVersionDetails
selectionState={selectionState}
setSelection={setSelection}
updateRange={updateRangeForUpdate(update)}
selectable={selectable}
>
{faded ? null : (
<HistoryDropdown
id={`${update.fromV}_${update.toV}`}
isOpened={dropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: update.toV,
isOpened,
whichDropDown: 'moreOptions',
})
}
>
{dropdownActive ? (
<HistoryDropdownContent
version={update.toV}
endTimestamp={update.meta.end_ts}
projectId={projectId}
closeDropdownForItem={closeDropdownForItem}
/>
) : null}
</HistoryDropdown>
)}
{selectionState !== 'selected' && !faded ? (
<div data-testid="compare-icon-version" className="float-end">
{selectionState !== 'withinSelected' ? (
<CompareItems
updateRange={updateRange}
selectionState={selectionState}
closeDropdown={closeDropdown}
/>
) : (
<CompareVersionDropdown
id={`${update.fromV}_${update.toV}`}
isOpened={compareDropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: update,
isOpened,
whichDropDown: 'compare',
})
}
>
{compareDropdownActive ? (
<CompareVersionDropdownContentAllHistory
update={update}
closeDropdownForItem={closeDropdownForItem}
/>
) : null}
</CompareVersionDropdown>
)}
</div>
) : null}
<div className="history-version-main-details">
<time
className="history-version-metadata-time"
data-testid="history-version-metadata-time"
>
<b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b>
</time>
{orderedLabels.map(label => (
<TagTooltip
key={label.id}
showTooltip
currentUserId={currentUserId}
label={label}
/>
))}
<ChangeEntry update={update} />
{update.meta.origin?.kind !== 'history-resync' ? (
<>
<MetadataUsersList
users={update.meta.users}
origin={update.meta.origin}
currentUserId={currentUserId}
/>
<Origin origin={update.meta.origin} />
</>
) : null}
</div>
</HistoryVersionDetails>
</div>
</>
)
}
function ChangeEntry({ update }: { update: LoadedUpdate }) {
switch (update.meta.origin?.kind) {
case 'file-restore':
return <FileRestoreChange origin={update.meta.origin} />
case 'history-resync':
return <HistoryResyncChange />
case 'project-restore':
return <ProjectRestoreChange origin={update.meta.origin} />
default:
return (
<Changes pathnames={update.pathnames} projectOps={update.project_ops} />
)
}
}
export default memo(HistoryVersion)

View File

@@ -0,0 +1,154 @@
import { memo, useCallback } from 'react'
import { UpdateRange, Version } from '../../services/types/update'
import TagTooltip from './tag-tooltip'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import HistoryDropdown from './dropdown/history-dropdown'
import HistoryVersionDetails from './history-version-details'
import { LoadedLabel } from '../../services/types/label'
import { useTranslation } from 'react-i18next'
import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
import { HistoryContextValue } from '../../context/types/history-context-value'
import CompareItems from './dropdown/menu-item/compare-items'
import { ItemSelectionState } from '../../utils/history-details'
import CompareVersionDropdown from './dropdown/compare-version-dropdown'
import { CompareVersionDropdownContentLabelsList } from './dropdown/compare-version-dropdown-content'
import HistoryDropdownContent from '@/features/history/components/change-list/dropdown/history-dropdown-content'
type LabelListItemProps = {
version: Version
labels: LoadedLabel[]
currentUserId: string
projectId: string
selectionState: ItemSelectionState
selectable: boolean
setSelection: HistoryContextValue['setSelection']
dropdownOpen: boolean
dropdownActive: boolean
compareDropdownOpen: boolean
compareDropdownActive: boolean
setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function LabelListItem({
version,
labels,
currentUserId,
projectId,
selectionState,
selectable,
setSelection,
dropdownOpen,
dropdownActive,
compareDropdownOpen,
compareDropdownActive,
setActiveDropdownItem,
closeDropdownForItem,
}: LabelListItemProps) {
const { t } = useTranslation()
// first label
const fromVTimestamp = Date.parse(labels[labels.length - 1].created_at)
// most recent label
const toVTimestamp = Date.parse(labels[0].created_at)
const updateRange: UpdateRange = {
fromV: version,
toV: version,
fromVTimestamp,
toVTimestamp,
}
const setIsOpened = useCallback(
(isOpened: boolean) => {
setActiveDropdownItem({
item: version,
isOpened,
whichDropDown: 'moreOptions',
})
},
[setActiveDropdownItem, version]
)
const closeDropdown = useCallback(() => {
closeDropdownForItem(version, 'moreOptions')
}, [closeDropdownForItem, version])
return (
<HistoryVersionDetails
key={version}
updateRange={updateRange}
selectionState={selectionState}
selectable={selectable}
setSelection={setSelection}
>
<HistoryDropdown
id={version.toString()}
isOpened={dropdownOpen}
setIsOpened={setIsOpened}
>
{dropdownActive ? (
<HistoryDropdownContent
version={version}
projectId={projectId}
closeDropdownForItem={closeDropdownForItem}
endTimestamp={toVTimestamp}
/>
) : null}
</HistoryDropdown>
{selectionState !== 'selected' ? (
<div data-testid="compare-icon-version" className="float-end">
{selectionState !== 'withinSelected' ? (
<CompareItems
updateRange={updateRange}
selectionState={selectionState}
closeDropdown={closeDropdown}
/>
) : (
<CompareVersionDropdown
id={version.toString()}
isOpened={compareDropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: version,
isOpened,
whichDropDown: 'compare',
})
}
>
{compareDropdownActive ? (
<CompareVersionDropdownContentLabelsList
version={version}
closeDropdownForItem={closeDropdownForItem}
versionTimestamp={toVTimestamp}
/>
) : null}
</CompareVersionDropdown>
)}
</div>
) : null}
<div className="history-version-main-details">
{labels.map(label => (
<div key={label.id} className="history-version-label">
<TagTooltip
showTooltip
currentUserId={currentUserId}
label={label}
/>
{label.lastUpdatedTimestamp && (
<time
className="history-version-metadata-time"
data-testid="history-version-metadata-time"
>
{t('last_edit')}{' '}
<FormatTimeBasedOnYear date={label.lastUpdatedTimestamp} />
</time>
)}
</div>
))}
</div>
</HistoryVersionDetails>
)
}
export default memo(LabelListItem)

View File

@@ -0,0 +1,56 @@
import { useUserContext } from '../../../../shared/context/user-context'
import { isVersionSelected } from '../../utils/history-details'
import { useMemo } from 'react'
import LabelListItem from './label-list-item'
import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
import { getVersionWithLabels } from '../../utils/label'
import { useHistoryContext } from '../../context/history-context'
function LabelsList() {
const { id: currentUserId } = useUserContext()
const { projectId, labels, selection, setSelection } = useHistoryContext()
const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
useDropdownActiveItem()
const versionWithLabels = useMemo(
() => getVersionWithLabels(labels),
[labels]
)
return (
<>
{versionWithLabels.map(({ version, labels }) => {
const selectionState = isVersionSelected(selection, version)
const dropdownActive =
version === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'moreOptions'
const compareDropdownActive =
version === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'compare'
return (
<LabelListItem
key={version}
labels={labels}
version={version}
currentUserId={currentUserId!}
projectId={projectId}
selectionState={selectionState}
selectable={selectionState !== 'selected'}
setSelection={setSelection}
dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
dropdownActive={dropdownActive}
compareDropdownActive={compareDropdownActive}
compareDropdownOpen={
activeDropdownItem.isOpened && compareDropdownActive
}
setActiveDropdownItem={setActiveDropdownItem}
closeDropdownForItem={closeDropdownForItem}
/>
)
})}
</>
)
}
export default LabelsList

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
import UserNameWithColoredBadge from './user-name-with-colored-badge'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
type MetadataUsersListProps = {
currentUserId: string
} & Pick<LoadedUpdate['meta'], 'users' | 'origin'>
function MetadataUsersList({
users,
origin,
currentUserId,
}: MetadataUsersListProps) {
const { t } = useTranslation()
return (
<ol
className="history-version-metadata-users"
data-testid="history-version-metadata-users"
>
{users.map((user, index) => (
<li key={index}>
<UserNameWithColoredBadge user={user} currentUserId={currentUserId} />
</li>
))}
{!users.length && (
<li>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getBackgroundColorForUserId() }}
/>
{origin?.kind === 'history-resync' ||
origin?.kind === 'history-migration'
? t('overleaf_history_system')
: t('anonymous')}
</li>
)}
</ol>
)
}
export default MetadataUsersList

View File

@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
// Using this workaround due to inconsistent and improper error responses from the server
type ModalErrorProps = {
error: {
response?: Response
data?: {
message?: string
}
}
}
function ModalError({ error }: ModalErrorProps) {
const { t } = useTranslation()
if (error.response?.status === 400 && error.data?.message) {
return (
<OLNotification
type="error"
content={error.data.message}
className="row-spaced-small"
/>
)
}
return (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
className="row-spaced-small"
/>
)
}
export default ModalError

View File

@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
export function NonOwnerPaywallPrompt() {
const { t } = useTranslation()
return (
<div className="history-paywall-prompt">
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
<p>{t('currently_seeing_only_24_hrs_history')}</p>
<p>
<strong>{t('ask_proj_owner_to_upgrade_for_full_history')}</strong>
</p>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
let result: string | null = null
if (origin?.kind === 'dropbox') result = t('history_entry_origin_dropbox')
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
if (origin?.kind === 'github') result = t('history_entry_origin_github')
if (result) {
return <span className="history-version-origin">({result})</span>
}
return null
}
export default Origin

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { useCallback, useEffect, useState } from 'react'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
function FeatureItem({ text }: { text: string }) {
return (
<li>
<Icon type="check" /> {text}
</li>
)
}
export function OwnerPaywallPrompt() {
const { t } = useTranslation()
const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
useEffect(() => {
eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')
eventTracking.sendMB('paywall-prompt', { 'paywall-type': 'history' })
}, [])
const handleFreeTrialClick = useCallback(() => {
setClickedFreeTrialButton(true)
}, [])
return (
<div className="history-paywall-prompt">
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
<p>{t('currently_seeing_only_24_hrs_history')}</p>
<p>
<strong>
{t('upgrade_to_get_feature', { feature: 'full project history' })}
</strong>
</p>
<ul className="history-feature-list">
<FeatureItem text={t('unlimited_projects')} />
<FeatureItem
text={t('collabs_per_proj', { collabcount: 'Multiple' })}
/>
<FeatureItem text={t('full_doc_history')} />
<FeatureItem text={t('sync_to_dropbox')} />
<FeatureItem text={t('sync_to_github')} />
<FeatureItem text={t('compile_larger_projects')} />
</ul>
<p>
<StartFreeTrialButton
source="history"
buttonProps={{ variant: 'premium' }}
handleClick={handleFreeTrialClick}
>
{t('start_free_trial')}
</StartFreeTrialButton>
</p>
{clickedFreeTrialButton ? (
<p className="small">{t('refresh_page_after_starting_free_trial')}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { formatTime } from '@/features/utils/format-date'
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function ProjectRestoreChange({
origin,
}: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
if (!origin || origin.kind !== 'project-restore') {
return null
}
return (
<div className="history-version-restore-project">
{t('file_action_restored_project', {
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
})}
</div>
)
}
export default ProjectRestoreChange

View File

@@ -0,0 +1,187 @@
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import ModalError from './modal-error'
import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAsync from '../../../../shared/hooks/use-async'
import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
import { useHistoryContext } from '../../context/history-context'
import { deleteLabel } from '../../services/api'
import { isPseudoLabel } from '../../utils/label'
import { LoadedLabel } from '../../services/types/label'
import { debugConsole } from '@/utils/debugging'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import { useEditorContext } from '@/shared/context/editor-context'
import OLTag from '@/features/ui/components/ol/ol-tag'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
type TagProps = {
label: LoadedLabel
currentUserId: string
}
const ChangeTag = forwardRef<HTMLElement, TagProps>(
({ label, currentUserId, ...props }: TagProps, ref) => {
const { isProjectOwner } = useEditorContext()
const { t } = useTranslation()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { projectId } = useHistoryContext()
const { signal } = useAbortController()
const { removeUpdateLabel } = useAddOrRemoveLabels()
const { isLoading, isSuccess, isError, error, reset, runAsync } = useAsync()
const isPseudoCurrentStateLabel = isPseudoLabel(label)
const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
? label.user_id === currentUserId
: null
const showConfirmationModal = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteModal(true)
}
const handleModalExited = () => {
if (!isSuccess) return
if (!isPseudoCurrentStateLabel) {
removeUpdateLabel(label)
}
reset()
}
const localDeleteHandler = () => {
runAsync(deleteLabel(projectId, label.id, signal))
.then(() => setShowDeleteModal(false))
.catch(debugConsole.error)
}
const responseError = error as unknown as {
response: Response
data?: {
message?: string
}
}
const showCloseButton = Boolean(
(isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
)
return (
<>
<OLTag
ref={ref}
prepend={<OLTagIcon />}
closeBtnProps={
showCloseButton
? { 'aria-label': t('delete'), onClick: showConfirmationModal }
: undefined
}
className="history-version-badge"
data-testid="history-version-badge"
{...props}
>
{isPseudoCurrentStateLabel
? t('history_label_project_current_state')
: label.comment}
</OLTag>
{!isPseudoCurrentStateLabel && (
<OLModal
show={showDeleteModal}
onExited={handleModalExited}
onHide={() => setShowDeleteModal(false)}
id="delete-history-label"
>
<OLModalHeader>
<OLModalTitle>{t('history_delete_label')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<p>
{t('history_are_you_sure_delete_label')}&nbsp;
<strong>"{label.comment}"</strong>?
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShowDeleteModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
disabled={isLoading}
isLoading={isLoading}
onClick={localDeleteHandler}
>
{t('history_delete_label')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
</>
)
}
)
ChangeTag.displayName = 'ChangeTag'
type LabelBadgesProps = {
showTooltip: boolean
currentUserId: string
label: LoadedLabel
}
function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
const { t } = useTranslation()
const { labels: allLabels } = useHistoryContext()
const isPseudoCurrentStateLabel = isPseudoLabel(label)
const currentLabelData = allLabels?.find(({ id }) => id === label.id)
const labelOwnerName =
currentLabelData && !isPseudoLabel(currentLabelData)
? currentLabelData.user_display_name
: t('anonymous')
return !isPseudoCurrentStateLabel ? (
<OLTooltip
description={
<div className="history-version-label-tooltip">
<div className="history-version-label-tooltip-row">
<b className="history-version-label-tooltip-row-comment">
<OLTagIcon />
&nbsp;
{label.comment}
</b>
</div>
<div className="history-version-label-tooltip-row">
{t('history_label_created_by')} {labelOwnerName}
</div>
<div className="history-version-label-tooltip-row">
<time>
<FormatTimeBasedOnYear date={label.created_at} />
</time>
</div>
</div>
}
id={label.id}
overlayProps={{ placement: 'left' }}
hidden={!showTooltip}
>
<ChangeTag label={label} currentUserId={currentUserId} />
</OLTooltip>
) : (
<ChangeTag label={label} currentUserId={currentUserId} />
)
}
export default TagTooltip

View File

@@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next'
import { useHistoryContext } from '../../context/history-context'
import {
getUpdateForVersion,
updateRangeForUpdate,
} from '../../utils/history-details'
import { isAnyVersionMatchingSelection } from '../../utils/label'
import { HistoryContextValue } from '../../context/types/history-context-value'
import { updateRangeUnion } from '../../utils/range'
type ToggleSwitchProps = Pick<
HistoryContextValue,
'labelsOnly' | 'setLabelsOnly'
>
function ToggleSwitch({ labelsOnly, setLabelsOnly }: ToggleSwitchProps) {
const { t } = useTranslation()
const { selection, setSelection, resetSelection, updatesInfo, labels } =
useHistoryContext()
const handleChange = (isLabelsOnly: boolean) => {
if (selection.comparing) {
// using the switch toggle should reset the selection when in `compare` mode
resetSelection()
} else {
if (isLabelsOnly) {
if (isAnyVersionMatchingSelection(labels, selection)) {
resetSelection()
}
} else {
// in labels only mode the `fromV` is equal to `toV` value
// switching to all history mode and triggering immediate comparison with
// an older version would cause a bug if the computation below is skipped.
const { updateRange } = selection
const update = updateRange?.toV
? getUpdateForVersion(updateRange.toV, updatesInfo.updates)
: null
if (
updateRange &&
update &&
(update.fromV !== updateRange.fromV || update.toV !== updateRange.toV)
) {
const range = updateRangeUnion(
updateRangeForUpdate(update),
updateRange
)
setSelection(({ previouslySelectedPathname }) => ({
updateRange: range,
comparing: false,
files: [],
previouslySelectedPathname,
}))
}
}
}
setLabelsOnly(isLabelsOnly)
}
return (
<fieldset className="toggle-switch">
<legend className="sr-only">{t('history_view_a11y_description')}</legend>
<input
type="radio"
name="labels-only-toggle-switch"
checked={!labelsOnly}
onChange={() => handleChange(false)}
className="toggle-switch-input"
id="toggle-switch-all-history"
/>
<label
htmlFor="toggle-switch-all-history"
className="toggle-switch-label"
>
<span>{t('history_view_all')}</span>
</label>
<input
type="radio"
name="labels-only-toggle-switch"
checked={labelsOnly}
onChange={() => handleChange(true)}
className="toggle-switch-input"
id="toggle-switch-labels"
/>
<label htmlFor="toggle-switch-labels" className="toggle-switch-label">
<span>{t('history_view_labels')}</span>
</label>
</fieldset>
)
}
export default ToggleSwitch

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import { formatUserName } from '../../utils/history-details'
import { User } from '../../services/types/shared'
import { Nullable } from '../../../../../../types/utils'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
type UserNameWithColoredBadgeProps = {
currentUserId: string
user: Nullable<User | { id: string; displayName: string }>
}
function UserNameWithColoredBadge({
user,
currentUserId,
}: UserNameWithColoredBadgeProps) {
const { t } = useTranslation()
let userName: string
if (!user) {
userName = t('anonymous')
} else if (user.id === currentUserId) {
userName = t('you')
} else if ('displayName' in user) {
userName = user.displayName
} else {
userName = formatUserName(user)
}
return (
<>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getBackgroundColorForUserId(user?.id) }}
/>
<span className="history-version-user-badge-text">{userName}</span>
</>
)
}
export default UserNameWithColoredBadge

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>
)
}

View File

@@ -0,0 +1,47 @@
import { memo } from 'react'
import classNames from 'classnames'
import HistoryFileTreeItem from './history-file-tree-item'
import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
import type { FileDiff } from '../../services/types/file'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryFileTreeDocProps = {
file: FileDiff
name: string
selected: boolean
onClick: (file: FileDiff, event: React.MouseEvent<HTMLLIElement>) => void
onKeyDown: (file: FileDiff, event: React.KeyboardEvent<HTMLLIElement>) => void
}
function HistoryFileTreeDoc({
file,
name,
selected,
onClick,
onKeyDown,
}: HistoryFileTreeDocProps) {
return (
<li
role="treeitem"
className={classNames({ selected })}
onClick={e => onClick(file, e)}
onKeyDown={e => onKeyDown(file, e)}
aria-selected={selected}
aria-label={name}
tabIndex={0}
>
<HistoryFileTreeItem
name={name}
operation={'operation' in file ? file.operation : undefined}
icons={
<MaterialIcon
type={iconTypeFromName(name)}
className="file-tree-icon"
/>
}
/>
</li>
)
}
export default memo(HistoryFileTreeDoc)

View File

@@ -0,0 +1,87 @@
import classNames from 'classnames'
import HistoryFileTreeDoc from './history-file-tree-doc'
import HistoryFileTreeFolder from './history-file-tree-folder'
import { ReactNode, useCallback } from 'react'
import type { HistoryFileTree, HistoryDoc } from '../../utils/file-tree'
import { useHistoryContext } from '../../context/history-context'
import { FileDiff } from '../../services/types/file'
import { fileFinalPathname } from '../../utils/file-diff'
type HistoryFileTreeFolderListProps = {
folders: HistoryFileTree[]
docs: HistoryDoc[]
rootClassName?: string
children?: ReactNode
}
function HistoryFileTreeFolderList({
folders,
docs,
rootClassName,
children,
}: HistoryFileTreeFolderListProps) {
const { selection, setSelection } = useHistoryContext()
const handleEvent = useCallback(
(file: FileDiff) => {
setSelection(prevSelection => {
if (file.pathname !== prevSelection.selectedFile?.pathname) {
return {
...prevSelection,
selectedFile: file,
previouslySelectedPathname: file.pathname,
}
}
return prevSelection
})
},
[setSelection]
)
const handleClick = useCallback(
(file: FileDiff) => {
handleEvent(file)
},
[handleEvent]
)
const handleKeyDown = useCallback(
(file: FileDiff, event: React.KeyboardEvent<HTMLLIElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
handleEvent(file)
}
},
[handleEvent]
)
return (
<ul className={classNames('list-unstyled', rootClassName)} role="tree">
{folders.map(folder => (
<HistoryFileTreeFolder
key={folder.name}
name={folder.name}
folders={folder.folders}
docs={folder.docs ?? []}
/>
))}
{docs.map(doc => (
<HistoryFileTreeDoc
key={doc.pathname}
name={doc.name}
file={doc}
selected={
!!selection.selectedFile &&
fileFinalPathname(selection.selectedFile) === doc.pathname
}
onClick={handleClick}
onKeyDown={handleKeyDown}
/>
))}
{children}
</ul>
)
}
export default HistoryFileTreeFolderList

View File

@@ -0,0 +1,88 @@
import { useState, memo } from 'react'
import { useTranslation } from 'react-i18next'
import HistoryFileTreeItem from './history-file-tree-item'
import HistoryFileTreeFolderList from './history-file-tree-folder-list'
import type { HistoryDoc, HistoryFileTree } from '../../utils/file-tree'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryFileTreeFolderProps = {
name: string
folders: HistoryFileTree[]
docs: HistoryDoc[]
}
function hasChanges(fileTree: HistoryFileTree): boolean {
const hasSameLevelChanges = fileTree.docs?.some(
(doc: HistoryDoc) => (doc as any).operation !== undefined
)
if (hasSameLevelChanges) {
return true
}
const hasNestedChanges = fileTree.folders?.some(folder => {
return hasChanges(folder)
})
if (hasNestedChanges) {
return true
}
return false
}
function HistoryFileTreeFolder({
name,
folders,
docs,
}: HistoryFileTreeFolderProps) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(() => {
return hasChanges({ name, folders, docs })
})
const icons = (
<>
<button
onClick={() => setExpanded(!expanded)}
aria-label={expanded ? t('collapse') : t('expand')}
className="history-file-tree-folder-button"
>
<MaterialIcon
type={expanded ? 'expand_more' : 'chevron_right'}
className="file-tree-expand-icon"
/>
</button>
<MaterialIcon
type={expanded ? 'folder_open' : 'folder'}
className="file-tree-folder-icon"
/>
</>
)
return (
<>
<li
// FIXME
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
aria-expanded={expanded}
aria-label={name}
tabIndex={0}
onClick={() => setExpanded(!expanded)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setExpanded(!expanded)
}
}}
>
<HistoryFileTreeItem name={name} icons={icons} />
</li>
{expanded ? (
<HistoryFileTreeFolderList folders={folders} docs={docs} />
) : null}
</>
)
}
export default memo(HistoryFileTreeFolder)

View File

@@ -0,0 +1,34 @@
import classNames from 'classnames'
import type { ReactNode } from 'react'
import type { FileOperation } from '../../services/types/file-operation'
import OLTag from '@/features/ui/components/ol/ol-tag'
type FileTreeItemProps = {
name: string
operation?: FileOperation
icons: ReactNode
}
export default function HistoryFileTreeItem({
name,
operation,
icons,
}: FileTreeItemProps) {
return (
<div className="history-file-tree-item" role="presentation">
{icons}
<div className="history-file-tree-item-name-wrapper">
<div
className={classNames('history-file-tree-item-name', {
strikethrough: operation === 'removed',
})}
>
{name}
</div>
{operation && (
<OLTag className="history-file-tree-item-badge">{operation}</OLTag>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react'
import { orderBy, reduce } from 'lodash'
import { useHistoryContext } from '../context/history-context'
import {
fileTreeDiffToFileTreeData,
reducePathsToTree,
} from '../utils/file-tree'
import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list'
export default function HistoryFileTree() {
const { selection } = useHistoryContext()
const fileTree = useMemo(
() => reduce(selection.files, reducePathsToTree, []),
[selection.files]
)
const sortedFileTree = useMemo(
() => orderBy(fileTree, ['-type', 'operation', 'name']),
[fileTree]
)
const mappedFileTree = useMemo(
() => fileTreeDiffToFileTreeData(sortedFileTree),
[sortedFileTree]
)
return (
<HistoryFileTreeFolderList
folders={mappedFileTree.folders}
docs={mappedFileTree.docs ?? []}
rootClassName="history-file-tree-list"
/>
)
}

View File

@@ -0,0 +1,375 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useState,
useMemo,
useRef,
} from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
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 } from '../utils/file-tree'
import { isFileRenamed } from '../utils/file-diff'
import { loadLabels } from '../utils/label'
import { autoSelectFile } from '../utils/auto-select-file'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import moment from 'moment'
import { cloneDeep } from 'lodash'
import {
FetchUpdatesResponse,
LoadedUpdate,
Update,
} from '../services/types/update'
import { Selection } from '../services/types/selection'
import { useErrorHandler } from 'react-error-boundary'
import { getUpdateForVersion } from '../utils/history-details'
import { getHueForUserId } from '@/shared/utils/colors'
// Allow testing of infinite scrolling by providing query string parameters to
// limit the number of updates returned in a batch and apply a delay
function limitUpdates(
promise: Promise<FetchUpdatesResponse>
): Promise<FetchUpdatesResponse> {
const queryParams = new URLSearchParams(window.location.search)
const maxBatchSizeParam = queryParams.get('history-max-updates')
const delayParam = queryParams.get('history-updates-delay')
if (delayParam === null && maxBatchSizeParam === null) {
return promise
}
return promise.then(response => {
let { updates, nextBeforeTimestamp } = response
const maxBatchSize = maxBatchSizeParam ? parseInt(maxBatchSizeParam, 10) : 0
const delay = delayParam ? parseInt(delayParam, 10) : 0
if (maxBatchSize > 0 && updates.length > maxBatchSize) {
updates = updates.slice(0, maxBatchSize)
nextBeforeTimestamp = updates[updates.length - 1].fromV
}
const limitedResponse = { updates, nextBeforeTimestamp }
if (delay > 0) {
return new Promise(resolve => {
window.setTimeout(() => resolve(limitedResponse), delay)
})
} else {
return limitedResponse
}
})
}
const selectionInitialState: Selection = {
updateRange: null,
comparing: false,
files: [],
previouslySelectedPathname: null,
}
const updatesInfoInitialState: HistoryContextValue['updatesInfo'] = {
updates: [],
visibleUpdateCount: null,
atEnd: false,
freeHistoryLimitHit: false,
nextBeforeTimestamp: undefined,
loadingState: 'loadingInitial',
}
function useHistory() {
const { view } = useLayoutContext()
const user = useUserContext()
const project = useProjectContext()
const userId = user.id
const projectId = project._id
const projectOwnerId = project.owner?._id
const userHasFullFeature = Boolean(
project.features?.versioning || user.isAdmin
)
const currentUserIsOwner = projectOwnerId === userId
const [selection, setSelection] = useState<Selection>(selectionInitialState)
const [updatesInfo, setUpdatesInfo] = useState<
HistoryContextValue['updatesInfo']
>(updatesInfoInitialState)
const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
const [labelsOnly, setLabelsOnly] = usePersistedState(
`history.userPrefs.showOnlyLabels.${projectId}`,
false
)
const updatesAbortControllerRef = useRef<AbortController | null>(null)
const handleError = useErrorHandler()
const fetchNextBatchOfUpdates = useCallback(() => {
// If there is an in-flight request for updates, just let it complete, by
// bailing out
if (updatesAbortControllerRef.current) {
return
}
const updatesLoadingState = updatesInfo.loadingState
const loadUpdates = (updatesData: Update[]) => {
const dateTimeNow = new Date()
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
let { updates, freeHistoryLimitHit, visibleUpdateCount } = updatesInfo
let previousUpdate = updates[updates.length - 1]
const loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
for (const [index, update] of loadedUpdates.entries()) {
for (const user of update.meta.users) {
if (user) {
user.hue = getHueForUserId(user.id)
}
}
if (
!previousUpdate ||
!moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
) {
update.meta.first_in_day = true
}
previousUpdate = update
// the free tier cutoff is 24 hours, so show one extra update
// after which will become the fade teaser above the paywall
if (
!userHasFullFeature &&
visibleUpdateCount === null &&
update.meta.end_ts < timestamp24hoursAgo
) {
// Make sure that we show at least one entry fully (to allow labelling), and one extra for fading
// Since the index for the first free tier cutoff will be at 0 if all versions were updated the day before (all version in the past),
// we need to +2 instead of +1. this gives us one which is selected and one which is faded
visibleUpdateCount = index > 0 ? index + 1 : 2
freeHistoryLimitHit = true
}
}
return {
updates: updates.concat(loadedUpdates),
visibleUpdateCount,
freeHistoryLimitHit,
}
}
if (
updatesInfo.atEnd ||
!(
updatesLoadingState === 'loadingInitial' ||
updatesLoadingState === 'ready'
)
) {
return
}
updatesAbortControllerRef.current = new AbortController()
const signal = updatesAbortControllerRef.current.signal
const updatesPromise = limitUpdates(
fetchUpdates(projectId, updatesInfo.nextBeforeTimestamp, signal)
)
const labelsPromise = labels == null ? fetchLabels(projectId, signal) : null
setUpdatesInfo({
...updatesInfo,
loadingState:
updatesLoadingState === 'ready' ? 'loadingUpdates' : 'loadingInitial',
})
Promise.all([updatesPromise, labelsPromise])
.then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
if (labels) {
setLabels(loadLabels(labels, updatesData))
}
const { updates, visibleUpdateCount, freeHistoryLimitHit } =
loadUpdates(updatesData)
const atEnd =
nextBeforeTimestamp == null || freeHistoryLimitHit || !updates.length
setUpdatesInfo({
updates,
visibleUpdateCount,
freeHistoryLimitHit,
atEnd,
nextBeforeTimestamp,
loadingState: 'ready',
})
})
.catch(handleError)
.finally(() => {
updatesAbortControllerRef.current = null
})
}, [updatesInfo, projectId, labels, handleError, userHasFullFeature])
// Abort in-flight updates request on unmount
useEffect(() => {
return () => {
if (updatesAbortControllerRef.current) {
updatesAbortControllerRef.current.abort()
}
}
}, [])
// Initial load on first render
const initialFetch = useRef(false)
useEffect(() => {
if (view === 'history' && !initialFetch.current) {
initialFetch.current = true
return fetchNextBatchOfUpdates()
}
}, [view, fetchNextBatchOfUpdates])
useEffect(() => {
// Reset some parts of the state
if (view !== 'history') {
initialFetch.current = false
setSelection(prevSelection => ({
...selectionInitialState,
// retain the previously selected pathname
previouslySelectedPathname: prevSelection.previouslySelectedPathname,
}))
setUpdatesInfo(updatesInfoInitialState)
setLabels(null)
}
}, [view])
const resetSelection = useCallback(() => {
setSelection(selectionInitialState)
}, [])
const { updateRange } = selection
const { fromV, toV } = updateRange || {}
const { updates } = updatesInfo
const updateForToV =
toV === undefined ? undefined : getUpdateForVersion(toV, updates)
// Load files when the update selection changes
const [loadingFileDiffs, setLoadingFileDiffs] = useState(false)
useEffect(() => {
if (fromV === undefined || toV === undefined) {
return
}
let abortController: AbortController | null = new AbortController()
setLoadingFileDiffs(true)
diffFiles(projectId, fromV, toV, abortController.signal)
.then(({ diff: files }) => {
setSelection(previousSelection => {
const selectedFile = autoSelectFile(
files,
toV,
previousSelection.comparing,
updateForToV,
previousSelection.previouslySelectedPathname
)
const newFiles = files.map(file => {
if (isFileRenamed(file) && file.newPathname) {
return renamePathnameKey(file)
}
return file
})
return {
...previousSelection,
files: newFiles,
selectedFile,
previouslySelectedPathname: selectedFile.pathname,
}
})
})
.catch(handleError)
.finally(() => {
setLoadingFileDiffs(false)
abortController = null
})
return () => {
if (abortController) {
abortController.abort()
}
}
}, [projectId, fromV, toV, updateForToV, handleError])
useEffect(() => {
// Set update range if there isn't one and updates have loaded
if (updates.length && !updateRange) {
setSelection(prevSelection => ({
...prevSelection,
updateRange: {
fromV: updates[0].fromV,
toV: updates[0].toV,
fromVTimestamp: updates[0].meta.end_ts,
toVTimestamp: updates[0].meta.end_ts,
},
comparing: false,
files: [],
}))
}
}, [updateRange, updates])
const value = useMemo<HistoryContextValue>(
() => ({
loadingFileDiffs,
updatesInfo,
setUpdatesInfo,
labels,
setLabels,
labelsOnly,
setLabelsOnly,
userHasFullFeature,
currentUserIsOwner,
projectId,
selection,
setSelection,
fetchNextBatchOfUpdates,
resetSelection,
}),
[
loadingFileDiffs,
updatesInfo,
setUpdatesInfo,
labels,
setLabels,
labelsOnly,
setLabelsOnly,
userHasFullFeature,
currentUserIsOwner,
projectId,
selection,
setSelection,
fetchNextBatchOfUpdates,
resetSelection,
]
)
return { value }
}
export const HistoryContext = createContext<HistoryContextValue | undefined>(
undefined
)
type HistoryProviderProps = {
children?: React.ReactNode
}
export function HistoryProvider({ ...props }: HistoryProviderProps) {
const { value } = useHistory()
return <HistoryContext.Provider value={value} {...props} />
}
export function useHistoryContext() {
const context = useContext(HistoryContext)
if (!context) {
throw new Error('HistoryContext is only available inside HistoryProvider')
}
return context
}

View File

@@ -0,0 +1,106 @@
import { sendMB } from '../../../../infrastructure/event-tracking'
import { useLayoutContext } from '../../../../shared/context/layout-context'
import { restoreFile } from '../../services/api'
import { isFileRemoved } from '../../utils/file-diff'
import { useHistoryContext } from '../history-context'
import type { HistoryContextValue } from '../types/history-context-value'
import { useErrorHandler } from 'react-error-boundary'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { useCallback, useEffect, useState } from 'react'
import { RestoreFileResponse } from '@/features/history/services/types/restore-file'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
type RestorationState =
| 'idle'
| 'restoring'
| 'waitingForFileTree'
| 'complete'
| 'error'
| 'timedOut'
export function useRestoreDeletedFile() {
const { projectId } = useHistoryContext()
const { setView } = useLayoutContext()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
const handleError = useErrorHandler()
const { fileTreeData } = useFileTreeData()
const [state, setState] = useState<RestorationState>('idle')
const [restoredFileMetadata, setRestoredFileMetadata] =
useState<RestoreFileResponse | null>(null)
const isLoading = state === 'restoring' || state === 'waitingForFileTree'
useEffect(() => {
if (state === 'waitingForFileTree' && restoredFileMetadata) {
const result = findInTree(fileTreeData, restoredFileMetadata.id)
if (result) {
setState('complete')
const { _id: id } = result.entity
setView('editor')
if (restoredFileMetadata.type === 'doc') {
openDocWithId(id)
} else {
openFileWithId(id)
}
}
}
}, [
state,
fileTreeData,
restoredFileMetadata,
openDocWithId,
openFileWithId,
setView,
])
useEffect(() => {
if (state === 'waitingForFileTree') {
const timer = window.setTimeout(() => {
setState('timedOut')
handleError(new Error('timed out'))
}, 3000)
return () => {
window.clearTimeout(timer)
}
}
}, [handleError, state])
const restoreDeletedFile = useCallback(
(selection: HistoryContextValue['selection']) => {
const { selectedFile, files } = selection
if (
selectedFile &&
selectedFile.pathname &&
isFileRemoved(selectedFile)
) {
const file = files.find(file => file.pathname === selectedFile.pathname)
if (file && isFileRemoved(file)) {
sendMB('history-v2-restore-deleted')
setState('restoring')
restoreFile(projectId, {
...selectedFile,
pathname: file.newPathname ?? file.pathname,
}).then(
(data: RestoreFileResponse) => {
setRestoredFileMetadata(data)
setState('waitingForFileTree')
},
error => {
setState('error')
handleError(error)
}
)
}
}
},
[handleError, projectId]
)
return { restoreDeletedFile, isLoading }
}

View File

@@ -0,0 +1,36 @@
import { useCallback, useState } from 'react'
import { useErrorHandler } from 'react-error-boundary'
import { restoreProjectToVersion } from '../../services/api'
import { useLayoutContext } from '@/shared/context/layout-context'
type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
export const useRestoreProject = () => {
const handleError = useErrorHandler()
const { setView } = useLayoutContext()
const [restorationState, setRestorationState] =
useState<RestorationState>('initial')
const restoreProject = useCallback(
(projectId: string, version: number) => {
setRestorationState('restoring')
restoreProjectToVersion(projectId, version)
.then(() => {
setRestorationState('restored')
setView('editor')
})
.catch(err => {
setRestorationState('error')
handleError(err)
})
},
[handleError, setView]
)
return {
restorationState,
restoreProject,
isRestoring: restorationState === 'restoring',
}
}

View File

@@ -0,0 +1,104 @@
import { useLayoutContext } from '../../../../shared/context/layout-context'
import { restoreFileToVersion } from '../../services/api'
import { isFileRemoved } from '../../utils/file-diff'
import { useHistoryContext } from '../history-context'
import type { HistoryContextValue } from '../types/history-context-value'
import { useErrorHandler } from 'react-error-boundary'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { useCallback, useEffect, useState } from 'react'
import { RestoreFileResponse } from '../../services/types/restore-file'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
const RESTORE_FILE_TIMEOUT = 3000
type RestoreState =
| 'idle'
| 'restoring'
| 'waitingForFileTree'
| 'complete'
| 'error'
| 'timedOut'
export function useRestoreSelectedFile() {
const { projectId } = useHistoryContext()
const { setView } = useLayoutContext()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
const handleError = useErrorHandler()
const { fileTreeData } = useFileTreeData()
const [state, setState] = useState<RestoreState>('idle')
const [restoredFileMetadata, setRestoredFileMetadata] =
useState<RestoreFileResponse | null>(null)
const isLoading = state === 'restoring' || state === 'waitingForFileTree'
useEffect(() => {
if (state === 'waitingForFileTree' && restoredFileMetadata) {
const result = findInTree(fileTreeData, restoredFileMetadata.id)
if (result) {
setState('complete')
const { _id: id } = result.entity
setView('editor')
if (restoredFileMetadata.type === 'doc') {
openDocWithId(id)
} else {
openFileWithId(id)
}
}
}
}, [
state,
fileTreeData,
restoredFileMetadata,
openDocWithId,
openFileWithId,
setView,
])
useEffect(() => {
if (state === 'waitingForFileTree') {
const timer = window.setTimeout(() => {
setState('timedOut')
handleError(new Error('timed out'))
}, RESTORE_FILE_TIMEOUT)
return () => {
window.clearTimeout(timer)
}
}
}, [handleError, state])
const restoreSelectedFile = useCallback(
(selection: HistoryContextValue['selection']) => {
const { selectedFile, files } = selection
if (selectedFile && selectedFile.pathname) {
const file = files.find(file => file.pathname === selectedFile.pathname)
if (file) {
const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
const toVersion = deletedAtV ?? selection.updateRange?.toV
if (!toVersion) {
return
}
setState('restoring')
restoreFileToVersion(projectId, file.pathname, toVersion).then(
(data: RestoreFileResponse) => {
setRestoredFileMetadata(data)
setState('waitingForFileTree')
},
error => {
setState('error')
handleError(error)
}
)
}
}
},
[handleError, projectId]
)
return { restoreSelectedFile, isLoading }
}

View File

@@ -0,0 +1,34 @@
import { Nullable } from '../../../../../../types/utils'
import { LoadedUpdate } from '../../services/types/update'
import { LoadedLabel } from '../../services/types/label'
import { Selection } from '../../services/types/selection'
type UpdatesLoadingState = 'loadingInitial' | 'loadingUpdates' | 'ready'
export type HistoryContextValue = {
updatesInfo: {
updates: LoadedUpdate[]
visibleUpdateCount: Nullable<number>
atEnd: boolean
nextBeforeTimestamp: number | undefined
freeHistoryLimitHit: boolean
loadingState: UpdatesLoadingState
}
setUpdatesInfo: React.Dispatch<
React.SetStateAction<HistoryContextValue['updatesInfo']>
>
userHasFullFeature: boolean
currentUserIsOwner: boolean
loadingFileDiffs: boolean
labels: Nullable<LoadedLabel[]>
setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
labelsOnly: boolean
setLabelsOnly: React.Dispatch<React.SetStateAction<boolean>>
projectId: string
selection: Selection
setSelection: React.Dispatch<
React.SetStateAction<HistoryContextValue['selection']>
>
fetchNextBatchOfUpdates: () => (() => void) | void
resetSelection: () => void
}

View File

@@ -0,0 +1,134 @@
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import { Highlight } from '../services/types/doc'
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { highlightDecorationsField } from './highlights'
import { throttle, isEqual } from 'lodash'
import { updateHasEffect } from '../../source-editor/utils/effects'
export type HighlightLocations = {
before: number
after: number
next?: Highlight
previous?: Highlight
}
const setHighlightLocationsEffect = StateEffect.define<HighlightLocations>()
const hasSetHighlightLocationsEffect = updateHasEffect(
setHighlightLocationsEffect
)
// Returns the range within the document that is currently visible to the user
function visibleRange(view: EditorView) {
const { top, bottom } = view.scrollDOM.getBoundingClientRect()
const first = view.lineBlockAtHeight(top - view.documentTop)
const last = view.lineBlockAtHeight(bottom - view.documentTop)
return { from: first.from, to: last.to }
}
function calculateHighlightLocations(view: EditorView): HighlightLocations {
const highlightsBefore: Highlight[] = []
const highlightsAfter: Highlight[] = []
let next
let previous
const highlights =
view.state.field(highlightDecorationsField)?.highlights || []
if (highlights.length === 0) {
return { before: 0, after: 0 }
}
const { from: visibleFrom, to: visibleTo } = visibleRange(view)
for (const highlight of highlights) {
if (highlight.range.to <= visibleFrom) {
highlightsBefore.push(highlight)
} else if (highlight.range.from >= visibleTo) {
highlightsAfter.push(highlight)
}
}
const before = highlightsBefore.length
const after = highlightsAfter.length
if (before > 0) {
previous = highlightsBefore[highlightsBefore.length - 1]
}
if (after > 0) {
next = highlightsAfter[0]
}
return {
before,
after,
previous,
next,
}
}
const plugin = ViewPlugin.fromClass(
class {
// eslint-disable-next-line no-useless-constructor
constructor(readonly view: EditorView) {}
dispatchIfChanged() {
const oldLocations = this.view.state.field(highlightLocationsField)
const newLocations = calculateHighlightLocations(this.view)
if (!isEqual(oldLocations, newLocations)) {
this.view.dispatch({
effects: setHighlightLocationsEffect.of(newLocations),
})
}
}
update(update: ViewUpdate) {
if (!hasSetHighlightLocationsEffect(update)) {
// Normally, a timeout is a poor choice, but in this case it doesn't
// matter that there is a slight delay or that it might run after the
// viewer has been torn down
window.setTimeout(() => this.dispatchIfChanged())
}
}
},
{
eventHandlers: {
scroll: throttle(
(event, view: EditorView) => {
view.plugin(plugin)?.dispatchIfChanged()
},
120,
{ trailing: true }
),
},
}
)
export const highlightLocationsField = StateField.define<HighlightLocations>({
create() {
return { before: 0, visible: 0, after: 0 }
},
update(highlightLocations, tr) {
for (const effect of tr.effects) {
if (effect.is(setHighlightLocationsEffect)) {
return effect.value
}
}
return highlightLocations
},
provide: () => [plugin],
})
export function highlightLocations() {
return highlightLocationsField
}
export function scrollToHighlight(view: EditorView, highlight: Highlight) {
view.dispatch({
effects: EditorView.scrollIntoView(
EditorSelection.range(highlight.range.from, highlight.range.to),
{
y: 'center',
}
),
})
}

View File

@@ -0,0 +1,413 @@
import {
EditorState,
Line,
Range,
RangeSet,
StateEffect,
StateField,
} from '@codemirror/state'
import {
Decoration,
DecorationSet,
EditorView,
showTooltip,
gutter,
gutterLineClass,
GutterMarker,
Tooltip,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { Highlight, HighlightType } from '../services/types/doc'
export const setHighlightsEffect = StateEffect.define<Highlight[]>()
const ADDITION_MARKER_CLASS = 'ol-cm-addition-marker'
const DELETION_MARKER_CLASS = 'ol-cm-deletion-marker'
function highlightToMarker(highlight: Highlight) {
const className =
highlight.type === 'addition'
? ADDITION_MARKER_CLASS
: DELETION_MARKER_CLASS
const { from, to } = highlight.range
return Decoration.mark({
class: className,
attributes: {
style: `--hue: ${highlight.hue}`,
},
}).range(from, to)
}
type LineStatus = {
line: Line
highlights: Highlight[]
empty: boolean
changeType: HighlightType | 'mixed'
}
type LineStatuses = Map<number, LineStatus>
function highlightedLines(highlights: Highlight[], state: EditorState) {
const lineStatuses = new Map<number, LineStatus>()
for (const highlight of highlights) {
const fromLine = state.doc.lineAt(highlight.range.from).number
const toLine = state.doc.lineAt(highlight.range.to).number
for (let lineNum = fromLine; lineNum <= toLine; ++lineNum) {
const status = lineStatuses.get(lineNum)
if (status) {
status.highlights.push(highlight)
if (status.changeType !== highlight.type) {
status.changeType = 'mixed'
}
} else {
const line = state.doc.line(lineNum)
lineStatuses.set(lineNum, {
line,
highlights: [highlight],
empty: line.length === 0,
changeType: highlight.type,
})
}
}
}
return lineStatuses
}
const tooltipTheme = EditorView.theme({
'.cm-tooltip': {
backgroundColor: 'transparent',
borderWidth: 0,
// Prevent a tooltip getting in the way of hovering over a line that it
// obscures
pointerEvents: 'none',
},
})
const theme = EditorView.baseTheme({
['.' + ADDITION_MARKER_CLASS]: {
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
backgroundColor: 'hsl(var(--hue), 70%, 85%)',
},
['.' + DELETION_MARKER_CLASS]: {
textDecoration: 'line-through',
color: 'hsl(var(--hue), 70%, 40%)',
},
'.cm-tooltip.ol-cm-highlight-tooltip': {
backgroundColor: 'hsl(var(--hue), 70%, 50%)',
borderRadius: '4px',
padding: '4px',
color: '#fff',
},
'.ol-cm-empty-line-addition-marker': {
padding: 'var(--half-leading) 2px',
},
'.ol-cm-changed-line': {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
'.ol-cm-change-gutter': {
width: '3px',
paddingLeft: '1px',
},
'.ol-cm-changed-line-gutter': {
backgroundColor: 'hsl(var(--hue), 70%, 40%)',
height: '100%',
},
'.ol-cm-highlighted-line-gutter': {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
})
function createHighlightTooltip(pos: number, highlight: Highlight) {
return {
pos,
above: true,
create: () => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-highlight-tooltip')
dom.style.setProperty('--hue', String(highlight.hue))
dom.textContent = highlight.label
return { dom }
},
}
}
const setHighlightTooltipEffect = StateEffect.define<Tooltip | null>()
const tooltipField = StateField.define<Tooltip | null>({
create() {
return null
},
update(tooltip, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setHighlightTooltipEffect)) {
return effect.value
}
}
return tooltip
},
provide: field => showTooltip.from(field),
})
function highlightAtPos(state: EditorState, pos: number) {
const highlights = state.field(highlightDecorationsField).highlights
return highlights.find(highlight => {
const { from, to } = highlight.range
return pos >= from && pos <= to
})
}
const highlightTooltipPlugin = ViewPlugin.fromClass(
class {
private lastTooltipPos: number | null = null
// eslint-disable-next-line no-useless-constructor
constructor(readonly view: EditorView) {}
setHighlightTooltip(tooltip: Tooltip | null) {
this.view.dispatch({
effects: setHighlightTooltipEffect.of(tooltip),
})
}
setTooltipFromEvent(event: MouseEvent) {
const pos = this.view.posAtCoords({ x: event.clientX, y: event.clientY })
if (pos !== this.lastTooltipPos) {
let tooltip = null
if (pos !== null) {
const highlight = highlightAtPos(this.view.state, pos)
if (highlight) {
tooltip = createHighlightTooltip(pos, highlight)
}
}
this.setHighlightTooltip(tooltip)
this.lastTooltipPos = pos
}
}
handleMouseMove(event: MouseEvent) {
this.setTooltipFromEvent(event)
}
startHover(event: MouseEvent, el: HTMLElement) {
const handleMouseMove = this.handleMouseMove.bind(this)
this.view.contentDOM.addEventListener('mousemove', handleMouseMove)
const handleMouseLeave = () => {
this.setHighlightTooltip(null)
this.lastTooltipPos = null
this.view.contentDOM.removeEventListener('mousemove', handleMouseMove)
el.removeEventListener('mouseleave', handleMouseLeave)
}
el.addEventListener('mouseleave', handleMouseLeave)
this.setTooltipFromEvent(event)
}
},
{
eventHandlers: {
mouseover(event) {
const el = event.target as HTMLElement
const classList = el.classList
if (
classList.contains(ADDITION_MARKER_CLASS) ||
classList.contains(DELETION_MARKER_CLASS) ||
// An empty line widget doesn't trigger a mouseover event, so detect
// an event on a line element that contains one instead
(classList.contains('cm-line') &&
el.querySelector(
`.ol-cm-empty-line-addition-marker, .ol-cm-empty-line-deletion-marker`
))
) {
this.startHover(event, el)
}
},
},
provide() {
return tooltipField
},
}
)
class EmptyLineAdditionMarkerWidget extends WidgetType {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.classList.add(
'ol-cm-empty-line-addition-marker',
ADDITION_MARKER_CLASS
)
element.style.setProperty('--hue', this.hue.toString())
return element
}
}
class EmptyLineDeletionMarkerWidget extends WidgetType {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.classList.add(
'ol-cm-empty-line-deletion-marker',
DELETION_MARKER_CLASS
)
element.style.setProperty('--hue', this.hue.toString())
element.textContent = ' '
return element
}
}
function createMarkers(highlights: Highlight[]) {
return RangeSet.of(highlights.map(highlight => highlightToMarker(highlight)))
}
function createEmptyLineHighlightMarkers(lineStatuses: LineStatuses) {
const markers: Range<Decoration>[] = []
for (const lineStatus of lineStatuses.values()) {
if (lineStatus.line.length === 0) {
const highlight = lineStatus.highlights[0]
const widget =
highlight.type === 'addition'
? new EmptyLineAdditionMarkerWidget(highlight.hue)
: new EmptyLineDeletionMarkerWidget(highlight.hue)
markers.push(
Decoration.widget({
widget,
}).range(lineStatus.line.from)
)
}
}
return RangeSet.of(markers)
}
class ChangeGutterMarker extends GutterMarker {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = 'ol-cm-changed-line-gutter'
el.style.setProperty('--hue', this.hue.toString())
return el
}
}
function createGutterMarkers(lineStatuses: LineStatuses) {
const gutterMarkers: Range<GutterMarker>[] = []
for (const lineStatus of lineStatuses.values()) {
gutterMarkers.push(
new ChangeGutterMarker(lineStatus.highlights[0].hue).range(
lineStatus.line.from
)
)
}
return RangeSet.of(gutterMarkers)
}
const lineHighlight = Decoration.line({ class: 'ol-cm-changed-line' })
function createLineHighlights(lineStatuses: LineStatuses) {
const lineHighlights: Range<Decoration>[] = []
for (const lineStatus of lineStatuses.values()) {
lineHighlights.push(lineHighlight.range(lineStatus.line.from))
}
return RangeSet.of(lineHighlights)
}
const changeLineGutterMarker = new (class extends GutterMarker {
elementClass = 'ol-cm-highlighted-line-gutter'
})()
function createGutterHighlights(lineStatuses: LineStatuses) {
const gutterMarkers: Range<GutterMarker>[] = []
for (const lineStatus of lineStatuses.values()) {
gutterMarkers.push(changeLineGutterMarker.range(lineStatus.line.from))
}
return RangeSet.of(gutterMarkers, true)
}
type HighlightDecorations = {
highlights: Highlight[]
highlightMarkers: DecorationSet
emptyLineHighlightMarkers: DecorationSet
lineHighlights: DecorationSet
gutterMarkers: RangeSet<GutterMarker>
gutterHighlights: RangeSet<GutterMarker>
}
export const highlightDecorationsField =
StateField.define<HighlightDecorations>({
create() {
return {
highlights: [],
highlightMarkers: Decoration.none,
emptyLineHighlightMarkers: Decoration.none,
lineHighlights: Decoration.none,
gutterMarkers: RangeSet.empty,
gutterHighlights: RangeSet.empty,
}
},
update(highlightDecorations, tr) {
for (const effect of tr.effects) {
if (effect.is(setHighlightsEffect)) {
const highlights = effect.value
const lineStatuses = highlightedLines(highlights, tr.state)
const highlightMarkers = createMarkers(highlights)
const emptyLineHighlightMarkers =
createEmptyLineHighlightMarkers(lineStatuses)
const lineHighlights = createLineHighlights(lineStatuses)
const gutterMarkers = createGutterMarkers(lineStatuses)
const gutterHighlights = createGutterHighlights(lineStatuses)
return {
highlights,
highlightMarkers,
emptyLineHighlightMarkers,
lineHighlights,
gutterMarkers,
gutterHighlights,
}
}
}
return highlightDecorations
},
provide: field => [
EditorView.decorations.from(field, value => value.highlightMarkers),
EditorView.decorations.from(
field,
value => value.emptyLineHighlightMarkers
),
EditorView.decorations.from(field, value => value.lineHighlights),
theme,
tooltipTheme,
highlightTooltipPlugin,
],
})
const changeGutter = gutter({
class: 'ol-cm-change-gutter',
markers: view => view.state.field(highlightDecorationsField).gutterMarkers,
renderEmptyElements: false,
})
const gutterHighlighter = gutterLineClass.from(
highlightDecorationsField,
value => value.gutterHighlights
)
export function highlights() {
return [highlightDecorationsField, changeGutter, gutterHighlighter]
}

View File

@@ -0,0 +1,71 @@
import { EditorView } from '@codemirror/view'
import { Compartment, TransactionSpec } from '@codemirror/state'
import { FontFamily, LineHeight, userStyles } from '@/shared/utils/styles'
export type Options = {
fontSize: number
fontFamily: FontFamily
lineHeight: LineHeight
}
const optionsThemeConf = new Compartment()
export const theme = (options: Options) => [
baseTheme,
optionsThemeConf.of(createThemeFromOptions(options)),
]
const createThemeFromOptions = ({
fontSize = 12,
fontFamily = 'monaco',
lineHeight = 'normal',
}: Options) => {
// Theme styles that depend on settings
const styles = userStyles({ fontSize, fontFamily, lineHeight })
return [
EditorView.editorAttributes.of({
style: Object.entries({
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
'--line-height': styles.lineHeight,
})
.map(([key, value]) => `${key}: ${value}`)
.join(';'),
}),
// Set variables for tooltips, which are outside the editor
// TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
EditorView.theme({
'.cm-tooltip': {
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
},
}),
]
}
const baseTheme = EditorView.theme({
'.cm-content': {
fontSize: 'var(--font-size)',
fontFamily: 'var(--source-font-family)',
lineHeight: 'var(--line-height)',
color: '#000',
},
'.cm-gutters': {
fontSize: 'var(--font-size)',
lineHeight: 'var(--line-height)',
},
'.cm-lineNumbers': {
fontFamily: 'var(--source-font-family)',
},
'.cm-tooltip': {
// NOTE: fontFamily is not set here, as most tooltips use the UI font
fontSize: 'var(--font-size)',
},
})
export const setOptionsTheme = (options: Options): TransactionSpec => {
return {
effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),
}
}

View File

@@ -0,0 +1,66 @@
import { useHistoryContext } from '../context/history-context'
import {
isAnyVersionMatchingSelection,
isLabel,
loadLabels,
} from '../utils/label'
import { Label } from '../services/types/label'
function useAddOrRemoveLabels() {
const {
updatesInfo,
setUpdatesInfo,
labels,
setLabels,
selection,
resetSelection,
} = useHistoryContext()
const addOrRemoveLabel = (
label: Label,
labelsHandler: (label: Label[]) => Label[]
) => {
const tempUpdates = [...updatesInfo.updates]
for (const [i, update] of tempUpdates.entries()) {
if (update.toV === label.version) {
tempUpdates[i] = {
...update,
labels: labelsHandler(update.labels),
}
break
}
}
setUpdatesInfo({ ...updatesInfo, updates: tempUpdates })
if (labels) {
const nonPseudoLabels = labels.filter(isLabel)
const processedNonPseudoLabels = labelsHandler(nonPseudoLabels)
const newLabels = loadLabels(processedNonPseudoLabels, tempUpdates)
setLabels(newLabels)
return newLabels
}
return null
}
const addUpdateLabel = (label: Label) => {
const labelHandler = (labels: Label[]) => labels.concat(label)
addOrRemoveLabel(label, labelHandler)
}
const removeUpdateLabel = (label: Label) => {
const labelHandler = (labels: Label[]) =>
labels.filter(({ id }) => id !== label.id)
const newLabels = addOrRemoveLabel(label, labelHandler)
// removing all labels from current selection should reset the selection
if (isAnyVersionMatchingSelection(newLabels, selection)) {
resetSelection()
}
}
return { addUpdateLabel, removeUpdateLabel }
}
export default useAddOrRemoveLabels

View File

@@ -0,0 +1,43 @@
import { Dispatch, SetStateAction, useCallback, useState } from 'react'
import { LoadedUpdate, Version } from '../services/types/update'
type DropdownItem = LoadedUpdate | Version
type WhichDropDownType = 'moreOptions' | 'compare' | null
export type ActiveDropdownValue = {
item: DropdownItem | null
isOpened: boolean
whichDropDown: WhichDropDownType
}
export type ActiveDropdown = {
activeDropdownItem: ActiveDropdownValue
setActiveDropdownItem: Dispatch<SetStateAction<ActiveDropdownValue>>
closeDropdownForItem: (
item: DropdownItem,
whichDropDown: WhichDropDownType
) => void
}
function useDropdownActiveItem(): ActiveDropdown {
const [activeDropdownItem, setActiveDropdownItem] =
useState<ActiveDropdownValue>({
item: null,
isOpened: false,
whichDropDown: null,
})
const closeDropdownForItem = useCallback(
(item: DropdownItem, whichDropDown: WhichDropDownType) =>
setActiveDropdownItem({ item, isOpened: false, whichDropDown }),
[setActiveDropdownItem]
)
return {
activeDropdownItem,
setActiveDropdownItem,
closeDropdownForItem,
}
}
export default useDropdownActiveItem

View File

@@ -0,0 +1,111 @@
import {
deleteJSON,
getJSON,
postJSON,
} from '../../../infrastructure/fetch-json'
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
export function fetchUpdates(
projectId: string,
before?: number,
signal?: AbortSignal
) {
const queryParams: Record<string, string> = {
min_count: BATCH_SIZE.toString(),
}
if (before != null) {
queryParams.before = before.toString()
}
const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
return getJSON<FetchUpdatesResponse>(updatesURL, { signal })
}
export function fetchLabels(projectId: string, signal?: AbortSignal) {
const labelsURL = `/project/${projectId}/labels`
return getJSON<Label[]>(labelsURL, { signal })
}
export function addLabel(
projectId: string,
body: { comment: string; version: number },
signal?: AbortSignal
) {
return postJSON(`/project/${projectId}/labels`, { body, signal })
}
export function deleteLabel(
projectId: string,
labelId: string,
signal?: AbortSignal
) {
return deleteJSON(`/project/${projectId}/labels/${labelId}`, { signal })
}
export function diffFiles(
projectId: string,
fromV: number,
toV: number,
signal?: AbortSignal
) {
const queryParams: Record<string, string> = {
from: fromV.toString(),
to: toV.toString(),
}
const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const diffUrl = `/project/${projectId}/filetree/diff?${queryParamsSerialized}`
return getJSON<{ diff: FileDiff[] }>(diffUrl, { signal })
}
export function diffDoc(
projectId: string,
fromV: number,
toV: number,
pathname: string,
signal?: AbortSignal
) {
const queryParams: Record<string, string> = {
from: fromV.toString(),
to: toV.toString(),
pathname,
}
const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const diffUrl = `/project/${projectId}/diff?${queryParamsSerialized}`
return getJSON<DocDiffResponse>(diffUrl, { signal })
}
export function restoreFile(projectId: string, selectedFile: FileRemoved) {
return postJSON<RestoreFileResponse>(`/project/${projectId}/restore_file`, {
body: {
version: selectedFile.deletedAtV,
pathname: selectedFile.pathname,
},
})
}
export function restoreFileToVersion(
projectId: string,
pathname: string,
version: number
) {
return postJSON<RestoreFileResponse>(`/project/${projectId}/revert_file`, {
body: {
version,
pathname,
},
})
}
export function restoreProjectToVersion(projectId: string, version: number) {
return postJSON(`/project/${projectId}/revert-project`, {
body: { version },
})
}

View File

@@ -0,0 +1,36 @@
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 type HighlightType = 'addition' | 'deletion'
export interface Highlight {
label: string
hue: number
range: Range
type: HighlightType
}
export type Diff = {
binary: boolean
docDiff?: {
doc: string
highlights: Highlight[]
}
}

View File

@@ -0,0 +1 @@
export type FileOperation = 'edited' | 'added' | 'renamed' | 'removed'

View File

@@ -0,0 +1,38 @@
import { FileOperation } from './file-operation'
interface File {
pathname: string
}
export interface FileWithEditable extends File {
editable: boolean
}
export type FileUnchanged = FileWithEditable
export interface FileAdded extends FileWithEditable {
operation: Extract<FileOperation, 'added'>
}
export interface FileRemoved extends FileWithEditable {
operation: Extract<FileOperation, 'removed'>
newPathname?: string
deletedAtV: number
}
export interface FileEdited extends File {
operation: Extract<FileOperation, 'edited'>
}
export interface FileRenamed extends FileWithEditable {
newPathname?: string
oldPathname?: string
operation: Extract<FileOperation, 'renamed'>
}
export type FileDiff =
| FileAdded
| FileRemoved
| FileEdited
| FileRenamed
| FileUnchanged

View File

@@ -0,0 +1,25 @@
import { Nullable } from '../../../../../../types/utils'
interface LabelBase {
id: string
created_at: string
lastUpdatedTimestamp: Nullable<number>
}
interface UpdateLabel extends LabelBase {
comment: string
version: number
user_id: string
}
export interface Label extends UpdateLabel {
user_display_name: string
}
export interface PseudoCurrentStateLabel extends LabelBase {
id: '1'
isPseudoCurrentStateLabel: true
version: Nullable<number>
}
export type LoadedLabel = Label | PseudoCurrentStateLabel

View File

@@ -0,0 +1,4 @@
export type RestoreFileResponse = {
id: string
type: 'doc' | 'file'
}

View File

@@ -0,0 +1,11 @@
import { FileDiff, FileUnchanged } from './file'
import { UpdateRange } from './update'
import { Nullable } from '../../../../../../types/utils'
export interface Selection {
updateRange: UpdateRange | null
comparing: boolean
files: FileDiff[]
selectedFile?: FileDiff
previouslySelectedPathname: Nullable<FileUnchanged['pathname']>
}

View File

@@ -0,0 +1,37 @@
import { Nullable } from '../../../../../../types/utils'
export interface User {
first_name: string
last_name: string
email: string
id: string
}
export interface Meta {
users: Nullable<User>[]
start_ts: number
end_ts: number
type?: 'external' // TODO
source?: 'git-bridge' // TODO
origin?:
| {
kind:
| 'dropbox'
| 'upload'
| 'git-bridge'
| 'github'
| 'history-resync'
| 'history-migration'
}
| {
kind: 'file-restore'
path: string
timestamp: number
version: number
}
| {
kind: 'project-restore'
timestamp: number
version: number
}
}

View File

@@ -0,0 +1,48 @@
import { Meta, User } from './shared'
import { Label } from './label'
import { Nullable } from '../../../../../../types/utils'
export type Version = number
export interface ProjectOp {
add?: { pathname: string }
rename?: { pathname: string; newPathname: string }
remove?: { pathname: string }
atV: Version
}
export interface UpdateRange {
fromV: Version
toV: Version
fromVTimestamp: number
toVTimestamp: number
}
export interface Update {
fromV: Version
toV: Version
meta: Meta
labels: Label[]
pathnames: string[]
project_ops: ProjectOp[]
}
export interface LoadedUpdateMetaUser extends User {
hue?: number
}
export type LoadedUpdateMetaUsers = Nullable<LoadedUpdateMetaUser>[]
interface LoadedUpdateMeta extends Meta {
first_in_day?: true
users: LoadedUpdateMetaUsers
}
export interface LoadedUpdate extends Update {
meta: LoadedUpdateMeta
}
export type FetchUpdatesResponse = {
updates: Update[]
nextBeforeTimestamp?: number
}

View File

@@ -0,0 +1,143 @@
import type { Nullable } from '../../../../../types/utils'
import type { FileDiff } from '../services/types/file'
import type { FileOperation } from '../services/types/file-operation'
import type { LoadedUpdate, Version } from '../services/types/update'
import type { Selection } from '../services/types/selection'
import { fileFinalPathname, isFileEditable } from './file-diff'
type FileWithOps = {
pathname: FileDiff['pathname']
editable: boolean
operation: FileOperation
}
function getFilesWithOps(
files: FileDiff[],
toV: Version,
comparing: boolean,
updateForToV: LoadedUpdate | undefined
): FileWithOps[] {
if (toV && !comparing) {
const filesWithOps: FileWithOps[] = []
if (updateForToV) {
const filesByPathname = new Map<string, FileDiff>()
for (const file of files) {
const pathname = fileFinalPathname(file)
filesByPathname.set(pathname, file)
}
const isEditable = (pathname: string) => {
const fileDiff = filesByPathname.get(pathname)
if (!fileDiff) {
return false
}
return isFileEditable(fileDiff)
}
for (const pathname of updateForToV.pathnames) {
filesWithOps.push({
pathname,
editable: isEditable(pathname),
operation: 'edited',
})
}
for (const op of updateForToV.project_ops) {
let pathAndOp: Nullable<Pick<FileWithOps, 'pathname' | 'operation'>> =
null
if (op.add) {
pathAndOp = {
pathname: op.add.pathname,
operation: 'added',
}
} else if (op.remove) {
pathAndOp = {
pathname: op.remove.pathname,
operation: 'removed',
}
} else if (op.rename) {
pathAndOp = {
pathname: op.rename.newPathname,
operation: 'renamed',
}
}
if (pathAndOp !== null) {
filesWithOps.push({
editable: isEditable(pathAndOp.pathname),
...pathAndOp,
})
}
}
}
return filesWithOps
} else {
const filesWithOps = files.reduce(
(curFilesWithOps, file) => {
if ('operation' in file) {
curFilesWithOps.push({
pathname: file.pathname,
editable: isFileEditable(file),
operation: file.operation,
})
}
return curFilesWithOps
},
<FileWithOps[]>[]
)
return filesWithOps
}
}
const orderedOpTypes: FileOperation[] = [
'edited',
'added',
'renamed',
'removed',
]
export function autoSelectFile(
files: FileDiff[],
toV: Version,
comparing: boolean,
updateForToV: LoadedUpdate | undefined,
previouslySelectedPathname: Selection['previouslySelectedPathname']
): FileDiff {
const filesWithOps = getFilesWithOps(files, toV, comparing, updateForToV)
const previouslySelectedFile = files.find(file => {
return file.pathname === previouslySelectedPathname
})
const previouslySelectedFileHasOp = filesWithOps.some(file => {
return file.pathname === previouslySelectedPathname
})
if (previouslySelectedFile && previouslySelectedFileHasOp) {
return previouslySelectedFile
}
for (const opType of orderedOpTypes) {
const fileWithMatchingOpType = filesWithOps.find(
file => file.operation === opType && file.editable
)
if (fileWithMatchingOpType) {
const fileToSelect = files.find(
file => fileFinalPathname(file) === fileWithMatchingOpType.pathname
)
if (fileToSelect) {
return fileToSelect
}
}
}
return (
previouslySelectedFile ||
files.find(file => /main\.tex$/.test(file.pathname)) ||
files.find(file => /\.tex$/.test(file.pathname)) ||
files[0]
)
}

View File

@@ -0,0 +1,22 @@
import { User } from '@/features/history/services/types/shared'
import getMeta from '@/utils/meta'
import { formatUserName } from '@/features/history/utils/history-details'
export default function displayNameForUser(
user:
| (User & {
name?: string
})
| null
) {
if (user == null) {
return 'Anonymous'
}
if (user.id === getMeta('ol-user').id) {
return 'you'
}
if (user.name != null) {
return user.name
}
return formatUserName(user)
}

View File

@@ -0,0 +1,30 @@
import type {
FileDiff,
FileRemoved,
FileRenamed,
FileWithEditable,
} 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'
}
function isFileWithEditable(fileDiff: FileDiff): fileDiff is FileWithEditable {
return 'editable' in (fileDiff as FileWithEditable)
}
export function isFileEditable(fileDiff: FileDiff) {
return isFileWithEditable(fileDiff)
? fileDiff.editable
: fileDiff.operation === 'edited'
}
export function fileFinalPathname(fileDiff: FileDiff) {
return (
(isFileRenamed(fileDiff) ? fileDiff.newPathname : null) || fileDiff.pathname
)
}

View File

@@ -0,0 +1,116 @@
import _ from 'lodash'
import type { FileDiff, FileRenamed } from '../services/types/file'
import { isFileEditable, isFileRemoved } from './file-diff'
export type FileTreeEntity = {
name?: string
type?: 'file' | 'folder'
children?: FileTreeEntity[]
} & FileDiff
export function reducePathsToTree(
currentFileTree: FileTreeEntity[],
fileObject: FileTreeEntity
) {
const filePathParts = fileObject?.pathname?.split('/') ?? ''
let currentFileTreeLocation = currentFileTree
for (let index = 0; index < filePathParts.length; index++) {
const pathPart = filePathParts[index]
const isFile = index === filePathParts.length - 1
if (isFile) {
const fileTreeEntity: FileTreeEntity = _.clone(fileObject)
fileTreeEntity.name = pathPart
fileTreeEntity.type = 'file'
currentFileTreeLocation.push(fileTreeEntity)
} else {
let fileTreeEntity: FileTreeEntity | undefined = _.find(
currentFileTreeLocation,
entity => entity.name === pathPart
)
if (fileTreeEntity === undefined) {
fileTreeEntity = {
name: pathPart,
type: 'folder',
children: <FileTreeEntity[]>[],
pathname: pathPart,
editable: false,
}
currentFileTreeLocation.push(fileTreeEntity)
}
currentFileTreeLocation = fileTreeEntity.children ?? []
}
}
return currentFileTree
}
export type HistoryDoc = {
name: string
} & FileDiff
export type HistoryFileTree = {
docs?: HistoryDoc[]
folders: HistoryFileTree[]
name: string
}
export function fileTreeDiffToFileTreeData(
fileTreeDiff: FileTreeEntity[],
currentFolderName = 'rootFolder' // default value from angular version
): HistoryFileTree {
const folders: HistoryFileTree[] = []
const docs: HistoryDoc[] = []
for (const file of fileTreeDiff) {
if (file.type === 'file') {
const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
const newDoc: HistoryDoc = {
pathname: file.pathname ?? '',
name: file.name ?? '',
deletedAtV,
editable: isFileEditable(file),
operation: 'operation' in file ? file.operation : undefined,
}
docs.push(newDoc)
} else if (file.type === 'folder') {
if (file.children) {
const folder = fileTreeDiffToFileTreeData(file.children, file.name)
folders.push(folder)
}
}
}
return {
docs,
folders,
name: currentFolderName,
}
}
// TODO: refactor the oldPathname/newPathname data
// It's an artifact from the angular version.
// Our API returns `pathname` and `newPathname` for `renamed` operation
// In the angular version, we change the key of the data:
// 1. `pathname` -> `oldPathname`
// 2. `newPathname` -> `pathname`
// 3. Delete the `newPathname` key from the object
// This is because the angular version wants to generalize the API usage
// In the operation other than the `renamed` operation, the diff API (/project/:id/diff) consumes the `pathname`
// But the `renamed` operation consumes the `newPathname` instead of the `pathname` data
//
// This behaviour can be refactored by introducing a conditional when calling the API
// i.e if `renamed` -> use `newPathname`, else -> use `pathname`
export function renamePathnameKey(file: FileRenamed): FileRenamed {
return {
oldPathname: file.pathname,
pathname: file.newPathname as string,
operation: file.operation,
editable: file.editable,
}
}

View File

@@ -0,0 +1,55 @@
import moment from 'moment/moment'
import { DocDiffChunk, Highlight } from '../services/types/doc'
import { TFunction } from 'i18next'
import displayNameForUser from './display-name-for-user'
import { getHueForUserId } from '@/shared/utils/colors'
export function highlightsFromDiffResponse(
chunks: DocDiffChunk[],
t: TFunction<'translation'> // Must be called `t` for i18next-scanner to find calls to it
) {
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: t('added_by_on', { name, date }),
range,
hue: getHueForUserId(user?.id),
})
} else if (isDeletion) {
highlights.push({
type: 'deletion',
// There doesn't seem to be a convenient way to make this translatable
label: t('deleted_by_on', { name, date }),
range,
hue: getHueForUserId(user?.id),
})
}
}
}
return { doc, highlights }
}

View File

@@ -0,0 +1,120 @@
import { User } from '../services/types/shared'
import { LoadedUpdate, ProjectOp, Version } from '../services/types/update'
import { Selection } from '../services/types/selection'
export const formatUserName = (user: User) => {
let name = [user.first_name, user.last_name]
.filter(n => n != null)
.join(' ')
.trim()
if (name === '') {
name = user.email.split('@')[0]
}
if (name == null || name === '') {
return '?'
}
return name
}
export const getProjectOpDoc = (projectOp: ProjectOp) => {
if (projectOp.rename) {
return `${projectOp.rename.pathname}${projectOp.rename.newPathname}`
}
if (projectOp.add) {
return `${projectOp.add.pathname}`
}
if (projectOp.remove) {
return `${projectOp.remove.pathname}`
}
return ''
}
export type ItemSelectionState =
| 'upperSelected'
| 'lowerSelected'
| 'withinSelected'
| 'aboveSelected'
| 'belowSelected'
| 'selected'
| null
export function isVersionSelected(
selection: Selection,
version: Version
): ItemSelectionState
// eslint-disable-next-line no-redeclare
export function isVersionSelected(
selection: Selection,
fromV: Version,
toV: Version
): ItemSelectionState
// eslint-disable-next-line no-redeclare
export function isVersionSelected(
selection: Selection,
...args: [Version] | [Version, Version]
): ItemSelectionState {
if (selection.updateRange) {
let [fromV, toV] = args
toV = toV ?? fromV
if (selection.comparing) {
if (
fromV > selection.updateRange.fromV &&
toV < selection.updateRange.toV
) {
return 'withinSelected'
}
// Condition for selectedEdge when the comparing versions are from labels list
if (fromV === toV) {
if (fromV === selection.updateRange.toV) {
return 'upperSelected'
}
if (toV === selection.updateRange.fromV) {
return 'lowerSelected'
}
}
// Comparing mode above selected condition
if (fromV >= selection.updateRange.toV) {
return 'aboveSelected'
}
// Comparing mode below selected condition
if (toV <= selection.updateRange.fromV) {
return 'belowSelected'
}
if (toV === selection.updateRange.toV) {
return 'upperSelected'
}
if (fromV === selection.updateRange.fromV) {
return 'lowerSelected'
}
} else if (toV === selection.updateRange.toV) {
// single version mode
return 'selected'
} else if (fromV >= selection.updateRange.toV) {
// Non-Comparing mode above selected condition
return 'aboveSelected'
} else if (toV <= selection.updateRange.fromV) {
// Non-Comparing mode below selected condition
return 'belowSelected'
}
}
return null
}
export const getUpdateForVersion = (version: number, updates: LoadedUpdate[]) =>
updates.find(update => update.toV === version)
export const updateRangeForUpdate = (update: LoadedUpdate) => {
const { fromV, toV, meta } = update
const fromVTimestamp = meta.end_ts
return {
fromV,
toV,
fromVTimestamp,
toVTimestamp: fromVTimestamp,
}
}

View File

@@ -0,0 +1,107 @@
import { orderBy, groupBy } from 'lodash'
import {
LoadedLabel,
Label,
PseudoCurrentStateLabel,
} from '../services/types/label'
import { Nullable } from '../../../../../types/utils'
import { Selection } from '../services/types/selection'
import { Update } from '../services/types/update'
export const isPseudoLabel = (
label: LoadedLabel
): label is PseudoCurrentStateLabel => {
return (label as PseudoCurrentStateLabel).isPseudoCurrentStateLabel === true
}
export const isLabel = (label: LoadedLabel): label is Label => {
return !isPseudoLabel(label)
}
const sortLabelsByVersionAndDate = (labels: LoadedLabel[]) => {
return orderBy(
labels,
['isPseudoCurrentStateLabel', 'version', 'created_at'],
['asc', 'desc', 'desc']
)
}
const deletePseudoCurrentStateLabelIfExistent = (labels: LoadedLabel[]) => {
if (labels.length && isPseudoLabel(labels[0])) {
const [, ...rest] = labels
return rest
}
return labels
}
const addPseudoCurrentStateLabelIfNeeded = (
labels: LoadedLabel[],
mostRecentVersion: Nullable<number>
) => {
if (!labels.length || labels[0].version !== mostRecentVersion) {
const pseudoCurrentStateLabel: PseudoCurrentStateLabel = {
id: '1',
isPseudoCurrentStateLabel: true,
version: mostRecentVersion,
created_at: new Date().toISOString(),
lastUpdatedTimestamp: null,
}
return [pseudoCurrentStateLabel, ...labels]
}
return labels
}
const addLastUpdatedTimestamp = (labels: LoadedLabel[], updates: Update[]) => {
return labels.map(label => {
const lastUpdatedTimestamp = updates.find(update =>
update.labels.find(l => l.id === label.id)
)?.meta.end_ts
return {
...label,
lastUpdatedTimestamp: lastUpdatedTimestamp || null,
}
})
}
export const loadLabels = (labels: Label[], updates: Update[]) => {
const lastUpdateToV = updates.length ? updates[0].toV : null
const sortedLabels = sortLabelsByVersionAndDate(labels)
const labelsWithoutPseudoLabel =
deletePseudoCurrentStateLabelIfExistent(sortedLabels)
const labelsWithPseudoLabelIfNeeded = addPseudoCurrentStateLabelIfNeeded(
labelsWithoutPseudoLabel,
lastUpdateToV
)
const labelsWithLastUpdatedTimestamp = addLastUpdatedTimestamp(
labelsWithPseudoLabelIfNeeded,
updates
)
return labelsWithLastUpdatedTimestamp
}
export const getVersionWithLabels = (labels: Nullable<LoadedLabel[]>) => {
let versionWithLabels: { version: number; labels: LoadedLabel[] }[] = []
if (labels) {
const groupedLabelsHash = groupBy(labels, 'version')
versionWithLabels = Object.keys(groupedLabelsHash).map(key => ({
version: parseInt(key, 10),
labels: groupedLabelsHash[key],
}))
versionWithLabels = orderBy(versionWithLabels, ['version'], ['desc'])
}
return versionWithLabels
}
export const isAnyVersionMatchingSelection = (
labels: Nullable<LoadedLabel[]>,
selection: Selection
) => {
// build an Array<number> of available versions
const versions = getVersionWithLabels(labels).map(v => v.version)
const selectedVersion = selection.updateRange?.toV
return selectedVersion && !versions.includes(selectedVersion)
}

View File

@@ -0,0 +1,19 @@
import { UpdateRange } from '../services/types/update'
export const updateRangeUnion = (
updateRange1: UpdateRange,
updateRange2: UpdateRange
) => {
return {
fromV: Math.min(updateRange1.fromV, updateRange2.fromV),
toV: Math.max(updateRange1.toV, updateRange2.toV),
fromVTimestamp: Math.min(
updateRange1.fromVTimestamp,
updateRange2.fromVTimestamp
),
toVTimestamp: Math.max(
updateRange1.toVTimestamp,
updateRange2.toVTimestamp
),
}
}

View File

@@ -0,0 +1,35 @@
import { debugConsole } from '@/utils/debugging'
export function waitFor<T>(
testFunction: () => T,
timeout: number,
pollInterval = 500
): Promise<T> {
const iterationLimit = Math.floor(timeout / pollInterval)
let iterations = 0
return new Promise<T>((resolve, reject) => {
const tryIteration = () => {
if (iterations > iterationLimit) {
const err = new Error(
`waiting too long, ${JSON.stringify({ timeout, pollInterval })}`
)
debugConsole.error(err)
reject(err)
return
}
iterations += 1
const result = testFunction()
if (result) {
resolve(result)
return
}
setTimeout(tryIteration, pollInterval)
}
tryIteration()
})
}