diff --git a/libraries/overleaf-editor-core/index.js b/libraries/overleaf-editor-core/index.js index e2c66ee7ae..df3548c2ed 100644 --- a/libraries/overleaf-editor-core/index.js +++ b/libraries/overleaf-editor-core/index.js @@ -42,6 +42,7 @@ const TrackedChangeList = require('./lib/file_data/tracked_change_list') const TrackingProps = require('./lib/file_data/tracking_props') const Range = require('./lib/range') const CommentList = require('./lib/file_data/comment_list') +const LazyStringFileData = require('./lib/file_data/lazy_string_file_data') exports.AddCommentOperation = AddCommentOperation exports.Author = Author @@ -56,6 +57,7 @@ exports.Comment = Comment exports.DeleteCommentOperation = DeleteCommentOperation exports.File = File exports.FileMap = FileMap +exports.LazyStringFileData = LazyStringFileData exports.History = History exports.Label = Label exports.AddFileOperation = AddFileOperation diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx index f52a33de9e..a0e9bcbd6f 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useRef } from 'react' import ReactDOM from 'react-dom' import { Dropdown } from 'react-bootstrap' -import { useEditorContext } from '../../../shared/context/editor-context' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeMainContext } from '../contexts/file-tree-main' import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items' function FileTreeContextMenu() { - const { permissionsLevel } = useEditorContext() + const { fileTreeReadOnly } = useFileTreeData() const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext() const toggleButtonRef = useRef(null) @@ -20,7 +20,7 @@ function FileTreeContextMenu() { } }, [contextMenuCoords]) - if (!contextMenuCoords || permissionsLevel === 'readOnly') return null + if (!contextMenuCoords || fileTreeReadOnly) return null // A11y - Move the focus to the context menu when it opens function focusContextMenu() { diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx index 75d2bf4216..7e5a0e71bb 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx @@ -2,7 +2,7 @@ import { ReactNode, useEffect, useRef } from 'react' import classNames from 'classnames' import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed' -import { useEditorContext } from '../../../../shared/context/editor-context' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeMainContext } from '../../contexts/file-tree-main' import { useDraggable } from '../../contexts/file-tree-draggable' @@ -25,16 +25,14 @@ function FileTreeItemInner({ isSelected: boolean icons?: ReactNode }) { - const { permissionsLevel } = useEditorContext() + const { fileTreeReadOnly } = useFileTreeData() const { setContextMenuCoords } = useFileTreeMainContext() const { isRenaming } = useFileTreeActionable() const { selectedEntityIds } = useFileTreeSelectable() const hasMenu = - permissionsLevel !== 'readOnly' && - isSelected && - selectedEntityIds.size === 1 + !fileTreeReadOnly && isSelected && selectedEntityIds.size === 1 const { dragRef, setIsDraggable } = useDraggable(id) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx index 321d973f4d..a769f98756 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx @@ -47,7 +47,11 @@ function FileTreeItemMenuItems() { {t('rename')} ) : null} {downloadPath ? ( - + {t('download')} ) : null} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx index 0b15ee72f8..561741e264 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx @@ -5,13 +5,13 @@ import { Button } from 'react-bootstrap' import Tooltip from '../../../shared/components/tooltip' import Icon from '../../../shared/components/icon' -import { useEditorContext } from '../../../shared/context/editor-context' import { useFileTreeActionable } from '../contexts/file-tree-actionable' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' function FileTreeToolbar() { - const { permissionsLevel } = useEditorContext() + const { fileTreeReadOnly } = useFileTreeData() - if (permissionsLevel === 'readOnly') return null + if (fileTreeReadOnly) return null return (
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index 1b0257e5b9..7fe0d7acdb 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx @@ -22,7 +22,6 @@ import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder' import { isBlockedFilename, isCleanFilename } from '../util/safe-path' import { useProjectContext } from '../../../shared/context/project-context' -import { useEditorContext } from '../../../shared/context/editor-context' import { useFileTreeData } from '../../../shared/context/file-tree-data-context' import { useFileTreeSelectable } from './file-tree-selectable' @@ -34,6 +33,7 @@ import { } from '../errors' import { Folder } from '../../../../../types/folder' import { useReferencesContext } from '@/features/ide-react/context/references-context' +import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context' type DroppedFile = File & { relativePath?: string @@ -219,11 +219,12 @@ function fileTreeActionableReducer(state: State, action: Action) { export const FileTreeActionableProvider: FC = ({ children }) => { const { _id: projectId } = useProjectContext() - const { permissionsLevel } = useEditorContext() + const { fileTreeReadOnly } = useFileTreeData() const { indexAllReferences } = useReferencesContext() + const { fileTreeFromHistory } = useSnapshotContext() const [state, dispatch] = useReducer( - permissionsLevel === 'readOnly' + fileTreeReadOnly ? fileTreeActionableReadOnlyReducer : fileTreeActionableReducer, defaultState @@ -493,6 +494,9 @@ export const FileTreeActionableProvider: FC = ({ children }) => { const selectedEntity = findInTree(fileTreeData, selectedEntityId) if (selectedEntity?.type === 'fileRef') { + if (fileTreeFromHistory) { + return `/project/${projectId}/blob/${selectedEntity.entity.hash}` + } return `/project/${projectId}/file/${selectedEntityId}` } @@ -500,7 +504,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { return `/project/${projectId}/doc/${selectedEntityId}/download` } } - }, [fileTreeData, projectId, selectedEntityIds]) + }, [fileTreeData, projectId, selectedEntityIds, fileTreeFromHistory]) // TODO: wrap in useMemo const value = { diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx index 959ca0cbfd..164611042e 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx @@ -14,7 +14,6 @@ import { import { useFileTreeActionable } from './file-tree-actionable' import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeSelectable } from '../contexts/file-tree-selectable' -import { useEditorContext } from '@/shared/context/editor-context' import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file' const DRAGGABLE_TYPE = 'ENTITY' @@ -48,8 +47,7 @@ type DropResult = { export function useDraggable(draggedEntityId: string) { const { t } = useTranslation() - const { permissionsLevel } = useEditorContext() - const { fileTreeData } = useFileTreeData() + const { fileTreeData, fileTreeReadOnly } = useFileTreeData() const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable() const { finishMoving } = useFileTreeActionable() @@ -73,7 +71,7 @@ export function useDraggable(draggedEntityId: string) { } }, canDrag() { - return permissionsLevel !== 'readOnly' && isDraggable + return !fileTreeReadOnly && isDraggable }, end(item: DragObject, monitor: DragSourceMonitor) { if (monitor.didDrop()) { diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx index 593d51c6e1..8ff7e87d61 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx @@ -10,6 +10,7 @@ import { pathInFolder, } from '@/features/file-tree/util/path' import { PreviewPath } from '../../../../../types/preview-path' +import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context' type FileTreePathContextValue = { pathInFolder: (id: string) => string | null @@ -24,6 +25,7 @@ export const FileTreePathContext = createContext< export const FileTreePathProvider: FC = ({ children }) => { const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData() + const { fileTreeFromHistory } = useSnapshotContext() const projectId = getMeta('ol-project_id') const pathInFileTree = useCallback( @@ -37,8 +39,9 @@ export const FileTreePathProvider: FC = ({ children }) => { ) const previewByPathInFileTree = useCallback( - (path: string) => previewByPath(fileTreeData, projectId, path), - [fileTreeData, projectId] + (path: string) => + previewByPath(fileTreeData, projectId, path, fileTreeFromHistory), + [fileTreeData, projectId, fileTreeFromHistory] ) const dirnameInFileTree = useCallback( diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx index 7c3d532717..e8658ed918 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx @@ -13,7 +13,6 @@ import _ from 'lodash' import { findInTree } from '../util/find-in-tree' import { useFileTreeData } from '../../../shared/context/file-tree-data-context' import { useProjectContext } from '../../../shared/context/project-context' -import { useEditorContext } from '../../../shared/context/editor-context' import { useLayoutContext } from '../../../shared/context/layout-context' import usePersistedState from '../../../shared/hooks/use-persisted-state' import usePreviousValue from '../../../shared/hooks/use-previous-value' @@ -123,19 +122,19 @@ export const FileTreeSelectableProvider: FC<{ onSelect: (value: FindResult[]) => void }> = ({ onSelect, children }) => { const { _id: projectId, rootDocId } = useProjectContext() - const { permissionsLevel } = useEditorContext() const [initialSelectedEntityId] = usePersistedState( `doc.open_id.${projectId}`, rootDocId ) - const { fileTreeData, setSelectedEntities } = useFileTreeData() + const { fileTreeData, setSelectedEntities, fileTreeReadOnly } = + useFileTreeData() const [isRootFolderSelected, setIsRootFolderSelected] = useState(false) const [selectedEntityIds, dispatch] = useReducer( - permissionsLevel === 'readOnly' + fileTreeReadOnly ? fileTreeSelectableReadOnlyReducer : fileTreeSelectableReadWriteReducer, null, diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts index 54adda0b76..b6197ce5a9 100644 --- a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts +++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts @@ -5,6 +5,7 @@ import { useFileTreeData } from '../../../shared/context/file-tree-data-context' import { useFileTreeSelectable } from '../contexts/file-tree-selectable' import { findInTree, findInTreeOrThrow } from '../util/find-in-tree' import { useIdeContext } from '@/shared/context/ide-context' +import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context' export function useFileTreeSocketListener(onDelete: (entity: any) => void) { const user = useUserContext() @@ -20,6 +21,7 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { const { selectedEntityIds, selectedEntityParentIds, select, unselect } = useFileTreeSelectable() const { socket } = useIdeContext() + const { fileTreeFromHistory } = useSnapshotContext() const selectEntityIfCreatedByUser = useCallback( // hack to automatically re-open refreshed linked files @@ -38,6 +40,7 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { ) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchRename(entityId: string, name: string) { dispatchRename(entityId, name) } @@ -46,9 +49,10 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { if (socket) socket.removeListener('reciveEntityRename', handleDispatchRename) } - }, [socket, dispatchRename]) + }, [socket, dispatchRename, fileTreeFromHistory]) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchDelete(entityId: string) { const entity = findInTree(fileTreeData, entityId) unselect(entityId) @@ -82,9 +86,11 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { selectedEntityIds, selectedEntityParentIds, onDelete, + fileTreeFromHistory, ]) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchMove(entityId: string, toFolderId: string) { dispatchMove(entityId, toFolderId) } @@ -92,9 +98,10 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { return () => { if (socket) socket.removeListener('reciveEntityMove', handleDispatchMove) } - }, [socket, dispatchMove]) + }, [socket, dispatchMove, fileTreeFromHistory]) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchCreateFolder(parentFolderId: string, folder: any) { dispatchCreateFolder(parentFolderId, folder) } @@ -103,9 +110,10 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { if (socket) socket.removeListener('reciveNewFolder', handleDispatchCreateFolder) } - }, [socket, dispatchCreateFolder]) + }, [socket, dispatchCreateFolder, fileTreeFromHistory]) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchCreateDoc( parentFolderId: string, doc: any, @@ -117,9 +125,10 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { return () => { if (socket) socket.removeListener('reciveNewDoc', handleDispatchCreateDoc) } - }, [socket, dispatchCreateDoc]) + }, [socket, dispatchCreateDoc, fileTreeFromHistory]) useEffect(() => { + if (fileTreeFromHistory) return function handleDispatchCreateFile( parentFolderId: string, file: any, @@ -137,5 +146,10 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { if (socket) socket.removeListener('reciveNewFile', handleDispatchCreateFile) } - }, [socket, dispatchCreateFile, selectEntityIfCreatedByUser]) + }, [ + socket, + dispatchCreateFile, + selectEntityIfCreatedByUser, + fileTreeFromHistory, + ]) } diff --git a/services/web/frontend/js/features/file-tree/util/path.ts b/services/web/frontend/js/features/file-tree/util/path.ts index 2205785bd9..0a63240a33 100644 --- a/services/web/frontend/js/features/file-tree/util/path.ts +++ b/services/web/frontend/js/features/file-tree/util/path.ts @@ -106,7 +106,8 @@ export function findEntityByPath( export function previewByPath( folder: Folder, projectId: string, - path: string + path: string, + fileTreeFromHistory: boolean ): PreviewPath | null { for (const suffix of [ '', @@ -121,10 +122,12 @@ export function previewByPath( ]) { const result = findEntityByPath(folder, path + suffix) - if (result) { - const { name, _id: id } = result.entity + if (result?.type === 'fileRef') { + const { name, _id: id, hash } = result.entity return { - url: `/project/${projectId}/file/${id}`, + url: fileTreeFromHistory + ? `/project/${projectId}/blob/${hash}` + : `/project/${projectId}/file/${id}`, extension: name.slice(name.lastIndexOf('.') + 1), } } diff --git a/services/web/frontend/js/features/file-view/components/file-view-header.tsx b/services/web/frontend/js/features/file-view/components/file-view-header.tsx index f6bcc49ab8..0e0a8ab4ea 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-header.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-header.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next' import Icon from '../../../shared/components/icon' import { formatTime, relativeDate } from '../../utils/format-date' -import { useEditorContext } from '../../../shared/context/editor-context' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useProjectContext } from '../../../shared/context/project-context' import { Nullable } from '../../../../../types/utils' @@ -49,7 +49,7 @@ type FileViewHeaderProps = { export default function FileViewHeader({ file }: FileViewHeaderProps) { const { _id: projectId } = useProjectContext() - const { permissionsLevel } = useEditorContext() + const { fileTreeReadOnly } = useFileTreeData() const { fileTreeFromHistory } = useSnapshotContext() const { t } = useTranslation() @@ -85,7 +85,7 @@ export default function FileViewHeader({ file }: FileViewHeaderProps) { tprFileViewInfo.map(({ import: { TPRFileViewInfo }, path }) => ( ))} - {file.linkedFileData && permissionsLevel !== 'readOnly' && ( + {file.linkedFileData && !fileTreeReadOnly && ( )}   diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.tsx b/services/web/frontend/js/shared/context/file-tree-data-context.tsx index 8186b79e70..5475c234c1 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.tsx +++ b/services/web/frontend/js/shared/context/file-tree-data-context.tsx @@ -38,6 +38,7 @@ const FileTreeDataContext = createContext< // by the file tree fileTreeData: Folder fileCount: { value: number; status: string; limit: number } | number + fileTreeReadOnly: boolean hasFolders: boolean selectedEntities: FindResult[] setSelectedEntities: (selectedEntities: FindResult[]) => void @@ -179,8 +180,11 @@ export const FileTreeDataProvider: FC = ({ children }) => { const [project] = useScopeValue('project') const [openDocId] = useScopeValue('editor.open_doc_id') const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name') + const [permissionsLevel] = useScopeValue('permissionsLevel') const { fileTreeFromHistory, snapshot, snapshotVersion } = useSnapshotContext() + const fileTreeReadOnly = + permissionsLevel === 'readOnly' || fileTreeFromHistory const [rootFolder, setRootFolder] = useState(project?.rootFolder) @@ -288,6 +292,7 @@ export const FileTreeDataProvider: FC = ({ children }) => { dispatchRename, fileCount, fileTreeData, + fileTreeReadOnly, hasFolders: fileTreeData?.folders.length > 0, selectedEntities, setSelectedEntities, @@ -302,6 +307,7 @@ export const FileTreeDataProvider: FC = ({ children }) => { dispatchRename, fileCount, fileTreeData, + fileTreeReadOnly, selectedEntities, setSelectedEntities, docs, diff --git a/services/web/test/frontend/features/file-tree/util/path.test.ts b/services/web/test/frontend/features/file-tree/util/path.test.ts index cfdade6f45..8b52f176e3 100644 --- a/services/web/test/frontend/features/file-tree/util/path.test.ts +++ b/services/web/test/frontend/features/file-tree/util/path.test.ts @@ -151,12 +151,25 @@ describe('Path utils', function () { const preview = previewByPath( rootFolder, 'test-project-id', - 'test-folder/example.png' + 'test-folder/example.png', + false ) expect(preview).to.deep.equal({ url: '/project/test-project-id/file/test-file-in-folder', extension: 'png', }) }) + it('returns handles history file-tree', function () { + const preview = previewByPath( + rootFolder, + 'test-project-id', + 'test-folder/example.png', + true + ) + expect(preview).to.deep.equal({ + url: '/project/test-project-id/blob/42', + extension: 'png', + }) + }) }) })