file-tree-actionable.tsx 19 KB

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