瀏覽代碼

Provider views rewrite (.files, .folders => .partialTree) (#5050)

* ProviderView.tsx - fix onedrive breadcrumbs

* providers - correct ch-unch-indeterminate states

* providers - made .breadcrumbs derived from .partialTree

* everywhere - { files, folders, isChecked } => .partialTree

GoogleDrive
- travelling down into folders works
- checking a file works
- breadcrumbs DONT work

* GoogleDrive - made breadcrumbs work

* .getFolder() - remove the `name` argument

* <Breadcrumbs/> - refactors "/"

* Instagram - made files get fetched onScroll

* clearSelection() - recover the functionality

* GoogleDrive - recover custom `.toggleCheckbox()` functionality

* providers - recover `.isDisabled` functionality

* <SearchProviderView/> - made Unsplash use .partialTree

* Facebook - change `.files, .folders` => `.partialTree`

* everywhere - we don't need to ! `partialTreeFile.data` anymore

* <ProviderView/> - implement folder caching

* <View/> - enable shift-clicking

* everywhere - get rid of unnecessary `.getNextFolder()`

* everywhere - fixing types

* <ProviderView/> - rename `requestPath` to `folderId`

* all providers - get rid of `.onFirstRender()`

* provider views - get rid of `.onFirstRender()`

* <ProviderView/> - make the root folder cacheable too

* TEMP - setup for working with FOLDERS + LAZY_LOADING

* <ProviderView/> - get rid of `.#listFilesAndFolders`

* <ProviderView/> - make `this.nextPagePath` per-folder

* everywhere - more refined types

* types - reintroduce `StatusInPartialTree`

* <SearchProviderView/> - made Unsplash work with the new structure

* <ProviderView/> - preemptive cleaning of `.absDirPath` and `.relDirPath`

* <ProviderView/> - give `.nextPagePath` a rigorous type

* <ProviderView/> - make `.nextPagePath` & `.cached` a composite key

* <ListItem/> - remove unnecessary indirection level

* css - factor out `.statusClassName`

* everywhere - refactor `.validateRestrictions()`

* nOfSelectedFiles - make "Selected (n)" as smart as possible

* <ProviderViews/> - prevent shift-clicking from highlighting file names

* `.validateRestrictions()` - make it accept a `CompanionFile` instead of `PartialTree`'s file

* `.getFolder()` - simplify code

* everywhere - account for `restrictions` in `.partialTree`

* `PartialTreeUtils.ts` - factor out `getPartialTreeAfterTogglingCheckboxes()`

* `PartialTreeUtils.ts` - factor out `clickOnFolder()`

* `PartialTreeUtils.ts` - factor out `getPartialTreeAfterScroll()`

* `PartialTreeUtils.ts` - rename methods

* `.donePicking()` - implement using recursion

* `.donePicking()` - integrate with `<ProviderView/>`

* `donePicking()` - show notifications after addition

* `#list()` - get rid of unnecessary indirection

* ProviderView.tsx - add `signal` everywhere, reduce try/catch indents everywhere

* `handleError()` - make error handling uniform

* `state.isSearchVisible` - remove, it's just not used anywhere

* state - reuse default state

* state - reset state on close panel (like we discussed in the uppy call)

* methods - remove unnecessary indirection in state setting

* `<CloseWrapper/>` - remove CloseWrappers, this is unnecessary indirection now too

* `this.requestClientId` - remove, again - this was unnecessary indirection

* `getTagFile()` - factor out into a separate file

* `recordShiftKeyPress()` - fix chaotic shift-clicking in Grid providers, remove endless prop drilling while we're at it

* `getNOfSelectedFiles.ts`, `filterItems.ts` - factor out, this removes props drilling

* <Browser/> - pass `displayedPartialTree` right away (because Search&NormalProvider have wildly different logics!)

* `searchTerm`, `filterInput` - we only need one of these of course!

* <SearchProvider/> - fix the issue where `afterToggleCheckbox()` thinks we should always filter by `searchString`

* <SearchProvider/> - remove `this.nextPageQuery`

Also: fix the issue where <SearchProvider/> upon searching for "ocean" and then "pajama" would just be adding pajama pictures after the ocean ones

* <Browser/> - remove unnecessary prop indirection

Typescript didn't actually know some of these props aren't used (removed those now)! It only discovers unused props upon normal props passing, like we do now.

* <SearchFilterInput/> - make the form controlled, hugely simplifies everything

* `filterItems.ts` - move to <ProviderView/>, because it's only used there

* /utils/PartialTreeUtils.ts - put every util in a separate file

* `shouldHandleScroll.ts` - factor out into a util

This brings all references to `this.isHandlingScroll` into a single place, and makes `shouldHandleScroll()` a self-contained simple function

* this.state - make sure state is reset 1. on cancel 2. on close

* `this.xxx` - never leave `this.xxx` variables undefined

* `this.username` - should be in `this.state`

Also - when there is no username, stop showing the little dot

* `SearchProviderPluginState` type - simplify this type, never leave state vars undefined

* <Header/> - remove completely unnecessary indirection, remove unused props

* Facebook.tsx - more sane `viewOptions` code

* providers - properly type `opts`

* `this.isShiftKeyPressed` - move this variable into <Browser/>

* `this.handleError()` - move to /utils

* `this.isHandlingScroll` - move to child classes

* `this.registerRequestClient()` - move to child classes

* `this.lastCheckbox` - move to child classes

* `this.setLoading()` - move to child classes

* `this.validateRestrictions()` - move to utils

* types - fully simplify provider types, remove `View.ts` parent class

* index.d.ts - we're not using `OnFirstRenderer` anymore

* <ProviderView/>, <SearchProviderView/> - more precise typing for options

* package.json - remove nanoid

* GoogleDrive - make shift-clicking work

* everywhere - fix types across uppy

* `afterToggleCheckbox.ts` - less redundant args, pass `ourItem.id` instead of `ourItem`

* tests - create `afterToggleCheckbox()` tests

* `getClickedRange.ts` - decouple `getClickedRange()` from `afterToggleCheckbox()`

* tests - wrote tests for `afterToggleCheckbox.ts`

* tests - wrote tests for `afterClickOnFolder.ts`

* everywhere - finally rename `getFolder` => `openFolder`

* tests - wrote tests for `afterScrollFolder.ts`

* getPaths.ts - make `absDirPath`, `relDirPath` work like in docs & add tests for that

* injectPaths.ts - improve performance

* getTagFile.ts - handle path injection all in one place

* getTagFile.ts - refactor

Just makes it easier to read the structure of TagFile

* fill.ts - `provider.list(currentPath, { signal })` => `apiList`

(remove the dependency on provider, just pass a callback)

* tests - wrote tests for `fill.ts`

* tests - wrote tests for `getNOfSelectedFiles.ts`

* everywhere - change `JSON.stringify()` => `clone()`

* `PartialTreeUtils.ts` - more consistent function naming + alphabetical order in tests

* `donePicking()` - superseded a notification to i18n one

* GoogleDrive - make the shared drive checkable

* `Item.tsx` - standardize names; remove unnecessary question marks from props

* ProviderView.tsx - clicking "Cancel" should make all files "unchecked"

* everywhere - move `document.getSelection()?.removeAllRanges()` to <Browser/> to avoid repetition

* everywhere - standardize names and types of passed props

* <Browser/> - only leave "list of files" to the browser

Moves stuff closer to where it's used, prevents props drilling

* TEMP - easier pageSize for alex to play with

When it's set to 5 pages you have to reduce the browser window to make it scrollable

* everywhere - only handle individual-file restrictions

* everywhere - add aggregate restrictions on top

* SearchProvider, NormalProvider - unite the way we addFiles()

Same notifications, same code, same everything

* `getTagFile.ts` - pass fewer arguments

* `addFiles.ts` - move conversion to tagFiles into `addFiles()`

* `uppy.validateRestrictions()` - remove legacy method

* `uppy.validateAggregateRestrictions()` - make aggregate restricter report aggregate error

* <FooterActions/> css - make aggregate errors look nice

* `PartialTreeUtils/index.test.ts` - accommodate tests to the latest changes

* tests - make all uppy tests work

* prettiness - run `yarn format`

* prettiness - run `yarn lint:fix`

* package.json - add `vitest` as a dev dependency

* eslint - fixing 1

eslint - fixing 2

eslint - fixing 3

* <SearchFilterInput/> - add default props as per eslint

* <SearchFilterInput/> - rename to <SearchInput/>

* eslint - fixing 4 (clone.ts)

* Uppy.ts - rewrite `partialTree` docs

* eslint - fixing 5

* eslint - fixing 6

* `getBreadcrumbs.ts` - factor out

* tests - fixing 7

* everywhere - remove `.toReversed()`, because it's not yet supported in all browsers

* dev/Dashboard.js - restore to pristine version

* prettiness - run `yarn format`

* fixing 8 (`yarn run build:ts`)

* fixing 9 (run `corepack yarn`)

* prettier - undo indentation harm done by prettier

* `getBreadcrumbs()` - add tests, and rewrite to avoid using `.toReversed()`

Clarification: `.toReversed()` is no supported by all browsers

* `<SearchInput/>` - make it work for eslint

* everywhere - remove `eslint-disable react/require-default-props`

* <GridItem/>, <ListItem/> - refactor to avoid prop drilling

* <ListItem/> - disable checkboxes for GoogleDrive team drives

See #5232

* merge (fixing up some lines from the previous merge)

* merge (fixing up some lines from the previous merge)

* everywhere - remove TEMP development values

* `this.validateSingleFile()` - switch to `.restrictionError`

* `afterToggleCheckbox.ts` - refactor, add comments

* `afterToggleCheckbox.ts` - refactor to use ids instead of whole objects

* `afterToggleCheckbox.ts` - try to satisfy prettier

* fixing 10 (try to satisfy `npx webpack`)

* fixing 11 (try to satisfy `npx webpack`)

* Antoine: use Math.min & Math.max in `getClickedRange()`

Co-authored-by: Antoine du Hamel <antoine@transloadit.com>

* fixing 12 (run `yarn run format`)

* `clone.ts` - rename to `shallowClone.ts`

* Antoine: in `package.json`, move `devDependencies` up

* Antoine: rename `getNOfSelectedFiles()` to `getNumberOfSelectedFiles()`

* `getNumberOfSelectedFiles()` - better comments

* Antoine: remove `<form/>` tag

* Antoine: change `{}` to `Object.create(null)`, write tests

* Antoine: `<SearchInput/>` - return dynamic <form/> element

* `<SearchInput/>` - return `buttonCSSClassName`

* `GoogleDrive.tsx` - make team drive checkboxes visible

* merge (more)

* Mifi: update packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx

Co-authored-by: Mikael Finstad <finstaden@gmail.com>

* merge (more changes)

* `Facebook.tsx`, `GooglePhotos.tsx` - render in 'grid' style on per-folder basis

* `<GridItem/>` - use the `.thumbnail` whenever possible (improves image quality, adds video icons)

* `prettier` - ensure `PartialTree` is always strongly indented in tests

---------

Co-authored-by: Antoine du Hamel <antoine@transloadit.com>
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
Evgenia Karunus 10 月之前
父節點
當前提交
4dc28609d7
共有 43 個文件被更改,包括 2481 次插入1549 次删除
  1. 1 1
      packages/@uppy/companion-client/src/Provider.ts
  2. 5 9
      packages/@uppy/core/src/Uppy.test.ts
  3. 96 27
      packages/@uppy/core/src/Uppy.ts
  4. 5 1
      packages/@uppy/core/src/_common.scss
  5. 13 13
      packages/@uppy/facebook/src/Facebook.tsx
  6. 12 8
      packages/@uppy/google-drive/src/DriveProviderViews.ts
  7. 7 4
      packages/@uppy/google-photos/src/GooglePhotos.tsx
  8. 3 0
      packages/@uppy/provider-views/package.json
  9. 17 35
      packages/@uppy/provider-views/src/Breadcrumbs.tsx
  10. 85 238
      packages/@uppy/provider-views/src/Browser.tsx
  11. 0 13
      packages/@uppy/provider-views/src/CloseWrapper.ts
  12. 57 23
      packages/@uppy/provider-views/src/FooterActions.tsx
  13. 29 50
      packages/@uppy/provider-views/src/Item/components/GridItem.tsx
  14. 59 73
      packages/@uppy/provider-views/src/Item/components/ListItem.tsx
  15. 38 55
      packages/@uppy/provider-views/src/Item/index.tsx
  16. 2 2
      packages/@uppy/provider-views/src/ProviderView/AuthView.tsx
  17. 27 15
      packages/@uppy/provider-views/src/ProviderView/Header.tsx
  18. 350 498
      packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
  19. 6 4
      packages/@uppy/provider-views/src/ProviderView/User.tsx
  20. 47 50
      packages/@uppy/provider-views/src/SearchInput.tsx
  21. 266 144
      packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx
  22. 0 261
      packages/@uppy/provider-views/src/View.ts
  23. 21 2
      packages/@uppy/provider-views/src/style.scss
  24. 1 1
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss
  25. 9 6
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss
  26. 24 12
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-checkbox.scss
  27. 108 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterFill.ts
  28. 63 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterOpenFolder.ts
  29. 65 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterScrollFolder.ts
  30. 122 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterToggleCheckbox.ts
  31. 31 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/getBreadcrumbs.ts
  32. 74 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/getCheckedFilesWithPaths.ts
  33. 25 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/getNumberOfSelectedFiles.ts
  34. 612 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/index.test.ts
  35. 11 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/index.ts
  36. 11 0
      packages/@uppy/provider-views/src/utils/PartialTreeUtils/shallowClone.ts
  37. 47 0
      packages/@uppy/provider-views/src/utils/addFiles.ts
  38. 33 0
      packages/@uppy/provider-views/src/utils/getClickedRange.ts
  39. 51 0
      packages/@uppy/provider-views/src/utils/getTagFile.ts
  40. 29 0
      packages/@uppy/provider-views/src/utils/handleError.ts
  41. 8 0
      packages/@uppy/provider-views/src/utils/shouldHandleScroll.ts
  42. 9 3
      packages/@uppy/utils/src/CompanionClientProvider.ts
  43. 2 1
      yarn.lock

+ 1 - 1
packages/@uppy/companion-client/src/Provider.ts

@@ -366,7 +366,7 @@ export default class Provider<M extends Meta, B extends Body>
   }
 
   list<ResBody>(
-    directory: string | undefined,
+    directory: string | null,
     options: RequestOptions,
   ): Promise<ResBody> {
     return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)

+ 5 - 9
packages/@uppy/core/src/Uppy.test.ts

@@ -2160,7 +2160,7 @@ describe('src/Core', () => {
       )
     })
 
