mirror of
https://github.com/overleaf/overleaf.git
synced 2024-10-24 21:12:38 -04:00
1182049d89
[web] tuning for readonly mirror on client GitOrigin-RevId: c0ecc8923cdec6c515da17750133632ebc430e8d
639 lines
18 KiB
TypeScript
639 lines
18 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useMemo,
|
|
useReducer,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
FC,
|
|
} from 'react'
|
|
|
|
import { mapSeries } from '../../../infrastructure/promise'
|
|
|
|
import {
|
|
syncRename,
|
|
syncDelete,
|
|
syncMove,
|
|
syncCreateEntity,
|
|
} from '../util/sync-mutation'
|
|
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
|
|
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
|
|
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
|
|
|
|
import { useProjectContext } from '../../../shared/context/project-context'
|
|
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
|
|
import { useFileTreeSelectable } from './file-tree-selectable'
|
|
|
|
import {
|
|
InvalidFilenameError,
|
|
BlockedFilenameError,
|
|
DuplicateFilenameError,
|
|
DuplicateFilenameMoveError,
|
|
} 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
|
|
}
|
|
|
|
type DroppedFiles = {
|
|
files: DroppedFile[]
|
|
targetFolderId: string
|
|
}
|
|
|
|
const FileTreeActionableContext = createContext<
|
|
| {
|
|
isDeleting: boolean
|
|
isRenaming: boolean
|
|
isCreatingFile: boolean
|
|
isCreatingFolder: boolean
|
|
isMoving: boolean
|
|
inFlight: boolean
|
|
actionedEntities: any | null
|
|
newFileCreateMode: any | null
|
|
error: any | null
|
|
canDelete: boolean
|
|
canRename: boolean
|
|
canCreate: boolean
|
|
parentFolderId: string
|
|
selectedFileName: string | null
|
|
isDuplicate: (parentFolderId: string, name: string) => boolean
|
|
startRenaming: any
|
|
finishRenaming: any
|
|
startDeleting: any
|
|
finishDeleting: any
|
|
finishMoving: any
|
|
startCreatingFile: any
|
|
startCreatingFolder: any
|
|
finishCreatingFolder: any
|
|
startCreatingDocOrFile: any
|
|
startUploadingDocOrFile: any
|
|
finishCreatingDoc: any
|
|
finishCreatingLinkedFile: any
|
|
cancel: () => void
|
|
droppedFiles: { files: File[]; targetFolderId: string } | null
|
|
setDroppedFiles: (value: DroppedFiles | null) => void
|
|
downloadPath?: string
|
|
}
|
|
| undefined
|
|
>(undefined)
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
enum ACTION_TYPES {
|
|
START_RENAME = 'START_RENAME',
|
|
START_DELETE = 'START_DELETE',
|
|
DELETING = 'DELETING',
|
|
START_CREATE_FILE = 'START_CREATE_FILE',
|
|
START_CREATE_FOLDER = 'START_CREATE_FOLDER',
|
|
CREATING_FILE = 'CREATING_FILE',
|
|
CREATING_FOLDER = 'CREATING_FOLDER',
|
|
MOVING = 'MOVING',
|
|
CANCEL = 'CANCEL',
|
|
CLEAR = 'CLEAR',
|
|
ERROR = 'ERROR',
|
|
}
|
|
/* eslint-enable no-unused-vars */
|
|
|
|
type State = {
|
|
isDeleting: boolean
|
|
isRenaming: boolean
|
|
isCreatingFile: boolean
|
|
isCreatingFolder: boolean
|
|
isMoving: boolean
|
|
inFlight: boolean
|
|
actionedEntities: any | null
|
|
newFileCreateMode: any | null
|
|
error: unknown | null
|
|
}
|
|
|
|
const defaultState: State = {
|
|
isDeleting: false,
|
|
isRenaming: false,
|
|
isCreatingFile: false,
|
|
isCreatingFolder: false,
|
|
isMoving: false,
|
|
inFlight: false,
|
|
actionedEntities: null,
|
|
newFileCreateMode: null,
|
|
error: null,
|
|
}
|
|
|
|
function fileTreeActionableReadOnlyReducer(state: State) {
|
|
return state
|
|
}
|
|
|
|
type Action =
|
|
| {
|
|
type: ACTION_TYPES.START_RENAME
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.START_DELETE
|
|
actionedEntities: any | null
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.START_CREATE_FILE
|
|
newFileCreateMode: any | null
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.START_CREATE_FOLDER
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.CREATING_FILE
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.CREATING_FOLDER
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.DELETING
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.MOVING
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.CLEAR
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.CANCEL
|
|
}
|
|
| {
|
|
type: ACTION_TYPES.ERROR
|
|
error: unknown
|
|
}
|
|
|
|
function fileTreeActionableReducer(state: State, action: Action) {
|
|
switch (action.type) {
|
|
case ACTION_TYPES.START_RENAME:
|
|
return { ...defaultState, isRenaming: true }
|
|
case ACTION_TYPES.START_DELETE:
|
|
return {
|
|
...defaultState,
|
|
isDeleting: true,
|
|
actionedEntities: action.actionedEntities,
|
|
}
|
|
case ACTION_TYPES.START_CREATE_FILE:
|
|
return {
|
|
...defaultState,
|
|
isCreatingFile: true,
|
|
newFileCreateMode: action.newFileCreateMode,
|
|
}
|
|
case ACTION_TYPES.START_CREATE_FOLDER:
|
|
return { ...defaultState, isCreatingFolder: true }
|
|
case ACTION_TYPES.CREATING_FILE:
|
|
return {
|
|
...defaultState,
|
|
isCreatingFile: true,
|
|
newFileCreateMode: state.newFileCreateMode,
|
|
inFlight: true,
|
|
}
|
|
case ACTION_TYPES.CREATING_FOLDER:
|
|
return { ...defaultState, isCreatingFolder: true, inFlight: true }
|
|
case ACTION_TYPES.DELETING:
|
|
// keep `actionedEntities` so the entities list remains displayed in the
|
|
// delete modal
|
|
return {
|
|
...defaultState,
|
|
isDeleting: true,
|
|
inFlight: true,
|
|
actionedEntities: state.actionedEntities,
|
|
}
|
|
case ACTION_TYPES.MOVING:
|
|
return {
|
|
...defaultState,
|
|
isMoving: true,
|
|
inFlight: true,
|
|
}
|
|
case ACTION_TYPES.CLEAR:
|
|
return { ...defaultState }
|
|
case ACTION_TYPES.CANCEL:
|
|
if (state.inFlight) return state
|
|
return { ...defaultState }
|
|
case ACTION_TYPES.ERROR:
|
|
return { ...state, inFlight: false, error: action.error }
|
|
default:
|
|
throw new Error(`Unknown user action type: ${(action as Action).type}`)
|
|
}
|
|
}
|
|
|
|
export const FileTreeActionableProvider: FC = ({ children }) => {
|
|
const { _id: projectId } = useProjectContext()
|
|
const { fileTreeReadOnly } = useFileTreeData()
|
|
const { indexAllReferences } = useReferencesContext()
|
|
const { fileTreeFromHistory } = useSnapshotContext()
|
|
|
|
const [state, dispatch] = useReducer(
|
|
fileTreeReadOnly
|
|
? fileTreeActionableReadOnlyReducer
|
|
: fileTreeActionableReducer,
|
|
defaultState
|
|
)
|
|
|
|
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
|
|
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
|
|
|
|
const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
|
|
|
|
const startRenaming = useCallback(() => {
|
|
dispatch({ type: ACTION_TYPES.START_RENAME })
|
|
}, [])
|
|
|
|
// update the entity with the new name immediately in the tree, but revert to
|
|
// the old name if the sync fails
|
|
const finishRenaming = useCallback(
|
|
(newName: string) => {
|
|
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
const oldName = found.entity.name
|
|
if (newName === oldName) {
|
|
return dispatch({ type: ACTION_TYPES.CLEAR })
|
|
}
|
|
|
|
const error = validateRename(fileTreeData, found, newName)
|
|
if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
|
|
dispatch({ type: ACTION_TYPES.CLEAR })
|
|
dispatchRename(selectedEntityId, newName)
|
|
return syncRename(projectId, found.type, found.entity._id, newName).catch(
|
|
error => {
|
|
dispatchRename(selectedEntityId, oldName)
|
|
// The state from this error action isn't used anywhere right now
|
|
// but we need to handle the error for linting
|
|
dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
}
|
|
)
|
|
},
|
|
[dispatchRename, fileTreeData, projectId, selectedEntityIds]
|
|
)
|
|
|
|
const isDuplicate = useCallback(
|
|
(parentFolderId: string, name: string) => {
|
|
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
|
|
},
|
|
[fileTreeData]
|
|
)
|
|
|
|
// init deletion flow (this will open the delete modal).
|
|
// A copy of the selected entities is set as `actionedEntities` so it is kept
|
|
// unchanged as the entities are deleted and the selection is updated
|
|
const startDeleting = useCallback(() => {
|
|
const actionedEntities = Array.from(selectedEntityIds).map(
|
|
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
|
|
)
|
|
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
|
|
}, [fileTreeData, selectedEntityIds])
|
|
|
|
// deletes entities in series. Tree will be updated via the socket event
|
|
const finishDeleting = useCallback(() => {
|
|
dispatch({ type: ACTION_TYPES.DELETING })
|
|
let shouldReindexReferences = false
|
|
|
|
return (
|
|
mapSeries(Array.from(selectedEntityIds), id => {
|
|
const found = findInTreeOrThrow(fileTreeData, id)
|
|
shouldReindexReferences =
|
|
shouldReindexReferences || /\.bib$/.test(found.entity.name)
|
|
return syncDelete(projectId, found.type, found.entity._id).catch(
|
|
error => {
|
|
// throw unless 404
|
|
if (error.info.statusCode !== 404) {
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
})
|
|
// @ts-ignore (TODO: improve mapSeries types)
|
|
.then(() => {
|
|
if (shouldReindexReferences) {
|
|
indexAllReferences(true)
|
|
}
|
|
dispatch({ type: ACTION_TYPES.CLEAR })
|
|
})
|
|
.catch((error: Error) => {
|
|
// set an error and allow user to retry
|
|
dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
})
|
|
)
|
|
}, [fileTreeData, projectId, selectedEntityIds, indexAllReferences])
|
|
|
|
// moves entities. Tree is updated immediately and data are sync'd after.
|
|
const finishMoving = useCallback(
|
|
(toFolderId, draggedEntityIds) => {
|
|
dispatch({ type: ACTION_TYPES.MOVING })
|
|
|
|
// find entities and filter out no-ops and nested files
|
|
const founds = Array.from(draggedEntityIds)
|
|
.map(draggedEntityId =>
|
|
findInTreeOrThrow(fileTreeData, draggedEntityId)
|
|
)
|
|
.filter(
|
|
found =>
|
|
found.parentFolderId !== toFolderId &&
|
|
!draggedEntityIds.has(found.parentFolderId)
|
|
)
|
|
|
|
// make sure all entities can be moved, return early otherwise
|
|
const isMoveToRoot = toFolderId === fileTreeData._id
|
|
const validationError = founds
|
|
.map(found =>
|
|
validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
|
|
)
|
|
.find(error => error)
|
|
if (validationError) {
|
|
return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
|
|
}
|
|
|
|
// keep track of old parent folder ids so we can revert entities if sync fails
|
|
const oldParentFolderIds: Record<string, string> = {}
|
|
let isMoveFailed = false
|
|
|
|
// dispatch moves immediately
|
|
founds.forEach(found => {
|
|
oldParentFolderIds[found.entity._id] = found.parentFolderId
|
|
dispatchMove(found.entity._id, toFolderId)
|
|
})
|
|
|
|
// sync dispatched moves after
|
|
return (
|
|
mapSeries(founds, async found => {
|
|
try {
|
|
await syncMove(projectId, found.type, found.entity._id, toFolderId)
|
|
} catch (error) {
|
|
isMoveFailed = true
|
|
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
|
|
dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
}
|
|
})
|
|
// @ts-ignore (TODO: improve mapSeries types)
|
|
.then(() => {
|
|
if (!isMoveFailed) {
|
|
dispatch({ type: ACTION_TYPES.CLEAR })
|
|
}
|
|
})
|
|
)
|
|
},
|
|
[dispatchMove, fileTreeData, projectId]
|
|
)
|
|
|
|
const startCreatingFolder = useCallback(() => {
|
|
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
|
|
}, [])
|
|
|
|
const parentFolderId = useMemo(() => {
|
|
return getSelectedParentFolderId(
|
|
fileTreeData,
|
|
selectedEntityIds,
|
|
isRootFolderSelected
|
|
)
|
|
}, [fileTreeData, selectedEntityIds, isRootFolderSelected])
|
|
|
|
// return the name of the selected file or doc if there is only one selected
|
|
const selectedFileName = useMemo(() => {
|
|
if (selectedEntityIds.size === 1) {
|
|
const [selectedEntityId] = selectedEntityIds
|
|
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
|
|
return selectedEntity?.entity?.name
|
|
}
|
|
return null
|
|
}, [fileTreeData, selectedEntityIds])
|
|
|
|
const finishCreatingEntity = useCallback(
|
|
entity => {
|
|
const error = validateCreate(fileTreeData, parentFolderId, entity)
|
|
if (error) {
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
return syncCreateEntity(projectId, parentFolderId, entity)
|
|
},
|
|
[fileTreeData, parentFolderId, projectId]
|
|
)
|
|
|
|
const finishCreatingFolder = useCallback(
|
|
name => {
|
|
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
|
|
return finishCreatingEntity({ endpoint: 'folder', name })
|
|
.then(() => {
|
|
dispatch({ type: ACTION_TYPES.CLEAR })
|
|
})
|
|
.catch(error => {
|
|
dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
})
|
|
},
|
|
[finishCreatingEntity]
|
|
)
|
|
|
|
const startCreatingFile = useCallback(newFileCreateMode => {
|
|
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
|
|
}, [])
|
|
|
|
const startCreatingDocOrFile = useCallback(() => {
|
|
startCreatingFile('doc')
|
|
}, [startCreatingFile])
|
|
|
|
const startUploadingDocOrFile = useCallback(() => {
|
|
startCreatingFile('upload')
|
|
}, [startCreatingFile])
|
|
|
|
const finishCreatingDocOrFile = useCallback(
|
|
entity => {
|
|
dispatch({ type: ACTION_TYPES.CREATING_FILE })
|
|
|
|
return finishCreatingEntity(entity)
|
|
.then(() => {
|
|
dispatch({ type: ACTION_TYPES.CLEAR })
|
|
})
|
|
.catch(error => {
|
|
dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
})
|
|
},
|
|
[finishCreatingEntity]
|
|
)
|
|
|
|
const finishCreatingDoc = useCallback(
|
|
entity => {
|
|
entity.endpoint = 'doc'
|
|
return finishCreatingDocOrFile(entity)
|
|
},
|
|
[finishCreatingDocOrFile]
|
|
)
|
|
|
|
const finishCreatingLinkedFile = useCallback(
|
|
entity => {
|
|
entity.endpoint = 'linked_file'
|
|
return finishCreatingDocOrFile(entity)
|
|
},
|
|
[finishCreatingDocOrFile]
|
|
)
|
|
|
|
const cancel = useCallback(() => {
|
|
dispatch({ type: ACTION_TYPES.CANCEL })
|
|
}, [])
|
|
|
|
// listen for `file-tree.start-creating` events
|
|
useEffect(() => {
|
|
function handleEvent(event: Event) {
|
|
dispatch({
|
|
type: ACTION_TYPES.START_CREATE_FILE,
|
|
newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
|
|
})
|
|
}
|
|
|
|
window.addEventListener('file-tree.start-creating', handleEvent)
|
|
|
|
return () => {
|
|
window.removeEventListener('file-tree.start-creating', handleEvent)
|
|
}
|
|
}, [])
|
|
|
|
// build the path for downloading a single file or doc
|
|
const downloadPath = useMemo(() => {
|
|
if (selectedEntityIds.size === 1) {
|
|
const [selectedEntityId] = selectedEntityIds
|
|
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
|
|
|
|
if (selectedEntity?.type === 'fileRef') {
|
|
if (fileTreeFromHistory) {
|
|
return `/project/${projectId}/blob/${selectedEntity.entity.hash}`
|
|
}
|
|
return `/project/${projectId}/file/${selectedEntityId}`
|
|
}
|
|
|
|
if (selectedEntity?.type === 'doc') {
|
|
return `/project/${projectId}/doc/${selectedEntityId}/download`
|
|
}
|
|
}
|
|
}, [fileTreeData, projectId, selectedEntityIds, fileTreeFromHistory])
|
|
|
|
// TODO: wrap in useMemo
|
|
const value = {
|
|
canDelete: selectedEntityIds.size > 0 && !isRootFolderSelected,
|
|
canRename: selectedEntityIds.size === 1 && !isRootFolderSelected,
|
|
canCreate: selectedEntityIds.size < 2,
|
|
...state,
|
|
parentFolderId,
|
|
selectedFileName,
|
|
isDuplicate,
|
|
startRenaming,
|
|
finishRenaming,
|
|
startDeleting,
|
|
finishDeleting,
|
|
finishMoving,
|
|
startCreatingFile,
|
|
startCreatingFolder,
|
|
finishCreatingFolder,
|
|
startCreatingDocOrFile,
|
|
startUploadingDocOrFile,
|
|
finishCreatingDoc,
|
|
finishCreatingLinkedFile,
|
|
cancel,
|
|
droppedFiles,
|
|
setDroppedFiles,
|
|
downloadPath,
|
|
}
|
|
|
|
return (
|
|
<FileTreeActionableContext.Provider value={value}>
|
|
{children}
|
|
</FileTreeActionableContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useFileTreeActionable() {
|
|
const context = useContext(FileTreeActionableContext)
|
|
|
|
if (!context) {
|
|
throw new Error(
|
|
'useFileTreeActionable is only available inside FileTreeActionableProvider'
|
|
)
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
function getSelectedParentFolderId(
|
|
fileTreeData: Folder,
|
|
selectedEntityIds: Set<string>,
|
|
isRootFolderSelected: boolean
|
|
) {
|
|
if (isRootFolderSelected) {
|
|
return fileTreeData._id
|
|
}
|
|
|
|
// we expect only one entity to be selected in that case, so we pick the first
|
|
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
if (!selectedEntityId) {
|
|
// in some cases no entities are selected. Return the root folder id then.
|
|
return fileTreeData._id
|
|
}
|
|
|
|
const found = findInTree(fileTreeData, selectedEntityId)
|
|
|
|
if (!found) {
|
|
// if the entity isn't in the tree, return the root folder id.
|
|
return fileTreeData._id
|
|
}
|
|
|
|
return found.type === 'folder' ? found.entity._id : found.parentFolderId
|
|
}
|
|
|
|
function validateCreate(
|
|
fileTreeData: Folder,
|
|
parentFolderId: string,
|
|
entity: { name: string; endpoint: string }
|
|
) {
|
|
if (!isCleanFilename(entity.name)) {
|
|
return new InvalidFilenameError()
|
|
}
|
|
|
|
if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
|
|
return new DuplicateFilenameError()
|
|
}
|
|
|
|
// check that the name of a file is allowed, if creating in the root folder
|
|
const isMoveToRoot = parentFolderId === fileTreeData._id
|
|
const isFolder = entity.endpoint === 'folder'
|
|
if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
|
|
return new BlockedFilenameError()
|
|
}
|
|
}
|
|
|
|
function validateRename(
|
|
fileTreeData: Folder,
|
|
found: { parentFolderId: string; path: string; type: string },
|
|
newName: string
|
|
) {
|
|
if (!isCleanFilename(newName)) {
|
|
return new InvalidFilenameError()
|
|
}
|
|
|
|
if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
|
|
return new DuplicateFilenameError()
|
|
}
|
|
|
|
const isTopLevel = found.path.length === 1
|
|
const isFolder = found.type === 'folder'
|
|
if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
|
|
return new BlockedFilenameError()
|
|
}
|
|
}
|
|
|
|
function validateMove(
|
|
fileTreeData: Folder,
|
|
toFolderId: string,
|
|
found: { entity: { name: string }; type: string },
|
|
isMoveToRoot: boolean
|
|
) {
|
|
if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
|
|
const error = new DuplicateFilenameMoveError()
|
|
;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
|
|
found.entity.name
|
|
return error
|
|
}
|
|
|
|
const isFolder = found.type === 'folder'
|
|
if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
|
|
return new BlockedFilenameError()
|
|
}
|
|
}
|