Server side search @uppy/Companion (#6003)

## High Level View

<img width="3367" height="1576" alt="Global Search (1)"
src="https://github.com/user-attachments/assets/134e8658-5cbd-4816-87a1-3bd42603089d"
/>


- Search View replicated , through minimal components `<GlobalSearchView
/>` and `<SearchResultItem />`
- Both components take only the minimal state needed to render the
search view no dependency on PartialTree. search response from companion
server is directly passed to GlobalSearchView for file state.
- `#buildPath` creates missing parent nodes in partialTree (if any) and
opens the folder in the normal way using a minimal wrapper over
openFolder function.
- Both interactions : "checking a file/folder" and "opening a folder"
use the same function `#buildPath` to build the path, then use the
already existing `openFolder` and `toggleCheckBox`.
- Max search results: 1000. Pagination removed for simplicity (can be
added later).
- From a UI/UX standpoint, all functionality works as expected.
- The only limitation is occasional inconsistent partial checked states
when the tree isn’t fully built — unavoidable since percolateUp and
percolateDown require the complete partialTree to sync state correctly.
This issue isn’t critical; even in other cases, we already mark folders
"checked" whereas they may be empty if not yet fetched.
- I figured out it's better to just derive the checkedState from
PartialTree , and then pass it to `GlobalSearchView` rather than keep it
separate and then worrying about checked state syncs across two views
for UI to look right.
- IMO this is the most simplest approach I could come up with. without
sacrificing any user functionality and it carefully reuses all the util
code.

---------

Co-authored-by: Merlijn Vos <merlijn@soverin.net>
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
This commit is contained in:
Prakash 2025-10-09 00:48:17 +05:30 committed by GitHub
parent b7605df940
commit 5ba2c1c8d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 507 additions and 66 deletions

View file

@ -0,0 +1,10 @@
---
"@uppy/companion-client": minor
"@uppy/provider-views": minor
"@uppy/companion": minor
"@uppy/utils": minor
"@uppy/core": minor
---
Introduce the concept of server-side search and add support for it for the Dropbox provider. Previously, only client-side filtering in the currently viewed folder was possible, which was limiting. Now users using Companion with Dropbox can perform a search across their entire account.

View file

@ -357,6 +357,22 @@ export default class Provider<M extends Meta, B extends Body>
return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
}
search<ResBody>(
text: string,
options: RequestOptions & {
path?: string | null | undefined
cursor?: string | undefined
} = {},
): Promise<ResBody> {
const qs = new URLSearchParams()
qs.set('q', text)
if (options.path) qs.set('path', options.path)
if (options.cursor) qs.set('cursor', options.cursor)
const base = `${this.id}/search`
const path = `${base}?${qs.toString()}`
return this.get<ResBody>(path, options)
}
async logout<ResBody>(options?: RequestOptions): Promise<ResBody> {
const response = await this.get<ResBody>(`${this.id}/logout`, options)
await this.removeAuthToken()

View file

@ -14,6 +14,7 @@ import grantConfigFn from './config/grant.js'
import googlePicker from './server/controllers/googlePicker.js'
import * as controllers from './server/controllers/index.js'
import s3 from './server/controllers/s3.js'
import searchController from './server/controllers/search.js'
import url from './server/controllers/url.js'
import createEmitter from './server/emitter/index.js'
import { getURLBuilder } from './server/helpers/utils.js'
@ -221,6 +222,12 @@ export function app(optionsArg = {}) {
middlewares.verifyToken,
controllers.list,
)
app.get(
'/:providerName/search',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
searchController,
)
// backwards compat:
app.get(
'/search/:providerName/list',

View file

@ -0,0 +1,17 @@
import { respondWithError } from '../provider/error.js'
export default async function search({ query, companion }, res, next) {
const { providerUserSession } = companion
try {
const data = await companion.provider.search({
companion,
providerUserSession,
query,
})
res.json(data)
} catch (err) {
if (respondWithError(err, res)) return
next(err)
}
}

View file

@ -35,6 +35,16 @@ export default class Provider {
throw new Error('method not implemented')
}
/**
* search for files/folders in the provider account
*
* @param {object} options
* @returns {Promise}
*/
async search(options) {
throw new Error('method not implemented')
}
/**
* download a certain file from the provider account
*

View file

@ -13,7 +13,7 @@ import adaptData from './adapter.js'
const charsToEncode = /[\u007f-\uffff]/g
function httpHeaderSafeJson(v) {
return JSON.stringify(v).replace(charsToEncode, (c) => {
return `\\u${(`000${c.charCodeAt(0).toString(16)}`).slice(-4)}`
return `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`
})
}
@ -96,6 +96,33 @@ async function list({ client, directory, query }) {
.json()
}
async function fetchSearchEntries({ client, query }) {
const scopePath =
typeof query.path === 'string' ? decodeURIComponent(query.path) : undefined
const searchRes = await client
.post('files/search_v2', {
json: {
query: query.q.trim(),
options: {
path: scopePath || '',
max_results: 1000,
file_status: 'active',
filename_only: false,
},
},
responseType: 'json',
})
.json()
const entries = searchRes.matches.map((m) => m.metadata.metadata)
return {
entries,
has_more: searchRes.has_more,
cursor: searchRes.cursor,
}
}
/**
* Adapter for API https://www.dropbox.com/developers/documentation/http/documentation
*/
@ -114,8 +141,28 @@ export default class Dropbox extends Provider {
}
/**
*
* @param {object} options
* Search entries
*/
async search(options) {
return this.#withErrorHandling(
'provider.dropbox.search.error',
async () => {
const { client, userInfo } = await getClient({
token: options.providerUserSession.accessToken,
namespaced: true,
})
const stats = await fetchSearchEntries({ client, query: options.query })
console.log(stats)
const { email } = userInfo
// we don't really need email, but let's mimic `list` response shape for consistency
return adaptData(stats, email, options.companion.buildURL)
},
)
}
/**
* List folder entries
*/
async list(options) {
return this.#withErrorHandling('provider.dropbox.list.error', async () => {

View file

@ -96,7 +96,10 @@ export type PartialTreeFolderNode = {
status: PartialTreeStatus
parentId: PartialTreeId
data: CompanionFile
data: Pick<
CompanionFile,
'name' | 'icon' | 'thumbnail' | 'isFolder' | 'author' | 'custom'
>
}
export type PartialTreeFolderRoot = {
@ -133,6 +136,7 @@ export type UnknownProviderPluginState = {
partialTree: PartialTree
currentFolderId: PartialTreeId
username: string | null
searchResults?: string[] | undefined
}
export interface AsyncStore {

View file

@ -93,6 +93,7 @@ export default class Dropbox<M extends Meta, B extends Body>
provider: this.provider,
loadAllFiles: true,
virtualList: true,
supportsSearch: true,
})
const { target } = this.opts

View file

@ -0,0 +1,68 @@
import type { PartialTreeFile, PartialTreeFolderNode } from '@uppy/core'
import type { I18n } from '@uppy/utils'
import classNames from 'classnames'
import type ProviderView from '../../ProviderView/ProviderView.js'
import ItemIcon from './ItemIcon.js'
interface SearchResultItemProps {
item: PartialTreeFile | PartialTreeFolderNode
i18n: I18n
openFolder: ProviderView<any, any>['openSearchResultFolder']
toggleCheckbox: ProviderView<any, any>['toggleCheckbox']
}
const SearchResultItem = ({
i18n,
item,
toggleCheckbox,
openFolder,
}: SearchResultItemProps) => {
const isDisabled =
'restrictionError' in item &&
item.restrictionError != null &&
item.status !== 'checked'
return (
<li
className={classNames(
'uppy-ProviderBrowserItem',
{ 'uppy-ProviderBrowserItem--disabled': isDisabled },
{ 'uppy-ProviderBrowserItem--noPreview': item.data.icon === 'video' },
{ 'uppy-ProviderBrowserItem--is-checked': item.status === 'checked' },
{ 'uppy-ProviderBrowserItem--is-partial': item.status === 'partial' },
)}
title={
('restrictionError' in item ? item.restrictionError : undefined) ??
undefined
}
>
<input
type="checkbox"
className="uppy-u-reset uppy-ProviderBrowserItem-checkbox"
onChange={() => toggleCheckbox(item, false)}
checked={item.status === 'checked'}
aria-label={item.data.name ?? i18n('unnamed')}
disabled={isDisabled}
data-uppy-super-focusable
/>
<button
type="button"
className="uppy-u-reset uppy-c-btn uppy-ProviderBrowserItem-inner"
disabled={isDisabled}
aria-label={item.data.name}
onClick={() => {
if (item.data.isFolder) {
openFolder(item.id)
}
}}
>
<div className="uppy-ProviderBrowserItem-iconWrap">
<ItemIcon itemIconString={item.data.icon} />
</div>
{item.data.name ?? i18n('unnamed')}
</button>
</li>
)
}
export default SearchResultItem

View file

@ -0,0 +1,40 @@
import type { PartialTreeFile, PartialTreeFolderNode } from '@uppy/core'
import type { I18n } from '@uppy/utils'
import SearchResultItem from '../Item/components/SearchResultItem.js'
import type ProviderView from './ProviderView.js'
interface GlobalSearchViewProps {
searchResults: (PartialTreeFile | PartialTreeFolderNode)[]
openFolder: ProviderView<any, any>['openSearchResultFolder']
toggleCheckbox: ProviderView<any, any>['toggleCheckbox']
i18n: I18n
}
const GlobalSearchView = ({
searchResults,
toggleCheckbox,
openFolder,
i18n,
}: GlobalSearchViewProps) => {
if (searchResults.length === 0) {
return <div className="uppy-Provider-empty">{i18n('noFilesFound')}</div>
}
return (
<div className="uppy-ProviderBrowser-body">
<ul className="uppy-ProviderBrowser-list">
{searchResults.map((item) => (
<SearchResultItem
i18n={i18n}
key={item.id}
item={item}
toggleCheckbox={toggleCheckbox}
openFolder={openFolder}
/>
))}
</ul>
</div>
)
}
export default GlobalSearchView

View file

@ -13,6 +13,7 @@ import type {
import type { CompanionFile, I18n } from '@uppy/utils'
import { remoteFileObjToLocal } from '@uppy/utils'
import classNames from 'classnames'
import debounce from 'lodash/debounce.js'
import type { h } from 'preact'
import packageJson from '../../package.json' with { type: 'json' }
import Browser from '../Browser.js'
@ -27,6 +28,7 @@ import getNumberOfSelectedFiles from '../utils/PartialTreeUtils/getNumberOfSelec
import PartialTreeUtils from '../utils/PartialTreeUtils/index.js'
import shouldHandleScroll from '../utils/shouldHandleScroll.js'
import AuthView from './AuthView.js'
import GlobalSearchView from './GlobalSearchView.js'
import Header from './Header.js'
export function defaultPickerIcon(): h.JSX.Element {
@ -78,6 +80,7 @@ export interface Opts<M extends Meta, B extends Body> {
onAuth: (authFormData: unknown) => Promise<void>
}) => h.JSX.Element
virtualList: boolean
supportsSearch?: boolean
}
type PassedOpts<M extends Meta, B extends Body> = Optional<
Opts<M, B>,
@ -93,9 +96,16 @@ type RenderOpts<M extends Meta, B extends Body> = Omit<
PassedOpts<M, B>,
'provider'
>
/**
* Class to easily generate generic views for Provider plugins
*
* We have a *search view* and a *normal view*.
* Search view is only used when the Provider supports server side search i.e. provider.search method is implemented for the provider.
* The state is stored in searchResults.
* Search view is implemented in components GlobalSearchView and SearchResultItem.
* We conditionally switch between search view and normal in the render method when a server side search is initiated.
* When users type their search query in search input box (SearchInput component), we debounce the input and call provider.search method to fetch results from the server.
* when the user enters a folder in search results or clears the search input query we switch back to Normal View.
*/
export default class ProviderView<M extends Meta, B extends Body> {
static VERSION = packageJson.version
@ -108,7 +118,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
isHandlingScroll: boolean = false
lastCheckbox: string | null = null
previousCheckbox: string | null = null
constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
this.plugin = plugin
@ -133,6 +143,8 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.render = this.render.bind(this)
this.cancelSelection = this.cancelSelection.bind(this)
this.toggleCheckbox = this.toggleCheckbox.bind(this)
this.openSearchResultFolder = this.openSearchResultFolder.bind(this)
this.clearSearchState = this.clearSearchState.bind(this)
// Set default state for the plugin
this.resetPluginState()
@ -159,6 +171,10 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.plugin.setPluginState({ loading })
}
get isLoading() {
return this.plugin.getPluginState().loading
}
cancelSelection(): void {
const { partialTree } = this.plugin.getPluginState()
const newPartialTree: PartialTree = partialTree.map((item) =>
@ -167,6 +183,12 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.plugin.setPluginState({ partialTree: newPartialTree })
}
clearSearchState(): void {
this.plugin.setPluginState({
searchResults: undefined,
})
}
#abortController: AbortController | undefined
async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
@ -195,13 +217,148 @@ export default class ProviderView<M extends Meta, B extends Body> {
}
}
async openFolder(folderId: string | null): Promise<void> {
this.lastCheckbox = null
async #search(): Promise<void> {
const { partialTree, currentFolderId, searchString } =
this.plugin.getPluginState()
const currentFolder = partialTree.find((i) => i.id === currentFolderId)!
if (searchString.trim() === '') {
this.#abortController?.abort()
this.clearSearchState()
return
}
this.setLoading(true)
await this.#withAbort(async (signal) => {
const scopePath =
currentFolder.type === 'root' ? undefined : currentFolderId
const { items } = await this.provider.search!(searchString, {
signal,
path: scopePath,
})
// For each searched file, build the entire path (from the root all the way to the leaf node)
// This is because we need to make sure all ancestor folders are present in the partialTree before we open the folder or check the file.
// This is needed because when the user opens a folder we need to have all its parent folders in the partialTree to be able to render the breadcrumbs correctly.
// Similarly when the user checks a file, we need to have all it's ancestor folders in the partialTree to be able to percolateUp the checked state correctly to its ancestors.
const { partialTree } = this.plugin.getPluginState()
const newPartialTree: PartialTree = [...partialTree]
for (const file of items) {
// Decode URI and split into path segments
const decodedPath = decodeURIComponent(file.requestPath)
const segments = decodedPath.split('/').filter((s) => s.length > 0)
// Start from root
let parentId: PartialTreeId = this.plugin.rootFolderId
let isParentFolderChecked: boolean
// Walk through each segment starting from the root and build child nodes if they don't exist
segments.forEach((segment, index, arr) => {
const pathSegments = segments.slice(0, index + 1)
const encodedPath = encodeURIComponent(`/${pathSegments.join('/')}`)
// Skip if node already exists
const existingNode = newPartialTree.find(
(n) => n.id === encodedPath && n.type !== 'root',
) as PartialTreeFolderNode | PartialTreeFile | undefined
if (existingNode) {
parentId = encodedPath
isParentFolderChecked = existingNode.status === 'checked'
return
}
const isLeafNode = index === arr.length - 1
let node: PartialTreeFolderNode | PartialTreeFile
// propagate checked state from parent to children, if the user has checked the parent folder before searching
// and the parent folder is an ancestor of the searched file
// see also afterOpenFolder which contains similar logic, we should probably refactor and reuse some
const status = isParentFolderChecked ? 'checked' : 'unchecked'
// Build the Leaf Node, it can be a file (`PartialTreeFile`) or a folder (`PartialTreeFolderNode`).
// Since we Already have the leaf node's data (`file`, `CompanionFile`) from the searchResults: CompanionFile[], we just use that.
if (isLeafNode) {
if (file.isFolder) {
node = {
type: 'folder',
id: encodedPath,
cached: false,
nextPagePath: null,
status,
parentId,
data: file,
}
} else {
const restrictionError = this.validateSingleFile(file)
node = {
type: 'file',
id: encodedPath,
restrictionError,
status: !restrictionError ? status : 'unchecked',
parentId,
data: file,
}
}
} else {
// not leaf node, so by definition it is a folder leading up to the leaf node
node = {
type: 'folder',
id: encodedPath,
cached: false,
nextPagePath: null,
status,
parentId,
data: {
// we don't have any data, so fill only the necessary fields
name: decodeURIComponent(segment),
icon: 'folder',
isFolder: true,
},
}
}
newPartialTree.push(node)
parentId = encodedPath // This node becomes parent for the next iteration
isParentFolderChecked = status === 'checked'
})
}
this.plugin.setPluginState({
partialTree: newPartialTree,
searchResults: items.map((item) => item.requestPath),
})
}).catch(handleError(this.plugin.uppy))
this.setLoading(false)
}
#searchDebounced = debounce(this.#search, 500)
onSearchInput = (s: string): void => {
this.plugin.setPluginState({ searchString: s })
if (this.opts.supportsSearch) this.#searchDebounced()
}
async openSearchResultFolder(folderId: PartialTreeId): Promise<void> {
// stop searching
this.plugin.setPluginState({ searchString: '' })
// now open folder using the normal view
await this.openFolder(folderId)
}
async openFolder(folderId: PartialTreeId): Promise<void> {
// always switch away from the search view when opening a folder, whether it happens from the search view or by clicking breadcrumbs
this.clearSearchState()
this.previousCheckbox = 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,
@ -331,6 +488,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
async donePicking(): Promise<void> {
const { partialTree } = this.plugin.getPluginState()
if (this.isLoading) return
this.setLoading(true)
await this.#withAbort(async (signal) => {
// 1. Enrich our partialTree by fetching all 'checked' but not-yet-fetched folders
@ -373,15 +531,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
ourItem.id,
this.getDisplayedPartialTree(),
isShiftKeyPressed,
this.lastCheckbox,
this.previousCheckbox,
)
const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
partialTree,
clickedRange,
)
this.plugin.setPluginState({ partialTree: newPartialTree })
this.lastCheckbox = ourItem.id
this.previousCheckbox = ourItem.id
}
getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
@ -390,14 +549,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
const inThisFolder = partialTree.filter(
(item) => item.type !== 'root' && item.parentId === currentFolderId,
) as (PartialTreeFile | PartialTreeFolderNode)[]
// If provider supports server side search, we don't filter the items client side
const filtered =
searchString === ''
this.opts.supportsSearch || searchString.trim() === ''
? inThisFolder
: inThisFolder.filter(
(item) =>
(item.data.name ?? this.plugin.uppy.i18n('unnamed'))
.toLowerCase()
.indexOf(searchString.toLowerCase()) !== -1,
.indexOf(searchString.trim().toLowerCase()) !== -1,
)
return filtered
@ -421,6 +582,36 @@ export default class ProviderView<M extends Meta, B extends Body> {
return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
}
#renderSearchResults() {
const { i18n } = this.plugin.uppy
const { searchResults: ids, partialTree } = this.plugin.getPluginState()
// todo memoize this so we don't have to do it on every render
const itemsById = new Map<string, PartialTreeFile | PartialTreeFolderNode>()
partialTree.forEach((item) => {
if (item.type !== 'root') {
itemsById.set(item.id, item)
}
})
// the search results view needs data from the partial tree,
const searchResults = ids!.map((id) => {
const partialTreeItem = itemsById.get(id)
if (partialTreeItem == null) throw new Error('Partial tree not complete')
return partialTreeItem
})
return (
<GlobalSearchView
searchResults={searchResults}
openFolder={this.openSearchResultFolder}
toggleCheckbox={this.toggleCheckbox}
i18n={i18n}
/>
)
}
render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
const { didFirstRender } = this.plugin.getPluginState()
const { i18n } = this.plugin.uppy
@ -448,7 +639,8 @@ export default class ProviderView<M extends Meta, B extends Body> {
)
}
const { partialTree, username, searchString } = this.plugin.getPluginState()
const { partialTree, username, searchString, searchResults } =
this.plugin.getPluginState()
const breadcrumbs = this.getBreadcrumbs()
return (
@ -468,13 +660,10 @@ export default class ProviderView<M extends Meta, B extends Body> {
username={username}
i18n={i18n}
/>
{opts.showFilter && (
<SearchInput
searchString={searchString}
setSearchString={(s: string) => {
this.plugin.setPluginState({ searchString: s })
}}
setSearchString={(s: string) => this.onSearchInput(s)}
submitSearchString={() => {}}
inputLabel={i18n('filter')}
clearSearchLabel={i18n('resetFilter')}
@ -483,19 +672,23 @@ export default class ProviderView<M extends Meta, B extends Body> {
/>
)}
<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}
utmSource="Companion"
/>
{searchResults ? (
this.#renderSearchResults()
) : (
<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}
utmSource="Companion"
/>
)}
<FooterActions
partialTree={partialTree}

View file

@ -13,8 +13,18 @@ const afterOpenFolder = (
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)
// Filter out existing items in the partial tree (we don't want duplicates)
// If we don't, we would get a duplicate when the item is already added to the partial tree in the search view
// and the user then enters its parent from the normal view e.g either through breadcrumbs or manually navigating to it.
const discoveredUniqueItems = discoveredItems.filter(
(i) => !oldPartialTree.find((f) => f.id === i.requestPath),
)
const discoveredFolders = discoveredUniqueItems.filter(
(i) => i.isFolder === true,
)
const discoveredFiles = discoveredUniqueItems.filter(
(i) => i.isFolder === false,
)
const isParentFolderChecked =
clickedFolder.type === 'folder' && clickedFolder.status === 'checked'

View file

@ -28,6 +28,7 @@ const percolateDown = (
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)
@ -69,7 +70,22 @@ const percolateUp = (tree: PartialTree, id: PartialTreeId) => {
(item) => item.status === 'unchecked',
)
if (areAllChildrenChecked) {
/**
* We should *only* set parent folder to checked/unchecked if it has been fully read (`cached`).
* Otherwise, checking a nested folder from the search view also marks its parent as checked,
* which could be incorrect because there might be more unselected (unloaded) files.
*
* Example: /foo/bar/new/myfolder
* If we search for "myfolder", we only build the minimal path (using ProviderView.#buildSearchResultPath)
* up to that folder adding nodes for "bar", "new", and "myfolder" (assuming "foo" is already
* present in the partialTree as part of the root folder).
* Since "foo", "bar", and "new" arent fully fetched yet, we dont know if they have other children.
* If the user checks "myfolder" from the search results and we propagate the checked state
* upward without verifying parent.cached, it would incorrectly mark all its parents as checked.
* Later, when the user navigates to any of "foo", "bar", "new" through the Normal View (via breadcrumbs or manually),
* PartialTreeUtils.afterOpenFolder would then incorrectly mark and display all its children as checked.
*/
if (areAllChildrenChecked && folder.cached) {
folder.status = 'checked'
} else if (areAllChildrenUnchecked) {
folder.status = 'unchecked'
@ -82,39 +98,29 @@ const percolateUp = (tree: PartialTree, id: PartialTreeId) => {
const afterToggleCheckbox = (
oldTree: PartialTree,
clickedRange: string[],
checkedIds: 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)[]
const newlyCheckedItems = tree.filter(
(item) => item.type !== 'root' && checkedIds.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) => {
// allow toggling:
const newStatus = item.status === 'checked' ? 'unchecked' : 'checked'
// and if it's a file, we need to respect restrictions
if (item.type === 'file') {
item.status = item.restrictionError ? 'unchecked' : newStatus
} else {
item.status = newStatus
}
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)
}
percolateDown(tree, item.id, item.status === 'checked')
})
// all checked items have the same parent so we only need to perlocate the first item
percolateUp(tree, newlyCheckedItems[0].parentId)
return tree
}