-    it('should check if a file validateRestrictions', () => {
+    it('should report error on validateSingleFile', () => {
       const core = new Core({
         restrictions: {
           minFileSize: 300000,
@@ -2185,17 +2185,13 @@ describe('src/Core', () => {
         size: 270733,
       }
 
-      // @ts-ignore
-      const validateRestrictions1 = core.validateRestrictions(newFile)
-      // @ts-ignore
-      const validateRestrictions2 = core2.validateRestrictions(newFile)
+      const validateRestrictions1 = core.validateSingleFile(newFile)
+      const validateRestrictions2 = core2.validateSingleFile(newFile)
 
-      expect(validateRestrictions1!.message).toEqual(
+      expect(validateRestrictions1).toEqual(
         'This file is smaller than the allowed size of 293 KB',
       )
-      expect(validateRestrictions2!.message).toEqual(
-        'You can only upload: image/png',
-      )
+      expect(validateRestrictions2).toEqual('You can only upload: image/png')
     })
 
     it('should emit `restriction-failed` event when some rule is violated', () => {

+ 96 - 27
packages/@uppy/core/src/Uppy.ts

@@ -58,23 +58,86 @@ export type UnknownPlugin<
   PluginState extends Record<string, unknown> = Record<string, unknown>,
 > = BasePlugin<any, M, B, PluginState>
 
-// `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`.
-type OmitFirstArg<T> = T extends [any, ...infer U] ? U : never
+/**
+ * ids are always `string`s, except the root folder's id can be `null`
+ */
+export type PartialTreeId = string | null
+
+export type PartialTreeStatusFile = 'checked' | 'unchecked'
+export type PartialTreeStatus = PartialTreeStatusFile | 'partial'
+
+export type PartialTreeFile = {
+  type: 'file'
+  id: string
+
+  /**
+   * There exist two types of restrictions:
+   * - individual restrictions (`allowedFileTypes`, `minFileSize`, `maxFileSize`), and
+   * - aggregate restrictions (`maxNumberOfFiles`, `maxTotalFileSize`).
+   *
+   * `.restrictionError` reports whether this file passes individual restrictions.
+   *
+   */
+  restrictionError: string | null
+
+  status: PartialTreeStatusFile
+  parentId: PartialTreeId
+  data: CompanionFile
+}
+
+export type PartialTreeFolderNode = {
+  type: 'folder'
+  id: string
+
+  /**
+   * Consider `(.nextPagePath, .cached)` a composite key that can represent 4 states:
+   * - `{ cached: true, nextPagePath: null }` - we fetched all pages in this folder
+   * - `{ cached: true, nextPagePath: 'smth' }` - we fetched 1st page, and there are still pages left to fetch in this folder
+   * - `{ cached: false, nextPagePath: null }` - we didn't fetch the 1st page in this folder
+   * - `{ cached: false, nextPagePath: 'someString' }` - ❌ CAN'T HAPPEN ❌
+   */
+  cached: boolean
+  nextPagePath: PartialTreeId
+
+  status: PartialTreeStatus
+  parentId: PartialTreeId
+  data: CompanionFile
+}
+
+export type PartialTreeFolderRoot = {
+  type: 'root'
+  id: PartialTreeId
+
+  cached: boolean
+  nextPagePath: PartialTreeId
+}
+
+export type PartialTreeFolder = PartialTreeFolderNode | PartialTreeFolderRoot
+
+/**
+ * PartialTree has the following structure.
+ *
+ *           FolderRoot
+ *         ┌─────┴─────┐
+ *     FolderNode     File
+ *   ┌─────┴────┐
+ *  File      File
+ *
+ * Root folder is called `PartialTreeFolderRoot`,
+ * all other folders are called `PartialTreeFolderNode`, because they are "internal nodes".
+ *
+ * It's possible for `PartialTreeFolderNode` to be a leaf node if it doesn't contain any files.
+ */
+export type PartialTree = (PartialTreeFile | PartialTreeFolder)[]
 
 export type UnknownProviderPluginState = {
   authenticated: boolean | undefined
-  breadcrumbs: {
-    requestPath?: string
-    name?: string
-    id?: string
-  }[]
   didFirstRender: boolean
-  currentSelection: CompanionFile[]
-  filterInput: string
+  searchString: string
   loading: boolean | string
-  folders: CompanionFile[]
-  files: CompanionFile[]
-  isSearchVisible: boolean
+  partialTree: PartialTree
+  currentFolderId: PartialTreeId
+  username: string | null
 }
 /*
  * UnknownProviderPlugin can be any Companion plugin (such as Google Drive).
@@ -89,8 +152,8 @@ export type UnknownProviderPlugin<
   M extends Meta,
   B extends Body,
 > = UnknownPlugin<M, B, UnknownProviderPluginState> & {
-  rootFolderId: string | null
   title: string
+  rootFolderId: string | null
   files: UppyFile<M, B>[]
   icon: () => h.JSX.Element
   provider: CompanionClientProvider
@@ -111,16 +174,10 @@ export type UnknownProviderPlugin<
  * `SearchProvider` does operate on Companion plugins with `uppy.getPlugin()`.
  */
 export type UnknownSearchProviderPluginState = {
-  isInputMode?: boolean
-  searchTerm?: string | null
+  isInputMode: boolean
 } & Pick<
   UnknownProviderPluginState,
-  | 'loading'
-  | 'files'
-  | 'folders'
-  | 'currentSelection'
-  | 'filterInput'
-  | 'didFirstRender'
+  'loading' | 'searchString' | 'partialTree' | 'currentFolderId'
 >
 export type UnknownSearchProviderPlugin<
   M extends Meta,
@@ -296,6 +353,9 @@ export interface UppyEventMap<M extends Meta, B extends Body>
   'upload-start': (files: UppyFile<M, B>[]) => void
 }
 
+/** `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`. */
+type OmitFirstArg<T> = T extends [any, ...infer U] ? U : never
+
 const defaultUploadState = {
   totalProgress: 0,
   allowNewUpload: true,
@@ -780,14 +840,23 @@ export class Uppy<M extends Meta, B extends Body = Record<string, never>> {
     }
   }
 
-  validateRestrictions(
-    file: ValidateableFile<M, B>,
-    files: ValidateableFile<M, B>[] = this.getFiles(),
-  ): RestrictionError<M, B> | null {
+  validateSingleFile(file: ValidateableFile<M, B>): string | null {
+    try {
+      this.#restricter.validateSingleFile(file)
+    } catch (err) {
+      return err.message
+    }
+    return null
+  }
+
+  validateAggregateRestrictions(
+    files: ValidateableFile<M, B>[],
+  ): string | null {
+    const existingFiles = this.getFiles()
     try {
-      this.#restricter.validate(files, [file])
+      this.#restricter.validateAggregateRestrictions(existingFiles, files)
     } catch (err) {
-      return err as any
+      return err.message
     }
     return null
   }

+ 5 - 1
packages/@uppy/core/src/_common.scss

@@ -127,7 +127,7 @@
   background-color: $blue;
   border-radius: 4px;
 
-  &:hover {
+  &:not(:disabled):hover {
     background-color: darken($blue, 10%);
   }
 
@@ -145,6 +145,10 @@
 
     @include blue-border-focus--dark;
   }
+
+  &.uppy-c-btn--disabled {
+    background-color: rgb(142, 178, 219);
+  }
 }
 
 .uppy-c-btn-link {

+ 13 - 13
packages/@uppy/facebook/src/Facebook.tsx

@@ -103,19 +103,19 @@ export default class Facebook<M extends Meta, B extends Body> extends UIPlugin<
   }
 
   render(state: unknown): ComponentChild {
-    const viewOptions: {
-      viewType?: string
-      showFilter?: boolean
-      showTitles?: boolean
-    } = {}
-    if (
-      this.getPluginState().files.length &&
-      !this.getPluginState().folders.length
-    ) {
-      viewOptions.viewType = 'grid'
-      viewOptions.showFilter = false
-      viewOptions.showTitles = false
+    const { partialTree, currentFolderId } = this.getPluginState()
+
+    const foldersInThisFolder = partialTree.filter(
+      (i) => i.type === 'folder' && i.parentId === currentFolderId,
+    )
+
+    if (foldersInThisFolder.length === 0) {
+      return this.view.render(state, {
+        viewType: 'grid',
+        showFilter: false,
+        showTitles: false,
+      })
     }
-    return this.view.render(state, viewOptions)
+    return this.view.render(state)
   }
 }

+ 12 - 8
packages/@uppy/google-drive/src/DriveProviderViews.ts

@@ -1,18 +1,22 @@
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+} from '@uppy/core/lib/Uppy'
 import { ProviderViews } from '@uppy/provider-views'
-import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 
 export default class DriveProviderViews<
   M extends Meta,
   B extends Body,
 > extends ProviderViews<M, B> {
-  toggleCheckbox(e: Event, file: CompanionFile): void {
-    e.stopPropagation()
-    e.preventDefault()
-
-    // Shared Drives aren't selectable; for all else, defer to the base ProviderView.
-    if (!file.custom!.isSharedDrive) {
-      super.toggleCheckbox(e, file)
+  toggleCheckbox(
+    item: PartialTreeFolderNode | PartialTreeFile,
+    isShiftKeyPressed: boolean,
+  ): void {
+    // We don't allow to check team drives; but we leave the checkboxes visible to show the 'partial' state
+    // (For a full explanation, see https://github.com/transloadit/uppy/issues/5232)
+    if (!item.data.custom?.isSharedDrive) {
+      super.toggleCheckbox(item, isShiftKeyPressed)
     }
   }
 }

+ 7 - 4
packages/@uppy/google-photos/src/GooglePhotos.tsx

@@ -114,10 +114,13 @@ export default class GooglePhotos<
   }
 
   render(state: unknown): ComponentChild {
-    if (
-      this.getPluginState().files.length &&
-      !this.getPluginState().folders.length
-    ) {
+    const { partialTree, currentFolderId } = this.getPluginState()
+
+    const foldersInThisFolder = partialTree.filter(
+      (i) => i.type === 'folder' && i.parentId === currentFolderId,
+    )
+
+    if (foldersInThisFolder.length === 0) {
       return this.view.render(state, {
         viewType: 'grid',
         showFilter: false,

+ 3 - 0
packages/@uppy/provider-views/package.json

@@ -25,6 +25,9 @@
     "p-queue": "^8.0.0",
     "preact": "^10.5.13"
   },
+  "devDependencies": {
+    "vitest": "^1.6.0"
+  },
   "peerDependencies": {
     "@uppy/core": "workspace:^"
   },

+ 17 - 35
packages/@uppy/provider-views/src/Breadcrumbs.tsx

@@ -1,53 +1,35 @@
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy'
+import type { PartialTreeFolder } from '@uppy/core/lib/Uppy'
 import { h, Fragment } from 'preact'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 import type ProviderView from './ProviderView/index.js'
 
-type BreadcrumbProps = {
-  getFolder: () => void
-  title: string
-  isLast: boolean
-}
-
-const Breadcrumb = (props: BreadcrumbProps) => {
-  const { getFolder, title, isLast } = props
-
-  return (
-    <Fragment>
-      <button
-        type="button"
-        className="uppy-u-reset uppy-c-btn"
-        onClick={getFolder}
-      >
-        {title}
-      </button>
-      {!isLast ? ' / ' : ''}
-    </Fragment>
-  )
-}
-
 type BreadcrumbsProps<M extends Meta, B extends Body> = {
-  getFolder: ProviderView<M, B>['getFolder']
+  openFolder: ProviderView<M, B>['openFolder']
   title: string
   breadcrumbsIcon: h.JSX.Element
-  breadcrumbs: UnknownProviderPluginState['breadcrumbs']
+  breadcrumbs: PartialTreeFolder[]
 }
 
 export default function Breadcrumbs<M extends Meta, B extends Body>(
   props: BreadcrumbsProps<M, B>,
-) {
-  const { getFolder, title, breadcrumbsIcon, breadcrumbs } = props
+): h.JSX.Element {
+  const { openFolder, title, breadcrumbsIcon, breadcrumbs } = props
 
   return (
     <div className="uppy-Provider-breadcrumbs">
       <div className="uppy-Provider-breadcrumbsIcon">{breadcrumbsIcon}</div>
-      {breadcrumbs.map((directory, i) => (
-        <Breadcrumb
-          key={directory.id}
-          getFolder={() => getFolder(directory.requestPath, directory.name)}
-          title={i === 0 ? title : (directory.name as string)}
-          isLast={i + 1 === breadcrumbs.length}
-        />
+      {breadcrumbs.map((folder, index) => (
+        <Fragment>
+          <button
+            key={folder.id}
+            type="button"
+            className="uppy-u-reset uppy-c-btn"
+            onClick={() => openFolder(folder.id)}
+          >
+            {folder.type === 'root' ? title : folder.data.name}
+          </button>
+          {breadcrumbs.length === index + 1 ? '' : ' / '}
+        </Fragment>
       ))}
     </div>
   )

+ 85 - 238
packages/@uppy/provider-views/src/Browser.tsx

@@ -1,273 +1,120 @@
-/* eslint-disable react/require-default-props */
 import { h } from 'preact'
 
-import classNames from 'classnames'
-import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
-import { useMemo } from 'preact/hooks'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore untyped
 import VirtualList from '@uppy/utils/lib/VirtualList'
-import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 import type { I18n } from '@uppy/utils/lib/Translator'
-import type Uppy from '@uppy/core'
-import SearchFilterInput from './SearchFilterInput.tsx'
-import FooterActions from './FooterActions.tsx'
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+} from '@uppy/core/lib/Uppy.ts'
+import { useEffect, useState } from 'preact/hooks'
 import Item from './Item/index.tsx'
-
-const VIRTUAL_SHARED_DIR = 'shared-with-me'
-
-type ListItemProps<M extends Meta, B extends Body> = {
-  currentSelection: any[]
-  uppyFiles: UppyFile<M, B>[]
-  viewType: string
-  isChecked: (file: any) => boolean
-  toggleCheckbox: (event: Event, file: CompanionFile) => void
-  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
-  showTitles: boolean
-  i18n: I18n
-  validateRestrictions: Uppy<M, B>['validateRestrictions']
-  getNextFolder?: (folder: any) => void
-  f: CompanionFile
-}
-
-function ListItem<M extends Meta, B extends Body>(props: ListItemProps<M, B>) {
-  const {
-    currentSelection,
-    uppyFiles,
-    viewType,
-    isChecked,
-    toggleCheckbox,
-    recordShiftKeyPress,
-    showTitles,
-    i18n,
-    validateRestrictions,
-    getNextFolder,
-    f,
-  } = props
-
-  if (f.isFolder) {
-    return Item<M, B>({
-      showTitles,
-      viewType,
-      i18n,
-      id: f.id,
-      title: f.name,
-      getItemIcon: () => f.icon,
-      isChecked: isChecked(f),
-      toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
-      recordShiftKeyPress,
-      type: 'folder',
-      // TODO: when was this supposed to be true?
-      isDisabled: false,
-      isCheckboxDisabled: f.id === VIRTUAL_SHARED_DIR,
-      // getNextFolder always exists when f.isFolder is true
-      handleFolderClick: () => getNextFolder!(f),
-    })
-  }
-  const restrictionError = validateRestrictions(remoteFileObjToLocal(f), [
-    ...uppyFiles,
-    ...currentSelection,
-  ])
-
-  return Item<M, B>({
-    id: f.id,
-    title: f.name,
-    author: f.author,
-    getItemIcon: () =>
-      viewType === 'grid' && f.thumbnail ? f.thumbnail : f.icon,
-    isChecked: isChecked(f),
-    toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
-    isCheckboxDisabled: false,
-    recordShiftKeyPress,
-    showTitles,
-    viewType,
-    i18n,
-    type: 'file',
-    isDisabled: Boolean(restrictionError) && !isChecked(f),
-    restrictionError,
-  })
-}
+import ProviderView from './ProviderView/ProviderView.tsx'
 
 type BrowserProps<M extends Meta, B extends Body> = {
-  currentSelection: any[]
-  folders: CompanionFile[]
-  files: CompanionFile[]
-  uppyFiles: UppyFile<M, B>[]
+  displayedPartialTree: (PartialTreeFile | PartialTreeFolderNode)[]
   viewType: string
-  headerComponent?: h.JSX.Element
-  showBreadcrumbs: boolean
-  isChecked: (file: any) => boolean
-  toggleCheckbox: (event: Event, file: CompanionFile) => void
-  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
-  handleScroll: (event: Event) => Promise<void>
+  toggleCheckbox: ProviderView<M, B>['toggleCheckbox']
+  handleScroll: ProviderView<M, B>['handleScroll']
   showTitles: boolean
   i18n: I18n
-  validateRestrictions: Uppy<M, B>['validateRestrictions']
   isLoading: boolean | string
-  showSearchFilter: boolean
-  search: (query: string) => void
-  searchTerm?: string | null
-  clearSearch: () => void
-  searchOnInput: boolean
-  searchInputLabel: string
-  clearSearchLabel: string
-  getNextFolder?: (folder: any) => void
-  cancel: () => void
-  done: () => void
+  openFolder: ProviderView<M, B>['openFolder']
   noResultsLabel: string
-  virtualList?: boolean
+  virtualList: boolean
 }
 
 function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
   const {
-    currentSelection,
-    folders,
-    files,
-    uppyFiles,
+    displayedPartialTree,
     viewType,
-    headerComponent,
-    showBreadcrumbs,
-    isChecked,
     toggleCheckbox,
-    recordShiftKeyPress,
     handleScroll,
     showTitles,
     i18n,
-    validateRestrictions,
     isLoading,
-    showSearchFilter,
-    search,
-    searchTerm,
-    clearSearch,
-    searchOnInput,
-    searchInputLabel,
-    clearSearchLabel,
-    getNextFolder,
-    cancel,
-    done,
+    openFolder,
     noResultsLabel,
     virtualList,
   } = props
 
-  const selected = currentSelection.length
+  const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false)
+
+  // This records whether the user is holding the SHIFT key this very moment.
+  // Typically, this is implemented using `onClick((e) => e.shiftKey)` -
+  // however we can't use that, because for accessibility reasons
+  // we're using html tags that don't support `e.shiftKey` property (see #3768).
+  useEffect(() => {
+    const handleKeyUp = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') setIsShiftKeyPressed(false)
+    }
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') setIsShiftKeyPressed(true)
+    }
+    document.addEventListener('keyup', handleKeyUp)
+    document.addEventListener('keydown', handleKeyDown)
+    return () => {
+      document.removeEventListener('keyup', handleKeyUp)
+      document.removeEventListener('keydown', handleKeyDown)
+    }
+  }, [])
+
+  if (isLoading) {
+    return (
+      <div className="uppy-Provider-loading">
+        <span>{i18n('loading')}</span>
+      </div>
+    )
+  }
 
-  const rows = useMemo(() => [...folders, ...files], [folders, files])
+  if (displayedPartialTree.length === 0) {
+    return <div className="uppy-Provider-empty">{noResultsLabel}</div>
+  }
 
-  return (
-    <div
-      className={classNames(
-        'uppy-ProviderBrowser',
-        `uppy-ProviderBrowser-viewType--${viewType}`,
-      )}
-    >
-      {headerComponent && (
-        <div className="uppy-ProviderBrowser-header">
-          <div
-            className={classNames(
-              'uppy-ProviderBrowser-headerBar',
-              !showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple',
-            )}
-          >
-            {headerComponent}
-          </div>
-        </div>
-      )}
+  const renderItem = (item: PartialTreeFile | PartialTreeFolderNode) => (
+    <Item
+      viewType={viewType}
+      toggleCheckbox={(event: Event) => {
+        event.stopPropagation()
+        event.preventDefault()
+        // Prevent shift-clicking from highlighting file names
+        // (https://stackoverflow.com/a/1527797/3192470)
+        document.getSelection()?.removeAllRanges()
+        toggleCheckbox(item, isShiftKeyPressed)
+      }}
+      showTitles={showTitles}
+      i18n={i18n}
+      openFolder={openFolder}
+      file={item}
+    />
+  )
 
-      {showSearchFilter && (
-        <div class="uppy-ProviderBrowser-searchFilter">
-          <SearchFilterInput
-            search={search}
-            searchTerm={searchTerm}
-            clearSearch={clearSearch}
-            inputLabel={searchInputLabel}
-            clearSearchLabel={clearSearchLabel}
-            inputClassName="uppy-ProviderBrowser-searchFilterInput"
-            searchOnInput={searchOnInput}
+  if (virtualList) {
+    return (
+      <div className="uppy-ProviderBrowser-body">
+        <ul className="uppy-ProviderBrowser-list">
+          <VirtualList
+            data={displayedPartialTree}
+            renderRow={renderItem}
+            rowHeight={31}
           />
-        </div>
-      )}
-
-      {(() => {
-        if (isLoading) {
-          return (
-            <div className="uppy-Provider-loading">
-              <span>
-                {typeof isLoading === 'string' ? isLoading : i18n('loading')}
-              </span>
-            </div>
-          )
-        }
-
-        if (!folders.length && !files.length) {
-          return <div className="uppy-Provider-empty">{noResultsLabel}</div>
-        }
-
-        if (virtualList) {
-          return (
-            <div className="uppy-ProviderBrowser-body">
-              <ul className="uppy-ProviderBrowser-list">
-                <VirtualList
-                  data={rows}
-                  renderRow={(f: CompanionFile) => (
-                    <ListItem
-                      currentSelection={currentSelection}
-                      uppyFiles={uppyFiles}
-                      viewType={viewType}
-                      isChecked={isChecked}
-                      toggleCheckbox={toggleCheckbox}
-                      recordShiftKeyPress={recordShiftKeyPress}
-                      showTitles={showTitles}
-                      i18n={i18n}
-                      validateRestrictions={validateRestrictions}
-                      getNextFolder={getNextFolder}
-                      f={f}
-                    />
-                  )}
-                  rowHeight={31}
-                />
-              </ul>
-            </div>
-          )
-        }
-
-        return (
-          <div className="uppy-ProviderBrowser-body">
-            <ul
-              className="uppy-ProviderBrowser-list"
-              onScroll={handleScroll}
-              role="listbox"
-              // making <ul> not focusable for firefox
-              tabIndex={-1}
-            >
-              {rows.map((f) => (
-                <ListItem
-                  currentSelection={currentSelection}
-                  uppyFiles={uppyFiles}
-                  viewType={viewType}
-                  isChecked={isChecked}
-                  toggleCheckbox={toggleCheckbox}
-                  recordShiftKeyPress={recordShiftKeyPress}
-                  showTitles={showTitles}
-                  i18n={i18n}
-                  validateRestrictions={validateRestrictions}
-                  getNextFolder={getNextFolder}
-                  f={f}
-                />
-              ))}
-            </ul>
-          </div>
-        )
-      })()}
-
-      {selected > 0 && (
-        <FooterActions
-          selected={selected}
-          done={done}
-          cancel={cancel}
-          i18n={i18n}
-        />
-      )}
+        </ul>
+      </div>
+    )
+  }
+  return (
+    <div className="uppy-ProviderBrowser-body">
+      <ul
+        className="uppy-ProviderBrowser-list"
+        onScroll={handleScroll}
+        role="listbox"
+        // making <ul> not focusable for firefox
+        tabIndex={-1}
+      >
+        {displayedPartialTree.map(renderItem)}
+      </ul>
     </div>
   )
 }

+ 0 - 13
packages/@uppy/provider-views/src/CloseWrapper.ts

@@ -1,13 +0,0 @@
-import { Component, toChildArray } from 'preact'
-
-export default class CloseWrapper extends Component<{ onUnmount: () => void }> {
-  componentWillUnmount(): void {
-    const { onUnmount } = this.props
-    onUnmount()
-  }
-
-  render(): ReturnType<typeof toChildArray>[0] {
-    const { children } = this.props
-    return toChildArray(children)[0]
-  }
-}

+ 57 - 23
packages/@uppy/provider-views/src/FooterActions.tsx

@@ -1,35 +1,69 @@
 import { h } from 'preact'
 import type { I18n } from '@uppy/utils/lib/Translator'
+import type { Meta, Body } from '@uppy/utils/lib/UppyFile'
+import classNames from 'classnames'
+import type { PartialTree } from '@uppy/core/lib/Uppy'
+import { useMemo } from 'preact/hooks'
+import getNumberOfSelectedFiles from './utils/PartialTreeUtils/getNumberOfSelectedFiles.ts'
+import ProviderView from './ProviderView/ProviderView.tsx'
 
-export default function FooterActions({
-  cancel,
-  done,
+export default function FooterActions<M extends Meta, B extends Body>({
+  cancelSelection,
+  donePicking,
   i18n,
-  selected,
+  partialTree,
+  validateAggregateRestrictions,
 }: {
-  cancel: () => void
-  done: () => void
+  cancelSelection: ProviderView<M, B>['cancelSelection']
+  donePicking: ProviderView<M, B>['donePicking']
   i18n: I18n
-  selected: number
+  partialTree: PartialTree
+  validateAggregateRestrictions: ProviderView<
+    M,
+    B
+  >['validateAggregateRestrictions']
 }) {
+  const aggregateRestrictionError = useMemo(() => {
+    return validateAggregateRestrictions(partialTree)
+  }, [partialTree, validateAggregateRestrictions])
+
+  const nOfSelectedFiles = useMemo(() => {
+    return getNumberOfSelectedFiles(partialTree)
+  }, [partialTree])
+
+  if (nOfSelectedFiles === 0) {
+    return null
+  }
+
   return (
     <div className="uppy-ProviderBrowser-footer">
-      <button
-        className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
-        onClick={done}
-        type="button"
-      >
-        {i18n('selectX', {
-          smart_count: selected,
-        })}
-      </button>
-      <button
-        className="uppy-u-reset uppy-c-btn uppy-c-btn-link"
-        onClick={cancel}
-        type="button"
-      >
-        {i18n('cancel')}
-      </button>
+      <div className="uppy-ProviderBrowser-footer-buttons">
+        <button
+          className={classNames('uppy-u-reset uppy-c-btn uppy-c-btn-primary', {
+            'uppy-c-btn--disabled': aggregateRestrictionError,
+          })}
+          disabled={!!aggregateRestrictionError}
+          onClick={donePicking}
+          type="button"
+        >
+          {i18n('selectX', {
+            smart_count: nOfSelectedFiles,
+          })}
+        </button>
+        <button
+          className="uppy-u-reset uppy-c-btn uppy-c-btn-link"
+          onClick={cancelSelection}
+          type="button"
+        >
+          {i18n('cancel')}
+        </button>
+      </div>
+
+      {aggregateRestrictionError && (
+        <div className="uppy-ProviderBrowser-footer-error">
+          {aggregateRestrictionError}
+        </div>
+      )}
     </div>
   )
 }

+ 29 - 50
packages/@uppy/provider-views/src/Item/components/GridItem.tsx

@@ -1,72 +1,51 @@
-/* eslint-disable react/require-default-props */
-import { h, type ComponentChildren } from 'preact'
-import classNames from 'classnames'
-import type { RestrictionError } from '@uppy/core/lib/Restricter'
-import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import { h } from 'preact'
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+} from '@uppy/core/lib/Uppy'
+import ItemIcon from './ItemIcon.tsx'
 
-type GridItemProps<M extends Meta, B extends Body> = {
+type GridItemProps = {
+  file: PartialTreeFile | PartialTreeFolderNode
+  toggleCheckbox: (event: Event) => void
   className: string
   isDisabled: boolean
-  restrictionError?: RestrictionError<M, B> | null
-  isChecked: boolean
-  title?: string
-  itemIconEl: any
-  showTitles?: boolean
-  toggleCheckbox: (event: Event) => void
-  recordShiftKeyPress: (event: KeyboardEvent) => void
-  id: string
-  children?: ComponentChildren
+  restrictionError: string | null
+  showTitles: boolean
+  children?: h.JSX.Element | null
 }
 
-function GridItem<M extends Meta, B extends Body>(
-  props: GridItemProps<M, B>,
-): h.JSX.Element {
-  const {
-    className,
-    isDisabled,
-    restrictionError,
-    isChecked,
-    title,
-    itemIconEl,
-    showTitles,
-    toggleCheckbox,
-    recordShiftKeyPress,
-    id,
-    children,
-  } = props
-
-  const checkBoxClassName = classNames(
-    'uppy-u-reset',
-    'uppy-ProviderBrowserItem-checkbox',
-    'uppy-ProviderBrowserItem-checkbox--grid',
-    { 'uppy-ProviderBrowserItem-checkbox--is-checked': isChecked },
-  )
-
+function GridItem({
+  file,
+  toggleCheckbox,
+  className,
+  isDisabled,
+  restrictionError,
+  showTitles,
+  children = null,
+}: GridItemProps): h.JSX.Element {
   return (
     <li
       className={className}
-      title={isDisabled ? restrictionError?.message : undefined}
+      title={isDisabled && restrictionError ? restrictionError : undefined}
     >
       <input
         type="checkbox"
-        className={checkBoxClassName}
+        className="uppy-u-reset uppy-ProviderBrowserItem-checkbox uppy-ProviderBrowserItem-checkbox--grid"
         onChange={toggleCheckbox}
-        onKeyDown={recordShiftKeyPress}
-        // @ts-expect-error this is fine onMouseDown too
-        onMouseDown={recordShiftKeyPress}
         name="listitem"
-        id={id}
-        checked={isChecked}
+        id={file.id}
+        checked={file.status === 'checked'}
         disabled={isDisabled}
         data-uppy-super-focusable
       />
       <label
-        htmlFor={id}
-        aria-label={title}
+        htmlFor={file.id}
+        aria-label={file.data.name}
         className="uppy-u-reset uppy-ProviderBrowserItem-inner"
       >
-        {itemIconEl}
-        {showTitles && title}
+        <ItemIcon itemIconString={file.data.thumbnail || file.data.icon} />
+        {showTitles && file.data.name}
         {children}
       </label>
     </li>

+ 59 - 73
packages/@uppy/provider-views/src/Item/components/ListItem.tsx

@@ -1,7 +1,10 @@
-/* eslint-disable react/require-default-props */
-import type { RestrictionError } from '@uppy/core/lib/Restricter'
-import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
 import { h } from 'preact'
+import ItemIcon from './ItemIcon.tsx'
 
 // if folder:
 //   + checkbox (selects all files from folder)
@@ -9,96 +12,79 @@ import { h } from 'preact'
 // if file:
 //   + checkbox (selects file)
 //   + file name (selects file)
-
-type ListItemProps<M extends Meta, B extends Body> = {
+type ListItemProps = {
+  file: PartialTreeFile | PartialTreeFolderNode
+  openFolder: (folderId: PartialTreeId) => void
+  toggleCheckbox: (event: Event) => void
   className: string
   isDisabled: boolean
-  restrictionError?: RestrictionError<M, B> | null
-  isCheckboxDisabled: boolean
-  isChecked: boolean
-  toggleCheckbox: (event: Event) => void
-  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
-  type: string
-  id: string
-  itemIconEl: any
-  title: string
-  handleFolderClick?: () => void
+  restrictionError: string | null
   showTitles: boolean
   i18n: any
 }
 
-export default function ListItem<M extends Meta, B extends Body>(
-  props: ListItemProps<M, B>,
-): h.JSX.Element {
-  const {
-    className,
-    isDisabled,
-    restrictionError,
-    isCheckboxDisabled,
-    isChecked,
-    toggleCheckbox,
-    recordShiftKeyPress,
-    type,
-    id,
-    itemIconEl,
-    title,
-    handleFolderClick,
-    showTitles,
-    i18n,
-  } = props
-
+export default function ListItem({
+  file,
+  openFolder,
+  className,
+  isDisabled,
+  restrictionError,
+  toggleCheckbox,
+  showTitles,
+  i18n,
+}: ListItemProps): h.JSX.Element {
   return (
     <li
       className={className}
-      title={isDisabled ? restrictionError?.message : undefined}
+      title={
+        file.status !== 'checked' && restrictionError ?
+          restrictionError
+        : undefined
+      }
     >
-      {!isCheckboxDisabled ?
-        <input
-          type="checkbox"
-          className={`uppy-u-reset uppy-ProviderBrowserItem-checkbox ${isChecked ? 'uppy-ProviderBrowserItem-checkbox--is-checked' : ''}`}
-          onChange={toggleCheckbox}
-          onKeyDown={recordShiftKeyPress}
-          onMouseDown={recordShiftKeyPress}
-          // for the <label/>
-          name="listitem"
-          id={id}
-          checked={isChecked}
-          aria-label={
-            type === 'file' ? null : (
-              i18n('allFilesFromFolderNamed', { name: title })
-            )
-          }
-          disabled={isDisabled}
-          data-uppy-super-focusable
-        />
-      : null}
+      <input
+        type="checkbox"
+        className="uppy-u-reset uppy-ProviderBrowserItem-checkbox"
+        onChange={toggleCheckbox}
+        // for the <label/>
+        name="listitem"
+        id={file.id}
+        checked={file.status === 'checked'}
+        aria-label={
+          file.data.isFolder ?
+            i18n('allFilesFromFolderNamed', { name: file.data.name })
+          : null
+        }
+        disabled={isDisabled}
+        data-uppy-super-focusable
+      />
 
       {
-        type === 'file' ?
-          // label for a checkbox
-          <label
-            htmlFor={id}
-            className="uppy-u-reset uppy-ProviderBrowserItem-inner"
-          >
-            <div className="uppy-ProviderBrowserItem-iconWrap">
-              {itemIconEl}
-            </div>
-            {showTitles && title}
-          </label>
+        file.data.isFolder ?
           // button to open a folder
-        : <button
+          <button
             type="button"
             className="uppy-u-reset uppy-c-btn uppy-ProviderBrowserItem-inner"
-            onClick={handleFolderClick}
-            aria-label={i18n('openFolderNamed', { name: title })}
+            onClick={() => openFolder(file.id)}
+            aria-label={i18n('openFolderNamed', { name: file.data.name })}
           >
             <div className="uppy-ProviderBrowserItem-iconWrap">
-              {itemIconEl}
+              <ItemIcon itemIconString={file.data.icon} />
             </div>
-            {showTitles && title ?
-              <span>{title}</span>
+            {showTitles && file.data.name ?
+              <span>{file.data.name}</span>
             : i18n('unnamed')}
           </button>
+          // label for a checkbox
+        : <label
+            htmlFor={file.id}
+            className="uppy-u-reset uppy-ProviderBrowserItem-inner"
+          >
+            <div className="uppy-ProviderBrowserItem-iconWrap">
+              <ItemIcon itemIconString={file.data.icon} />
+            </div>
+            {showTitles && file.data.name}
+          </label>
 
       }
     </li>

+ 38 - 55
packages/@uppy/provider-views/src/Item/index.tsx

@@ -1,83 +1,66 @@
-/* eslint-disable react/require-default-props */
+/* eslint-disable react/jsx-props-no-spreading */
 import { h } from 'preact'
 
 import classNames from 'classnames'
 import type { I18n } from '@uppy/utils/lib/Translator'
-import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import type { RestrictionError } from '@uppy/core/lib/Restricter'
-import type { Meta, Body } from '@uppy/utils/lib/UppyFile'
-import ItemIcon from './components/ItemIcon.tsx'
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy.ts'
 import GridItem from './components/GridItem.tsx'
 import ListItem from './components/ListItem.tsx'
 
-type ItemProps<M extends Meta, B extends Body> = {
-  showTitles: boolean
-  i18n: I18n
-  id: string
-  title: string
+type ItemProps = {
+  file: PartialTreeFile | PartialTreeFolderNode
+  openFolder: (folderId: PartialTreeId) => void
   toggleCheckbox: (event: Event) => void
-  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
-  handleFolderClick?: () => void
-  restrictionError?: RestrictionError<M, B> | null
-  isCheckboxDisabled: boolean
-  type: 'folder' | 'file'
-  author?: CompanionFile['author']
-  getItemIcon: () => string
-  isChecked: boolean
-  isDisabled: boolean
   viewType: string
+  showTitles: boolean
+  i18n: I18n
 }
 
-export default function Item<M extends Meta, B extends Body>(
-  props: ItemProps<M, B>,
-): h.JSX.Element {
-  const { author, getItemIcon, isChecked, isDisabled, viewType } = props
-  const itemIconString = getItemIcon()
+export default function Item(props: ItemProps): h.JSX.Element {
+  const { viewType, toggleCheckbox, showTitles, i18n, openFolder, file } = props
+
+  const restrictionError = file.type === 'folder' ? null : file.restrictionError
+  const isDisabled = !!restrictionError && file.status !== 'checked'
 
-  const className = classNames(
-    'uppy-ProviderBrowserItem',
-    { 'uppy-ProviderBrowserItem--selected': isChecked },
-    { 'uppy-ProviderBrowserItem--disabled': isDisabled },
-    { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' },
-  )
+  const ourProps = {
+    file,
+    openFolder,
+    toggleCheckbox,
 
-  const itemIconEl = <ItemIcon itemIconString={itemIconString} />
+    i18n,
+    viewType,
+    showTitles,
+    className: classNames(
+      'uppy-ProviderBrowserItem',
+      { 'uppy-ProviderBrowserItem--disabled': isDisabled },
+      { 'uppy-ProviderBrowserItem--noPreview': file.data.icon === 'video' },
+      { 'uppy-ProviderBrowserItem--is-checked': file.status === 'checked' },
+      { 'uppy-ProviderBrowserItem--is-partial': file.status === 'partial' },
+    ),
+    isDisabled,
+    restrictionError,
+  }
 
   switch (viewType) {
     case 'grid':
-      return (
-        <GridItem<M, B>
-          // eslint-disable-next-line react/jsx-props-no-spreading
-          {...props}
-          className={className}
-          itemIconEl={itemIconEl}
-        />
-      )
+      return <GridItem {...ourProps} />
     case 'list':
-      return (
-        <ListItem<M, B>
-          // eslint-disable-next-line react/jsx-props-no-spreading
-          {...props}
-          className={className}
-          itemIconEl={itemIconEl}
-        />
-      )
+      return <ListItem {...ourProps} />
     case 'unsplash':
       return (
-        <GridItem<M, B>
-          // eslint-disable-next-line react/jsx-props-no-spreading
-          {...props}
-          className={className}
-          itemIconEl={itemIconEl}
-        >
+        <GridItem {...ourProps}>
           <a
-            href={`${author!.url}?utm_source=Companion&utm_medium=referral`}
+            href={`${file.data.author!.url}?utm_source=Companion&utm_medium=referral`}
             target="_blank"
             rel="noopener noreferrer"
             className="uppy-ProviderBrowserItem-author"
             tabIndex={-1}
           >
-            {author!.name}
+            {file.data.author!.name}
           </a>
         </GridItem>
       )

+ 2 - 2
packages/@uppy/provider-views/src/ProviderView/AuthView.tsx

@@ -2,7 +2,7 @@ import { h } from 'preact'
 import { useCallback } from 'preact/hooks'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 import type Translator from '@uppy/utils/lib/Translator'
-import type { ProviderViewOptions } from './ProviderView.js'
+import type { Opts } from './ProviderView.js'
 import type ProviderViews from './ProviderView.js'
 
 type AuthViewProps<M extends Meta, B extends Body> = {
@@ -11,7 +11,7 @@ type AuthViewProps<M extends Meta, B extends Body> = {
   pluginIcon: () => h.JSX.Element
   i18n: Translator['translateArray']
   handleAuth: ProviderViews<M, B>['handleAuth']
-  renderForm?: ProviderViewOptions<M, B>['renderAuthForm']
+  renderForm?: Opts<M, B>['renderAuthForm']
 }
 
 function GoogleIcon() {

+ 27 - 15
packages/@uppy/provider-views/src/ProviderView/Header.tsx

@@ -1,20 +1,21 @@
 /* eslint-disable react/destructuring-assignment */
-import { h, Fragment } from 'preact'
+import { h } from 'preact'
 import type { I18n } from '@uppy/utils/lib/Translator'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy'
+import type { PartialTreeFolder } from '@uppy/core/lib/Uppy.ts'
+import classNames from 'classnames'
 import User from './User.tsx'
 import Breadcrumbs from '../Breadcrumbs.tsx'
 import type ProviderView from './ProviderView.js'
 
 type HeaderProps<M extends Meta, B extends Body> = {
   showBreadcrumbs: boolean
-  getFolder: ProviderView<M, B>['getFolder']
-  breadcrumbs: UnknownProviderPluginState['breadcrumbs']
+  openFolder: ProviderView<M, B>['openFolder']
+  breadcrumbs: PartialTreeFolder[]
   pluginIcon: () => h.JSX.Element
   title: string
   logout: () => void
-  username: string | undefined
+  username: string | null
   i18n: I18n
 }
 
@@ -22,16 +23,27 @@ export default function Header<M extends Meta, B extends Body>(
   props: HeaderProps<M, B>,
 ) {
   return (
-    <Fragment>
-      {props.showBreadcrumbs && (
-        <Breadcrumbs
-          getFolder={props.getFolder}
-          breadcrumbs={props.breadcrumbs}
-          breadcrumbsIcon={props.pluginIcon && props.pluginIcon()}
-          title={props.title}
+    <div className="uppy-ProviderBrowser-header">
+      <div
+        className={classNames(
+          'uppy-ProviderBrowser-headerBar',
+          !props.showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple',
+        )}
+      >
+        {props.showBreadcrumbs && (
+          <Breadcrumbs
+            openFolder={props.openFolder}
+            breadcrumbs={props.breadcrumbs}
+            breadcrumbsIcon={props.pluginIcon && props.pluginIcon()}
+            title={props.title}
+          />
+        )}
+        <User
+          logout={props.logout}
+          username={props.username}
+          i18n={props.i18n}
         />
-      )}
-      <User logout={props.logout} username={props.username} i18n={props.i18n} />
-    </Fragment>
+      </div>
+    </div>
   )
 }

+ 350 - 498
packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx

@@ -1,42 +1,37 @@
 import { h } from 'preact'
-import PQueue from 'p-queue'
-
-import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
-
 import type {
   UnknownProviderPlugin,
+  PartialTreeFolder,
+  PartialTreeFolderNode,
+  PartialTreeFile,
   UnknownProviderPluginState,
-  Uppy,
+  PartialTreeId,
+  PartialTree,
 } from '@uppy/core/lib/Uppy.ts'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile.ts'
 import type Translator from '@uppy/utils/lib/Translator'
-import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
+import classNames from 'classnames'
+import type { ValidateableFile } from '@uppy/core/lib/Restricter.ts'
+import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
 import AuthView from './AuthView.tsx'
 import Header from './Header.tsx'
 import Browser from '../Browser.tsx'
-import CloseWrapper from '../CloseWrapper.ts'
-import View, { type ViewOptions } from '../View.ts'
 
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
 import packageJson from '../../package.json'
-
-function formatBreadcrumbs(
-  breadcrumbs: UnknownProviderPluginState['breadcrumbs'],
-): string {
-  return breadcrumbs
-    .slice(1)
-    .map((directory) => directory.name)
-    .join('/')
-}
-
-function prependPath(path: string | undefined, component: string): string {
-  if (!path) return component
-  return `${path}/${component}`
-}
-
-export function defaultPickerIcon() {
+import PartialTreeUtils from '../utils/PartialTreeUtils/index.ts'
+import shouldHandleScroll from '../utils/shouldHandleScroll.ts'
+import handleError from '../utils/handleError.ts'
+import getClickedRange from '../utils/getClickedRange.ts'
+import SearchInput from '../SearchInput.tsx'
+import FooterActions from '../FooterActions.tsx'
+import addFiles from '../utils/addFiles.ts'
+import getCheckedFilesWithPaths from '../utils/PartialTreeUtils/getCheckedFilesWithPaths.ts'
+import getBreadcrumbs from '../utils/PartialTreeUtils/getBreadcrumbs.ts'
+
+export function defaultPickerIcon(): h.JSX.Element {
   return (
     <svg
       aria-hidden="true"
@@ -50,79 +45,112 @@ export function defaultPickerIcon() {
   )
 }
 
-type PluginType = 'Provider'
-
-const defaultOptions = {
-  viewType: 'list',
-  showTitles: true,
-  showFilter: true,
-  showBreadcrumbs: true,
-  loadAllFiles: false,
-  virtualList: false,
-}
-
-export interface ProviderViewOptions<M extends Meta, B extends Body>
-  extends ViewOptions<M, B, PluginType> {
+const getDefaultState = (
+  rootFolderId: string | null,
+): UnknownProviderPluginState => ({
+  authenticated: undefined, // we don't know yet
+  partialTree: [
+    {
+      type: 'root',
+      id: rootFolderId,
+      cached: false,
+      nextPagePath: null,
+    },
+  ],
+  currentFolderId: rootFolderId,
+  searchString: '',
+  didFirstRender: false,
+  username: null,
+  loading: false,
+})
+
+type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
+
+export interface Opts<M extends Meta, B extends Body> {
+  provider: UnknownProviderPlugin<M, B>['provider']
+  viewType: 'list' | 'grid'
+  showTitles: boolean
+  showFilter: boolean
+  showBreadcrumbs: boolean
+  loadAllFiles: boolean
   renderAuthForm?: (args: {
     pluginName: string
     i18n: Translator['translateArray']
     loading: boolean | string
     onAuth: (authFormData: unknown) => Promise<void>
   }) => h.JSX.Element
-  virtualList?: boolean
+  virtualList: boolean
 }
-
-type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
-  ProviderViewOptions<M, B>,
-  keyof typeof defaultOptions
+type PassedOpts<M extends Meta, B extends Body> = Optional<
+  Opts<M, B>,
+  | 'viewType'
+  | 'showTitles'
+  | 'showFilter'
+  | 'showBreadcrumbs'
+  | 'loadAllFiles'
+  | 'virtualList'
+>
+type DefaultOpts<M extends Meta, B extends Body> = Omit<Opts<M, B>, 'provider'>
+type RenderOpts<M extends Meta, B extends Body> = Omit<
+  PassedOpts<M, B>,
+  'provider'
 >
 
 /**
  * Class to easily generate generic views for Provider plugins
  */
-export default class ProviderView<M extends Meta, B extends Body> extends View<
-  M,
-  B,
-  PluginType,
-  Opts<M, B>
-> {
+export default class ProviderView<M extends Meta, B extends Body> {
   static VERSION = packageJson.version
 
-  username: string | undefined
+  plugin: UnknownProviderPlugin<M, B>
 
-  nextPagePath: string | undefined
+  provider: UnknownProviderPlugin<M, B>['provider']
 
-  constructor(
-    plugin: UnknownProviderPlugin<M, B>,
-    opts: ProviderViewOptions<M, B>,
-  ) {
-    super(plugin, { ...defaultOptions, ...opts })
+  opts: Opts<M, B>
+
+  isHandlingScroll: boolean = false
+
+  lastCheckbox: string | null = null
 
-    // Logic
-    this.filterQuery = this.filterQuery.bind(this)
-    this.clearFilter = this.clearFilter.bind(this)
-    this.getFolder = this.getFolder.bind(this)
-    this.getNextFolder = this.getNextFolder.bind(this)
+  constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
+    this.plugin = plugin
+    this.provider = opts.provider
+
+    const defaultOptions: DefaultOpts<M, B> = {
+      viewType: 'list',
+      showTitles: true,
+      showFilter: true,
+      showBreadcrumbs: true,
+      loadAllFiles: false,
+      virtualList: false,
+    }
+    this.opts = { ...defaultOptions, ...opts }
+
+    this.openFolder = this.openFolder.bind(this)
     this.logout = this.logout.bind(this)
     this.handleAuth = this.handleAuth.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
+    this.resetPluginState = this.resetPluginState.bind(this)
     this.donePicking = this.donePicking.bind(this)
-
-    // Visual
     this.render = this.render.bind(this)
+    this.cancelSelection = this.cancelSelection.bind(this)
+    this.toggleCheckbox = this.toggleCheckbox.bind(this)
 
     // Set default state for the plugin
-    this.plugin.setPluginState({
-      authenticated: undefined, // we don't know yet
-      files: [],
-      folders: [],
-      breadcrumbs: [],
-      filterInput: '',
-      isSearchVisible: false,
-      currentSelection: [],
-    })
-
-    this.registerRequestClient()
+    this.resetPluginState()
+
+    // todo
+    // @ts-expect-error this should be typed in @uppy/dashboard.
+    this.plugin.uppy.on('dashboard:close-panel', this.resetPluginState)
+
+    this.plugin.uppy.registerRequestClient(
+      this.provider.provider,
+      this.provider,
+    )
+  }
+
+  resetPluginState(): void {
+    this.plugin.setPluginState(getDefaultState(this.plugin.rootFolderId))
   }
 
   // eslint-disable-next-line class-methods-use-this
@@ -130,6 +158,18 @@ export default class ProviderView<M extends Meta, B extends Body> extends View<
     // Nothing.
   }
 
+  setLoading(loading: boolean | string): void {
+    this.plugin.setPluginState({ loading })
+  }
+
+  cancelSelection(): void {
+    const { partialTree } = this.plugin.getPluginState()
+    const newPartialTree: PartialTree = partialTree.map((item) =>
+      item.type === 'root' ? item : { ...item, status: 'unchecked' },
+    )
+    this.plugin.setPluginState({ partialTree: newPartialTree })
+  }
+
   #abortController: AbortController | undefined
 
   async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
@@ -139,7 +179,6 @@ export default class ProviderView<M extends Meta, B extends Body> extends View<
     this.#abortController = abortController
     const cancelRequest = () => {
       abortController.abort()
-      this.clearSelection()
     }
     try {
       // @ts-expect-error this should be typed in @uppy/dashboard.
@@ -159,485 +198,298 @@ export default class ProviderView<M extends Meta, B extends Body> extends View<
     }
   }
 
-  async #list({
-    requestPath,
-    absDirPath,
-    signal,
-  }: {
-    requestPath?: string
-    absDirPath: string
-    signal: AbortSignal
-  }) {
-    const { username, nextPagePath, items } = await this.provider.list<{
-      username: string
-      nextPagePath: string
-      items: CompanionFile[]
-    }>(requestPath, { signal })
-    this.username = username || this.username
-
-    return {
-      items: items.map((item) => ({
-        ...item,
-        absDirPath,
-      })),
-      nextPagePath,
+  async openFolder(folderId: string | null): Promise<void> {
+    this.lastCheckbox = null
+    // Returning cached folder
+    const { partialTree } = this.plugin.getPluginState()
+    const clickedFolder = partialTree.find(
+      (folder) => folder.id === folderId,
+    )! as PartialTreeFolder
+    if (clickedFolder.cached) {
+      this.plugin.setPluginState({
+        currentFolderId: folderId,
+        searchString: '',
+      })
+      return
     }
-  }
-
-  async #listFilesAndFolders({
-    breadcrumbs,
-    signal,
-  }: {
-    breadcrumbs: UnknownProviderPluginState['breadcrumbs']
-    signal: AbortSignal
-  }) {
-    const absDirPath = formatBreadcrumbs(breadcrumbs)
-
-    const { items, nextPagePath } = await this.#list({
-      requestPath: this.nextPagePath,
-      absDirPath,
-      signal,
-    })
-
-    this.nextPagePath = nextPagePath
-
-    const files: CompanionFile[] = []
-    const folders: CompanionFile[] = []
-
-    items.forEach((item) => {
-      if (item.isFolder) {
-        folders.push(item)
-      } else {
-        files.push(item)
-      }
-    })
-
-    return { files, folders }
-  }
 
-  /**
-   * Select a folder based on its id: fetches the folder and then updates state with its contents
-   * TODO rename to something better like selectFolder or navigateToFolder (breaking change?)
-   *
-   */
-  async getFolder(requestPath?: string, name?: string): Promise<void> {
     this.setLoading(true)
-    try {
-      await this.#withAbort(async (signal) => {
-        this.lastCheckbox = undefined
-
-        let { breadcrumbs } = this.plugin.getPluginState()
-
-        const index = breadcrumbs.findIndex(
-          (dir) => requestPath === dir.requestPath,
+    await this.#withAbort(async (signal) => {
+      let currentPagePath = folderId
+      let currentItems: CompanionFile[] = []
+      do {
+        const { username, nextPagePath, items } = await this.provider.list(
+          currentPagePath,
+          { signal },
         )
+        // It's important to set the username during one of our first fetches
+        this.plugin.setPluginState({ username })
 
-        if (index !== -1) {
-          // means we navigated back to a known directory (already in the stack), so cut the stack off there
-          breadcrumbs = breadcrumbs.slice(0, index + 1)
-        } else {
-          // we have navigated into a new (unknown) folder, add it to the stack
-          breadcrumbs = [...breadcrumbs, { requestPath, name }]
-        }
-
-        this.nextPagePath = requestPath
-        let files: CompanionFile[] = []
-        let folders: CompanionFile[] = []
-        do {
-          const { files: newFiles, folders: newFolders } =
-            await this.#listFilesAndFolders({
-              breadcrumbs,
-              signal,
-            })
-
-          files = files.concat(newFiles)
-          folders = folders.concat(newFolders)
-
-          this.setLoading(
-            this.plugin.uppy.i18n('loadedXFiles', {
-              numFiles: files.length + folders.length,
-            }),
-          )
-        } while (this.opts.loadAllFiles && this.nextPagePath)
-
-        this.plugin.setPluginState({
-          folders,
-          files,
-          breadcrumbs,
-          filterInput: '',
-        })
-      })
-    } catch (err) {
-      // This is the first call that happens when the provider view loads, after auth, so it's probably nice to show any
-      // error occurring here to the user.
-      if (err?.name === 'UserFacingApiError') {
-        this.plugin.uppy.info(
-          { message: this.plugin.uppy.i18n(err.message) },
-          'warning',
-          5000,
+        currentPagePath = nextPagePath
+        currentItems = currentItems.concat(items)
+        this.setLoading(
+          this.plugin.uppy.i18n('loadedXFiles', { numFiles: items.length }),
         )
-        return
-      }
+      } while (this.opts.loadAllFiles && currentPagePath)
+
+      const newPartialTree = PartialTreeUtils.afterOpenFolder(
+        partialTree,
+        currentItems,
+        clickedFolder,
+        currentPagePath,
+        this.validateSingleFile,
+      )
 
-      this.handleError(err)
-    } finally {
-      this.setLoading(false)
-    }
-  }
+      this.plugin.setPluginState({
+        partialTree: newPartialTree,
+        currentFolderId: folderId,
+        searchString: '',
+      })
+    }).catch(handleError(this.plugin.uppy))
 
-  /**
-   * Fetches new folder
-   */
-  getNextFolder(folder: CompanionFile): void {
-    this.getFolder(folder.requestPath, folder.name)
-    this.lastCheckbox = undefined
+    this.setLoading(false)
   }
 
   /**
    * Removes session token on client side.
    */
   async logout(): Promise<void> {
-    try {
-      await this.#withAbort(async (signal) => {
-        const res = await this.provider.logout<{
-          ok: boolean
-          revoked: boolean
-          manual_revoke_url: string
-        }>({
-          signal,
-        })
-        // res.ok is from the JSON body, not to be confused with Response.ok
-        if (res.ok) {
-          if (!res.revoked) {
-            const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
-              provider: this.plugin.title,
-              url: res.manual_revoke_url,
-            })
-            this.plugin.uppy.info(message, 'info', 7000)
-          }
-
-          const newState = {
-            authenticated: false,
-            files: [],
-            folders: [],
-            breadcrumbs: [],
-            filterInput: '',
-          }
-          this.plugin.setPluginState(newState)
-        }
+    await this.#withAbort(async (signal) => {
+      const res = await this.provider.logout<{
+        ok: boolean
+        revoked: boolean
+        manual_revoke_url: string
+      }>({
+        signal,
       })
-    } catch (err) {
-      this.handleError(err)
-    }
-  }
-
-  filterQuery(input: string): void {
-    this.plugin.setPluginState({ filterInput: input })
-  }
+      // res.ok is from the JSON body, not to be confused with Response.ok
+      if (res.ok) {
+        if (!res.revoked) {
+          const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
+            provider: this.plugin.title,
+            url: res.manual_revoke_url,
+          })
+          this.plugin.uppy.info(message, 'info', 7000)
+        }
 
-  clearFilter(): void {
-    this.plugin.setPluginState({ filterInput: '' })
+        this.plugin.setPluginState({
+          ...getDefaultState(this.plugin.rootFolderId),
+          authenticated: false,
+        })
+      }
+    }).catch(handleError(this.plugin.uppy))
   }
 
   async handleAuth(authFormData?: unknown): Promise<void> {
-    try {
-      await this.#withAbort(async (signal) => {
-        this.setLoading(true)
-        await this.provider.login({ authFormData, signal })
-        this.plugin.setPluginState({ authenticated: true })
-        await this.getFolder(this.plugin.rootFolderId || undefined)
-      })
-    } catch (err) {
-      if (err.name === 'UserFacingApiError') {
-        this.plugin.uppy.info(
-          { message: this.plugin.uppy.i18n(err.message) },
-          'warning',
-          5000,
-        )
-        return
-      }
-
-      this.plugin.uppy.log(`login failed: ${err.message}`)
-    } finally {
-      this.setLoading(false)
-    }
+    await this.#withAbort(async (signal) => {
+      this.setLoading(true)
+      await this.provider.login({ authFormData, signal })
+      this.plugin.setPluginState({ authenticated: true })
+      await Promise.all([
+        this.provider.fetchPreAuthToken(),
+        this.openFolder(this.plugin.rootFolderId),
+      ])
+    }).catch(handleError(this.plugin.uppy))
+    this.setLoading(false)
   }
 
   async handleScroll(event: Event): Promise<void> {
-    if (this.shouldHandleScroll(event) && this.nextPagePath) {
+    const { partialTree, currentFolderId } = this.plugin.getPluginState()
+    const currentFolder = partialTree.find(
+      (i) => i.id === currentFolderId,
+    ) as PartialTreeFolder
+    if (
+      shouldHandleScroll(event) &&
+      !this.isHandlingScroll &&
+      currentFolder.nextPagePath
+    ) {
       this.isHandlingScroll = true
+      await this.#withAbort(async (signal) => {
+        const { nextPagePath, items } = await this.provider.list(
+          currentFolder.nextPagePath,
+          { signal },
+        )
+        const newPartialTree = PartialTreeUtils.afterScrollFolder(
+          partialTree,
+          currentFolderId,
+          items,
+          nextPagePath,
+          this.validateSingleFile,
+        )
 
-      try {
-        await this.#withAbort(async (signal) => {
-          const { files, folders, breadcrumbs } = this.plugin.getPluginState()
-
-          const { files: newFiles, folders: newFolders } =
-            await this.#listFilesAndFolders({
-              breadcrumbs,
-              signal,
-            })
-
-          const combinedFiles = files.concat(newFiles)
-          const combinedFolders = folders.concat(newFolders)
-
-          this.plugin.setPluginState({
-            folders: combinedFolders,
-            files: combinedFiles,
-          })
-        })
-      } catch (error) {
-        this.handleError(error)
-      } finally {
-        this.isHandlingScroll = false
-      }
+        this.plugin.setPluginState({ partialTree: newPartialTree })
+      }).catch(handleError(this.plugin.uppy))
+      this.isHandlingScroll = false
     }
   }
 
-  async #recursivelyListAllFiles({
-    requestPath,
-    absDirPath,
-    relDirPath,
-    queue,
-    onFiles,
-    signal,
-  }: {
-    requestPath: string
-    absDirPath: string
-    relDirPath: string
-    queue: PQueue
-    onFiles: (files: CompanionFile[]) => void
-    signal: AbortSignal
-  }) {
-    let curPath = requestPath
-
-    while (curPath) {
-      const res = await this.#list({ requestPath: curPath, absDirPath, signal })
-      curPath = res.nextPagePath
-
-      const files = res.items.filter((item) => !item.isFolder)
-      const folders = res.items.filter((item) => item.isFolder)
-
-      onFiles(files)
-
-      // recursively queue call to self for each folder
-      const promises = folders.map(async (folder) =>
-        queue.add(async () =>
-          this.#recursivelyListAllFiles({
-            requestPath: folder.requestPath,
-            absDirPath: prependPath(absDirPath, folder.name),
-            relDirPath: prependPath(relDirPath, folder.name),
-            queue,
-            onFiles,
-            signal,
-          }),
-        ),
-      )
-      await Promise.all(promises) // in case we get an error
-    }
+  validateSingleFile = (file: CompanionFile): string | null => {
+    const companionFile: ValidateableFile<M, B> = remoteFileObjToLocal(file)
+    const result = this.plugin.uppy.validateSingleFile(companionFile)
+    return result
   }
 
   async donePicking(): Promise<void> {
+    const { partialTree } = this.plugin.getPluginState()
+
     this.setLoading(true)
-    try {
-      await this.#withAbort(async (signal) => {
-        const { currentSelection } = this.plugin.getPluginState()
+    await this.#withAbort(async (signal) => {
+      // 1. Enrich our partialTree by fetching all 'checked' but not-yet-fetched folders
+      const enrichedTree: PartialTree = await PartialTreeUtils.afterFill(
+        partialTree,
+        (path: PartialTreeId) => this.provider.list(path, { signal }),
+        this.validateSingleFile,
+      )
 
-        const messages: string[] = []
-        const newFiles: CompanionFile[] = []
+      // 2. Now that we know how many files there are - recheck aggregateRestrictions!
+      const aggregateRestrictionError =
+        this.validateAggregateRestrictions(enrichedTree)
+      if (aggregateRestrictionError) {
+        this.plugin.setPluginState({ partialTree: enrichedTree })
+        return
+      }
 
-        for (const selectedItem of currentSelection) {
-          const { requestPath } = selectedItem
+      // 3. Add files
+      const companionFiles = getCheckedFilesWithPaths(enrichedTree)
+      addFiles(companionFiles, this.plugin, this.provider)
 
-          const withRelDirPath = (newItem: CompanionFile) => ({
-            ...newItem,
-            // calculate the file's path relative to the user's selected item's path
-            // see https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
-            relDirPath: (newItem.absDirPath as string)
-              .replace(selectedItem.absDirPath as string, '')
-              .replace(/^\//, ''),
-          })
+      // 4. Reset state
+      this.resetPluginState()
+    }).catch(handleError(this.plugin.uppy))
+    this.setLoading(false)
+  }
 
-          if (selectedItem.isFolder) {
-            let isEmpty = true
-            let numNewFiles = 0
-
-            const queue = new PQueue({ concurrency: 6 })
-
-            const onFiles = (files: CompanionFile[]) => {
-              for (const newFile of files) {
-                const tagFile = this.getTagFile(newFile)
-
-                const id = getSafeFileId(tagFile, this.plugin.uppy.getID())
-                // If the same folder is added again, we don't want to send
-                // X amount of duplicate file notifications, we want to say
-                // the folder was already added. This checks if all files are duplicate,
-                // if that's the case, we don't add the files.
-                if (!this.plugin.uppy.checkIfFileAlreadyExists(id)) {
-                  newFiles.push(withRelDirPath(newFile))
-                  numNewFiles++
-                  this.setLoading(
-                    this.plugin.uppy.i18n('addedNumFiles', {
-                      numFiles: numNewFiles,
-                    }),
-                  )
-                }
-                isEmpty = false
-              }
-            }
-
-            await this.#recursivelyListAllFiles({
-              requestPath,
-              absDirPath: prependPath(
-                selectedItem.absDirPath,
-                selectedItem.name,
-              ),
-              relDirPath: selectedItem.name,
-              queue,
-              onFiles,
-              signal,
-            })
-            await queue.onIdle()
-
-            let message
-            if (isEmpty) {
-              message = this.plugin.uppy.i18n('emptyFolderAdded')
-            } else if (numNewFiles === 0) {
-              message = this.plugin.uppy.i18n('folderAlreadyAdded', {
-                folder: selectedItem.name,
-              })
-            } else {
-              // TODO we don't really know at this point whether any files were actually added
-              // (only later after addFiles has been called) so we should probably rewrite this.
-              // Example: If all files fail to add due to restriction error, it will still say "Added 100 files from folder"
-              message = this.plugin.uppy.i18n('folderAdded', {
-                smart_count: numNewFiles,
-                folder: selectedItem.name,
-              })
-            }
-
-            messages.push(message)
-          } else {
-            newFiles.push(withRelDirPath(selectedItem))
-          }
-        }
+  toggleCheckbox(
+    ourItem: PartialTreeFolderNode | PartialTreeFile,
+    isShiftKeyPressed: boolean,
+  ) {
+    const { partialTree } = this.plugin.getPluginState()
+
+    const clickedRange = getClickedRange(
+      ourItem.id,
+      this.getDisplayedPartialTree(),
+      isShiftKeyPressed,
+      this.lastCheckbox,
+    )
+    const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
+      partialTree,
+      clickedRange,
+    )
 
-        // Note: this.plugin.uppy.addFiles must be only run once we are done fetching all files,
-        // because it will cause the loading screen to disappear,
-        // and that will allow the user to start the upload, so we need to make sure we have
-        // finished all async operations before we add any file
-        // see https://github.com/transloadit/uppy/pull/4384
-        this.plugin.uppy.log('Adding files from a remote provider')
-        this.plugin.uppy.addFiles(
-          // @ts-expect-error `addFiles` expects `body` to be `File` or `Blob`,
-          // but as the todo comment in `View.ts` indicates, we strangly pass `CompanionFile` as `body`.
-          // For now it's better to ignore than to have a potential breaking change.
-          newFiles.map((file) => this.getTagFile(file, this.requestClientId)),
+    this.plugin.setPluginState({ partialTree: newPartialTree })
+    this.lastCheckbox = ourItem.id
+  }
+
+  getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
+    const { partialTree, currentFolderId, searchString } =
+      this.plugin.getPluginState()
+    const inThisFolder = partialTree.filter(
+      (item) => item.type !== 'root' && item.parentId === currentFolderId,
+    ) as (PartialTreeFile | PartialTreeFolderNode)[]
+    const filtered =
+      searchString === '' ? inThisFolder : (
+        inThisFolder.filter(
+          (item) =>
+            item.data.name.toLowerCase().indexOf(searchString.toLowerCase()) !==
+            -1,
         )
+      )
 
-        this.plugin.setPluginState({ filterInput: '' })
-        messages.forEach((message) => this.plugin.uppy.info(message))
+    return filtered
+  }
 
-        this.clearSelection()
-      })
-    } catch (err) {
-      this.handleError(err)
-    } finally {
-      this.setLoading(false)
-    }
+  validateAggregateRestrictions = (partialTree: PartialTree) => {
+    const checkedFiles = partialTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    ) as PartialTreeFile[]
+    const uppyFiles = checkedFiles.map((file) => file.data)
+    return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
   }
 
