-
-
Notifications
You must be signed in to change notification settings - Fork 5.1k
feat(core): add unified search loading controller #61941
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+735
−8
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
37d052a
feat(core): add unified search loading controller
pringelmann 00077e0
feat(core): order search results by priority with blocking and reveal
pringelmann 59214ba
feat(core): reveal blocked search results on a timer
pringelmann a2057db
fix(core): reset unified search state on each new search
pringelmann 4626e70
feat(core): cancel and dispose unified search requests
pringelmann 2913589
feat(core): paginate unified search results per category
pringelmann a3c91f0
fix(core): improve naming
pringelmann 68bfa34
refactor(core): use async/await in unified search controller
pringelmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { search as unifiedSearch } from './UnifiedSearchService.js' | ||
|
|
||
| type CategorySearchStatus = 'loading' | 'loaded' | 'failed' | 'blocked' | ||
|
|
||
| interface CategorySearchState { | ||
| status: CategorySearchStatus | ||
| entries: unknown[] | ||
| cursor: string | null | ||
| hasMore: boolean | ||
| loadMoreFailed: boolean | ||
| } | ||
|
|
||
| export const REVEAL_INTERVAL_MS = 1500 | ||
|
|
||
| /** | ||
| * Runs a unified search across categories in priority order, blocking | ||
| * lower-priority results until their predecessors arrive or a timer reveals them. | ||
| */ | ||
| export class UnifiedSearchController { | ||
| private query: string = '' | ||
| private searchStates: Record<string, CategorySearchState> = {} | ||
| private searchGeneration: number = 0 | ||
| private revealTimer: ReturnType<typeof setTimeout> | null = null | ||
| private pendingCancels: (() => void)[] = [] | ||
|
|
||
| /** | ||
| * Start a search. Cancels and replaces any search already in flight. | ||
| * | ||
| * @param query the search term | ||
| * @param categories category ids in priority order | ||
| * @return resolves once every category has settled | ||
| */ | ||
| async search(query: string, categories: string[]): Promise<void> { | ||
| this.cancelPendingRequests() | ||
| this.searchStates = {} | ||
| this.searchGeneration++ | ||
| const generation = this.searchGeneration | ||
| this.query = query | ||
|
|
||
| this.startRevealTimer() | ||
|
|
||
| await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories))) | ||
| } | ||
|
|
||
| /** | ||
| * Fetch the next page for one category and append it. A no-op unless the | ||
| * category is loaded with more pages. On failure the existing results stay | ||
| * and `loadMoreFailed` is raised, so calling again retries. | ||
| * | ||
| * @param category the category id to page | ||
| */ | ||
| async loadMore(category: string): Promise<void> { | ||
| const generation = this.searchGeneration | ||
| const categoryState = this.searchStates[category] | ||
| if (!categoryState || !categoryState.hasMore || categoryState.status !== 'loaded') { | ||
| return | ||
| } | ||
| categoryState.status = 'loading' | ||
| categoryState.loadMoreFailed = false | ||
|
|
||
| const { request, cancel } = unifiedSearch({ | ||
| type: category, | ||
| query: this.query, | ||
| cursor: categoryState.cursor, | ||
| }) | ||
|
|
||
| this.pendingCancels.push(cancel) | ||
|
|
||
| try { | ||
| const response = await request() | ||
| if (this.searchGeneration !== generation) { | ||
| return | ||
| } | ||
| const { entries, cursor, hasMore } = response.data.ocs.data | ||
| categoryState.entries.push(...entries) | ||
| categoryState.cursor = cursor | ||
| categoryState.hasMore = hasMore | ||
| } catch { | ||
| if (this.searchGeneration !== generation) { | ||
| return | ||
| } | ||
| categoryState.loadMoreFailed = true | ||
| } | ||
|
|
||
| categoryState.status = 'loaded' | ||
| } | ||
|
|
||
| /** | ||
| * A shallow copy of the current per-category state, safe to read for rendering. | ||
| * | ||
| * @return the current search states keyed by category id | ||
| */ | ||
| getSnapshot(): Record<string, CategorySearchState> { | ||
| return { ...this.searchStates } | ||
| } | ||
|
|
||
| /** | ||
| * Tear down on unmount: cancels in-flight requests and stops the reveal timer. | ||
| */ | ||
| dispose(): void { | ||
| this.cancelPendingRequests() | ||
| this.stopRevealTimer() | ||
| } | ||
|
|
||
| private async searchCategory(category: string, generation: number, categories: string[]): Promise<void> { | ||
| this.searchStates[category] = { | ||
| status: 'loading', | ||
| entries: [], | ||
| cursor: null, | ||
| hasMore: false, | ||
| loadMoreFailed: false, | ||
| } | ||
| const { request, cancel } = unifiedSearch({ | ||
| type: category, | ||
| query: this.query, | ||
| cursor: null, | ||
| }) | ||
|
|
||
| this.pendingCancels.push(cancel) | ||
|
|
||
| try { | ||
| const response = await request() | ||
| if (this.searchGeneration !== generation) { | ||
| // A new search has been started, ignore this result | ||
| return | ||
| } | ||
|
|
||
| const { entries, cursor, hasMore } = response.data.ocs.data | ||
| this.searchStates[category] = { | ||
| status: 'loaded', | ||
| entries, | ||
| cursor, | ||
| hasMore, | ||
| loadMoreFailed: false, | ||
| } | ||
| } catch { | ||
| if (this.searchGeneration !== generation) { | ||
| return | ||
| } | ||
| this.searchStates[category] = { | ||
| status: 'failed', | ||
| entries: [], | ||
| cursor: null, | ||
| hasMore: false, | ||
| loadMoreFailed: false, | ||
| } | ||
| } | ||
|
|
||
| this.reconcileCategoryStatuses(categories) | ||
| } | ||
|
|
||
| private reconcileCategoryStatuses(categories: string[]): void { | ||
| categories.forEach((category) => { | ||
| if (['loading', 'failed'].includes(this.searchStates[category].status)) { | ||
| return | ||
| } | ||
| this.searchStates[category].status = this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded' | ||
| }) | ||
| } | ||
|
|
||
| private startRevealTimer(): void { | ||
| this.stopRevealTimer() | ||
| this.revealTimer = setTimeout(() => { | ||
| const categories = Object.keys(this.searchStates) | ||
| const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchStates[category].status)) | ||
| this.unblockAllCategories(categories) | ||
| if (hasPendingCategories) { | ||
| this.startRevealTimer() | ||
| } | ||
| }, REVEAL_INTERVAL_MS) | ||
| } | ||
|
|
||
| private stopRevealTimer(): void { | ||
| if (this.revealTimer) { | ||
| clearTimeout(this.revealTimer) | ||
| this.revealTimer = null | ||
| } | ||
| } | ||
|
|
||
| private cancelPendingRequests(): void { | ||
| this.pendingCancels.forEach((cancel) => cancel()) | ||
| this.pendingCancels = [] | ||
| } | ||
|
|
||
| private unblockAllCategories(categories: string[]): void { | ||
| categories.forEach((category) => { | ||
| if (this.searchStates[category].status === 'blocked') { | ||
| this.searchStates[category].status = 'loaded' | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| private shouldBlockCategory(category: string, categories: string[]): boolean { | ||
| if (!this.searchStates[category]) { | ||
| return false | ||
| } | ||
|
|
||
| return categories.slice(0, categories.indexOf(category)).some((c) => { | ||
| const categoryState = this.searchStates[c] | ||
| return categoryState && ['loading', 'blocked'].includes(categoryState.status) | ||
| }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,13 +43,13 @@ export async function getProviders() { | |
| * | ||
| * @param {object} options destructuring object | ||
| * @param {string} options.type the type to search | ||
| * @param {string} options.query the search | ||
| * @param {number|string|undefined} options.cursor the offset for paginated searches | ||
| * @param {string} options.since the search | ||
| * @param {string} options.until the search | ||
| * @param {string} options.limit the search | ||
| * @param {string} options.person the search | ||
| * @param {object} options.extraQueries additional queries to filter search results | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small cleanup since the param docs were copy-paste stubs. No behaviour change. |
||
| * @param {string} options.query the search term | ||
| * @param {number|string|null} [options.cursor] the offset for paginated searches | ||
| * @param {string} [options.since] start of the date-range filter | ||
| * @param {string} [options.until] end of the date-range filter | ||
| * @param {string} [options.limit] maximum number of results | ||
| * @param {string} [options.person] filter results by person | ||
| * @param {object} [options.extraQueries] additional queries to filter search results | ||
| * @return {object} {request: Promise, cancel: Promise} | ||
| */ | ||
| export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) { | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.