View file

@ -6,19 +6,19 @@ const getClickedRange = (
clickedId: string,
displayedPartialTree: (PartialTreeFolderNode | PartialTreeFile)[],
isShiftKeyPressed: boolean,
lastCheckbox: string | null,
previousCheckbox: string | null,
): string[] => {
const lastCheckboxIndex = displayedPartialTree.findIndex(
(item) => item.id === lastCheckbox,
const previousCheckboxIndex = displayedPartialTree.findIndex(
(item) => item.id === previousCheckbox,
)
if (lastCheckboxIndex !== -1 && isShiftKeyPressed) {
if (previousCheckboxIndex !== -1 && isShiftKeyPressed) {
const newCheckboxIndex = displayedPartialTree.findIndex(
(item) => item.id === clickedId,
)
const clickedRange = displayedPartialTree.slice(
Math.min(lastCheckboxIndex, newCheckboxIndex),
Math.max(lastCheckboxIndex, newCheckboxIndex) + 1,
Math.min(previousCheckboxIndex, newCheckboxIndex),
Math.max(previousCheckboxIndex, newCheckboxIndex) + 1,
)
return clickedRange.map((item) => item.id)

View file

@ -35,6 +35,18 @@ export interface CompanionClientProvider {
nextPagePath: string | null
items: CompanionFile[]
}>
// Optional: not every provider implements server-side search.
search?: (
text: string,
options?: RequestOptions & {
path?: string | null | undefined
cursor?: string | undefined
},
) => Promise<{
username: string
nextPagePath: string | null
items: CompanionFile[]
}>
}
export interface CompanionClientSearchProvider {
name: string