-  render(
-    state: unknown,
-    viewOptions: Omit<ViewOptions<M, B, PluginType>, 'provider'> = {},
-  ) {
-    const { authenticated, didFirstRender } = this.plugin.getPluginState()
+  render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
+    const { didFirstRender } = this.plugin.getPluginState()
     const { i18n } = this.plugin.uppy
 
     if (!didFirstRender) {
       this.plugin.setPluginState({ didFirstRender: true })
       this.provider.fetchPreAuthToken()
-      this.getFolder(this.plugin.rootFolderId || undefined)
+      this.openFolder(this.plugin.rootFolderId)
     }
 
-    const targetViewOptions = { ...this.opts, ...viewOptions }
-    const { files, folders, filterInput, loading, currentSelection } =
-      this.plugin.getPluginState()
-    const { isChecked, recordShiftKeyPress, filterItems } = this
-    const hasInput = filterInput !== ''
+    const opts: Opts<M, B> = { ...this.opts, ...viewOptions }
+    const { authenticated, loading } = this.plugin.getPluginState()
     const pluginIcon = this.plugin.icon || defaultPickerIcon
 
-    const headerProps = {
-      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
-      getFolder: this.getFolder,
-      breadcrumbs: this.plugin.getPluginState().breadcrumbs,
-      pluginIcon,
-      title: this.plugin.title,
-      logout: this.logout,
-      username: this.username,
-      i18n,
-    }
-
-    const browserProps = {
-      isChecked,
-      toggleCheckbox: this.toggleCheckbox.bind(this),
-      recordShiftKeyPress,
-      currentSelection,
-      files: hasInput ? filterItems(files) : files,
-      folders: hasInput ? filterItems(folders) : folders,
-      getNextFolder: this.getNextFolder,
-      getFolder: this.getFolder,
-      loadAllFiles: this.opts.loadAllFiles,
-      virtualList: this.opts.virtualList,
-
-      // For SearchFilterInput component
-      showSearchFilter: targetViewOptions.showFilter,
-      search: this.filterQuery,
-      clearSearch: this.clearFilter,
-      searchTerm: filterInput,
-      searchOnInput: true,
-      searchInputLabel: i18n('filter'),
-      clearSearchLabel: i18n('resetFilter'),
-
-      noResultsLabel: i18n('noFilesFound'),
-      logout: this.logout,
-      handleScroll: this.handleScroll,
-      done: this.donePicking,
-      cancel: this.cancelPicking,
-      // eslint-disable-next-line react/jsx-props-no-spreading
-      headerComponent: <Header<M, B> {...headerProps} />,
-      title: this.plugin.title,
-      viewType: targetViewOptions.viewType,
-      showTitles: targetViewOptions.showTitles,
-      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
-      pluginIcon,
-      i18n: this.plugin.uppy.i18n,
-      uppyFiles: this.plugin.uppy.getFiles(),
-      validateRestrictions: (
-        ...args: Parameters<Uppy<M, B>['validateRestrictions']>
-      ) => this.plugin.uppy.validateRestrictions(...args),
-      isLoading: loading,
-    }
-
     if (authenticated === false) {
       return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <AuthView
-            pluginName={this.plugin.title}
-            pluginIcon={pluginIcon}
-            handleAuth={this.handleAuth}
-            i18n={this.plugin.uppy.i18nArray}
-            renderForm={this.opts.renderAuthForm}
-            loading={loading}
-          />
-        </CloseWrapper>
+        <AuthView
+          pluginName={this.plugin.title}
+          pluginIcon={pluginIcon}
+          handleAuth={this.handleAuth}
+          i18n={this.plugin.uppy.i18nArray}
+          renderForm={opts.renderAuthForm}
+          loading={loading}
+        />
       )
     }
 
