file-tree-actionable.tsx 19 KB

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