file-tree-actionable.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import {
  2. createContext,
  3. useCallback,
  4. useMemo,
  5. useReducer,
  6. useContext,
  7. useEffect,
  8. useState,
  9. FC,
  10. } from 'react'
  11. import { mapSeries } from '../../../infrastructure/promise'
  12. import {
  13. syncRename,
  14. syncDelete,
  15. syncMove,
  16. syncCreateEntity,
  17. NewDocEntity,
  18. NewLinkedFileEntity,
  19. NewEntity,
  20. } from '../util/sync-mutation'
  21. import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
  22. import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
  23. import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
  24. import { useProjectContext } from '../../../shared/context/project-context'
  25. import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
  26. import { useFileTreeSelectable } from './file-tree-selectable'
  27. import {
  28. InvalidFilenameError,
  29. BlockedFilenameError,
  30. DuplicateFilenameError,
  31. DuplicateFilenameMoveError,
  32. } from '../errors'
  33. import { Folder } from '../../../../../types/folder'
  34. import { useReferencesContext } from '@/features/ide-react/context/references-context'
  35. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  36. import { FileTreeEntity } from '@ol-types/file-tree-entity'
  37. import { Doc } from '@ol-types/doc'
  38. type DroppedFile = File & {
  39. relativePath?: string
  40. }
  41. type DroppedFiles = {
  42. files: DroppedFile[]
  43. targetFolderId: string
  44. }
  45. const FileTreeActionableContext = createContext<
  46. | {
  47. isDeleting: boolean
  48. isRenaming: boolean
  49. isCreatingFile: boolean
  50. isCreatingFolder: boolean
  51. isMoving: boolean
  52. inFlight: boolean
  53. actionedEntities: FileTreeEntity[] | null
  54. newFileCreateMode: any | null
  55. error: any | null
  56. canDelete: boolean
  57. canBulkDelete: boolean
  58. canRename: boolean
  59. canCreate: boolean
  60. parentFolderId: string
  61. selectedFileName: string | null | undefined
  62. isDuplicate: (parentFolderId: string, name: string) => boolean
  63. startRenaming: any
  64. finishRenaming: any
  65. startDeleting: any
  66. finishDeleting: any
  67. finishMoving: any
  68. startCreatingFile: any
  69. startCreatingFolder: any
  70. finishCreatingFolder: any
  71. startCreatingDocOrFile: any
  72. startUploadingDocOrFile: any
  73. finishCreatingDoc: (
  74. entity: Omit<NewDocEntity, 'endpoint'>
  75. ) => Promise<Doc | undefined>
  76. finishCreatingLinkedFile: (
  77. entity: Omit<NewLinkedFileEntity, 'endpoint'>
  78. ) => Promise<{ new_file_id: string } | undefined>
  79. cancel: () => void
  80. droppedFiles: { files: File[]; targetFolderId: string } | null
  81. setDroppedFiles: (value: DroppedFiles | null) => void
  82. downloadPath?: string
  83. }
  84. | undefined
  85. >(undefined)
  86. /* eslint-disable no-unused-vars */
  87. enum ACTION_TYPES {
  88. START_RENAME = 'START_RENAME',
  89. START_DELETE = 'START_DELETE',
  90. DELETING = 'DELETING',
  91. START_CREATE_FILE = 'START_CREATE_FILE',
  92. START_CREATE_FOLDER = 'START_CREATE_FOLDER',
  93. CREATING_FILE = 'CREATING_FILE',
  94. CREATING_FOLDER = 'CREATING_FOLDER',
  95. MOVING = 'MOVING',
  96. CANCEL = 'CANCEL',
  97. CLEAR = 'CLEAR',
  98. ERROR = 'ERROR',
  99. }
  100. /* eslint-enable no-unused-vars */
  101. type State = {
  102. isDeleting: boolean
  103. isRenaming: boolean
  104. isCreatingFile: boolean
  105. isCreatingFolder: boolean
  106. isMoving: boolean
  107. inFlight: boolean
  108. actionedEntities: FileTreeEntity[] | null
  109. newFileCreateMode: any | null
  110. error: unknown | null
  111. }
  112. const defaultState: State = {
  113. isDeleting: false,
  114. isRenaming: false,
  115. isCreatingFile: false,
  116. isCreatingFolder: false,
  117. isMoving: false,
  118. inFlight: false,
  119. actionedEntities: null,
  120. newFileCreateMode: null,
  121. error: null,
  122. }
  123. function fileTreeActionableReadOnlyReducer(state: State) {
  124. return state
  125. }
  126. type Action =
  127. | {
  128. type: ACTION_TYPES.START_RENAME
  129. }
  130. | {
  131. type: ACTION_TYPES.START_DELETE
  132. actionedEntities: FileTreeEntity[] | null
  133. }
  134. | {
  135. type: ACTION_TYPES.START_CREATE_FILE
  136. newFileCreateMode: any | null
  137. }
  138. | {
  139. type: ACTION_TYPES.START_CREATE_FOLDER
  140. }
  141. | {
  142. type: ACTION_TYPES.CREATING_FILE
  143. }
  144. | {
  145. type: ACTION_TYPES.CREATING_FOLDER
  146. }
  147. | {
  148. type: ACTION_TYPES.DELETING
  149. }
  150. | {
  151. type: ACTION_TYPES.MOVING
  152. }
  153. | {
  154. type: ACTION_TYPES.CLEAR
  155. }
  156. | {
  157. type: ACTION_TYPES.CANCEL
  158. }
  159. | {
  160. type: ACTION_TYPES.ERROR
  161. error: unknown
  162. }
  163. function fileTreeActionableReducer(state: State, action: Action) {
  164. switch (action.type) {
  165. case ACTION_TYPES.START_RENAME:
  166. return { ...defaultState, isRenaming: true }
  167. case ACTION_TYPES.START_DELETE:
  168. return {
  169. ...defaultState,
  170. isDeleting: true,
  171. actionedEntities: action.actionedEntities,
  172. }
  173. case ACTION_TYPES.START_CREATE_FILE:
  174. return {
  175. ...defaultState,
  176. isCreatingFile: true,
  177. newFileCreateMode: action.newFileCreateMode,
  178. }
  179. case ACTION_TYPES.START_CREATE_FOLDER:
  180. return { ...defaultState, isCreatingFolder: true }
  181. case ACTION_TYPES.CREATING_FILE:
  182. return {
  183. ...defaultState,
  184. isCreatingFile: true,
  185. newFileCreateMode: state.newFileCreateMode,
  186. inFlight: true,
  187. }
  188. case ACTION_TYPES.CREATING_FOLDER:
  189. return { ...defaultState, isCreatingFolder: true, inFlight: true }
  190. case ACTION_TYPES.DELETING:
  191. // keep `actionedEntities` so the entities list remains displayed in the
  192. // delete modal
  193. return {
  194. ...defaultState,
  195. isDeleting: true,
  196. inFlight: true,
  197. actionedEntities: state.actionedEntities,
  198. }
  199. case ACTION_TYPES.MOVING:
  200. return {
  201. ...defaultState,
  202. isMoving: true,
  203. inFlight: true,
  204. }
  205. case ACTION_TYPES.CLEAR:
  206. return { ...defaultState }
  207. case ACTION_TYPES.CANCEL:
  208. if (state.inFlight) return state
  209. return { ...defaultState }
  210. case ACTION_TYPES.ERROR:
  211. return { ...state, inFlight: false, error: action.error }
  212. default:
  213. throw new Error(`Unknown user action type: ${(action as Action).type}`)
  214. }
  215. }
  216. export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
  217. children,
  218. }) => {
  219. const { projectId } = useProjectContext()
  220. const { fileTreeReadOnly } = useFileTreeData()
  221. const { indexAllReferences } = useReferencesContext()
  222. const { write } = usePermissionsContext()
  223. const [state, dispatch] = useReducer(
  224. fileTreeReadOnly
  225. ? fileTreeActionableReadOnlyReducer
  226. : fileTreeActionableReducer,
  227. defaultState
  228. )
  229. const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
  230. const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
  231. const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
  232. const startRenaming = useCallback(() => {
  233. dispatch({ type: ACTION_TYPES.START_RENAME })
  234. }, [])
  235. // update the entity with the new name immediately in the tree, but revert to
  236. // the old name if the sync fails
  237. const finishRenaming = useCallback(
  238. (newName: string) => {
  239. const selectedEntityId = Array.from(selectedEntityIds)[0]
  240. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  241. const oldName = found.entity.name
  242. if (newName === oldName) {
  243. return dispatch({ type: ACTION_TYPES.CLEAR })
  244. }
  245. const error = validateRename(fileTreeData, found, newName)
  246. if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
  247. dispatch({ type: ACTION_TYPES.CLEAR })
  248. dispatchRename(selectedEntityId, newName)
  249. return syncRename(projectId, found.type, found.entity._id, newName).catch(
  250. error => {
  251. dispatchRename(selectedEntityId, oldName)
  252. // The state from this error action isn't used anywhere right now
  253. // but we need to handle the error for linting
  254. dispatch({ type: ACTION_TYPES.ERROR, error })
  255. }
  256. )
  257. },
  258. [dispatchRename, fileTreeData, projectId, selectedEntityIds]
  259. )
  260. const isDuplicate = useCallback(
  261. (parentFolderId: string, name: string) => {
  262. return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
  263. },
  264. [fileTreeData]
  265. )
  266. // init deletion flow (this will open the delete modal).
  267. // A copy of the selected entities is set as `actionedEntities` so it is kept
  268. // unchanged as the entities are deleted and the selection is updated
  269. const startDeleting = useCallback(() => {
  270. const actionedEntities = Array.from(selectedEntityIds).map(
  271. entityId => findInTreeOrThrow(fileTreeData, entityId).entity
  272. )
  273. dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  274. }, [fileTreeData, selectedEntityIds])
  275. // deletes entities in series. Tree will be updated via the socket event
  276. const finishDeleting = useCallback(() => {
  277. dispatch({ type: ACTION_TYPES.DELETING })
  278. let shouldReindexReferences = false
  279. return (
  280. mapSeries(Array.from(selectedEntityIds), id => {
  281. const found = findInTreeOrThrow(fileTreeData, id)
  282. shouldReindexReferences =
  283. shouldReindexReferences || /\.bib$/.test(found.entity.name)
  284. return syncDelete(projectId, found.type, found.entity._id).catch(
  285. error => {
  286. // throw unless 404
  287. if (error.info.statusCode !== 404) {
  288. throw error
  289. }
  290. }
  291. )
  292. })
  293. // @ts-ignore (TODO: improve mapSeries types)
  294. .then(() => {
  295. if (shouldReindexReferences) {
  296. indexAllReferences(true)
  297. }
  298. dispatch({ type: ACTION_TYPES.CLEAR })
  299. })
  300. .catch((error: Error) => {
  301. // set an error and allow user to retry
  302. dispatch({ type: ACTION_TYPES.ERROR, error })
  303. })
  304. )
  305. }, [fileTreeData, projectId, selectedEntityIds, indexAllReferences])
  306. // moves entities. Tree is updated immediately and data are sync'd after.
  307. const finishMoving = useCallback(
  308. (toFolderId: string, draggedEntityIds: Set<string>) => {
  309. dispatch({ type: ACTION_TYPES.MOVING })
  310. // find entities and filter out no-ops and nested files
  311. const founds = Array.from(draggedEntityIds)
  312. .map(draggedEntityId =>
  313. findInTreeOrThrow(fileTreeData, draggedEntityId)
  314. )
  315. .filter(
  316. found =>
  317. found.parentFolderId !== toFolderId &&
  318. !draggedEntityIds.has(found.parentFolderId)
  319. )
  320. // make sure all entities can be moved, return early otherwise
  321. const isMoveToRoot = toFolderId === fileTreeData._id
  322. const validationError = founds
  323. .map(found =>
  324. validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
  325. )
  326. .find(error => error)
  327. if (validationError) {
  328. return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
  329. }
  330. // keep track of old parent folder ids so we can revert entities if sync fails
  331. const oldParentFolderIds: Record<string, string> = {}
  332. let isMoveFailed = false
  333. // dispatch moves immediately
  334. founds.forEach(found => {
  335. oldParentFolderIds[found.entity._id] = found.parentFolderId
  336. dispatchMove(found.entity._id, toFolderId)
  337. })
  338. // sync dispatched moves after
  339. return (
  340. mapSeries(founds, async found => {
  341. try {
  342. await syncMove(projectId, found.type, found.entity._id, toFolderId)
  343. } catch (error) {
  344. isMoveFailed = true
  345. dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
  346. dispatch({ type: ACTION_TYPES.ERROR, error })
  347. }
  348. })
  349. // @ts-ignore (TODO: improve mapSeries types)
  350. .then(() => {
  351. if (!isMoveFailed) {
  352. dispatch({ type: ACTION_TYPES.CLEAR })
  353. }
  354. })
  355. )
  356. },
  357. [dispatchMove, fileTreeData, projectId]
  358. )
  359. const startCreatingFolder = useCallback(() => {
  360. dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  361. }, [])
  362. const parentFolderId = useMemo(() => {
  363. return getSelectedParentFolderId(
  364. fileTreeData,
  365. selectedEntityIds,
  366. isRootFolderSelected
  367. )
  368. }, [fileTreeData, selectedEntityIds, isRootFolderSelected])
  369. // return the name of the selected file or doc if there is only one selected
  370. const selectedFileName = useMemo(() => {
  371. if (selectedEntityIds.size === 1) {
  372. const [selectedEntityId] = selectedEntityIds
  373. const selectedEntity = findInTree(fileTreeData, selectedEntityId)
  374. return selectedEntity?.entity?.name
  375. }
  376. return null
  377. }, [fileTreeData, selectedEntityIds])
  378. const finishCreatingEntity = useCallback(
  379. (entity: NewEntity) => {
  380. const error = validateCreate(fileTreeData, parentFolderId, entity)
  381. if (error) {
  382. return Promise.reject(error)
  383. }
  384. return syncCreateEntity(projectId, parentFolderId, entity)
  385. },
  386. [fileTreeData, parentFolderId, projectId]
  387. )
  388. const finishCreatingFolder = useCallback(
  389. (name: string) => {
  390. dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
  391. return finishCreatingEntity({ endpoint: 'folder', name })
  392. .then(() => {
  393. dispatch({ type: ACTION_TYPES.CLEAR })
  394. })
  395. .catch(error => {
  396. dispatch({ type: ACTION_TYPES.ERROR, error })
  397. })
  398. },
  399. [finishCreatingEntity]
  400. )
  401. const startCreatingFile = useCallback((newFileCreateMode: any) => {
  402. dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
  403. }, [])
  404. const startCreatingDocOrFile = useCallback(() => {
  405. startCreatingFile('doc')
  406. }, [startCreatingFile])
  407. const startUploadingDocOrFile = useCallback(() => {
  408. startCreatingFile('upload')
  409. }, [startCreatingFile])
  410. type FinishCreatingDocOrFileReturn<T> = T extends NewDocEntity
  411. ? Promise<Doc | undefined>
  412. : T extends NewLinkedFileEntity
  413. ? Promise<{ new_file_id: string } | undefined>
  414. : never
  415. const finishCreatingDocOrFile = useCallback(
  416. <T extends NewDocEntity | NewLinkedFileEntity>(
  417. entity: T
  418. ): FinishCreatingDocOrFileReturn<T> => {
  419. dispatch({ type: ACTION_TYPES.CREATING_FILE })
  420. return finishCreatingEntity(entity)
  421. .then(docOrFile => {
  422. dispatch({ type: ACTION_TYPES.CLEAR })
  423. return docOrFile
  424. })
  425. .catch(error => {
  426. dispatch({ type: ACTION_TYPES.ERROR, error })
  427. }) as FinishCreatingDocOrFileReturn<T>
  428. },
  429. [finishCreatingEntity]
  430. )
  431. const finishCreatingDoc = useCallback(
  432. (entity: Omit<NewDocEntity, 'endpoint'>) => {
  433. return finishCreatingDocOrFile({ ...entity, endpoint: 'doc' })
  434. },
  435. [finishCreatingDocOrFile]
  436. )
  437. const finishCreatingLinkedFile = useCallback(
  438. (entity: Omit<NewLinkedFileEntity, 'endpoint'>) => {
  439. return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' })
  440. },
  441. [finishCreatingDocOrFile]
  442. )
  443. const cancel = useCallback(() => {
  444. dispatch({ type: ACTION_TYPES.CANCEL })
  445. }, [])
  446. // listen for `file-tree.start-creating` events
  447. useEffect(() => {
  448. function handleEvent(event: Event) {
  449. dispatch({
  450. type: ACTION_TYPES.START_CREATE_FILE,
  451. newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
  452. })
  453. }
  454. window.addEventListener('file-tree.start-creating', handleEvent)
  455. return () => {
  456. window.removeEventListener('file-tree.start-creating', handleEvent)
  457. }
  458. }, [])
  459. // build the path for downloading a single file or doc
  460. const downloadPath = useMemo(() => {
  461. if (selectedEntityIds.size === 1) {
  462. const [selectedEntityId] = selectedEntityIds
  463. const selectedEntity = findInTree(fileTreeData, selectedEntityId)
  464. if (selectedEntity?.type === 'fileRef') {
  465. return `/project/${projectId}/blob/${selectedEntity.entity.hash}`
  466. }
  467. if (selectedEntity?.type === 'doc') {
  468. return `/project/${projectId}/doc/${selectedEntityId}/download`
  469. }
  470. }
  471. }, [fileTreeData, projectId, selectedEntityIds])
  472. const value = useMemo(
  473. () => ({
  474. canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
  475. canBulkDelete:
  476. write && selectedEntityIds.size > 1 && !isRootFolderSelected,
  477. canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected,
  478. canCreate: write && selectedEntityIds.size < 2,
  479. ...state,
  480. parentFolderId,
  481. selectedFileName,
  482. isDuplicate,
  483. startRenaming,
  484. finishRenaming,
  485. startDeleting,
  486. finishDeleting,
  487. finishMoving,
  488. startCreatingFile,
  489. startCreatingFolder,
  490. finishCreatingFolder,
  491. startCreatingDocOrFile,
  492. startUploadingDocOrFile,
  493. finishCreatingDoc,
  494. finishCreatingLinkedFile,
  495. cancel,
  496. droppedFiles,
  497. setDroppedFiles,
  498. downloadPath,
  499. }),
  500. [
  501. cancel,
  502. downloadPath,
  503. droppedFiles,
  504. finishCreatingDoc,
  505. finishCreatingFolder,
  506. finishCreatingLinkedFile,
  507. finishDeleting,
  508. finishMoving,
  509. finishRenaming,
  510. isDuplicate,
  511. isRootFolderSelected,
  512. parentFolderId,
  513. selectedFileName,
  514. selectedEntityIds.size,
  515. startCreatingDocOrFile,
  516. startCreatingFile,
  517. startCreatingFolder,
  518. startDeleting,
  519. startRenaming,
  520. startUploadingDocOrFile,
  521. state,
  522. write,
  523. ]
  524. )
  525. return (
  526. <FileTreeActionableContext.Provider value={value}>
  527. {children}
  528. </FileTreeActionableContext.Provider>
  529. )
  530. }
  531. export function useFileTreeActionable() {
  532. const context = useContext(FileTreeActionableContext)
  533. if (!context) {
  534. throw new Error(
  535. 'useFileTreeActionable is only available inside FileTreeActionableProvider'
  536. )
  537. }
  538. return context
  539. }
  540. function getSelectedParentFolderId(
  541. fileTreeData: Folder,
  542. selectedEntityIds: Set<string>,
  543. isRootFolderSelected: boolean
  544. ) {
  545. if (isRootFolderSelected) {
  546. return fileTreeData._id
  547. }
  548. // we expect only one entity to be selected in that case, so we pick the first
  549. const selectedEntityId = Array.from(selectedEntityIds)[0]
  550. if (!selectedEntityId) {
  551. // in some cases no entities are selected. Return the root folder id then.
  552. return fileTreeData._id
  553. }
  554. const found = findInTree(fileTreeData, selectedEntityId)
  555. if (!found) {
  556. // if the entity isn't in the tree, return the root folder id.
  557. return fileTreeData._id
  558. }
  559. return found.type === 'folder' ? found.entity._id : found.parentFolderId
  560. }
  561. function validateCreate(
  562. fileTreeData: Folder,
  563. parentFolderId: string,
  564. entity: { name: string; endpoint: string }
  565. ) {
  566. if (!isCleanFilename(entity.name)) {
  567. return new InvalidFilenameError()
  568. }
  569. if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
  570. return new DuplicateFilenameError()
  571. }
  572. // check that the name of a file is allowed, if creating in the root folder
  573. const isMoveToRoot = parentFolderId === fileTreeData._id
  574. const isFolder = entity.endpoint === 'folder'
  575. if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
  576. return new BlockedFilenameError()
  577. }
  578. }
  579. function validateRename(
  580. fileTreeData: Folder,
  581. found: { parentFolderId: string; path: string[]; type: string },
  582. newName: string
  583. ) {
  584. if (!isCleanFilename(newName)) {
  585. return new InvalidFilenameError()
  586. }
  587. if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
  588. return new DuplicateFilenameError()
  589. }
  590. const isTopLevel = found.path.length === 1
  591. const isFolder = found.type === 'folder'
  592. if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
  593. return new BlockedFilenameError()
  594. }
  595. }
  596. function validateMove(
  597. fileTreeData: Folder,
  598. toFolderId: string,
  599. found: { entity: { name: string }; type: string },
  600. isMoveToRoot: boolean
  601. ) {
  602. if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
  603. const error = new DuplicateFilenameMoveError()
  604. ;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
  605. found.entity.name
  606. return error
  607. }
  608. const isFolder = found.type === 'folder'
  609. if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
  610. return new BlockedFilenameError()
  611. }
  612. }