+    const { partialTree, currentFolderId, username, searchString } =
+      this.plugin.getPluginState()
+    const breadcrumbs = getBreadcrumbs(partialTree, currentFolderId)
+
     return (
-      <CloseWrapper onUnmount={this.clearSelection}>
-        {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-        <Browser<M, B> {...browserProps} />
-      </CloseWrapper>
+      <div
+        className={classNames(
+          'uppy-ProviderBrowser',
+          `uppy-ProviderBrowser-viewType--${opts.viewType}`,
+        )}
+      >
+        <Header<M, B>
+          showBreadcrumbs={opts.showBreadcrumbs}
+          openFolder={this.openFolder}
+          breadcrumbs={breadcrumbs}
+          pluginIcon={pluginIcon}
+          title={this.plugin.title}
+          logout={this.logout}
+          username={username}
+          i18n={i18n}
+        />
+
+        {opts.showFilter && (
+          <SearchInput
+            searchString={searchString}
+            setSearchString={(s: string) => {
+              this.plugin.setPluginState({ searchString: s })
+            }}
+            submitSearchString={() => {}}
+            inputLabel={i18n('filter')}
+            clearSearchLabel={i18n('resetFilter')}
+            wrapperClassName="uppy-ProviderBrowser-searchFilter"
+            inputClassName="uppy-ProviderBrowser-searchFilterInput"
+          />
+        )}
+
+        <Browser<M, B>
+          toggleCheckbox={this.toggleCheckbox}
+          displayedPartialTree={this.getDisplayedPartialTree()}
+          openFolder={this.openFolder}
+          virtualList={opts.virtualList}
+          noResultsLabel={i18n('noFilesFound')}
+          handleScroll={this.handleScroll}
+          viewType={opts.viewType}
+          showTitles={opts.showTitles}
+          i18n={this.plugin.uppy.i18n}
+          isLoading={loading}
+        />
+
+        <FooterActions
+          partialTree={partialTree}
+          donePicking={this.donePicking}
+          cancelSelection={this.cancelSelection}
+          i18n={i18n}
+          validateAggregateRestrictions={this.validateAggregateRestrictions}
+        />
+      </div>
     )
   }
 }

