storage-explorer.tsx 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912
  1. import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
  2. import { IS_PLATFORM } from 'common'
  3. import { capitalize, chunk, compact, find, findIndex, has, isObject, uniq, uniqBy } from 'lodash'
  4. import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
  5. import { useLatest } from 'react-use'
  6. import { toast } from 'sonner'
  7. import * as tus from 'tus-js-client'
  8. import { Button, SONNER_DEFAULT_DURATION, SonnerProgress } from 'ui'
  9. import { proxy, useSnapshot } from 'valtio'
  10. import { useSelectedBucket } from '@/components/interfaces/Storage/FilesBuckets/useSelectedBucket'
  11. import {
  12. STORAGE_ROW_STATUS,
  13. STORAGE_ROW_TYPES,
  14. } from '@/components/interfaces/Storage/Storage.constants'
  15. import {
  16. StorageColumn,
  17. StorageItem,
  18. StorageItemMetadata,
  19. StorageItemWithColumn,
  20. } from '@/components/interfaces/Storage/Storage.types'
  21. import {
  22. calculateTotalRemainingTime,
  23. EMPTY_FOLDER_PLACEHOLDER_FILE_NAME,
  24. formatFolderItems,
  25. formatTime,
  26. getFilesDataTransferItems,
  27. getPathAlongFoldersToIndex,
  28. sanitizeNameForDuplicateInColumn,
  29. validateFolderName,
  30. } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
  31. import { fetchFileUrl } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
  32. import { getStoragePreference } from '@/components/interfaces/Storage/StorageExplorer/useStoragePreference'
  33. import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
  34. import { InlineLink } from '@/components/ui/InlineLink'
  35. import { getOrRefreshTemporaryApiKey } from '@/data/api-keys/temp-api-keys-utils'
  36. import { configKeys } from '@/data/config/keys'
  37. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  38. import type { ProjectStorageConfigResponse } from '@/data/config/project-storage-config-query'
  39. import { getQueryClient } from '@/data/query-client'
  40. import { deleteBucketObject } from '@/data/storage/bucket-object-delete-mutation'
  41. import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation'
  42. import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation'
  43. import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation'
  44. import type { Bucket } from '@/data/storage/buckets-query'
  45. import { moveStorageObject } from '@/data/storage/object-move-mutation'
  46. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  47. import { PROJECT_STATUS } from '@/lib/constants'
  48. import { lookupMime } from '@/lib/mime'
  49. import { createProjectBrivenClient } from '@/lib/project-briven-client'
  50. import { ResponseError } from '@/types'
  51. type UploadProgress = {
  52. percentage: number
  53. elapsed: number
  54. uploadSpeed: number
  55. remainingBytes: number
  56. remainingTime: number
  57. }
  58. const LIMIT = 200
  59. const OFFSET = 0
  60. const DEFAULT_RETRY_SECONDS = 5
  61. const RATE_LIMIT_RETRY_SECONDS = 60
  62. const STORAGE_PROGRESS_INFO_TEXT = "Do not close the browser until it's completed"
  63. let abortController: AbortController
  64. if (typeof window !== 'undefined') {
  65. abortController = new AbortController()
  66. }
  67. function createStorageExplorerState({
  68. projectRef,
  69. connectionString,
  70. bucket,
  71. resumableUploadUrl,
  72. clientEndpoint,
  73. }: {
  74. projectRef: string
  75. connectionString: string
  76. bucket?: Bucket
  77. resumableUploadUrl: string
  78. clientEndpoint: string
  79. }) {
  80. const getSortOptions = () => {
  81. const { sortBy, sortByOrder } = getStoragePreference(projectRef)
  82. return { column: sortBy, order: sortByOrder }
  83. }
  84. const state = proxy({
  85. projectRef,
  86. connectionString,
  87. resumableUploadUrl,
  88. uploadProgresses: [] as UploadProgress[],
  89. selectedBucket: bucket as Bucket,
  90. abortApiCalls: () => {
  91. if (abortController) {
  92. abortController.abort()
  93. abortController = new AbortController()
  94. }
  95. },
  96. abortUploadCallbacks: {} as { [key: string]: (() => void)[] },
  97. abortUploads: (toastId: string | number) => {
  98. state.abortUploadCallbacks[toastId].forEach((callback) => callback())
  99. state.abortUploadCallbacks[toastId] = []
  100. },
  101. columns: [] as StorageColumn[],
  102. popColumn: () => {
  103. state.abortApiCalls()
  104. state.columns = state.columns.slice(0, state.getLatestColumnIndex())
  105. },
  106. popColumnAtIndex: (index: number) => {
  107. state.columns = state.columns.slice(0, index + 1)
  108. },
  109. pushColumnAtIndex: (column: StorageColumn, index: number) => {
  110. state.columns = state.columns.slice(0, index + 1).concat([column])
  111. },
  112. openedFolders: [] as StorageItem[],
  113. pushOpenedFolderAtIndex: (folder: StorageItem, index: number) => {
  114. state.openedFolders = state.openedFolders.slice(0, index).concat(folder)
  115. },
  116. popOpenedFolders: () => {
  117. state.openedFolders = state.openedFolders.slice(0, state.openedFolders.length - 1)
  118. },
  119. popOpenedFoldersAtIndex: (index: number) => {
  120. state.openedFolders = state.openedFolders.slice(0, index + 1)
  121. },
  122. clearOpenedFolders: () => {
  123. state.openedFolders = []
  124. },
  125. selectedItems: [] as StorageItemWithColumn[],
  126. setSelectedItems: (items: StorageItemWithColumn[]) => (state.selectedItems = items),
  127. clearSelectedItems: (columnIndex?: number) => {
  128. if (columnIndex !== undefined) {
  129. state.selectedItems = state.selectedItems.filter((item) => item.columnIndex !== columnIndex)
  130. } else {
  131. state.selectedItems = []
  132. }
  133. },
  134. selectedItemsToDelete: [] as StorageItemWithColumn[],
  135. setSelectedItemsToDelete: (items: StorageItemWithColumn[]) => {
  136. state.selectedItemsToDelete = items
  137. },
  138. selectedItemsToMove: [] as StorageItemWithColumn[],
  139. setSelectedItemsToMove: (items: StorageItemWithColumn[]) => {
  140. state.selectedItemsToMove = items
  141. },
  142. setSelectedItemToRename: (file: { name: string; columnIndex: number }) => {
  143. state.updateRowStatus({
  144. name: file.name,
  145. status: STORAGE_ROW_STATUS.EDITING,
  146. columnIndex: file.columnIndex,
  147. })
  148. },
  149. isSearching: false,
  150. setIsSearching: (value: boolean) => (state.isSearching = value),
  151. isRefreshing: false,
  152. selectedFilePreview: undefined as StorageItemWithColumn | undefined,
  153. setSelectedFilePreview: (file?: StorageItemWithColumn) => (state.selectedFilePreview = file),
  154. selectedFileCustomExpiry: undefined as StorageItem | undefined,
  155. setSelectedFileCustomExpiry: (item?: StorageItem) => (state.selectedFileCustomExpiry = item),
  156. // Functions that manage the UI of the Storage Explorer
  157. getLatestColumnIndex: () => {
  158. return state.columns.length - 1
  159. },
  160. setColumnIsLoadingMore: (index: number, isLoadingMoreItems: boolean = true) => {
  161. state.columns = state.columns.map((col, idx) => {
  162. return idx === index ? { ...col, isLoadingMoreItems } : col
  163. })
  164. },
  165. // ======== Folders CRUD ========
  166. addNewFolderPlaceholder: (columnIndex: number) => {
  167. const isPrepend = true
  168. const folderName = 'Untitled folder'
  169. const folderType = STORAGE_ROW_TYPES.FOLDER
  170. const columnIdx = columnIndex === -1 ? state.getLatestColumnIndex() : columnIndex
  171. state.addTempRow({
  172. type: folderType,
  173. name: folderName,
  174. status: STORAGE_ROW_STATUS.EDITING,
  175. columnIndex: columnIdx,
  176. metadata: null,
  177. isPrepend,
  178. })
  179. },
  180. addNewFolder: async ({
  181. folderName,
  182. columnIndex,
  183. onError,
  184. }: {
  185. folderName: string
  186. columnIndex: number
  187. onError?: () => void
  188. }) => {
  189. const autofix = false
  190. const formattedName = sanitizeNameForDuplicateInColumn(state, {
  191. name: folderName,
  192. autofix,
  193. columnIndex,
  194. })
  195. if (formattedName === null) {
  196. onError?.()
  197. return
  198. }
  199. if (formattedName.length === 0) {
  200. return state.removeTempRows(columnIndex)
  201. }
  202. const folderNameError = validateFolderName(formattedName)
  203. if (folderNameError) {
  204. onError?.()
  205. return toast.error(folderNameError)
  206. }
  207. state.updateFolderAfterEdit({ folderName: formattedName, columnIndex })
  208. const emptyPlaceholderFile = `${formattedName}/${EMPTY_FOLDER_PLACEHOLDER_FILE_NAME}`
  209. const pathToFolder = state.openedFolders
  210. .slice(0, columnIndex)
  211. .map((folder) => folder.name)
  212. .join('/')
  213. const formattedPathToEmptyPlaceholderFile =
  214. pathToFolder.length > 0 ? `${pathToFolder}/${emptyPlaceholderFile}` : emptyPlaceholderFile
  215. const client = await createProjectBrivenClient(state.projectRef, clientEndpoint)
  216. await client.storage
  217. .from(state.selectedBucket.name)
  218. .upload(
  219. formattedPathToEmptyPlaceholderFile,
  220. new File([], EMPTY_FOLDER_PLACEHOLDER_FILE_NAME)
  221. )
  222. if (pathToFolder.length > 0) {
  223. await deleteBucketObject({
  224. projectRef: state.projectRef,
  225. bucketId: state.selectedBucket.id,
  226. paths: [`${pathToFolder}/${EMPTY_FOLDER_PLACEHOLDER_FILE_NAME}`],
  227. })
  228. }
  229. const newFolder = state.columns[columnIndex]?.items?.find((x) => x?.name === formattedName)
  230. if (newFolder) state.openFolder(columnIndex, newFolder)
  231. },
  232. fetchFolderContents: async ({
  233. bucketId,
  234. folderId,
  235. folderName,
  236. index,
  237. searchString,
  238. }: {
  239. bucketId: string
  240. folderId: string | null
  241. folderName: string
  242. index: number
  243. searchString?: string
  244. }) => {
  245. state.abortApiCalls()
  246. state.updateRowStatus({
  247. name: folderName,
  248. status: STORAGE_ROW_STATUS.LOADING,
  249. columnIndex: index,
  250. })
  251. const prefix = state.openedFolders
  252. .slice(0, index + 1)
  253. .map((folder) => folder.name)
  254. .join('/')
  255. state.pushColumnAtIndex(
  256. {
  257. id: folderId,
  258. name: folderName,
  259. path: prefix,
  260. status: STORAGE_ROW_STATUS.LOADING,
  261. items: [],
  262. },
  263. index
  264. )
  265. const options = {
  266. limit: LIMIT,
  267. offset: OFFSET,
  268. search: searchString,
  269. sortBy: getSortOptions(),
  270. }
  271. try {
  272. const data = await listBucketObjects(
  273. {
  274. bucketId,
  275. projectRef: state.projectRef,
  276. path: prefix,
  277. options,
  278. },
  279. abortController?.signal
  280. )
  281. state.updateRowStatus({
  282. name: folderName,
  283. status: STORAGE_ROW_STATUS.READY,
  284. columnIndex: index,
  285. })
  286. const formattedItems = formatFolderItems(data, prefix)
  287. state.pushColumnAtIndex(
  288. {
  289. id: folderId || folderName,
  290. name: folderName,
  291. path: prefix,
  292. status: STORAGE_ROW_STATUS.READY,
  293. items: formattedItems,
  294. hasMoreItems: formattedItems.length === LIMIT,
  295. isLoadingMoreItems: false,
  296. },
  297. index
  298. )
  299. } catch (error: any) {
  300. if (error.name === 'AbortError') {
  301. state.updateRowStatus({
  302. name: folderName,
  303. status: STORAGE_ROW_STATUS.READY,
  304. columnIndex: index,
  305. })
  306. } else {
  307. toast.error(`Failed to retrieve folder contents from "${folderName}": ${error.message}`)
  308. }
  309. }
  310. },
  311. fetchMoreFolderContents: async ({
  312. index,
  313. column,
  314. searchString = '',
  315. }: {
  316. index: number
  317. column: StorageColumn
  318. searchString?: string
  319. }) => {
  320. state.setColumnIsLoadingMore(index)
  321. const options = {
  322. limit: LIMIT,
  323. offset: column.items.length,
  324. search: searchString,
  325. sortBy: getSortOptions(),
  326. }
  327. try {
  328. const data = await listBucketObjects(
  329. {
  330. projectRef: state.projectRef,
  331. bucketId: state.selectedBucket.id,
  332. path: column.path,
  333. options,
  334. },
  335. abortController?.signal
  336. )
  337. // Add items to column
  338. const formattedItems = formatFolderItems(data, column.path)
  339. state.columns = state.columns.map((col, idx) => {
  340. if (idx === index) {
  341. return {
  342. ...col,
  343. items: col.items.concat(formattedItems),
  344. isLoadingMoreItems: false,
  345. hasMoreItems: data.length === LIMIT,
  346. }
  347. }
  348. return col
  349. })
  350. } catch (error: any) {
  351. if (!error.message.includes('aborted')) {
  352. toast.error(`Failed to retrieve more folder contents: ${error.message}`)
  353. }
  354. }
  355. },
  356. refetchAllOpenedFolders: async () => {
  357. const paths = state.openedFolders.map((folder) => folder.name)
  358. await state.fetchFoldersByPath({ paths })
  359. },
  360. refreshAll: async () => {
  361. state.isRefreshing = true
  362. try {
  363. await state.refetchAllOpenedFolders()
  364. } finally {
  365. state.isRefreshing = false
  366. }
  367. },
  368. fetchFoldersByPath: async ({
  369. paths,
  370. searchString = '',
  371. showLoading = false,
  372. }: {
  373. paths: string[]
  374. searchString?: string
  375. showLoading?: boolean
  376. }) => {
  377. if (state.selectedBucket.id === undefined) return
  378. const pathsWithEmptyPrefix = [''].concat(paths)
  379. if (showLoading) {
  380. state.columns = [state.selectedBucket.name].concat(paths).map((path) => {
  381. return { id: path, name: path, path, status: STORAGE_ROW_STATUS.LOADING, items: [] }
  382. })
  383. }
  384. const foldersItems = await Promise.all(
  385. pathsWithEmptyPrefix.map(async (_path, idx) => {
  386. const prefix = paths.slice(0, idx).join('/')
  387. const options = {
  388. limit: LIMIT,
  389. offset: OFFSET,
  390. search: searchString,
  391. sortBy: getSortOptions(),
  392. }
  393. try {
  394. const data = await listBucketObjects({
  395. projectRef: state.projectRef,
  396. bucketId: state.selectedBucket.id,
  397. path: prefix,
  398. options,
  399. })
  400. return data
  401. } catch (error: any) {
  402. toast.error(`Failed to fetch folders: ${error.message}`)
  403. return []
  404. }
  405. })
  406. )
  407. const formattedFolders = foldersItems.map((folderItems, idx) => {
  408. const prefix = paths.slice(0, idx).join('/')
  409. const formattedItems = formatFolderItems(folderItems, prefix)
  410. return {
  411. id: null,
  412. status: STORAGE_ROW_STATUS.READY,
  413. name: idx === 0 ? state.selectedBucket.name : pathsWithEmptyPrefix[idx],
  414. path: prefix,
  415. items: formattedItems,
  416. hasMoreItems: formattedItems.length === LIMIT,
  417. isLoadingMoreItems: false,
  418. }
  419. })
  420. // Package into columns and update this.columns
  421. state.columns = formattedFolders
  422. // Update openedFolders as well
  423. const updatedOpenedFolders: StorageItem[] = paths.map((path, idx) => {
  424. const folderInfo = find(formattedFolders[idx].items, { name: path })
  425. // Folder doesnt exist, FE just scaffolds a "fake" folder
  426. if (!folderInfo) {
  427. return {
  428. id: null,
  429. name: path,
  430. type: STORAGE_ROW_TYPES.FOLDER,
  431. status: STORAGE_ROW_STATUS.READY,
  432. metadata: null,
  433. isCorrupted: false,
  434. created_at: null,
  435. updated_at: null,
  436. last_accessed_at: null,
  437. }
  438. }
  439. return folderInfo
  440. })
  441. state.openedFolders = updatedOpenedFolders
  442. },
  443. /**
  444. * Check parent folder if its empty, if yes, reinstate .emptyFolderPlaceholder. Used when deleting folder or deleting files
  445. */
  446. validateParentFolderEmpty: async (parentFolderPrefix: string) => {
  447. try {
  448. const data = await listBucketObjects({
  449. projectRef: state.projectRef,
  450. bucketId: state.selectedBucket.id,
  451. path: parentFolderPrefix,
  452. options: {
  453. limit: LIMIT,
  454. offset: OFFSET,
  455. sortBy: getSortOptions(),
  456. },
  457. })
  458. if (data.length === 0) {
  459. const prefixToPlaceholder = `${parentFolderPrefix}/${EMPTY_FOLDER_PLACEHOLDER_FILE_NAME}`
  460. const client = await createProjectBrivenClient(state.projectRef, clientEndpoint)
  461. await client.storage
  462. .from(state.selectedBucket.name)
  463. .upload(prefixToPlaceholder, new File([], EMPTY_FOLDER_PLACEHOLDER_FILE_NAME))
  464. }
  465. } catch (error) {}
  466. },
  467. deleteFolder: async (folder: StorageItemWithColumn) => {
  468. try {
  469. const isDeleteFolder = true
  470. const files = await state.getAllItemsAlongFolder(folder)
  471. if (files.length === 0) {
  472. // [Joshen] This is to self-remediate orphan prefixes
  473. await deleteBucketPrefix({
  474. projectRef: state.projectRef,
  475. connectionString: state.connectionString,
  476. bucketId: state.selectedBucket.id,
  477. prefix: folder.path,
  478. })
  479. } else {
  480. await state.deleteFiles({ files: files as any[], isDeleteFolder })
  481. }
  482. state.popColumnAtIndex(folder.columnIndex)
  483. state.popOpenedFoldersAtIndex(folder.columnIndex - 1)
  484. if (folder.columnIndex > 0) {
  485. const parentFolderPrefix = state.openedFolders
  486. .slice(0, folder.columnIndex)
  487. .map((folder) => folder.name)
  488. .join('/')
  489. if (parentFolderPrefix.length > 0)
  490. await state.validateParentFolderEmpty(parentFolderPrefix)
  491. }
  492. await state.refetchAllOpenedFolders()
  493. state.setSelectedItemsToDelete([])
  494. toast.success(`Successfully deleted ${folder.name}`)
  495. } catch (error: any) {
  496. toast.error(`Failed to delete folder: ${error.message}`)
  497. }
  498. },
  499. renameFolder: async ({
  500. folder,
  501. newName,
  502. columnIndex,
  503. onError,
  504. }: {
  505. folder: StorageItemWithColumn
  506. newName: string
  507. columnIndex: number
  508. onError?: () => void
  509. }) => {
  510. const originalName = folder.name
  511. if (originalName === newName) {
  512. return state.updateRowStatus({
  513. name: originalName,
  514. status: STORAGE_ROW_STATUS.READY,
  515. columnIndex,
  516. })
  517. }
  518. const folderNameError = validateFolderName(newName)
  519. if (folderNameError) {
  520. onError?.()
  521. return toast.error(folderNameError)
  522. }
  523. const toastId = toast(
  524. <SonnerProgress progress={0} message={`Renaming folder to ${newName}`} />,
  525. { closeButton: false, position: 'top-right' }
  526. )
  527. try {
  528. state.updateRowStatus({
  529. name: originalName,
  530. status: STORAGE_ROW_STATUS.LOADING,
  531. columnIndex,
  532. updatedName: newName,
  533. })
  534. const files = await state.getAllItemsAlongFolder(folder)
  535. let progress = 0
  536. let failedFiles = 0
  537. let retrySeconds = DEFAULT_RETRY_SECONDS
  538. for (const file of files) {
  539. const fromPath = `${file.prefix}/${file.name}`
  540. const pathSegments = fromPath.split('/')
  541. const toPath = pathSegments
  542. .slice(0, columnIndex)
  543. .concat(newName)
  544. .concat(pathSegments.slice(columnIndex + 1))
  545. .join('/')
  546. let success = false
  547. let isRateLimited = false
  548. for (let attempt = 0; attempt < 3 && !success; attempt++) {
  549. try {
  550. if (attempt > 0) {
  551. await new Promise<void>((resolve) => {
  552. let seconds = retrySeconds
  553. const interval = setInterval(() => {
  554. toast(
  555. <SonnerProgress
  556. progress={Math.min(progress * 100, 100)}
  557. message={`Renaming folder to ${newName}`}
  558. description={`${isRateLimited ? 'API rate limited' : 'Error moving file'} - retrying in ${seconds} seconds (${attempt}/3)`}
  559. />,
  560. { id: toastId, closeButton: false, position: 'top-right', duration: Infinity }
  561. )
  562. seconds--
  563. if (seconds <= 0) {
  564. clearInterval(interval)
  565. resolve()
  566. }
  567. }, 1000)
  568. })
  569. }
  570. await moveStorageObject({
  571. projectRef: state.projectRef,
  572. bucketId: state.selectedBucket.id,
  573. from: fromPath,
  574. to: toPath,
  575. })
  576. success = true
  577. } catch (error) {
  578. if ((error as ResponseError).code === 429) {
  579. isRateLimited = true
  580. retrySeconds = RATE_LIMIT_RETRY_SECONDS
  581. } else {
  582. isRateLimited = false
  583. retrySeconds = DEFAULT_RETRY_SECONDS
  584. }
  585. if (attempt === 2) failedFiles += 1
  586. }
  587. }
  588. progress += 1 / files.length
  589. toast(
  590. <SonnerProgress
  591. progress={Math.min(progress * 100, 100)}
  592. message={`Renaming folder to ${newName}`}
  593. />,
  594. { id: toastId, closeButton: false, position: 'top-right', duration: Infinity }
  595. )
  596. }
  597. if (failedFiles === 0) {
  598. toast.success(`Successfully renamed folder to ${newName}`, {
  599. id: toastId,
  600. closeButton: true,
  601. duration: SONNER_DEFAULT_DURATION,
  602. })
  603. } else {
  604. toast.error(
  605. <div>
  606. <p>
  607. Renamed folder to {newName} with {failedFiles} error{failedFiles > 1 ? 's' : ''}
  608. </p>
  609. <p className="text-foreground-light">
  610. You may try again to rename the folder {originalName} to {newName}
  611. </p>
  612. </div>,
  613. {
  614. id: toastId,
  615. closeButton: true,
  616. duration: Infinity,
  617. }
  618. )
  619. }
  620. if (state.openedFolders[columnIndex]?.name === folder.name) {
  621. state.setSelectedFilePreview(undefined)
  622. state.popOpenedFoldersAtIndex(columnIndex - 1)
  623. }
  624. await state.refetchAllOpenedFolders()
  625. // TODO: Should we invalidate the file preview cache when renaming folders?
  626. } catch (e: any) {
  627. toast.error(`Failed to rename folder to ${newName}: ${e.message}`, {
  628. id: toastId,
  629. closeButton: true,
  630. duration: SONNER_DEFAULT_DURATION,
  631. })
  632. }
  633. },
  634. updateFolderAfterEdit: ({
  635. folderName,
  636. columnIndex,
  637. status = STORAGE_ROW_STATUS.READY,
  638. }: {
  639. folderName: string
  640. columnIndex?: number
  641. status?: STORAGE_ROW_STATUS
  642. }) => {
  643. const columnIndex_ = columnIndex !== undefined ? columnIndex : state.getLatestColumnIndex()
  644. const updatedColumns = state.columns.map((column, idx) => {
  645. if (idx === columnIndex_) {
  646. const updatedItems = column.items.map((item) => {
  647. if (item.status === STORAGE_ROW_STATUS.EDITING) {
  648. const currentTime = new Date().toISOString()
  649. return {
  650. ...item,
  651. status,
  652. name: folderName,
  653. createdAt: currentTime,
  654. lastAccessedAt: currentTime,
  655. updatedAt: currentTime,
  656. metadata: null,
  657. id: null,
  658. }
  659. }
  660. return item
  661. })
  662. return { ...column, items: updatedItems }
  663. }
  664. return column
  665. })
  666. state.columns = updatedColumns
  667. },
  668. openFolder: async (columnIndex: number, folder: StorageItem) => {
  669. state.setSelectedFilePreview(undefined)
  670. state.clearSelectedItems(columnIndex + 1)
  671. state.popOpenedFoldersAtIndex(columnIndex - 1)
  672. state.pushOpenedFolderAtIndex(folder, columnIndex)
  673. await state.fetchFolderContents({
  674. bucketId: state.selectedBucket.id,
  675. folderId: folder.id,
  676. folderName: folder.name,
  677. index: columnIndex,
  678. })
  679. },
  680. downloadFolder: async (folder: StorageItemWithColumn) => {
  681. let progress = 0
  682. const toastId = toast.loading('Retrieving files from folder...')
  683. try {
  684. const files = await state.getAllItemsAlongFolder(folder)
  685. toast(
  686. <SonnerProgress
  687. progress={0}
  688. message={`Downloading ${files.length} file${files.length > 1 ? 's' : ''}...`}
  689. />,
  690. { id: toastId, closeButton: false, position: 'top-right' }
  691. )
  692. // Pre-fetch all URLs in a single batch to avoid N management API calls
  693. const filePaths = files.map((file) => `${file.prefix}/${file.name}`)
  694. const urlByPath = new Map<string, string>()
  695. if (state.selectedBucket.public) {
  696. for (const filePath of filePaths) {
  697. urlByPath.set(
  698. filePath,
  699. `${clientEndpoint}/storage/v1/object/public/${state.selectedBucket.id}/${filePath}`
  700. )
  701. }
  702. } else {
  703. const signedUrls = await signBucketObjects({
  704. projectRef: state.projectRef,
  705. bucketId: state.selectedBucket.id,
  706. paths: filePaths,
  707. expiresIn: 60 * 60, // 1 hour — enough for large folder downloads
  708. })
  709. for (const item of signedUrls) {
  710. if (item.path && item.signedUrl) {
  711. urlByPath.set(item.path, item.signedUrl)
  712. }
  713. }
  714. }
  715. const promises = files.map((file) => {
  716. const fileMimeType = (file.metadata?.mimetype as string) ?? null
  717. const filePath = `${file.prefix}/${file.name}`
  718. return () => {
  719. return new Promise<
  720. | {
  721. name: string
  722. prefix: string
  723. blob: Blob
  724. }
  725. | boolean
  726. >(async (resolve) => {
  727. try {
  728. const url = urlByPath.get(filePath)
  729. if (!url) throw new Error(`Failed to retrieve file ${filePath}`)
  730. const response = await fetch(url)
  731. if (!response.ok) throw new Error(`Failed to retrieve file ${filePath}`)
  732. const data = await response.blob()
  733. progress = progress + 1 / files.length
  734. resolve({
  735. name: file.name,
  736. prefix: file.prefix,
  737. blob: new Blob([data], { type: fileMimeType ?? data.type }),
  738. })
  739. } catch (error) {
  740. console.error('Failed to download file', filePath)
  741. resolve(false)
  742. }
  743. })
  744. }
  745. })
  746. const batchedPromises = chunk(promises, 10)
  747. const downloadedFiles = await batchedPromises.reduce(
  748. async (previousPromise, nextBatch) => {
  749. const previousResults = await previousPromise
  750. const batchResults = await Promise.allSettled(nextBatch.map((batch) => batch()))
  751. toast(
  752. <SonnerProgress
  753. progress={progress * 100}
  754. message={`Downloading ${files.length} file${files.length > 1 ? 's' : ''}...`}
  755. />,
  756. { id: toastId, closeButton: false, position: 'top-right' }
  757. )
  758. return previousResults.concat(batchResults.map((x: any) => x.value).filter(Boolean))
  759. },
  760. Promise.resolve<
  761. {
  762. name: string
  763. prefix: string
  764. blob: Blob
  765. }[]
  766. >([])
  767. )
  768. const zipFileWriter = new BlobWriter('application/zip')
  769. const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
  770. if (downloadedFiles.length === 0) {
  771. toast.error(`Failed to download files from "${folder.name}"`, {
  772. id: toastId,
  773. closeButton: true,
  774. duration: SONNER_DEFAULT_DURATION,
  775. })
  776. }
  777. downloadedFiles.forEach((file) => {
  778. if (file.blob) zipWriter.add(`${file.prefix}/${file.name}`, new BlobReader(file.blob))
  779. })
  780. const blobURL = URL.createObjectURL(await zipWriter.close())
  781. const link = document.createElement('a')
  782. link.href = blobURL
  783. link.setAttribute('download', `${folder.name}.zip`)
  784. document.body.appendChild(link)
  785. link.click()
  786. link.parentNode?.removeChild(link)
  787. toast.success(
  788. downloadedFiles.length === files.length
  789. ? `Successfully downloaded folder "${folder.name}"`
  790. : `Downloaded folder "${folder.name}". However, ${
  791. files.length - downloadedFiles.length
  792. } files did not download successfully.`,
  793. { id: toastId, closeButton: true, duration: SONNER_DEFAULT_DURATION }
  794. )
  795. } catch (error: any) {
  796. toast.error(`Failed to download folder: ${error.message}`, {
  797. id: toastId,
  798. closeButton: true,
  799. duration: SONNER_DEFAULT_DURATION,
  800. })
  801. }
  802. },
  803. /**
  804. * Recursively returns a list of items along every directory within the specified base folder
  805. * Each item has an extra property 'prefix' which has the prefix that leads to the item
  806. * Used specifically for any operation that deals with every file along the folder
  807. * e.g Delete folder, rename folder
  808. */
  809. getAllItemsAlongFolder: async (folder: {
  810. name: string
  811. columnIndex: number
  812. prefix?: string
  813. }): Promise<(StorageObject & { prefix: string })[]> => {
  814. const items: (StorageObject & { prefix: string })[] = []
  815. let hasError = false
  816. let formattedPathToFolder = ''
  817. const { name, columnIndex, prefix } = folder
  818. if (prefix === undefined) {
  819. const pathToFolder = state.openedFolders
  820. .slice(0, columnIndex)
  821. .map((folder) => folder.name)
  822. .join('/')
  823. formattedPathToFolder = pathToFolder.length > 0 ? `${pathToFolder}/${name}` : name
  824. } else {
  825. formattedPathToFolder = `${prefix}/${name}`
  826. }
  827. // [Joshen] limit is set to 10k to optimize reduction of requests, we've done some experiments
  828. // that prove that the time to fetch all files in a folder reduces as the batch size increases
  829. // 10k however, is the hard limit at the API level.
  830. const options = {
  831. limit: 10000,
  832. offset: OFFSET,
  833. sortBy: getSortOptions(),
  834. }
  835. let folderContents: StorageObject[] = []
  836. for (;;) {
  837. try {
  838. const data = await listBucketObjects({
  839. projectRef: state.projectRef,
  840. bucketId: state.selectedBucket.id,
  841. path: formattedPathToFolder,
  842. options,
  843. })
  844. folderContents = folderContents.concat(data)
  845. options.offset += options.limit
  846. if ((data || []).length < options.limit) {
  847. break
  848. }
  849. } catch (e) {
  850. hasError = true
  851. break
  852. }
  853. }
  854. if (hasError) {
  855. throw new Error('Failed to retrieve all files within folder')
  856. }
  857. const subfolders = folderContents?.filter((item) => item.id === null) ?? []
  858. const folderItems = folderContents?.filter((item) => item.id !== null) ?? []
  859. folderItems.forEach((item) => items.push({ ...item, prefix: formattedPathToFolder }))
  860. const subFolderContents = await Promise.all(
  861. subfolders.map((folder) =>
  862. state.getAllItemsAlongFolder({ ...folder, columnIndex: 0, prefix: formattedPathToFolder })
  863. )
  864. )
  865. subFolderContents.map((subfolderContent) => {
  866. subfolderContent.map((item) => items.push(item))
  867. })
  868. return items
  869. },
  870. // ======== Files CRUD ========
  871. uploadFiles: async ({
  872. files,
  873. columnIndex,
  874. isDrop = false,
  875. }: {
  876. files: FileList | DataTransferItemList
  877. columnIndex: number
  878. isDrop?: boolean
  879. }) => {
  880. const queryClient = getQueryClient()
  881. const storageConfiguration = queryClient
  882. .getQueryCache()
  883. .find({ queryKey: configKeys.storage(state.projectRef) })?.state.data as
  884. | ProjectStorageConfigResponse
  885. | undefined
  886. const fileSizeLimit = storageConfiguration?.fileSizeLimit
  887. const t1 = new Date()
  888. const autofix = true
  889. // We filter out any folders which are just '#' until we can properly encode such characters in the URL
  890. const filesToUpload: (File & { path?: string })[] = isDrop
  891. ? (await getFilesDataTransferItems(files as DataTransferItemList)).filter(
  892. (file) => !file.path.includes('#/')
  893. )
  894. : Array.from(files as FileList)
  895. const derivedColumnIndex = columnIndex === -1 ? state.getLatestColumnIndex() : columnIndex
  896. const filesWithinUploadLimit =
  897. fileSizeLimit !== undefined
  898. ? filesToUpload.filter((file) => file.size <= fileSizeLimit)
  899. : filesToUpload
  900. if (filesWithinUploadLimit.length < filesToUpload.length) {
  901. const numberOfFilesRejected = filesToUpload.length - filesWithinUploadLimit.length
  902. const { value, unit } = convertFromBytes(fileSizeLimit as number)
  903. toast.error(
  904. <div className="flex flex-col gap-y-1">
  905. <p className="text-foreground">
  906. Failed to upload {numberOfFilesRejected} file{numberOfFilesRejected > 1 ? 's' : ''} as{' '}
  907. {numberOfFilesRejected > 1 ? 'their' : 'its'} size
  908. {numberOfFilesRejected > 1 ? 's are' : ' is'} beyond the global upload limit of{' '}
  909. {value} {unit}.
  910. </p>
  911. <p className="text-foreground-light">
  912. You can change the global file size upload limit in{' '}
  913. <InlineLink href={`/project/${state.projectRef}/storage/settings`}>
  914. Storage Settings
  915. </InlineLink>
  916. .
  917. </p>
  918. </div>,
  919. { duration: 8000 }
  920. )
  921. if (numberOfFilesRejected === filesToUpload.length) return
  922. }
  923. const filesWithNonZeroSize = filesWithinUploadLimit.filter((file) => file.size > 0)
  924. if (filesWithNonZeroSize.length < filesWithinUploadLimit.length) {
  925. const numberOfFilesRejected = filesWithinUploadLimit.length - filesWithNonZeroSize.length
  926. toast.error(
  927. <div className="flex flex-col gap-y-1">
  928. <p className="text-foreground">
  929. Failed to upload {numberOfFilesRejected} file{numberOfFilesRejected > 1 ? 's' : ''} as{' '}
  930. {numberOfFilesRejected > 1 ? 'their' : 'its'} size
  931. {numberOfFilesRejected > 1 ? 's are' : ' is'} 0.
  932. </p>
  933. </div>
  934. )
  935. if (numberOfFilesRejected === filesWithinUploadLimit.length) return
  936. }
  937. // If we're uploading a folder which name already exists in the same folder that we're uploading to
  938. // We sanitize the folder name and let all file uploads through. (This is only via drag drop)
  939. const topLevelFolders: string[] = (state.columns?.[derivedColumnIndex]?.items ?? [])
  940. .filter((item) => !item.id)
  941. .map((item) => item.name)
  942. const formattedFilesToUpload = filesWithinUploadLimit
  943. .filter((file) => file.name !== '.DS_Store')
  944. .map((file) => {
  945. // If the files are from clicking "Upload button", just take them as they are since users cannot
  946. // upload folders from clicking that button, only via drag drop
  947. if (!file.path) return file
  948. const path = file.path.split('/')
  949. const topLevelFolder = path.length > 1 ? path[0] : null
  950. if (topLevelFolders.includes(topLevelFolder as string)) {
  951. const newTopLevelFolder = sanitizeNameForDuplicateInColumn(state, {
  952. name: topLevelFolder as string,
  953. autofix,
  954. columnIndex,
  955. })
  956. path[0] = newTopLevelFolder as string
  957. file.path = path.join('/')
  958. }
  959. return file
  960. })
  961. state.uploadProgresses = new Array(formattedFilesToUpload.length).fill({
  962. percentage: 0,
  963. elapsed: 0,
  964. uploadSpeed: 0,
  965. remainingBytes: 0,
  966. remainingTime: 0,
  967. })
  968. const uploadedTopLevelFolders: string[] = []
  969. const numberOfFilesToUpload = formattedFilesToUpload.length
  970. let numberOfFilesUploadedSuccess = 0
  971. let numberOfFilesUploadedFail = 0
  972. const pathToFile = state.openedFolders
  973. .slice(0, derivedColumnIndex)
  974. .map((folder) => folder.name)
  975. .join('/')
  976. const toastId = state.onUploadProgress()
  977. // Upload files in batches
  978. const promises = formattedFilesToUpload.map((file, index) => {
  979. const extension = file.name.split('.').pop()
  980. const metadata = {
  981. mimetype: (file.type || lookupMime(extension)) ?? '',
  982. size: file.size,
  983. } as StorageItemMetadata
  984. const fileOptions = { cacheControl: '3600', contentType: metadata.mimetype }
  985. const isWithinFolder = (file?.path ?? '').split('/').length > 1
  986. const fileName = !isWithinFolder
  987. ? sanitizeNameForDuplicateInColumn(state, { name: file.name, autofix })
  988. : file.name
  989. const unsanitizedFormattedFileName =
  990. has(file, ['path']) && isWithinFolder ? file.path : fileName
  991. /**
  992. * Storage maintains a list of allowed characters, which excludes
  993. * characters such as the narrow no-break space used in Mac screenshots.
  994. * [Joshen] Am limiting to just replacing nbsp with a blank space instead of
  995. * all non-word characters per before
  996. * */
  997. const formattedFileName = (unsanitizedFormattedFileName ?? 'unknown').replaceAll(
  998. /\u{202F}/gu,
  999. ' '
  1000. )
  1001. const formattedPathToFile =
  1002. pathToFile.length > 0
  1003. ? `${pathToFile}/${formattedFileName}`
  1004. : (formattedFileName as string)
  1005. if (isWithinFolder) {
  1006. const topLevelFolder = file.path?.split('/')[0] || ''
  1007. if (!uploadedTopLevelFolders.includes(topLevelFolder)) {
  1008. state.addTempRow({
  1009. type: STORAGE_ROW_TYPES.FOLDER,
  1010. name: topLevelFolder,
  1011. status: STORAGE_ROW_STATUS.LOADING,
  1012. columnIndex: derivedColumnIndex,
  1013. metadata,
  1014. })
  1015. uploadedTopLevelFolders.push(topLevelFolder)
  1016. }
  1017. } else {
  1018. state.addTempRow({
  1019. type: STORAGE_ROW_TYPES.FILE,
  1020. name: fileName!,
  1021. status: STORAGE_ROW_STATUS.LOADING,
  1022. columnIndex: derivedColumnIndex,
  1023. metadata,
  1024. })
  1025. }
  1026. let startingBytes = 0
  1027. const bucketName = state.selectedBucket.name
  1028. return () => {
  1029. return new Promise<void>(async (resolve, reject) => {
  1030. const fileSizeInMB = file.size / (1024 * 1024)
  1031. const startTime = Date.now()
  1032. let chunkSize: number
  1033. if (fileSizeInMB < 30) {
  1034. chunkSize = 6 * 1024 * 1024 // 6MB
  1035. } else if (fileSizeInMB < 100) {
  1036. chunkSize = Math.floor(file.size / 8) // maximum 8 chunks, 12,5MB each
  1037. } else if (fileSizeInMB < 500) {
  1038. chunkSize = Math.floor(file.size / 10) // maximum 10 chunks, 50MB each
  1039. } else if (fileSizeInMB < 1024) {
  1040. chunkSize = Math.floor(file.size / 20) // maximum 20 chunks, 50MB each
  1041. } else if (fileSizeInMB < 10 * 1024) {
  1042. chunkSize = Math.floor(file.size / 30) // maximum 30 chunks, 333MB each
  1043. } else {
  1044. chunkSize = Math.floor(file.size / 50) // maximum 50 chunks
  1045. }
  1046. // Max chunk size is 500MB
  1047. chunkSize = Math.min(chunkSize, 500 * 1024 * 1024)
  1048. const uploadDataDuringCreation = file.size <= chunkSize
  1049. const upload = new tus.Upload(file, {
  1050. endpoint: state.resumableUploadUrl,
  1051. retryDelays: [0, 200, 500, 1500, 3000, 5000],
  1052. headers: {
  1053. 'x-source': 'briven-dashboard',
  1054. },
  1055. uploadDataDuringCreation: uploadDataDuringCreation,
  1056. removeFingerprintOnSuccess: true,
  1057. metadata: {
  1058. bucketName,
  1059. objectName: formattedPathToFile,
  1060. ...fileOptions,
  1061. },
  1062. chunkSize,
  1063. onBeforeRequest: async (req) => {
  1064. try {
  1065. // Use the shared temporary key for batch uploads
  1066. // This checks if the key is still valid and refreshes if needed
  1067. const { apiKey } = await getOrRefreshTemporaryApiKey(state.projectRef)
  1068. req.setHeader('apikey', apiKey)
  1069. if (!IS_PLATFORM) {
  1070. req.setHeader('Authorization', `Bearer ${apiKey}`)
  1071. }
  1072. } catch (error) {
  1073. throw error
  1074. }
  1075. },
  1076. onShouldRetry(error) {
  1077. const status = error.originalResponse ? error.originalResponse.getStatus() : 0
  1078. const doNotRetryStatuses = [400, 403, 404, 409, 413, 415, 429]
  1079. return !doNotRetryStatuses.includes(status)
  1080. },
  1081. onError: (error) => {
  1082. numberOfFilesUploadedFail += 1
  1083. if (error instanceof tus.DetailedError) {
  1084. const status = error.originalResponse?.getStatus()
  1085. switch (status) {
  1086. case 415: {
  1087. // Unsupported mime type
  1088. toast.error(
  1089. capitalize(
  1090. error?.originalResponse?.getBody() ||
  1091. `Failed to upload ${file.name}: ${metadata.mimetype} is not allowed`
  1092. ),
  1093. {
  1094. description: `Allowed MIME types: ${state.selectedBucket.allowed_mime_types?.join(', ')}`,
  1095. }
  1096. )
  1097. break
  1098. }
  1099. case 413: {
  1100. // Payload too large
  1101. toast.error(
  1102. `Failed to upload ${file.name}: File size exceeds the bucket file size limit.`
  1103. )
  1104. break
  1105. }
  1106. case 409: {
  1107. // Resource already exists
  1108. toast.error(`Failed to upload ${file.name}: File name already exists.`)
  1109. break
  1110. }
  1111. case 400: {
  1112. const responseBody = error.originalResponse?.getBody()
  1113. if (typeof responseBody === 'string') {
  1114. if (responseBody.includes('Invalid key:')) {
  1115. toast.error(`Failed to upload ${file.name}: File name is invalid.`)
  1116. break
  1117. }
  1118. if (responseBody.includes('Invalid Compact JWS')) {
  1119. toast.error(`Failed to upload ${file.name}: Invalid Compact JWS.`)
  1120. break
  1121. }
  1122. }
  1123. // if it's not handled by the two ifs, fallthrough to the default case which shows the generic error message
  1124. }
  1125. default: {
  1126. toast.error(`Failed to upload ${file.name}: ${error.message}`)
  1127. break
  1128. }
  1129. }
  1130. } else {
  1131. toast.error(`Failed to upload ${file.name}: ${error.message}`)
  1132. }
  1133. reject(error)
  1134. },
  1135. onProgress: (bytesSent, bytesTotal) => {
  1136. if (startingBytes === 0 && bytesSent > chunkSize) {
  1137. startingBytes = bytesSent
  1138. }
  1139. const percentage = bytesTotal === 0 ? 0 : bytesSent / bytesTotal
  1140. const realBytesSent = bytesSent - startingBytes
  1141. const elapsed = (Date.now() - startTime) / 1000 // in seconds
  1142. const uploadSpeed = realBytesSent / elapsed // in bytes per second
  1143. const remainingBytes = bytesTotal - realBytesSent
  1144. const remainingTime = remainingBytes / uploadSpeed // in seconds
  1145. state.uploadProgresses[index] = {
  1146. percentage,
  1147. elapsed,
  1148. uploadSpeed,
  1149. remainingBytes,
  1150. remainingTime,
  1151. }
  1152. state.onUploadProgress(toastId)
  1153. },
  1154. onSuccess() {
  1155. numberOfFilesUploadedSuccess += 1
  1156. resolve()
  1157. },
  1158. })
  1159. if (!Array.isArray(state.abortUploadCallbacks[toastId])) {
  1160. state.abortUploadCallbacks[toastId] = []
  1161. }
  1162. state.abortUploadCallbacks[toastId].push(() => {
  1163. try {
  1164. upload.abort(true)
  1165. } catch (error) {
  1166. // Ignore error
  1167. }
  1168. reject(new Error('Upload aborted by user'))
  1169. })
  1170. // Check if there are any previous uploads to continue.
  1171. return upload.findPreviousUploads().then((previousUploads) => {
  1172. // Found previous uploads so we select the first one.
  1173. if (previousUploads.length) {
  1174. upload.resumeFromPreviousUpload(previousUploads[0])
  1175. }
  1176. upload.start()
  1177. })
  1178. })
  1179. }
  1180. })
  1181. // For file uploads specifically, we have to lower the batch size for now as the client side
  1182. // is just unable to handle such volumes of transfer
  1183. // [Joshen] I realised this can be simplified with just a vanilla for loop, no need for reduce
  1184. // Just take note, but if it's working fine, then it's okay
  1185. const batchedPromises = chunk(promises, 10)
  1186. try {
  1187. await batchedPromises.reduce(async (previousPromise, nextBatch) => {
  1188. await previousPromise
  1189. await Promise.allSettled(nextBatch.map((batch) => batch()))
  1190. state.onUploadProgress(toastId)
  1191. }, Promise.resolve())
  1192. if (numberOfFilesUploadedSuccess > 0) {
  1193. await deleteBucketObject({
  1194. projectRef: state.projectRef,
  1195. bucketId: state.selectedBucket.id,
  1196. paths: [`${pathToFile}/${EMPTY_FOLDER_PLACEHOLDER_FILE_NAME}`],
  1197. })
  1198. }
  1199. await state.refetchAllOpenedFolders()
  1200. if (
  1201. numberOfFilesToUpload === 0 ||
  1202. (numberOfFilesUploadedSuccess === 0 && numberOfFilesUploadedFail === 0)
  1203. ) {
  1204. toast.dismiss(toastId)
  1205. } else if (numberOfFilesUploadedFail === numberOfFilesToUpload) {
  1206. if (numberOfFilesToUpload === 1) {
  1207. // [Joshen] We'd already be showing a toast when the upload files, so this is to prevent a
  1208. // duplicate error toast if its only one file that's getting uploaded
  1209. toast.dismiss(toastId)
  1210. } else {
  1211. toast.error(
  1212. `Failed to upload ${numberOfFilesToUpload} file${numberOfFilesToUpload > 1 ? 's' : ''}!`,
  1213. { id: toastId, closeButton: true, duration: SONNER_DEFAULT_DURATION }
  1214. )
  1215. }
  1216. } else if (numberOfFilesUploadedSuccess === numberOfFilesToUpload) {
  1217. toast.success(
  1218. `Successfully uploaded ${numberOfFilesToUpload} file${
  1219. numberOfFilesToUpload > 1 ? 's' : ''
  1220. }!`,
  1221. { id: toastId, closeButton: true, duration: SONNER_DEFAULT_DURATION }
  1222. )
  1223. } else {
  1224. toast.success(
  1225. `Successfully uploaded ${numberOfFilesUploadedSuccess} out of ${numberOfFilesToUpload} file${
  1226. numberOfFilesToUpload > 1 ? 's' : ''
  1227. }!`,
  1228. { id: toastId, closeButton: true, duration: SONNER_DEFAULT_DURATION }
  1229. )
  1230. }
  1231. } catch (e) {
  1232. toast.error('Failed to upload files', {
  1233. id: toastId,
  1234. closeButton: true,
  1235. duration: SONNER_DEFAULT_DURATION,
  1236. })
  1237. }
  1238. const t2 = new Date()
  1239. console.log(
  1240. `Total time taken for ${formattedFilesToUpload.length} files: ${((t2 as any) - (t1 as any)) / 1000} seconds`
  1241. )
  1242. },
  1243. moveFiles: async (newPathToFile: string) => {
  1244. const newPaths = compact(newPathToFile.split('/'))
  1245. const formattedNewPathToFile = newPaths.join('/')
  1246. let numberOfFilesMovedFail = 0
  1247. state.clearSelectedItems()
  1248. const toastId = toast(
  1249. `Moving ${state.selectedItemsToMove.length} file${state.selectedItemsToMove.length > 1 ? 's' : ''}...`,
  1250. {
  1251. description: STORAGE_PROGRESS_INFO_TEXT,
  1252. duration: Infinity,
  1253. }
  1254. )
  1255. await Promise.all(
  1256. state.selectedItemsToMove.map(async (item) => {
  1257. const pathToFile = state.openedFolders
  1258. .slice(0, item.columnIndex)
  1259. .map((folder) => folder.name)
  1260. .join('/')
  1261. const fromPath = pathToFile.length > 0 ? `${pathToFile}/${item.name}` : item.name
  1262. const toPath =
  1263. newPathToFile.length > 0 ? `${formattedNewPathToFile}/${item.name}` : item.name
  1264. try {
  1265. await moveStorageObject({
  1266. projectRef: state.projectRef,
  1267. bucketId: state.selectedBucket.id,
  1268. from: fromPath,
  1269. to: toPath,
  1270. })
  1271. } catch (error: any) {
  1272. numberOfFilesMovedFail += 1
  1273. toast.error(error.message)
  1274. }
  1275. })
  1276. )
  1277. if (numberOfFilesMovedFail === state.selectedItemsToMove.length) {
  1278. toast.error('Failed to move files')
  1279. } else {
  1280. toast(
  1281. `Successfully moved ${
  1282. state.selectedItemsToMove.length - numberOfFilesMovedFail
  1283. } files to ${formattedNewPathToFile.length > 0 ? formattedNewPathToFile : 'the root of your bucket'}`
  1284. )
  1285. }
  1286. toast.dismiss(toastId)
  1287. // TODO: invalidate the file preview cache when moving files
  1288. await state.refetchAllOpenedFolders()
  1289. state.setSelectedItemsToMove([])
  1290. },
  1291. deleteFiles: async ({
  1292. files,
  1293. isDeleteFolder = false,
  1294. }: {
  1295. files: (StorageItemWithColumn & { prefix?: string })[]
  1296. isDeleteFolder?: boolean
  1297. }) => {
  1298. state.setSelectedFilePreview(undefined)
  1299. // If every file has the 'prefix' property, then just construct the prefix
  1300. // directly (from delete folder). Otherwise go by the opened folders.
  1301. const prefixes = !files.some((f) => f.prefix)
  1302. ? files.map((file) => {
  1303. const { name, columnIndex } = file
  1304. const pathToFile = state.openedFolders
  1305. .slice(0, columnIndex)
  1306. .map((folder) => folder.name)
  1307. .join('/')
  1308. state.updateRowStatus({ name, status: STORAGE_ROW_STATUS.LOADING, columnIndex })
  1309. return pathToFile.length > 0 ? `${pathToFile}/${name}` : name
  1310. })
  1311. : files.map((file) => `${file.prefix}/${file.name}`)
  1312. state.clearSelectedItems()
  1313. const toastId = toast.loading(`Deleting ${prefixes.length} file(s)...`)
  1314. try {
  1315. await deleteBucketObject({
  1316. projectRef: state.projectRef,
  1317. bucketId: state.selectedBucket.id,
  1318. paths: prefixes,
  1319. })
  1320. if (!isDeleteFolder) {
  1321. // If parent folders are empty, reinstate .emptyFolderPlaceholder to persist them
  1322. const parentFolderPrefixes = uniq(
  1323. prefixes.map((prefix) => {
  1324. const segments = prefix.split('/')
  1325. return segments.slice(0, segments.length - 1).join('/')
  1326. })
  1327. )
  1328. await Promise.all(
  1329. parentFolderPrefixes.map((prefix) => state.validateParentFolderEmpty(prefix))
  1330. )
  1331. toast.success(`Successfully deleted ${prefixes.length} file(s)`, {
  1332. id: toastId,
  1333. closeButton: true,
  1334. duration: SONNER_DEFAULT_DURATION,
  1335. description: undefined,
  1336. })
  1337. await state.refetchAllOpenedFolders()
  1338. state.setSelectedItemsToDelete([])
  1339. } else {
  1340. toast.dismiss(toastId)
  1341. }
  1342. } catch (err) {
  1343. if (!isDeleteFolder) {
  1344. toast.error(`Failed to delete ${prefixes.length} file(s)`, {
  1345. id: toastId,
  1346. closeButton: true,
  1347. duration: SONNER_DEFAULT_DURATION,
  1348. description: (err as ResponseError).message,
  1349. })
  1350. if (!files.some((f) => f.prefix)) {
  1351. files.forEach((file) => {
  1352. const { name, columnIndex } = file
  1353. state.updateRowStatus({ name, status: STORAGE_ROW_STATUS.READY, columnIndex })
  1354. })
  1355. }
  1356. } else {
  1357. toast.dismiss(toastId)
  1358. throw err
  1359. }
  1360. }
  1361. },
  1362. downloadFile: async (file: StorageItemWithColumn, showToast = true) => {
  1363. if (!file.path) {
  1364. toast.error('Failed to download: Unable to find path to file')
  1365. return false
  1366. }
  1367. const fileName: string = file.name
  1368. const fileMimeType = file?.metadata?.mimetype ?? undefined
  1369. const toastId = showToast ? toast.loading(`Retrieving ${fileName}...`) : undefined
  1370. try {
  1371. const url = await fetchFileUrl(
  1372. file.path,
  1373. state.projectRef,
  1374. state.selectedBucket.id,
  1375. state.selectedBucket.public
  1376. )
  1377. const response = await fetch(url)
  1378. if (!response.ok) throw new Error(`Failed to retrieve file ${file.path}`)
  1379. const data = await response.blob()
  1380. const newBlob = new Blob([data], { type: fileMimeType ?? data.type })
  1381. const blobUrl = window.URL.createObjectURL(newBlob)
  1382. const link = document.createElement('a')
  1383. link.href = blobUrl
  1384. link.setAttribute('download', `${fileName}`)
  1385. document.body.appendChild(link)
  1386. link.click()
  1387. link.parentNode?.removeChild(link)
  1388. window.URL.revokeObjectURL(blobUrl)
  1389. if (toastId) {
  1390. toast.success(`Downloading ${fileName}`, {
  1391. id: toastId,
  1392. closeButton: true,
  1393. duration: SONNER_DEFAULT_DURATION,
  1394. })
  1395. }
  1396. return true
  1397. } catch (err) {
  1398. if (toastId) {
  1399. toast.error(`Failed to download ${fileName}`, {
  1400. id: toastId,
  1401. closeButton: true,
  1402. duration: SONNER_DEFAULT_DURATION,
  1403. })
  1404. }
  1405. return false
  1406. }
  1407. },
  1408. downloadFileAsBlob: async (file: StorageItemWithColumn) => {
  1409. if (!file.path) return false
  1410. try {
  1411. const url = await fetchFileUrl(
  1412. file.path,
  1413. state.projectRef,
  1414. state.selectedBucket.id,
  1415. state.selectedBucket.public
  1416. )
  1417. const response = await fetch(url)
  1418. if (!response.ok) throw new Error(`Failed to retrieve file ${file.path}`)
  1419. const data = await response.blob()
  1420. const fileMimeType = file?.metadata?.mimetype ?? undefined
  1421. return { name: file.name, blob: new Blob([data], { type: fileMimeType ?? data.type }) }
  1422. } catch (err) {
  1423. console.error('Failed to download file', file.path)
  1424. return false
  1425. }
  1426. },
  1427. downloadSelectedFiles: async (files: StorageItemWithColumn[]) => {
  1428. const lowestColumnIndex = Math.min(...files.map((file) => file.columnIndex))
  1429. const formattedFilesWithPrefix: any[] = files.map((file) => {
  1430. const { name, columnIndex } = file
  1431. const pathToFile = state.openedFolders
  1432. .slice(lowestColumnIndex, columnIndex)
  1433. .map((folder) => folder.name)
  1434. .join('/')
  1435. const formattedPathToFile = pathToFile.length > 0 ? `${pathToFile}/${name}` : name
  1436. return { ...file, formattedPathToFile }
  1437. })
  1438. let progress = 0
  1439. const toastId = toast.loading(
  1440. `Downloading ${files.length} file${files.length > 1 ? 's' : ''}...`
  1441. )
  1442. const promises = formattedFilesWithPrefix.map((file) => {
  1443. return () => {
  1444. return new Promise<{ name: string; blob: Blob } | boolean>(async (resolve) => {
  1445. const data = await state.downloadFileAsBlob(file)
  1446. progress = progress + 1 / formattedFilesWithPrefix.length
  1447. if (isObject(data)) {
  1448. resolve({ ...data, name: file.formattedPathToFile })
  1449. }
  1450. resolve(false)
  1451. })
  1452. }
  1453. })
  1454. // Increase batch size to 50 since Storage API doesn't throttle like Management API
  1455. const batchedPromises = chunk(promises, 50)
  1456. const downloadedFiles = await batchedPromises.reduce(async (previousPromise, nextBatch) => {
  1457. const previousResults = await previousPromise
  1458. const batchResults = await Promise.allSettled(nextBatch.map((batch) => batch()))
  1459. toast(
  1460. <SonnerProgress
  1461. progress={progress * 100}
  1462. message={`Downloading ${files.length} file${files.length > 1 ? 's' : ''}...`}
  1463. />,
  1464. { id: toastId, closeButton: false, position: 'top-right' }
  1465. )
  1466. return previousResults.concat(batchResults.map((x: any) => x.value).filter(Boolean))
  1467. }, Promise.resolve<{ name: string; blob: Blob }[]>([]))
  1468. const zipFileWriter = new BlobWriter('application/zip')
  1469. const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
  1470. downloadedFiles.forEach((file) => {
  1471. zipWriter.add(file.name, new BlobReader(file.blob))
  1472. })
  1473. const blobURL = URL.createObjectURL(await zipWriter.close())
  1474. const link = document.createElement('a')
  1475. link.href = blobURL
  1476. link.setAttribute('download', `briven-files.zip`)
  1477. document.body.appendChild(link)
  1478. link.click()
  1479. link.parentNode?.removeChild(link)
  1480. toast.success(`Successfully downloaded ${downloadedFiles.length} files`, {
  1481. id: toastId,
  1482. closeButton: true,
  1483. duration: SONNER_DEFAULT_DURATION,
  1484. })
  1485. },
  1486. renameFile: async (file: StorageItem, newName: string, columnIndex: number) => {
  1487. const originalName = file.name
  1488. if (originalName === newName || newName.length === 0) {
  1489. state.updateRowStatus({ name: originalName, status: STORAGE_ROW_STATUS.READY, columnIndex })
  1490. } else {
  1491. state.updateRowStatus({
  1492. name: originalName,
  1493. status: STORAGE_ROW_STATUS.LOADING,
  1494. columnIndex,
  1495. updatedName: newName,
  1496. })
  1497. const pathToFile = getPathAlongFoldersToIndex(state, columnIndex)
  1498. const fromPath = pathToFile.length > 0 ? `${pathToFile}/${originalName}` : originalName
  1499. const toPath = pathToFile.length > 0 ? `${pathToFile}/${newName}` : newName
  1500. try {
  1501. await moveStorageObject({
  1502. projectRef: state.projectRef,
  1503. bucketId: state.selectedBucket.id,
  1504. from: fromPath,
  1505. to: toPath,
  1506. })
  1507. toast.success(`Successfully renamed "${originalName}" to "${newName}"`)
  1508. // TODO: Should we invalidate the file preview cache when renaming files?
  1509. if (state.selectedFilePreview?.name === originalName) {
  1510. const { previewUrl, ...fileData } = file as any
  1511. state.setSelectedFilePreview({ ...fileData, name: newName })
  1512. }
  1513. await state.refetchAllOpenedFolders()
  1514. } catch (error: any) {
  1515. toast.error(`Failed to rename file: ${error.message}`)
  1516. state.updateRowStatus({
  1517. name: originalName,
  1518. status: STORAGE_ROW_STATUS.READY,
  1519. columnIndex,
  1520. })
  1521. }
  1522. }
  1523. },
  1524. // ======== UI Helper functions ========
  1525. selectRangeItems: (columnIndex: number, toItemIndex: number) => {
  1526. const columnItems = state.columns[columnIndex].items
  1527. const toItem = columnItems[toItemIndex]
  1528. const selectedItemIds = state.selectedItems.map((item) => item.id)
  1529. const lastSelectedItemId = selectedItemIds[selectedItemIds.length - 1]
  1530. const lastSelectedItemIndex = findIndex(columnItems, { id: lastSelectedItemId })
  1531. // Get the start and end index of the range to select
  1532. const start = Math.min(toItemIndex, lastSelectedItemIndex)
  1533. const end = Math.max(toItemIndex, lastSelectedItemIndex)
  1534. // Get the range to select and reverse the order if necessary
  1535. const rangeToSelect = columnItems
  1536. .slice(start, end + 1)
  1537. // we need `columnIndex` in all item of `selectedItems`
  1538. .map((item) => ({ ...item, columnIndex }))
  1539. if (toItemIndex < lastSelectedItemIndex) {
  1540. rangeToSelect.reverse()
  1541. }
  1542. if (selectedItemIds.includes(toItem.id)) {
  1543. const rangeToDeselectIds = rangeToSelect.map((item) => item.id)
  1544. // Deselect all items within the selection range
  1545. state.setSelectedItems(
  1546. state.selectedItems.filter(
  1547. (item) => item.id === toItem.id || !rangeToDeselectIds.includes(item.id)
  1548. )
  1549. )
  1550. } else {
  1551. // Select items within the range
  1552. state.setSelectedItems(uniqBy(state.selectedItems.concat(rangeToSelect), 'id'))
  1553. }
  1554. },
  1555. addTempRow: ({
  1556. type,
  1557. name,
  1558. status,
  1559. columnIndex,
  1560. metadata,
  1561. isPrepend = false,
  1562. }: {
  1563. type: STORAGE_ROW_TYPES
  1564. name: string
  1565. status: STORAGE_ROW_STATUS
  1566. columnIndex: number
  1567. metadata: StorageItemMetadata | null
  1568. isPrepend?: boolean
  1569. }) => {
  1570. const updatedColumns = state.columns.map((column, idx) => {
  1571. if (idx === columnIndex) {
  1572. const tempRow = { type, name, status, metadata } as StorageItem
  1573. const updatedItems = isPrepend
  1574. ? [tempRow].concat(column.items)
  1575. : column.items.concat([tempRow])
  1576. return { ...column, items: updatedItems }
  1577. }
  1578. return column
  1579. })
  1580. state.columns = updatedColumns
  1581. },
  1582. removeTempRows: (columnIndex: number) => {
  1583. const updatedColumns = state.columns.map((column, idx) => {
  1584. if (idx === columnIndex) {
  1585. const updatedItems = column.items.filter((item) => has(item, 'id'))
  1586. return { ...column, items: updatedItems }
  1587. }
  1588. return column
  1589. })
  1590. state.columns = updatedColumns
  1591. },
  1592. onUploadProgress: (toastId?: string | number) => {
  1593. const totalFiles = state.uploadProgresses.length
  1594. const progress =
  1595. (state.uploadProgresses.reduce((acc, { percentage }) => acc + percentage, 0) / totalFiles) *
  1596. 100
  1597. const remainingTime = calculateTotalRemainingTime(state.uploadProgresses)
  1598. return toast(
  1599. <SonnerProgress
  1600. progress={progress}
  1601. message={`Uploading ${totalFiles} file${totalFiles > 1 ? 's' : ''}...`}
  1602. progressPrefix={`${remainingTime && !isNaN(remainingTime) && isFinite(remainingTime) && remainingTime !== 0 ? `${formatTime(remainingTime)} remaining – ` : ''}`}
  1603. action={
  1604. toastId && (
  1605. <Button
  1606. size="tiny"
  1607. type="default"
  1608. className="ml-6"
  1609. onClick={() => state.abortUploads(toastId)}
  1610. >
  1611. Cancel
  1612. </Button>
  1613. )
  1614. }
  1615. />,
  1616. { id: toastId, closeButton: false, position: 'top-right', duration: Infinity }
  1617. )
  1618. },
  1619. updateRowStatus: ({
  1620. name,
  1621. status,
  1622. columnIndex,
  1623. updatedName,
  1624. }: {
  1625. name: string
  1626. status: STORAGE_ROW_STATUS
  1627. columnIndex?: number
  1628. updatedName?: string
  1629. }) => {
  1630. const columnIndex_ = columnIndex !== undefined ? columnIndex : state.getLatestColumnIndex()
  1631. const updatedColumns = state.columns.map((column, idx) => {
  1632. if (idx === columnIndex_) {
  1633. const updatedColumnItems = column.items.map((item) => {
  1634. return item.name === name
  1635. ? {
  1636. ...item,
  1637. status,
  1638. ...(updatedName && { name: updatedName }),
  1639. }
  1640. : item
  1641. })
  1642. return { ...column, items: updatedColumnItems }
  1643. }
  1644. return column
  1645. })
  1646. state.columns = updatedColumns
  1647. },
  1648. })
  1649. return state
  1650. }
  1651. export type StorageExplorerState = ReturnType<typeof createStorageExplorerState>
  1652. const DEFAULT_STATE_CONFIG = {
  1653. projectRef: '',
  1654. connectionString: '',
  1655. resumableUploadUrl: '',
  1656. clientEndpoint: '',
  1657. bucket: {} as Bucket,
  1658. }
  1659. const StorageExplorerStateContext = createContext<StorageExplorerState>(
  1660. createStorageExplorerState(DEFAULT_STATE_CONFIG)
  1661. )
  1662. export const StorageExplorerStateContextProvider = ({ children }: PropsWithChildren) => {
  1663. const { data: project } = useSelectedProjectQuery()
  1664. const { data: bucket } = useSelectedBucket()
  1665. const isPaused = project?.status === PROJECT_STATUS.INACTIVE
  1666. const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG))
  1667. const stateRef = useLatest(state)
  1668. const {
  1669. storageEndpoint,
  1670. hostEndpoint,
  1671. isSuccess: isSuccessSettings,
  1672. } = useProjectApiUrl({ projectRef: project?.ref })
  1673. // [Joshen] JFYI opting with the useEffect here as the storage explorer state was being loaded
  1674. // before the project details were ready, hence the store kept returning project ref as undefined
  1675. // Can be verified when we're saving the storage explorer preferences into local storage, that ref is undefined
  1676. // So the useEffect here is to make sure that the project ref is loaded into the state properly
  1677. // Although I'd be keen to re-investigate this to see if we can remove this
  1678. useEffect(() => {
  1679. const hasDataReady = !!project && !!bucket
  1680. const storeAlreadyLoaded = state.projectRef === project?.ref
  1681. if (!isPaused && hasDataReady && !storeAlreadyLoaded && isSuccessSettings) {
  1682. const clientEndpoint = storageEndpoint ?? hostEndpoint ?? ''
  1683. const resumableUploadUrl = `${clientEndpoint}/storage/v1/upload/resumable`
  1684. setState(
  1685. createStorageExplorerState({
  1686. projectRef: project.ref,
  1687. connectionString: project.connectionString ?? '',
  1688. bucket,
  1689. resumableUploadUrl,
  1690. clientEndpoint,
  1691. })
  1692. )
  1693. }
  1694. }, [
  1695. state.projectRef,
  1696. project,
  1697. stateRef,
  1698. isPaused,
  1699. hostEndpoint,
  1700. storageEndpoint,
  1701. isSuccessSettings,
  1702. bucket,
  1703. ])
  1704. return (
  1705. <StorageExplorerStateContext.Provider value={state}>
  1706. {children}
  1707. </StorageExplorerStateContext.Provider>
  1708. )
  1709. }
  1710. export function useStorageExplorerStateSnapshot(options?: Parameters<typeof useSnapshot>[1]) {
  1711. const state = useContext(StorageExplorerStateContext)
  1712. return useSnapshot(state, options) as StorageExplorerState
  1713. }