file-tree-actionable.tsx 18 KB

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