+ 6 - 4
packages/@uppy/provider-views/src/ProviderView/User.tsx

@@ -3,15 +3,17 @@ import { h, Fragment } from 'preact'
 type UserProps = {
   i18n: (phrase: string) => string
   logout: () => void
-  username: string | undefined
+  username: string | null
 }
 
 export default function User({ i18n, logout, username }: UserProps) {
   return (
     <Fragment>
-      <span className="uppy-ProviderBrowser-user" key="username">
-        {username}
-      </span>
+      {username && (
+        <span className="uppy-ProviderBrowser-user" key="username">
+          {username}
+        </span>
+      )}
       <button
         type="button"
         onClick={logout}

+ 47 - 50
packages/@uppy/provider-views/src/SearchFilterInput.tsx → packages/@uppy/provider-views/src/SearchInput.tsx

@@ -1,60 +1,53 @@
-/* eslint-disable react/require-default-props */
-import { h, Fragment } from 'preact'
+import { h } from 'preact'
 import { useEffect, useState, useCallback } from 'preact/hooks'
+import { type ChangeEvent } from 'preact/compat'
 import { nanoid } from 'nanoid/non-secure'
 
 type Props = {
-  search: (query: string) => void
-  searchOnInput?: boolean
-  searchTerm?: string | null
-  showButton?: boolean
+  searchString: string
+  setSearchString: (s: string) => void
+  submitSearchString: () => void
+
+  wrapperClassName: string
+  inputClassName: string
+
   inputLabel: string
   clearSearchLabel?: string
+
+  showButton?: boolean
   buttonLabel?: string
-  // eslint-disable-next-line react/require-default-props
-  clearSearch?: () => void
-  inputClassName: string
   buttonCSSClassName?: string
 }
 
-export default function SearchFilterInput(props: Props) {
-  const {
-    search,
-    searchOnInput,
-    searchTerm,
-    showButton,
-    inputLabel,
-    clearSearchLabel,
-    buttonLabel,
-    clearSearch,
-    inputClassName,
-    buttonCSSClassName,
-  } = props
-  const [searchText, setSearchText] = useState(searchTerm ?? '')
-  // const debouncedSearch = debounce((q) => search(q), 1000)
+function SearchInput({
+  searchString,
+  setSearchString,
+  submitSearchString,
 
-  const validateAndSearch = useCallback(
-    (ev: Event) => {
-      ev.preventDefault()
-      search(searchText)
-    },
-    [search, searchText],
-  )
+  wrapperClassName,
+  inputClassName,
+
+  inputLabel,
+  clearSearchLabel = '',
+
+  showButton = false,
+  buttonLabel = '',
+  buttonCSSClassName = '',
+}: Props) {
+  const onInput = (e: ChangeEvent) => {
+    setSearchString((e.target as HTMLInputElement).value)
+  }
 
-  const handleInput = useCallback(
+  const submit = useCallback(
     (ev: Event) => {
-      const inputValue = (ev.target as HTMLInputElement).value
-      setSearchText(inputValue)
-      if (searchOnInput) search(inputValue)
+      ev.preventDefault()
+      submitSearchString()
     },
-    [setSearchText, searchOnInput, search],
+    [submitSearchString],
   )
 
-  const handleReset = () => {
-    setSearchText('')
-    if (clearSearch) clearSearch()
-  }
-
+  // We do this to avoid nested <form>s
+  // (See https://github.com/transloadit/uppy/pull/5050#discussion_r1640392516)
   const [form] = useState(() => {
     const formEl = document.createElement('form')
     formEl.setAttribute('tabindex', '-1')
@@ -64,26 +57,27 @@ export default function SearchFilterInput(props: Props) {
 
   useEffect(() => {
     document.body.appendChild(form)
-    form.addEventListener('submit', validateAndSearch)
+    form.addEventListener('submit', submit)
     return () => {
-      form.removeEventListener('submit', validateAndSearch)
+      form.removeEventListener('submit', submit)
       document.body.removeChild(form)
     }
-  }, [form, validateAndSearch])
+  }, [form, submit])
 
   return (
-    <Fragment>
+    <section className={wrapperClassName}>
       <input
         className={`uppy-u-reset ${inputClassName}`}
         type="search"
         aria-label={inputLabel}
         placeholder={inputLabel}
-        value={searchText}
-        onInput={handleInput}
+        value={searchString}
+        onInput={onInput}
         form={form.id}
         data-uppy-super-focusable
       />
       {!showButton && (
+        // 🔍
         <svg
           aria-hidden="true"
           focusable="false"
@@ -95,13 +89,14 @@ export default function SearchFilterInput(props: Props) {
           <path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
         </svg>
       )}
-      {!showButton && searchText && (
+      {!showButton && searchString && (
+        // ❌
         <button
           className="uppy-u-reset uppy-ProviderBrowser-searchFilterReset"
           type="button"
           aria-label={clearSearchLabel}
           title={clearSearchLabel}
-          onClick={handleReset}
+          onClick={() => setSearchString('')}
         >
           <svg
             aria-hidden="true"
@@ -122,6 +117,8 @@ export default function SearchFilterInput(props: Props) {
           {buttonLabel}
         </button>
       )}
-    </Fragment>
+    </section>
   )
 }
+
+export default SearchInput

+ 266 - 144
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx

@@ -1,43 +1,64 @@
 import { h } from 'preact'
 
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownSearchProviderPlugin } from '@uppy/core/lib/Uppy'
-import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
-import type Uppy from '@uppy/core'
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeFolderRoot,
+  UnknownSearchProviderPlugin,
+  UnknownSearchProviderPluginState,
+} from '@uppy/core/lib/Uppy.ts'
 import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import SearchFilterInput from '../SearchFilterInput.tsx'
+import classNames from 'classnames'
+import type { ValidateableFile } from '@uppy/core/lib/Restricter.ts'
+import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
+import SearchInput from '../SearchInput.tsx'
 import Browser from '../Browser.tsx'
-import CloseWrapper from '../CloseWrapper.ts'
-import View, { type ViewOptions } from '../View.ts'
 
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
 import packageJson from '../../package.json'
-
-const defaultState = {
+import PartialTreeUtils from '../utils/PartialTreeUtils/index.ts'
+import shouldHandleScroll from '../utils/shouldHandleScroll.ts'
+import handleError from '../utils/handleError.ts'
+import getClickedRange from '../utils/getClickedRange.ts'
+import FooterActions from '../FooterActions.tsx'
+import addFiles from '../utils/addFiles.ts'
+import getCheckedFilesWithPaths from '../utils/PartialTreeUtils/getCheckedFilesWithPaths.ts'
+
+const defaultState: UnknownSearchProviderPluginState = {
+  loading: false,
+  searchString: '',
+  partialTree: [
+    {
+      type: 'root',
+      id: null,
+      cached: false,
+      nextPagePath: null,
+    },
+  ],
+  currentFolderId: null,
   isInputMode: true,
-  files: [],
-  folders: [],
-  breadcrumbs: [],
-  filterInput: '',
-  currentSelection: [],
-  searchTerm: null,
 }
 
-type PluginType = 'SearchProvider'
+type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
 
-const defaultOptions = {
-  viewType: 'grid',
-  showTitles: true,
-  showFilter: true,
-  showBreadcrumbs: true,
+interface Opts<M extends Meta, B extends Body> {
+  provider: UnknownSearchProviderPlugin<M, B>['provider']
+  viewType: 'list' | 'grid' | 'unsplash'
+  showTitles: boolean
+  showFilter: boolean
 }
-
-type Opts<
-  M extends Meta,
-  B extends Body,
-  T extends PluginType,
-> = DefinePluginOpts<ViewOptions<M, B, T>, keyof typeof defaultOptions>
+type PassedOpts<M extends Meta, B extends Body> = Optional<
+  Opts<M, B>,
+  'viewType' | 'showTitles' | 'showFilter'
+>
+type DefaultOpts<M extends Meta, B extends Body> = Omit<Opts<M, B>, 'provider'>
+type RenderOpts<M extends Meta, B extends Body> = Omit<
+  PassedOpts<M, B>,
+  'provider'
+>
 
 type Res = {
   items: CompanionFile[]
@@ -49,31 +70,52 @@ type Res = {
  * SearchProviderView, used for Unsplash and future image search providers.
  * Extends generic View, shared with regular providers like Google Drive and Instagram.
  */
-export default class SearchProviderView<
-  M extends Meta,
-  B extends Body,
-> extends View<M, B, PluginType, Opts<M, B, PluginType>> {
+export default class SearchProviderView<M extends Meta, B extends Body> {
   static VERSION = packageJson.version
 
-  nextPageQuery: string | null = null
+  plugin: UnknownSearchProviderPlugin<M, B>
+
+  provider: UnknownSearchProviderPlugin<M, B>['provider']
+
+  opts: Opts<M, B>
+
+  isHandlingScroll: boolean = false
+
+  lastCheckbox: string | null = null
 
   constructor(
     plugin: UnknownSearchProviderPlugin<M, B>,
-    opts: ViewOptions<M, B, PluginType>,
+    opts: PassedOpts<M, B>,
   ) {
-    super(plugin, { ...defaultOptions, ...opts })
+    this.plugin = plugin
+    this.provider = opts.provider
+    const defaultOptions: DefaultOpts<M, B> = {
+      viewType: 'grid',
+      showTitles: true,
+      showFilter: true,
+    }
+    this.opts = { ...defaultOptions, ...opts }
 
+    this.setSearchString = this.setSearchString.bind(this)
     this.search = this.search.bind(this)
-    this.clearSearch = this.clearSearch.bind(this)
     this.resetPluginState = this.resetPluginState.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.donePicking = this.donePicking.bind(this)
+    this.cancelSelection = this.cancelSelection.bind(this)
+    this.toggleCheckbox = this.toggleCheckbox.bind(this)
 
     this.render = this.render.bind(this)
 
-    this.plugin.setPluginState(defaultState)
+    // Set default state for the plugin
+    this.resetPluginState()
+
+    // @ts-expect-error this should be typed in @uppy/dashboard.
+    this.plugin.uppy.on('dashboard:close-panel', this.resetPluginState)
 
-    this.registerRequestClient()
+    this.plugin.uppy.registerRequestClient(
+      this.provider.provider,
+      this.provider,
+    )
   }
 
   // eslint-disable-next-line class-methods-use-this
@@ -81,147 +123,227 @@ export default class SearchProviderView<
     // Nothing.
   }
 
+  setLoading(loading: boolean | string): void {
+    this.plugin.setPluginState({ loading })
+  }
+
   resetPluginState(): void {
     this.plugin.setPluginState(defaultState)
   }
 
-  #updateFilesAndInputMode(res: Res, files: CompanionFile[]): void {
-    this.nextPageQuery = res.nextPageQuery
-    res.items.forEach((item) => {
-      files.push(item)
-    })
-    this.plugin.setPluginState({
-      currentSelection: [],
-      isInputMode: false,
-      files,
-      searchTerm: res.searchedFor,
-    })
+  cancelSelection(): void {
+    const { partialTree } = this.plugin.getPluginState()
+    const newPartialTree: PartialTree = partialTree.map((item) =>
+      item.type === 'root' ? item : { ...item, status: 'unchecked' },
+    )
+    this.plugin.setPluginState({ partialTree: newPartialTree })
   }
 
-  async search(query: string): Promise<void> {
-    const { searchTerm } = this.plugin.getPluginState()
-    if (query && query === searchTerm) {
-      // no need to search again as this is the same as the previous search
-      return
-    }
+  async search(): Promise<void> {
+    const { searchString } = this.plugin.getPluginState()
+    if (searchString === '') return
 
     this.setLoading(true)
     try {
-      const res = await this.provider.search<Res>(query)
-      this.#updateFilesAndInputMode(res, [])
-    } catch (err) {
-      this.handleError(err)
-    } finally {
-      this.setLoading(false)
+      const response = await this.provider.search<Res>(searchString)
+
+      const newPartialTree: PartialTree = [
+        {
+          type: 'root',
+          id: null,
+          cached: false,
+          nextPagePath: response.nextPageQuery,
+        },
+        ...response.items.map(
+          (item) =>
+            ({
+              type: 'file',
+              id: item.requestPath,
+              status: 'unchecked',
+              parentId: null,
+              data: item,
+            }) as PartialTreeFile,
+        ),
+      ]
+      this.plugin.setPluginState({
+        partialTree: newPartialTree,
+        isInputMode: false,
+      })
+    } catch (error) {
+      handleError(this.plugin.uppy)(error)
     }
-  }
-
-  clearSearch(): void {
-    this.plugin.setPluginState({
-      currentSelection: [],
-      files: [],
-      searchTerm: null,
-    })
+    this.setLoading(false)
   }
 
   async handleScroll(event: Event): Promise<void> {
-    const query = this.nextPageQuery || null
-
-    if (this.shouldHandleScroll(event) && query) {
+    const { partialTree, searchString } = this.plugin.getPluginState()
+    const root = partialTree.find(
+      (i) => i.type === 'root',
+    ) as PartialTreeFolderRoot
+
+    if (
+      shouldHandleScroll(event) &&
+      !this.isHandlingScroll &&
+      root.nextPagePath
+    ) {
       this.isHandlingScroll = true
-
       try {
-        const { files, searchTerm } = this.plugin.getPluginState()
-        const response = await this.provider.search<Res>(searchTerm!, query)
-
-        this.#updateFilesAndInputMode(response, files)
+        const response = await this.provider.search<Res>(
+          searchString,
+          root.nextPagePath,
+        )
+
+        const newRoot: PartialTreeFolderRoot = {
+          ...root,
+          nextPagePath: response.nextPageQuery,
+        }
+        const oldItems = partialTree.filter((i) => i.type !== 'root')
+
+        const newPartialTree: PartialTree = [
+          newRoot,
+          ...oldItems,
+          ...response.items.map(
+            (item) =>
+              ({
+                type: 'file',
+                id: item.requestPath,
+                status: 'unchecked',
+                parentId: null,
+                data: item,
+              }) as PartialTreeFile,
+          ),
+        ]
+        this.plugin.setPluginState({ partialTree: newPartialTree })
       } catch (error) {
-        this.handleError(error)
-      } finally {
-        this.isHandlingScroll = false
+        handleError(this.plugin.uppy)(error)
       }
+      this.isHandlingScroll = false
     }
   }
 
-  donePicking(): void {
-    const { currentSelection } = this.plugin.getPluginState()
-    this.plugin.uppy.log('Adding remote search provider files')
-    this.plugin.uppy.addFiles(
-      currentSelection.map((file) => this.getTagFile(file)),
-    )
+  async donePicking(): Promise<void> {
+    const { partialTree } = this.plugin.getPluginState()
+
+    // 1. Add files
+    const companionFiles = getCheckedFilesWithPaths(partialTree)
+    addFiles(companionFiles, this.plugin, this.provider)
+
+    // 2. Reset state
     this.resetPluginState()
   }
 
-  render(
-    state: unknown,
-    viewOptions: Omit<ViewOptions<M, B, PluginType>, 'provider'> = {},
+  toggleCheckbox(
+    ourItem: PartialTreeFolderNode | PartialTreeFile,
+    isShiftKeyPressed: boolean,
   ) {
-    const { isInputMode, searchTerm } = this.plugin.getPluginState()
-    const { i18n } = this.plugin.uppy
+    const { partialTree } = this.plugin.getPluginState()
 
-    const targetViewOptions = { ...this.opts, ...viewOptions }
-    const { files, folders, filterInput, loading, currentSelection } =
-      this.plugin.getPluginState()
-    const { isChecked, filterItems, recordShiftKeyPress } = this
-    const hasInput = filterInput !== ''
-
-    const browserProps = {
-      isChecked,
-      toggleCheckbox: this.toggleCheckbox.bind(this),
-      recordShiftKeyPress,
-      currentSelection,
-      files: hasInput ? filterItems(files) : files,
-      folders: hasInput ? filterItems(folders) : folders,
-      handleScroll: this.handleScroll,
-      done: this.donePicking,
-      cancel: this.cancelPicking,
-
-      // For SearchFilterInput component
-      showSearchFilter: targetViewOptions.showFilter,
-      search: this.search,
-      clearSearch: this.clearSearch,
-      searchTerm,
-      searchOnInput: false,
-      searchInputLabel: i18n('search'),
-      clearSearchLabel: i18n('resetSearch'),
-
-      noResultsLabel: i18n('noSearchResults'),
-      title: this.plugin.title,
-      viewType: targetViewOptions.viewType,
-      showTitles: targetViewOptions.showTitles,
-      showFilter: targetViewOptions.showFilter,
-      isLoading: loading,
-      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
-      pluginIcon: this.plugin.icon,
-      i18n,
-      uppyFiles: this.plugin.uppy.getFiles(),
-      validateRestrictions: (
-        ...args: Parameters<Uppy<M, B>['validateRestrictions']>
-      ) => this.plugin.uppy.validateRestrictions(...args),
+    const clickedRange = getClickedRange(
+      ourItem.id,
+      this.getDisplayedPartialTree(),
+      isShiftKeyPressed,
+      this.lastCheckbox,
+    )
+    const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
+      partialTree,
+      clickedRange,
+    )
+
+    this.plugin.setPluginState({ partialTree: newPartialTree })
+    this.lastCheckbox = ourItem.id
+  }
+
+  validateSingleFile = (file: CompanionFile): string | null => {
+    const companionFile: ValidateableFile<M, B> = remoteFileObjToLocal(file)
+    const result = this.plugin.uppy.validateSingleFile(companionFile)
+    return result
+  }
+
+  getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
+    const { partialTree } = this.plugin.getPluginState()
+    return partialTree.filter((item) => item.type !== 'root') as (
+      | PartialTreeFolderNode
+      | PartialTreeFile
+    )[]
+  }
+
+  setSearchString = (searchString: string) => {
+    this.plugin.setPluginState({ searchString })
+    if (searchString === '') {
+      this.plugin.setPluginState({ partialTree: [] })
     }
+  }
+
+  validateAggregateRestrictions = (partialTree: PartialTree) => {
+    const checkedFiles = partialTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    ) as PartialTreeFile[]
+    const uppyFiles = checkedFiles.map((file) => file.data)
+    return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
+  }
+
+  render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
+    const { isInputMode, searchString, loading, partialTree } =
+      this.plugin.getPluginState()
+    const { i18n } = this.plugin.uppy
+    const opts: Opts<M, B> = { ...this.opts, ...viewOptions }
 
     if (isInputMode) {
       return (
-        <CloseWrapper onUnmount={this.resetPluginState}>
-          <div className="uppy-SearchProvider">
-            <SearchFilterInput
-              search={this.search}
-              inputLabel={i18n('enterTextToSearch')}
-              buttonLabel={i18n('searchImages')}
-              inputClassName="uppy-c-textInput uppy-SearchProvider-input"
-              buttonCSSClassName="uppy-SearchProvider-searchButton"
-              showButton
-            />
-          </div>
-        </CloseWrapper>
+        <SearchInput
+          searchString={searchString}
+          setSearchString={this.setSearchString}
+          submitSearchString={this.search}
+          inputLabel={i18n('enterTextToSearch')}
+          buttonLabel={i18n('searchImages')}
+          wrapperClassName="uppy-SearchProvider"
+          inputClassName="uppy-c-textInput uppy-SearchProvider-input"
+          showButton
+          buttonCSSClassName="uppy-SearchProvider-searchButton"
+        />
       )
     }
 
     return (
-      <CloseWrapper onUnmount={this.resetPluginState}>
-        {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-        <Browser {...browserProps} />
-      </CloseWrapper>
+      <div
+        className={classNames(
+          'uppy-ProviderBrowser',
+          `uppy-ProviderBrowser-viewType--${opts.viewType}`,
+        )}
+      >
+        {opts.showFilter && (
+          <SearchInput
+            searchString={searchString}
+            setSearchString={this.setSearchString}
+            submitSearchString={this.search}
+            inputLabel={i18n('search')}
+            clearSearchLabel={i18n('resetSearch')}
+            wrapperClassName="uppy-ProviderBrowser-searchFilter"
+            inputClassName="uppy-ProviderBrowser-searchFilterInput"
+          />
+        )}
+
+        <Browser
+          toggleCheckbox={this.toggleCheckbox}
+          displayedPartialTree={this.getDisplayedPartialTree()}
+          handleScroll={this.handleScroll}
+          openFolder={async () => {}}
+          noResultsLabel={i18n('noSearchResults')}
+          viewType={opts.viewType}
+          showTitles={opts.showTitles}
+          isLoading={loading}
+          i18n={i18n}
+          virtualList={false}
+        />
+
+        <FooterActions
+          partialTree={partialTree}
+          donePicking={this.donePicking}
+          cancelSelection={this.cancelSelection}
+          i18n={i18n}
+          validateAggregateRestrictions={this.validateAggregateRestrictions}
+        />
+      </div>
     )
   }
 }

+ 0 - 261
packages/@uppy/provider-views/src/View.ts

@@ -1,261 +0,0 @@
-import type {
-  UnknownProviderPlugin,
-  UnknownSearchProviderPlugin,
-} from '@uppy/core/lib/Uppy'
-import type { Body, Meta, TagFile } from '@uppy/utils/lib/UppyFile'
-import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
-
-type PluginType = 'Provider' | 'SearchProvider'
-
-// Conditional type for selecting the plugin
-type SelectedPlugin<M extends Meta, B extends Body, T extends PluginType> =
-  T extends 'Provider' ? UnknownProviderPlugin<M, B>
-  : T extends 'SearchProvider' ? UnknownSearchProviderPlugin<M, B>
-  : never
-
-// Conditional type for selecting the provider from the selected plugin
-type SelectedProvider<
-  M extends Meta,
-  B extends Body,
-  T extends PluginType,
-> = SelectedPlugin<M, B, T>['provider']
-
-export interface ViewOptions<
-  M extends Meta,
-  B extends Body,
-  T extends PluginType,
-> {
-  provider: SelectedProvider<M, B, T>
-  viewType?: string
-  showTitles?: boolean
-  showFilter?: boolean
-  showBreadcrumbs?: boolean
-  loadAllFiles?: boolean
-}
-
-export default class View<
-  M extends Meta,
-  B extends Body,
-  T extends PluginType,
-  O extends ViewOptions<M, B, T>,
-> {
-  plugin: SelectedPlugin<M, B, T>
-
-  provider: SelectedProvider<M, B, T>
-
-  isHandlingScroll: boolean
-
-  requestClientId?: string
-
-  isShiftKeyPressed: boolean = false
-
-  lastCheckbox: CompanionFile | undefined
-
-  protected opts: O
-
-  constructor(plugin: SelectedPlugin<M, B, T>, opts: O) {
-    this.plugin = plugin
-    this.provider = opts.provider
-    this.opts = opts
-
-    this.isHandlingScroll = false
-
-    this.handleError = this.handleError.bind(this)
-    this.clearSelection = this.clearSelection.bind(this)
-    this.cancelPicking = this.cancelPicking.bind(this)
-  }
-
-  shouldHandleScroll(event: Event): boolean {
-    const { scrollHeight, scrollTop, offsetHeight } =
-      event.target as HTMLElement
-    const scrollPosition = scrollHeight - (scrollTop + offsetHeight)
-
-    return scrollPosition < 50 && !this.isHandlingScroll
-  }
-
-  clearSelection(): void {
-    this.plugin.setPluginState({ currentSelection: [], filterInput: '' })
-  }
-
-  cancelPicking(): void {
-    this.clearSelection()
-
-    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
-
-    if (dashboard) {
-      // @ts-expect-error impossible to type this correctly without adding dashboard
-      // as a dependency to this package.
-      dashboard.hideAllPanels()
-    }
-  }
-
-  handleError(error: Error): void {
-    const { uppy } = this.plugin
-    const message = uppy.i18n('companionError')
-
-    uppy.log(error.toString())
-
-    if (
-      (error as any).isAuthError ||
-      (error.cause as Error)?.name === 'AbortError'
-    ) {
-      // authError just means we're not authenticated, don't show to user
-      // AbortError means the user has clicked "cancel" on an operation
-      return
-    }
-
-    uppy.info({ message, details: error.toString() }, 'error', 5000)
-  }
-
-  registerRequestClient(): void {
-    this.requestClientId = this.provider.provider
-    this.plugin.uppy.registerRequestClient(this.requestClientId, this.provider)
-  }
-
-  // TODO: document what is a "tagFile" or get rid of this concept
-  getTagFile(file: CompanionFile): TagFile<M> {
-    const tagFile: TagFile<M> = {
-      id: file.id,
-      source: this.plugin.id,
-      name: file.name || file.id,
-      type: file.mimeType,
-      isRemote: true,
-      data: file,
-      // @ts-expect-error meta is filled conditionally below
-      meta: {},
-      body: {
-        fileId: file.id,
-      },
-      remote: {
-        companionUrl: this.plugin.opts.companionUrl,
-        // @ts-expect-error untyped for now
-        url: `${this.provider.fileUrl(file.requestPath)}`,
-        body: {
-          fileId: file.id,
-        },
-        providerName: this.provider.name,
-        provider: this.provider.provider,
-        requestClientId: this.requestClientId!,
-      },
-    }
-
-    if (file.thumbnail) {
-      tagFile.preview = file.thumbnail
-    }
-
-    if (file.author) {
-      if (file.author.name != null)
-        tagFile.meta!.authorName = String(file.author.name)
-      if (file.author.url) tagFile.meta!.authorUrl = file.author.url
-    }
-
-    // add relativePath similar to non-remote files: https://github.com/transloadit/uppy/pull/4486#issuecomment-1579203717
-    if (file.relDirPath != null)
-      tagFile.meta!.relativePath =
-        file.relDirPath ? `${file.relDirPath}/${tagFile.name}` : null
-    // and absolutePath (with leading slash) https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
-    if (file.absDirPath != null)
-      tagFile.meta!.absolutePath =
-        file.absDirPath ?
-          `/${file.absDirPath}/${tagFile.name}`
-        : `/${tagFile.name}`
-
-    return tagFile
-  }
-
-  filterItems = (items: CompanionFile[]): CompanionFile[] => {
-    const state = this.plugin.getPluginState()
-    if (!state.filterInput || state.filterInput === '') {
-      return items
-    }
-    return items.filter((folder) => {
-      return (
-        folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !==
-        -1
-      )
-    })
-  }
-
-  recordShiftKeyPress = (e: KeyboardEvent | MouseEvent): void => {
-    this.isShiftKeyPressed = e.shiftKey
-  }
-
-  /**
-   * Toggles file/folder checkbox to on/off state while updating files list.
-   *
-   * Note that some extra complexity comes from supporting shift+click to
-   * toggle multiple checkboxes at once, which is done by getting all files
-   * in between last checked file and current one.
-   */
-  toggleCheckbox(e: Event, file: CompanionFile): void {
-    e.stopPropagation()
-    e.preventDefault()
-    ;(e.currentTarget as HTMLInputElement).focus()
-    const { folders, files } = this.plugin.getPluginState()
-    const items = this.filterItems(folders.concat(files))
-    // Shift-clicking selects a single consecutive list of items
-    // starting at the previous click.
-    if (this.lastCheckbox && this.isShiftKeyPressed) {
-      const { currentSelection } = this.plugin.getPluginState()
-      const prevIndex = items.indexOf(this.lastCheckbox)
-      const currentIndex = items.indexOf(file)
-      const newSelection =
-        prevIndex < currentIndex ?
-          items.slice(prevIndex, currentIndex + 1)
-        : items.slice(currentIndex, prevIndex + 1)
-      const reducedNewSelection: CompanionFile[] = []
-
-      // Check restrictions on each file in currentSelection,
-      // reduce it to only contain files that pass restrictions
-      for (const item of newSelection) {
-        const { uppy } = this.plugin
-        const restrictionError = uppy.validateRestrictions(
-          remoteFileObjToLocal(item),
-          [...uppy.getFiles(), ...reducedNewSelection],
-        )
-
-        if (!restrictionError) {
-          reducedNewSelection.push(item)
-        } else {
-          uppy.info(
-            { message: restrictionError.message },
-            'error',
-            uppy.opts.infoTimeout,
-          )
-        }
-      }
-      this.plugin.setPluginState({
-        currentSelection: [
-          ...new Set([...currentSelection, ...reducedNewSelection]),
-        ],
-      })
-      return
-    }
-
-    this.lastCheckbox = file
-    const { currentSelection } = this.plugin.getPluginState()
-    if (this.isChecked(file)) {
-      this.plugin.setPluginState({
-        currentSelection: currentSelection.filter(
-          (item) => item.id !== file.id,
-        ),
-      })
-    } else {
-      this.plugin.setPluginState({
-        currentSelection: currentSelection.concat([file]),
-      })
-    }
-  }
-
-  isChecked = (file: CompanionFile): boolean => {
-    const { currentSelection } = this.plugin.getPluginState()
-    // comparing id instead of the file object, because the reference to the object
-    // changes when we switch folders, and the file list is updated
-    return currentSelection.some((item) => item.id === file.id)
-  }
-
-  setLoading(loading: boolean | string): void {
-    this.plugin.setPluginState({ loading })
-  }
-}

+ 21 - 2
packages/@uppy/provider-views/src/style.scss

@@ -346,8 +346,8 @@
 .uppy-ProviderBrowser-footer {
   display: flex;
   align-items: center;
-  height: 65px;
-  padding: 0 15px;
+  justify-content: space-between;
+  padding: 15px;
   background-color: $white;
   border-top: 1px solid $gray-200;
 
@@ -360,3 +360,22 @@
     border-top: 1px solid $gray-800;
   }
 }
+
+.uppy-ProviderBrowser-footer-buttons {
+  flex-shrink: 0;
+}
+
+.uppy-ProviderBrowser-footer-error {
+  color: $red;
+  line-height: 18px;
+}
+
+@media (max-width: 426px) {
+  .uppy-ProviderBrowser-footer {
+    flex-direction: column-reverse;
+    align-items: stretch;
+  }
+  .uppy-ProviderBrowser-footer-error {
+    padding-bottom: 10px;
+  }
+}

+ 1 - 1
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss

@@ -142,7 +142,7 @@
     }
   }
   // Checked: show the checkmark
-  .uppy-ProviderBrowserItem-checkbox--is-checked {
+  .uppy-ProviderBrowserItem--is-checked .uppy-ProviderBrowserItem-checkbox {
     opacity: 1;
   }
 

+ 9 - 6
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss

@@ -55,12 +55,15 @@
     }
   }
   // Checked: color the background, show the checkmark
-  .uppy-ProviderBrowserItem-checkbox--is-checked {
-    background-color: $blue;
-    border-color: $blue;
-
-    &::after {
-      opacity: 1;
+  .uppy-ProviderBrowserItem--is-checked,
+  .uppy-ProviderBrowserItem--is-partial {
+    .uppy-ProviderBrowserItem-checkbox {
+      background-color: $blue;
+      border-color: $blue;
+
+      &::after {
+        opacity: 1;
+      }
     }
   }
 

+ 24 - 12
packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-checkbox.scss

@@ -7,6 +7,20 @@
     cursor: default;
   }
 
+  &:disabled::after {
+    cursor: default;
+  }
+
+  [data-uppy-theme='dark'] & {
+    background-color: $gray-900;
+    border-color: $gray-500;
+  }
+}
+
+.uppy-ProviderBrowserItem--is-checked .uppy-ProviderBrowserItem-checkbox {
+  [data-uppy-theme='dark'] & {
+    background-color: $gray-800;
+  }
   // Checkmark icon
   &::after {
     position: absolute;
@@ -18,19 +32,17 @@
     cursor: pointer;
     content: '';
   }
-
-  &:disabled::after {
-    cursor: default;
-  }
-
-  [data-uppy-theme='dark'] & {
-    background-color: $gray-900;
-    border-color: $gray-500;
-  }
 }
 
-.uppy-ProviderBrowserItem-checkbox--is-checked {
-  [data-uppy-theme='dark'] & {
-    background-color: $gray-800;
+.uppy-ProviderBrowserItem--is-partial .uppy-ProviderBrowserItem-checkbox {
+  &::after {
+    content: '' !important;
+    position: absolute !important;
+    top: 50% !important;
+    left: 20% !important;
+    right: 20% !important;
+    height: 2px !important;
+    background-color: $gray-200 !important;
+    transform: translateY(-50%) !important;
   }
 }

+ 108 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterFill.ts

@@ -0,0 +1,108 @@
+/* eslint-disable no-param-reassign */
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import PQueue from 'p-queue'
+import shallowClone from './shallowClone.ts'
+
+export interface ApiList {
+  (directory: PartialTreeId): Promise<{
+    nextPagePath: PartialTreeId
+    items: CompanionFile[]
+  }>
+}
+
+const recursivelyFetch = async (
+  queue: PQueue,
+  poorTree: PartialTree,
+  poorFolder: PartialTreeFolderNode,
+  apiList: ApiList,
+  validateSingleFile: (file: CompanionFile) => string | null,
+) => {
+  let items: CompanionFile[] = []
+  let currentPath: PartialTreeId =
+    poorFolder.cached ? poorFolder.nextPagePath : poorFolder.id
+  while (currentPath) {
+    const response = await apiList(currentPath)
+    items = items.concat(response.items)
+    currentPath = response.nextPagePath
+  }
+
+  const newFolders = items.filter((i) => i.isFolder === true)
+  const newFiles = items.filter((i) => i.isFolder === false)
+
+  const folders: PartialTreeFolderNode[] = newFolders.map((folder) => ({
+    type: 'folder',
+    id: folder.requestPath,
+
+    cached: false,
+    nextPagePath: null,
+
+    status: 'checked',
+    parentId: poorFolder.id,
+    data: folder,
+  }))
+  const files: PartialTreeFile[] = newFiles.map((file) => {
+    const restrictionError = validateSingleFile(file)
+    return {
+      type: 'file',
+      id: file.requestPath,
+
+      restrictionError,
+
+      status: restrictionError ? 'unchecked' : 'checked',
+      parentId: poorFolder.id,
+      data: file,
+    }
+  })
+
+  poorFolder.cached = true
+  poorFolder.nextPagePath = null
+  poorTree.push(...files, ...folders)
+
+  folders.forEach(async (folder) => {
+    queue.add(() =>
+      recursivelyFetch(queue, poorTree, folder, apiList, validateSingleFile),
+    )
+  })
+}
+
+const afterFill = async (
+  partialTree: PartialTree,
+  apiList: ApiList,
+  validateSingleFile: (file: CompanionFile) => string | null,
+): Promise<PartialTree> => {
+  const queue = new PQueue({ concurrency: 6 })
+
+  // fill up the missing parts of a partialTree!
+  const poorTree: PartialTree = shallowClone(partialTree)
+  const poorFolders = poorTree.filter(
+    (item) =>
+      item.type === 'folder' &&
+      item.status === 'checked' &&
+      // either "not yet cached at all" or "some pages are left to fetch"
+      (item.cached === false || item.nextPagePath),
+  ) as PartialTreeFolderNode[]
+  // per each poor folder, recursively fetch all files and make them .checked!!!
+  poorFolders.forEach((poorFolder) => {
+    queue.add(() =>
+      recursivelyFetch(
+        queue,
+        poorTree,
+        poorFolder,
+        apiList,
+        validateSingleFile,
+      ),
+    )
+  })
+
+  await queue.onIdle()
+
+  return poorTree
+}
+
+export default afterFill

+ 63 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterOpenFolder.ts

@@ -0,0 +1,63 @@
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolder,
+  PartialTreeFolderNode,
+} from '@uppy/core/lib/Uppy'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+
+const afterOpenFolder = (
+  oldPartialTree: PartialTree,
+  discoveredItems: CompanionFile[],
+  clickedFolder: PartialTreeFolder,
+  currentPagePath: string | null,
+  validateSingleFile: (file: CompanionFile) => string | null,
+): PartialTree => {
+  const discoveredFolders = discoveredItems.filter((i) => i.isFolder === true)
+  const discoveredFiles = discoveredItems.filter((i) => i.isFolder === false)
+
+  const isParentFolderChecked =
+    clickedFolder.type === 'folder' && clickedFolder.status === 'checked'
+  const folders: PartialTreeFolderNode[] = discoveredFolders.map((folder) => ({
+    type: 'folder',
+    id: folder.requestPath,
+    cached: false,
+    nextPagePath: null,
+    status: isParentFolderChecked ? 'checked' : 'unchecked',
+    parentId: clickedFolder.id,
+    data: folder,
+  }))
+  const files: PartialTreeFile[] = discoveredFiles.map((file) => {
+    const restrictionError = validateSingleFile(file)
+    return {
+      type: 'file',
+      id: file.requestPath,
+
+      restrictionError,
+
+      status:
+        isParentFolderChecked && !restrictionError ? 'checked' : 'unchecked',
+      parentId: clickedFolder.id,
+      data: file,
+    }
+  })
+
+  // just doing `clickedFolder.cached = true` in a non-mutating way
+  const updatedClickedFolder: PartialTreeFolder = {
+    ...clickedFolder,
+    cached: true,
+    nextPagePath: currentPagePath,
+  }
+  const partialTreeWithUpdatedClickedFolder = oldPartialTree.map((folder) =>
+    folder.id === updatedClickedFolder.id ? updatedClickedFolder : folder,
+  )
+
+  const newPartialTree = [
+    ...partialTreeWithUpdatedClickedFolder,
+    ...folders,
+    ...files,
+  ]
+  return newPartialTree
+}
+
+export default afterOpenFolder

+ 65 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterScrollFolder.ts

@@ -0,0 +1,65 @@
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolder,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+
+const afterScrollFolder = (
+  oldPartialTree: PartialTree,
+  currentFolderId: PartialTreeId,
+  items: CompanionFile[],
+  nextPagePath: string | null,
+  validateSingleFile: (file: CompanionFile) => string | null,
+): PartialTree => {
+  const currentFolder = oldPartialTree.find(
+    (i) => i.id === currentFolderId,
+  ) as PartialTreeFolder
+
+  const newFolders = items.filter((i) => i.isFolder === true)
+  const newFiles = items.filter((i) => i.isFolder === false)
+
+  // just doing `scrolledFolder.nextPagePath = ...` in a non-mutating way
+  const scrolledFolder: PartialTreeFolder = { ...currentFolder, nextPagePath }
+  const partialTreeWithUpdatedScrolledFolder = oldPartialTree.map((folder) =>
+    folder.id === scrolledFolder.id ? scrolledFolder : folder,
+  )
+  const isParentFolderChecked =
+    scrolledFolder.type === 'folder' && scrolledFolder.status === 'checked'
+  const folders: PartialTreeFolderNode[] = newFolders.map((folder) => ({
+    type: 'folder',
+    id: folder.requestPath,
+
+    cached: false,
+    nextPagePath: null,
+
+    status: isParentFolderChecked ? 'checked' : 'unchecked',
+    parentId: scrolledFolder.id,
+    data: folder,
+  }))
+  const files: PartialTreeFile[] = newFiles.map((file) => {
+    const restrictionError = validateSingleFile(file)
+    return {
+      type: 'file',
+      id: file.requestPath,
+
+      restrictionError,
+
+      status:
+        isParentFolderChecked && !restrictionError ? 'checked' : 'unchecked',
+      parentId: scrolledFolder.id,
+      data: file,
+    }
+  })
+
+  const newPartialTree: PartialTree = [
+    ...partialTreeWithUpdatedScrolledFolder,
+    ...folders,
+    ...files,
+  ]
+  return newPartialTree
+}
+
+export default afterScrollFolder

+ 122 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/afterToggleCheckbox.ts

@@ -0,0 +1,122 @@
+/* eslint-disable no-param-reassign */
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolder,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
+import shallowClone from './shallowClone.ts'
+
+/*
+  FROM        | TO
+
+  root        |  root
+    folder    |    folder
+    folder ✅︎  |    folder ✅︎
+      file    |      file ✅︎
+      file    |      file ✅︎
+      folder  |      folder ✅︎
+        file  |        file ✅︎
+    file      |    file
+    file      |    file
+*/
+const percolateDown = (
+  tree: PartialTree,
+  id: PartialTreeId,
+  shouldMarkAsChecked: boolean,
+) => {
+  const children = tree.filter(
+    (item) => item.type !== 'root' && item.parentId === id,
+  ) as (PartialTreeFolderNode | PartialTreeFile)[]
+  children.forEach((item) => {
+    item.status =
+      shouldMarkAsChecked && !(item.type === 'file' && item.restrictionError) ?
+        'checked'
+      : 'unchecked'
+    percolateDown(tree, item.id, shouldMarkAsChecked)
+  })
+}
+
+/*
+  FROM         | TO
+
+  root         |  root
+    folder     |    folder
+    folder     |    folder [▬] ('partial' status)
+      file     |      file
+      folder   |      folder ✅︎
+        file ✅︎ |       file ✅︎
+    file       |    file
+    file       |    file
+*/
+const percolateUp = (tree: PartialTree, id: PartialTreeId) => {
+  const folder = tree.find((item) => item.id === id) as PartialTreeFolder
+  if (folder.type === 'root') return
+
+  const validChildren = tree.filter(
+    (item) =>
+      // is a child
+      item.type !== 'root' &&
+      item.parentId === folder.id &&
+      // does pass validations
+      !(item.type === 'file' && item.restrictionError),
+  ) as (PartialTreeFile | PartialTreeFolderNode)[]
+
+  const areAllChildrenChecked = validChildren.every(
+    (item) => item.status === 'checked',
+  )
+  const areAllChildrenUnchecked = validChildren.every(
+    (item) => item.status === 'unchecked',
+  )
+
+  if (areAllChildrenChecked) {
+    folder.status = 'checked'
+  } else if (areAllChildrenUnchecked) {
+    folder.status = 'unchecked'
+  } else {
+    folder.status = 'partial'
+  }
+
+  percolateUp(tree, folder.parentId)
+}
+
+const afterToggleCheckbox = (
+  oldTree: PartialTree,
+  clickedRange: string[],
+): PartialTree => {
+  const tree: PartialTree = shallowClone(oldTree)
+
+  if (clickedRange.length >= 2) {
+    // We checked two or more items
+    const newlyCheckedItems = tree.filter(
+      (item) => item.type !== 'root' && clickedRange.includes(item.id),
+    ) as (PartialTreeFile | PartialTreeFolderNode)[]
+
+    newlyCheckedItems.forEach((item) => {
+      if (item.type === 'file') {
+        item.status = item.restrictionError ? 'unchecked' : 'checked'
+      } else {
+        item.status = 'checked'
+      }
+    })
+
+    newlyCheckedItems.forEach((item) => {
+      percolateDown(tree, item.id, true)
+    })
+    percolateUp(tree, newlyCheckedItems[0].parentId)
+  } else {
+    // We checked exactly one item
+    const clickedItem = tree.find((item) => item.id === clickedRange[0]) as
+      | PartialTreeFile
+      | PartialTreeFolderNode
+    clickedItem.status =
+      clickedItem.status === 'checked' ? 'unchecked' : 'checked'
+    percolateDown(tree, clickedItem.id, clickedItem.status === 'checked')
+    percolateUp(tree, clickedItem.parentId)
+  }
+
+  return tree
+}
+
+export default afterToggleCheckbox

+ 31 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/getBreadcrumbs.ts

@@ -0,0 +1,31 @@
+import type {
+  PartialTree,
+  PartialTreeFolder,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
+
+const getBreadcrumbs = (
+  partialTree: PartialTree,
+  currentFolderId: PartialTreeId,
+): PartialTreeFolder[] => {
+  let folder = partialTree.find(
+    (f) => f.id === currentFolderId,
+  ) as PartialTreeFolder
+
+  let breadcrumbs: PartialTreeFolder[] = []
+  // eslint-disable-next-line no-constant-condition
+  while (true) {
+    breadcrumbs = [folder, ...breadcrumbs]
+
+    if (folder.type === 'root') break
+    const currentParentId = (folder as PartialTreeFolderNode).parentId
+    folder = partialTree.find(
+      (f) => f.id === currentParentId,
+    ) as PartialTreeFolder
+  }
+
+  return breadcrumbs
+}
+
+export default getBreadcrumbs

+ 74 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/getCheckedFilesWithPaths.ts

@@ -0,0 +1,74 @@
+/* eslint-disable no-param-reassign */
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+
+export interface Cache {
+  [key: string]: (PartialTreeFile | PartialTreeFolderNode)[]
+}
+
+const getPath = (
+  partialTree: PartialTree,
+  id: PartialTreeId,
+  cache: Cache,
+): (PartialTreeFile | PartialTreeFolderNode)[] => {
+  const sId = id === null ? 'null' : id
+  if (cache[sId]) return cache[sId]
+
+  const file = partialTree.find((f) => f.id === id)!
+
+  if (file.type === 'root') return []
+
+  const meAndParentPath = [...getPath(partialTree, file.parentId, cache), file]
+  cache[sId] = meAndParentPath
+  return meAndParentPath
+}
+
+// See "Uppy file properties" documentation for `.absolutePath` and `.relativePath`
+// (https://uppy.io/docs/uppy/#working-with-uppy-files)
+const getCheckedFilesWithPaths = (
+  partialTree: PartialTree,
+): CompanionFile[] => {
+  // Equivalent to `const cache = {}`, but makes keys such as 'hasOwnProperty' safe too
+  const cache: Cache = Object.create(null)
+
+  // We're only interested in injecting paths into 'checked' files
+  const checkedFiles = partialTree.filter(
+    (item) => item.type === 'file' && item.status === 'checked',
+  ) as PartialTreeFile[]
+
+  const companionFilesWithInjectedPaths = checkedFiles.map((file) => {
+    const absFolders: (PartialTreeFile | PartialTreeFolderNode)[] = getPath(
+      partialTree,
+      file.id,
+      cache,
+    )
+
+    const firstCheckedFolderIndex = absFolders.findIndex(
+      (i) => i.type === 'folder' && i.status === 'checked',
+    )
+    const relFolders = absFolders.slice(firstCheckedFolderIndex)
+
+    const absDirPath = `/${absFolders.map((i) => i.data.name).join('/')}`
+    const relDirPath =
+      relFolders.length === 1 ?
+        // Must return `undefined` (which later turns into `null` in `.getTagFile()`)
+        // (https://github.com/transloadit/uppy/pull/4537#issuecomment-1629136652)
+        undefined
+      : relFolders.map((i) => i.data.name).join('/')
+
+    return {
+      ...file.data,
+      absDirPath,
+      relDirPath,
+    }
+  })
+
+  return companionFilesWithInjectedPaths
+}
+
+export default getCheckedFilesWithPaths

+ 25 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/getNumberOfSelectedFiles.ts

@@ -0,0 +1,25 @@
+import type { PartialTree } from '@uppy/core/lib/Uppy'
+
+/**
+ * We're interested in all 'checked' leaves of this tree,
+ * but we don't yet know how many files there are inside of each checked folder.
+ * `getNumberOfSelectedFiles()` returns the most intuitive number we can show to the user
+ * in this situation.
+ */
+const getNumberOfSelectedFiles = (partialTree: PartialTree): number => {
+  const checkedLeaves = partialTree.filter((item) => {
+    if (item.type === 'file' && item.status === 'checked') {
+      return true
+    }
+    if (item.type === 'folder' && item.status === 'checked') {
+      const doesItHaveChildren = partialTree.some(
+        (i) => i.type !== 'root' && i.parentId === item.id,
+      )
+      return !doesItHaveChildren
+    }
+    return false
+  })
+  return checkedLeaves.length
+}
+
+export default getNumberOfSelectedFiles

+ 612 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/index.test.ts

@@ -0,0 +1,612 @@
+/* eslint-disable no-underscore-dangle */
+import { describe, expect, it, vi } from 'vitest'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type {
+  PartialTree,
+  PartialTreeFile,
+  PartialTreeFolderNode,
+  PartialTreeFolderRoot,
+  PartialTreeId,
+} from '@uppy/core/lib/Uppy.ts'
+import afterToggleCheckbox from './afterToggleCheckbox.ts'
+import afterOpenFolder from './afterOpenFolder.ts'
+import afterScrollFolder from './afterScrollFolder.ts'
+import afterFill from './afterFill.ts'
+import getCheckedFilesWithPaths from './getCheckedFilesWithPaths.ts'
+import getNumberOfSelectedFiles from './getNumberOfSelectedFiles.ts'
+import getBreadcrumbs from './getBreadcrumbs.ts'
+
+const _root = (id: string, options: any = {}): PartialTreeFolderRoot => ({
+  type: 'root',
+  id,
+  cached: true,
+  nextPagePath: null,
+  ...options,
+})
+
+const _cFile = (id: string) =>
+  ({
+    id,
+    requestPath: id,
+    name: `name_${id}.jpg`,
+    isFolder: false,
+  }) as CompanionFile
+
+const _cFolder = (id: string) =>
+  ({
+    id,
+    requestPath: id,
+    name: `name_${id}`,
+    isFolder: true,
+  }) as CompanionFile
+
+const _folder = (id: string, options: any): PartialTreeFolderNode => ({
+  type: 'folder',
+  id,
+  cached: true,
+  nextPagePath: null,
+  status: 'unchecked',
+  data: _cFolder(id),
+  ...options,
+})
+
+const _file = (id: string, options: any): PartialTreeFile => ({
+  type: 'file',
+  id,
+  status: 'unchecked',
+  parentId: options.parentId,
+  data: _cFile(id),
+  ...options,
+})
+
+const getFolder = (tree: PartialTree, id: string) =>
+  tree.find((i) => i.id === id) as PartialTreeFolderNode
+const getFile = (tree: PartialTree, id: string) =>
+  tree.find((i) => i.id === id) as PartialTreeFile
+
+describe('afterFill()', () => {
+  it('preserves .checked files in an already .cached folder', async () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: true }),
+              _file('2_1', { parentId: '2' }),
+              _file('2_2', { parentId: '2', status: 'checked' }),
+              _file('2_3', { parentId: '2' }),
+              _folder('2_4', { parentId: '2' }),
+          _file('3', { parentId: 'ourRoot' }),
+          _file('4', { parentId: 'ourRoot' }),
+    ]
+    const mock = vi.fn()
+    const enrichedTree = await afterFill(tree, mock, () => null)
+
+    // While we're at it - make sure we're not doing excessive api calls!
+    expect(mock.mock.calls.length).toEqual(0)
+
+    const checkedFiles = enrichedTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    )
+    expect(checkedFiles.length).toEqual(1)
+    expect(checkedFiles[0].id).toEqual('2_2')
+  })
+
+  it('fetches a .checked folder', async () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: false, status: 'checked' }),
+    ]
+    const mock = (path: PartialTreeId) => {
+      if (path === '2') {
+        const items = [_cFile('2_1'), _cFile('2_2')]
+        return Promise.resolve({ nextPagePath: '666', items })
+      }
+      if (path === '666') {
+        const items = [_cFile('2_3'), _cFile('2_4')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      return Promise.reject()
+    }
+    const enrichedTree = await afterFill(tree, mock, () => null)
+
+    const checkedFiles = enrichedTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    )
+    expect(checkedFiles.length).toEqual(4)
+    expect(checkedFiles.map((f) => f.id)).toEqual(['2_1', '2_2', '2_3', '2_4'])
+  })
+
+  it('fetches remaining pages in a folder', async () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', {
+            parentId: 'ourRoot',
+            cached: true,
+            nextPagePath: '666',
+            status: 'checked',
+          }),
+    ]
+    const mock = (path: PartialTreeId) => {
+      if (path === '666') {
+        const items = [_cFile('111'), _cFile('222')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      return Promise.reject()
+    }
+    const enrichedTree = await afterFill(tree, mock, () => null)
+
+    const checkedFiles = enrichedTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    )
+    expect(checkedFiles.length).toEqual(2)
+    expect(checkedFiles.map((f) => f.id)).toEqual(['111', '222'])
+  })
+
+  it('fetches a folder two levels deep', async () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', {
+              parentId: 'ourRoot',
+              cached: true,
+              nextPagePath: '2_next',
+              status: 'checked',
+          }),
+              _file('2_1', { parentId: '2', status: 'checked' }),
+              _file('2_2', { parentId: '2', status: 'checked' }),
+    ]
+    const mock = (path: PartialTreeId) => {
+      if (path === '2_next') {
+        const items = [_cFile('2_3'), _cFolder('666')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      if (path === '666') {
+        const items = [_cFile('666_1'), _cFile('666_2')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      return Promise.reject()
+    }
+    const enrichedTree = await afterFill(tree, mock, () => null)
+
+    const checkedFiles = enrichedTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    )
+    expect(checkedFiles.length).toEqual(5)
+    expect(checkedFiles.map((f) => f.id)).toEqual([
+      '2_1',
+      '2_2',
+      '2_3',
+      '666_1',
+      '666_2',
+    ])
+  })
+
+  it('complex situation', async () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          // folder we'll be recursively fetching really deeply
+          _folder('2', {
+            parentId: 'ourRoot',
+            cached: true,
+            nextPagePath: '2_next',
+            status: 'checked',
+          }),
+              _file('2_1', { parentId: '2', status: 'checked' }),
+              _file('2_2', { parentId: '2', status: 'checked' }),
+          // folder with only some files checked
+          _folder('3', { parentId: 'ourRoot', cached: true, status: 'partial' }),
+              // empty folder
+              _folder('0', { parentId: '3', cached: false, status: 'checked' }),
+              _file('3_1', { parentId: '3', status: 'checked' }),
+              _file('3_2', { parentId: '3', status: 'unchecked' }),
+    ]
+    const mock = (path: PartialTreeId) => {
+      if (path === '2_next') {
+        const items = [_cFile('2_3'), _cFolder('666')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      if (path === '666') {
+        const items = [_cFile('666_1'), _cFolder('777')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      if (path === '777') {
+        const items = [_cFile('777_1'), _cFolder('777_2')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      if (path === '777_2') {
+        const items = [_cFile('777_2_1')]
+        return Promise.resolve({ nextPagePath: '777_2_next', items })
+      }
+      if (path === '777_2_next') {
+        const items = [_cFile('777_2_1_1')]
+        return Promise.resolve({ nextPagePath: null, items })
+      }
+      if (path === '0') {
+        return Promise.resolve({ nextPagePath: null, items: [] })
+      }
+      return Promise.reject()
+    }
+    const enrichedTree = await afterFill(tree, mock, () => null)
+
+    const checkedFiles = enrichedTree.filter(
+      (item) => item.type === 'file' && item.status === 'checked',
+    )
+    expect(checkedFiles.length).toEqual(8)
+    expect(checkedFiles.map((f) => f.id)).toEqual([
+      '2_1',
+      '2_2',
+      '3_1',
+      '2_3',
+      '666_1',
+      '777_1',
+      '777_2_1',
+      '777_2_1_1',
+    ])
+  })
+})
+
+describe('afterOpenFolder()', () => {
+  it('open "checked" folder - all discovered files are marked as "checked"', () => {
+    // prettier-ignore
+    const oldPartialTree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: false, status: 'checked' }),
+    ]
+
+    const fakeCompanionFiles = [
+      { requestPath: '666', isFolder: true },
+      { requestPath: '777', isFolder: false },
+      { requestPath: '888', isFolder: false },
+    ] as CompanionFile[]
+
+    const clickedFolder = oldPartialTree.find(
+      (f) => f.id === '2',
+    ) as PartialTreeFolderNode
+
+    const newTree = afterOpenFolder(
+      oldPartialTree,
+      fakeCompanionFiles,
+      clickedFolder,
+      null,
+      () => null,
+    )
+
+    expect(getFolder(newTree, '666').status).toEqual('checked')
+    expect(getFile(newTree, '777').status).toEqual('checked')
+    expect(getFile(newTree, '888').status).toEqual('checked')
+  })
+
+  it('open "unchecked" folder - all discovered files are marked as "unchecked"', () => {
+    // prettier-ignore
+    const oldPartialTree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: false, status: 'unchecked' }),
+    ]
+
+    const fakeCompanionFiles = [
+      { requestPath: '666', isFolder: true },
+      { requestPath: '777', isFolder: false },
+      { requestPath: '888', isFolder: false },
+    ] as CompanionFile[]
+
+    const clickedFolder = oldPartialTree.find(
+      (f) => f.id === '2',
+    ) as PartialTreeFolderNode
+
+    const newTree = afterOpenFolder(
+      oldPartialTree,
+      fakeCompanionFiles,
+      clickedFolder,
+      null,
+      () => null,
+    )
+
+    expect(getFolder(newTree, '666').status).toEqual('unchecked')
+    expect(getFile(newTree, '777').status).toEqual('unchecked')
+    expect(getFile(newTree, '888').status).toEqual('unchecked')
+  })
+})
+
+describe('afterScrollFolder()', () => {
+  it('scroll "checked" folder - all discovered files are marked as "checked"', () => {
+    // prettier-ignore
+    const oldPartialTree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: true, status: 'checked' }),
+              _file('2_1', { parentId: '2' }),
+              _file('2_2', { parentId: '2' }),
+              _file('2_3', { parentId: '2' }),
+    ]
+
+    const fakeCompanionFiles = [
+      { requestPath: '666', isFolder: true },
+      { requestPath: '777', isFolder: false },
+      { requestPath: '888', isFolder: false },
+    ] as CompanionFile[]
+
+    const newTree = afterScrollFolder(
+      oldPartialTree,
+      '2',
+      fakeCompanionFiles,
+      null,
+      () => null,
+    )
+
+    expect(getFolder(newTree, '666').status).toEqual('checked')
+    expect(getFile(newTree, '777').status).toEqual('checked')
+    expect(getFile(newTree, '888').status).toEqual('checked')
+  })
+
+  it('scroll "checked" folder - all discovered files are marked as "unchecked"', () => {
+    // prettier-ignore
+    const oldPartialTree: PartialTree = [
+      _root('ourRoot'),
+          _folder('1', { parentId: 'ourRoot' }),
+          _folder('2', { parentId: 'ourRoot', cached: true, status: 'unchecked' }),
+              _file('2_1', { parentId: '2' }),
+              _file('2_2', { parentId: '2' }),
+              _file('2_3', { parentId: '2' }),
+    ]
+
+    const fakeCompanionFiles = [
+      { requestPath: '666', isFolder: true },
+      { requestPath: '777', isFolder: false },
+      { requestPath: '888', isFolder: false },
+    ] as CompanionFile[]
+
+    const newTree = afterScrollFolder(
+      oldPartialTree,
+      '2',
+      fakeCompanionFiles,
+      null,
+      () => null,
+    )
+
+    expect(getFolder(newTree, '666').status).toEqual('unchecked')
+    expect(getFile(newTree, '777').status).toEqual('unchecked')
+    expect(getFile(newTree, '888').status).toEqual('unchecked')
+  })
+})
+
+describe('afterToggleCheckbox()', () => {
+  // prettier-ignore
+  const oldPartialTree: PartialTree = [
+    _root('ourRoot'),
+        _folder('1', { parentId: 'ourRoot' }),
+        _folder('2', { parentId: 'ourRoot' }),
+            _file('2_1', { parentId: '2' }),
+            _file('2_2', { parentId: '2' }),
+            _file('2_3', { parentId: '2' }),
+            _folder('2_4', { parentId: '2' }), // click
+                _file('2_4_1', { parentId: '2_4' }),
+                _file('2_4_2', { parentId: '2_4' }),
+                _file('2_4_3', { parentId: '2_4' }),
+        _file('3', { parentId: 'ourRoot' }),
+        _file('4', { parentId: 'ourRoot' }),
+  ]
+
+  it('check folder: percolates up and down', () => {
+    const newTree = afterToggleCheckbox(oldPartialTree, ['2_4'])
+
+    expect(getFolder(newTree, '2_4').status).toEqual('checked')
+    // percolates down
+    expect(getFile(newTree, '2_4_1').status).toEqual('checked')
+    expect(getFile(newTree, '2_4_2').status).toEqual('checked')
+    expect(getFile(newTree, '2_4_3').status).toEqual('checked')
+    // percolates up
+    expect(getFolder(newTree, '2').status).toEqual('partial')
+  })
+
+  it('uncheck folder: percolates up and down', () => {
+    const treeAfterClick1 = afterToggleCheckbox(oldPartialTree, ['2_4'])
+
+    const tree = afterToggleCheckbox(treeAfterClick1, ['2_4'])
+
+    expect(getFolder(tree, '2_4').status).toEqual('unchecked')
+    // percolates down
+    expect(getFile(tree, '2_4_1').status).toEqual('unchecked')
+    expect(getFile(tree, '2_4_2').status).toEqual('unchecked')
+    expect(getFile(tree, '2_4_3').status).toEqual('unchecked')
+    // percolates up
+    expect(getFolder(tree, '2').status).toEqual('unchecked')
+  })
+
+  it('gradually check all subfolders: marks parent folder as checked', () => {
+    const tree = afterToggleCheckbox(oldPartialTree, [
+      '2_4_1',
+      '2_4_2',
+      '2_4_3',
+    ])
+
+    // marks children as checked
+    expect(getFolder(tree, '2_4_1').status).toEqual('checked')
+    expect(getFolder(tree, '2_4_2').status).toEqual('checked')
+    expect(getFolder(tree, '2_4_3').status).toEqual('checked')
+    // marks parent folder as checked
+    expect(getFolder(tree, '2_4').status).toEqual('checked')
+    // marks parent parent folder as partially checked
+    expect(getFolder(tree, '2').status).toEqual('partial')
+    // and just randomly making sure unnrelated items didn't get checked
+    expect(getFile(tree, '3').status).toEqual('unchecked')
+    expect(getFile(tree, '2_2').status).toEqual('unchecked')
+  })
+
+  it('clicking partial folder: partial => checked => unchecked', () => {
+    // 1. click on 2_4_1, thus making 2_4 "partial"
+    const tree_1 = afterToggleCheckbox(oldPartialTree, ['2_4_1'])
+
+    expect(getFolder(tree_1, '2_4').status).toEqual('partial')
+    // and test children while we're at it
+    expect(getFolder(tree_1, '2_4_1').status).toEqual('checked')
+
+    // 2. click on 2_4, thus making 2_4 "checked"
+    const tree_2 = afterToggleCheckbox(tree_1, ['2_4'])
+
+    expect(getFolder(tree_2, '2_4').status).toEqual('checked')
+    // and test children while we're at it
+    expect(getFolder(tree_2, '2_4_1').status).toEqual('checked')
+    expect(getFolder(tree_2, '2_4_2').status).toEqual('checked')
+    expect(getFolder(tree_2, '2_4_3').status).toEqual('checked')
+
+    // 3. click on 2_4, thus making 2_4 "unchecked"
+    const tree_3 = afterToggleCheckbox(tree_2, ['2_4'])
+
+    expect(getFolder(tree_3, '2_4').status).toEqual('unchecked')
+    // and test children while we're at it
+    expect(getFolder(tree_3, '2_4_1').status).toEqual('unchecked')
+    expect(getFolder(tree_3, '2_4_2').status).toEqual('unchecked')
+    expect(getFolder(tree_3, '2_4_3').status).toEqual('unchecked')
+  })
+
+  it('old partialTree is NOT mutated', () => {
+    const oldPartialTreeCopy = JSON.parse(JSON.stringify(oldPartialTree))
+    afterToggleCheckbox(oldPartialTree, ['2_4_1'])
+    expect(oldPartialTree).toEqual(oldPartialTreeCopy)
+  })
+})
+
+describe('getNumberOfSelectedFiles()', () => {
+  it('gets all leaf items', () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          // leaf .checked folder
+          _folder('1', { parentId: 'ourRoot', cached: false, status: 'checked' }),
+          // NON-left .checked folder
+          _folder('2', { parentId: 'ourRoot', status: 'checked' }),
+              // leaf .checked file
+              _file('2_1', { parentId: '2', status: 'checked' }),
+              // leaf .checked file
+              _file('2_2', { parentId: '2', status: 'checked' }),
+    ]
+    const result = getNumberOfSelectedFiles(tree)
+
+    expect(result).toEqual(3)
+  })
+
+  it('empty folder, even after being opened, counts as leaf node', () => {
+    // prettier-ignore
+    const tree: PartialTree = [
+      _root('ourRoot'),
+          // empty .checked .cached folder
+          _folder('1', { parentId: 'ourRoot', cached: true, status: 'checked' }),
+    ]
+    const result = getNumberOfSelectedFiles(tree)
+    // This should be "1" for more pleasant UI - if the user unchecks this folder,
+    // they should immediately see "Selected (1)" turning into "Selected (0)".
+    expect(result).toEqual(1)
+  })
+})
+
+describe('getCheckedFilesWithPaths()', () => {
+  // Note that this is a tree that doesn't require any api calls, everything is cached already
+  // prettier-ignore
+  const tree: PartialTree = [
+    _root('ourRoot'),
+        _folder('1', { parentId: 'ourRoot' }),
+        _folder('2', { parentId: 'ourRoot' }),
+            _file('2_1', { parentId: '2' }),
+            _file('2_2', { parentId: '2', status: 'checked' }),
+            _file('2_3', { parentId: '2' }),
+            _folder('2_4', { parentId: '2', status: 'checked' }),
+                _file('2_4_1', { parentId: '2_4', status: 'checked' }),
+                _file('2_4_2', { parentId: '2_4', status: 'checked' }),
+                _file('2_4_3', { parentId: '2_4', status: 'checked' }),
+        _file('3', { parentId: 'ourRoot' }),
+        _file('4', { parentId: 'ourRoot' }),
+  ]
+
+  // These test cases are based on documentation for .absolutePath and .relativePath (https://uppy.io/docs/uppy/#filemeta)
+  it('.absolutePath always begins with / + always ends with the file’s name.', () => {
+    const result = getCheckedFilesWithPaths(tree)
+
+    expect(result.find((f) => f.id === '2_2')!.absDirPath).toEqual(
+      '/name_2/name_2_2.jpg',
+    )
+    expect(result.find((f) => f.id === '2_4_3')!.absDirPath).toEqual(
+      '/name_2/name_2_4/name_2_4_3.jpg',
+    )
+  })
+
+  it('.relativePath is null when file is selected independently', () => {
+    const result = getCheckedFilesWithPaths(tree)
+
+    // .relDirPath should be `undefined`, which will make .relativePath `null` eventually
+    expect(result.find((f) => f.id === '2_2')!.relDirPath).toEqual(undefined)
+  })
+
+  it('.relativePath attends to highest checked folder', () => {
+    const result = getCheckedFilesWithPaths(tree)
+
+    expect(result.find((f) => f.id === '2_4_1')!.relDirPath).toEqual(
+      'name_2_4/name_2_4_1.jpg',
+    )
+  })
+
+  // (See github.com/transloadit/uppy/pull/5050#discussion_r1638523560)
+  it('file ids such as "hasOwnProperty" are safe', () => {
+    const weirdIdsTree = [
+      _root('ourRoot'),
+      _folder('1', { parentId: 'ourRoot', status: 'checked' }),
+      _file('hasOwnProperty', { parentId: '1', status: 'checked' }),
+    ]
+    const result = getCheckedFilesWithPaths(weirdIdsTree)
+
+    expect(result.find((f) => f.id === 'hasOwnProperty')!.relDirPath).toEqual(
+      'name_1/name_hasOwnProperty.jpg',
+    )
+  })
+})
+
+describe('getBreadcrumbs()', () => {
+  // prettier-ignore
+  const tree: PartialTree = [
+    _root('ourRoot'),
+        _folder('1', { parentId: 'ourRoot' }),
+        _folder('2', { parentId: 'ourRoot' }),
+            _file('2_1', { parentId: '2' }),
+            _file('2_2', { parentId: '2' }),
+            _file('2_3', { parentId: '2' }),
+            _folder('2_4', { parentId: '2' }),
+                _file('2_4_1', { parentId: '2_4' }),
+                _file('2_4_2', { parentId: '2_4' }),
+                _file('2_4_3', { parentId: '2_4' }),
+        _file('3', { parentId: 'ourRoot' }),
+        _file('4', { parentId: 'ourRoot' }),
+  ]
+
+  it('returns root folder: "/ourRoot"', () => {
+    const result = getBreadcrumbs(tree, 'ourRoot')
+    expect(result.map((f) => f.id)).toEqual(['ourRoot'])
+  })
+
+  it('returns nested folder: "/ourRoot/4"', () => {
+    const result = getBreadcrumbs(tree, '4')
+    expect(result.map((f) => f.id)).toEqual(['ourRoot', '4'])
+  })
+
+  it('returns deeply nested folder: "/ourRoot/2/2_4"', () => {
+    const result = getBreadcrumbs(tree, '2_4')
+    expect(result.map((f) => f.id)).toEqual(['ourRoot', '2', '2_4'])
+  })
+
+  it('returns folders when currentFolderId=null', () => {
+    // prettier-ignore
+    const treeWithNullRoot: PartialTree = [
+      _root(null!),
+          _folder('1', { parentId: null })
+    ]
+    const result = getBreadcrumbs(treeWithNullRoot, null)
+    expect(result.map((f) => f.id)).toEqual([null])
+  })
+})

