| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- 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 { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
- import { FileTreeEntity } from '@ol-types/file-tree-entity'
- 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: FileTreeEntity[] | null
- newFileCreateMode: any | null
- error: any | null
- canDelete: boolean
- canBulkDelete: boolean
- canRename: boolean
- canCreate: boolean
- parentFolderId: string
- selectedFileName: string | null | undefined
- 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: FileTreeEntity[] | 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: FileTreeEntity[] | 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<React.PropsWithChildren> = ({
- children,
- }) => {
- const { projectId } = useProjectContext()
- const { fileTreeReadOnly } = useFileTreeData()
- const { indexAllReferences } = useReferencesContext()
- const { write } = usePermissionsContext()
- 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: string, draggedEntityIds: Set<string>) => {
- 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: any) => {
- const error = validateCreate(fileTreeData, parentFolderId, entity)
- if (error) {
- return Promise.reject(error)
- }
- return syncCreateEntity(projectId, parentFolderId, entity)
- },
- [fileTreeData, parentFolderId, projectId]
- )
- const finishCreatingFolder = useCallback(
- (name: any) => {
- 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: any) => {
- dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
- }, [])
- const startCreatingDocOrFile = useCallback(() => {
- startCreatingFile('doc')
- }, [startCreatingFile])
- const startUploadingDocOrFile = useCallback(() => {
- startCreatingFile('upload')
- }, [startCreatingFile])
- const finishCreatingDocOrFile = useCallback(
- (entity: any) => {
- dispatch({ type: ACTION_TYPES.CREATING_FILE })
- return finishCreatingEntity(entity)
- .then(docOrFile => {
- dispatch({ type: ACTION_TYPES.CLEAR })
- return docOrFile
- })
- .catch(error => {
- dispatch({ type: ACTION_TYPES.ERROR, error })
- })
- },
- [finishCreatingEntity]
- )
- const finishCreatingDoc = useCallback(
- (entity: any) => {
- entity.endpoint = 'doc'
- return finishCreatingDocOrFile(entity)
- },
- [finishCreatingDocOrFile]
- )
- const finishCreatingLinkedFile = useCallback(
- (entity: any) => {
- 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') {
- return `/project/${projectId}/blob/${selectedEntity.entity.hash}`
- }
- if (selectedEntity?.type === 'doc') {
- return `/project/${projectId}/doc/${selectedEntityId}/download`
- }
- }
- }, [fileTreeData, projectId, selectedEntityIds])
- const value = useMemo(
- () => ({
- canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
- canBulkDelete:
- write && selectedEntityIds.size > 1 && !isRootFolderSelected,
- canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected,
- canCreate: write && selectedEntityIds.size < 2,
- ...state,
- parentFolderId,
- selectedFileName,
- isDuplicate,
- startRenaming,
- finishRenaming,
- startDeleting,
- finishDeleting,
- finishMoving,
- startCreatingFile,
- startCreatingFolder,
- finishCreatingFolder,
- startCreatingDocOrFile,
- startUploadingDocOrFile,
- finishCreatingDoc,
- finishCreatingLinkedFile,
- cancel,
- droppedFiles,
- setDroppedFiles,
- downloadPath,
- }),
- [
- cancel,
- downloadPath,
- droppedFiles,
- finishCreatingDoc,
- finishCreatingFolder,
- finishCreatingLinkedFile,
- finishDeleting,
- finishMoving,
- finishRenaming,
- isDuplicate,
- isRootFolderSelected,
- parentFolderId,
- selectedFileName,
- selectedEntityIds.size,
- startCreatingDocOrFile,
- startCreatingFile,
- startCreatingFolder,
- startDeleting,
- startRenaming,
- startUploadingDocOrFile,
- state,
- write,
- ]
- )
- 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()
- }
- }
|