file-tree-actionable.tsx 18 KB

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