+ 11 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/index.ts

@@ -0,0 +1,11 @@
+import afterOpenFolder from './afterOpenFolder.ts'
+import afterScrollFolder from './afterScrollFolder.ts'
+import afterToggleCheckbox from './afterToggleCheckbox.ts'
+import afterFill from './afterFill.ts'
+
+export default {
+  afterOpenFolder,
+  afterScrollFolder,
+  afterToggleCheckbox,
+  afterFill,
+}

+ 11 - 0
packages/@uppy/provider-views/src/utils/PartialTreeUtils/shallowClone.ts

@@ -0,0 +1,11 @@
+import type { PartialTree } from '@uppy/core/lib/Uppy'
+
+/**
+ * One-level copying is sufficient as mutations within our `partialTree` are limited to properties
+ * such as `.status`, `.cached`, `.nextPagePath`, and not `.data = { THIS }`.
+ */
+const shallowClone = (partialTree: PartialTree): PartialTree => {
+  return partialTree.map((item) => ({ ...item }))
+}
+
+export default shallowClone

+ 47 - 0
packages/@uppy/provider-views/src/utils/addFiles.ts

@@ -0,0 +1,47 @@
+import type { UnknownPlugin } from '@uppy/core'
+import type {
+  CompanionClientProvider,
+  CompanionClientSearchProvider,
+} from '@uppy/utils/lib/CompanionClientProvider'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type { Meta, Body, TagFile } from '@uppy/utils/lib/UppyFile'
+import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
+import getTagFile from './getTagFile.ts'
+
+const addFiles = <M extends Meta, B extends Body>(
+  companionFiles: CompanionFile[],
+  plugin: UnknownPlugin<M, B>,
+  provider: CompanionClientProvider | CompanionClientSearchProvider,
+): void => {
+  const tagFiles: TagFile<M>[] = companionFiles.map((f) =>
+    getTagFile<M, B>(f, plugin, provider),
+  )
+
+  const filesToAdd: TagFile<M>[] = []
+  const filesAlreadyAdded: TagFile<M>[] = []
+  tagFiles.forEach((tagFile) => {
+    if (
+      plugin.uppy.checkIfFileAlreadyExists(
+        getSafeFileId(tagFile, plugin.uppy.getID()),
+      )
+    ) {
+      filesAlreadyAdded.push(tagFile)
+    } else {
+      filesToAdd.push(tagFile)
+    }
+  })
+
+  if (filesToAdd.length > 0) {
+    plugin.uppy.info(
+      plugin.uppy.i18n('addedNumFiles', { numFiles: filesToAdd.length }),
+    )
+  }
+  if (filesAlreadyAdded.length > 0) {
+    plugin.uppy.info(
+      `Not adding ${filesAlreadyAdded.length} files because they already exist`,
+    )
+  }
+  plugin.uppy.addFiles(filesToAdd)
+}
+
+export default addFiles

+ 33 - 0
packages/@uppy/provider-views/src/utils/getClickedRange.ts

@@ -0,0 +1,33 @@
+import type {
+  PartialTreeFile,
+  PartialTreeFolderNode,
+} from '@uppy/core/lib/Uppy'
+
+// Shift-clicking selects a single consecutive list of items
+// starting at the previous click.
+const getClickedRange = (
+  clickedId: string,
+  displayedPartialTree: (PartialTreeFolderNode | PartialTreeFile)[],
+  isShiftKeyPressed: boolean,
+  lastCheckbox: string | null,
+): string[] => {
+  const lastCheckboxIndex = displayedPartialTree.findIndex(
+    (item) => item.id === lastCheckbox,
+  )
+
+  if (lastCheckboxIndex !== -1 && isShiftKeyPressed) {
+    const newCheckboxIndex = displayedPartialTree.findIndex(
+      (item) => item.id === clickedId,
+    )
+    const clickedRange = displayedPartialTree.slice(
+      Math.min(lastCheckboxIndex, newCheckboxIndex),
+      Math.max(lastCheckboxIndex, newCheckboxIndex) + 1,
+    )
+
+    return clickedRange.map((item) => item.id)
+  }
+
+  return [clickedId]
+}
+
+export default getClickedRange

+ 51 - 0
packages/@uppy/provider-views/src/utils/getTagFile.ts

@@ -0,0 +1,51 @@
+import type { UnknownPlugin } from '@uppy/core'
+import type {
+  CompanionClientProvider,
+  CompanionClientSearchProvider,
+} from '@uppy/utils/lib/CompanionClientProvider'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type { Meta, Body, TagFile } from '@uppy/utils/lib/UppyFile'
+
+// TODO: document what is a "tagFile" or get rid of this concept
+const getTagFile = <M extends Meta, B extends Body>(
+  file: CompanionFile,
+  plugin: UnknownPlugin<M, B>,
+  provider: CompanionClientProvider | CompanionClientSearchProvider,
+): TagFile<M> => {
+  const tagFile: TagFile<any> = {
+    id: file.id,
+    source: plugin.id,
+    name: file.name || file.id,
+    type: file.mimeType,
+    isRemote: true,
+    data: file,
+    preview: file.thumbnail || undefined,
+    meta: {
+      authorName: file.author?.name,
+      authorUrl: file.author?.url,
+      // We need to do this `|| null` check, because null value
+      // for .relDirPath is `undefined` and for .relativePath is `null`.
+      // I do think we should just use `null` everywhere.
+      relativePath: file.relDirPath || null,
+      absolutePath: file.absDirPath,
+    },
+    body: {
+      fileId: file.id,
+    },
+    remote: {
+      companionUrl: plugin.opts.companionUrl,
+      // @ts-expect-error untyped for now
+      url: `${provider.fileUrl(file.requestPath)}`,
+      body: {
+        fileId: file.id,
+      },
+      providerName: provider.name,
+      provider: provider.provider,
+      requestClientId: provider.provider,
+    },
+  }
+
+  return tagFile
+}
+
+export default getTagFile

+ 29 - 0
packages/@uppy/provider-views/src/utils/handleError.ts

@@ -0,0 +1,29 @@
+import type Uppy from '@uppy/core'
+
+const handleError =
+  (uppy: Uppy<any, any>) =>
+  (error: Error): void => {
+    // authError just means we're not authenticated, don't report it
+    if ((error as any).isAuthError) {
+      return
+    }
+    // AbortError means the user has clicked "cancel" on an operation
+    if (error.name === 'AbortError') {
+      uppy.log('Aborting request', 'warning')
+      return
+    }
+    uppy.log(error, 'error')
+
+    if (error.name === 'UserFacingApiError') {
+      uppy.info(
+        {
+          message: uppy.i18n('companionError'),
+          details: uppy.i18n(error.message),
+        },
+        'warning',
+        5000,
+      )
+    }
+  }
+
+export default handleError

+ 8 - 0
packages/@uppy/provider-views/src/utils/shouldHandleScroll.ts

@@ -0,0 +1,8 @@
+const shouldHandleScroll = (event: Event): boolean => {
+  const { scrollHeight, scrollTop, offsetHeight } = event.target as HTMLElement
+  const scrollPosition = scrollHeight - (scrollTop + offsetHeight)
+
+  return scrollPosition < 50
+}
+
+export default shouldHandleScroll

+ 9 - 3
packages/@uppy/utils/src/CompanionClientProvider.ts

@@ -1,3 +1,5 @@
+import type { CompanionFile } from './CompanionFile.js'
+
 export type RequestOptions = {
   method?: string
   data?: Record<string, unknown>
@@ -24,10 +26,14 @@ export interface CompanionClientProvider {
   login(options?: RequestOptions): Promise<void>
   logout<ResBody>(options?: RequestOptions): Promise<ResBody>
   fetchPreAuthToken(): Promise<void>
-  list<ResBody>(
-    directory: string | undefined,
+  list(
+    directory: string | null,
     options: RequestOptions,
-  ): Promise<ResBody>
+  ): Promise<{
+    username: string
+    nextPagePath: string | null
+    items: CompanionFile[]
+  }>
 }
 export interface CompanionClientSearchProvider {
   name: string

+ 2 - 1
yarn.lock

@@ -10300,6 +10300,7 @@ __metadata:
     nanoid: "npm:^5.0.0"
     p-queue: "npm:^8.0.0"
     preact: "npm:^10.5.13"
+    vitest: "npm:^1.6.0"
   peerDependencies:
     "@uppy/core": "workspace:^"
   languageName: unknown
@@ -31823,7 +31824,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vitest@npm:^1.2.1":
+"vitest@npm:^1.2.1, vitest@npm:^1.6.0":
   version: 1.6.0
   resolution: "vitest@npm:1.6.0"
   dependencies: