Skip to content

Commit eb85e51

Browse files
authored
Merge pull request #61941 from nextcloud/feat/search-loading-controller
feat(core): add unified search loading controller
2 parents b3fa7f5 + 68bfa34 commit eb85e51

4 files changed

Lines changed: 735 additions & 8 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { search as unifiedSearch } from './UnifiedSearchService.js'
7+
8+
type CategorySearchStatus = 'loading' | 'loaded' | 'failed' | 'blocked'
9+
10+
interface CategorySearchState {
11+
status: CategorySearchStatus
12+
entries: unknown[]
13+
cursor: string | null
14+
hasMore: boolean
15+
loadMoreFailed: boolean
16+
}
17+
18+
export const REVEAL_INTERVAL_MS = 1500
19+
20+
/**
21+
* Runs a unified search across categories in priority order, blocking
22+
* lower-priority results until their predecessors arrive or a timer reveals them.
23+
*/
24+
export class UnifiedSearchController {
25+
private query: string = ''
26+
private searchStates: Record<string, CategorySearchState> = {}
27+
private searchGeneration: number = 0
28+
private revealTimer: ReturnType<typeof setTimeout> | null = null
29+
private pendingCancels: (() => void)[] = []
30+
31+
/**
32+
* Start a search. Cancels and replaces any search already in flight.
33+
*
34+
* @param query the search term
35+
* @param categories category ids in priority order
36+
* @return resolves once every category has settled
37+
*/
38+
async search(query: string, categories: string[]): Promise<void> {
39+
this.cancelPendingRequests()
40+
this.searchStates = {}
41+
this.searchGeneration++
42+
const generation = this.searchGeneration
43+
this.query = query
44+
45+
this.startRevealTimer()
46+
47+
await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories)))
48+
}
49+
50+
/**
51+
* Fetch the next page for one category and append it. A no-op unless the
52+
* category is loaded with more pages. On failure the existing results stay
53+
* and `loadMoreFailed` is raised, so calling again retries.
54+
*
55+
* @param category the category id to page
56+
*/
57+
async loadMore(category: string): Promise<void> {
58+
const generation = this.searchGeneration
59+
const categoryState = this.searchStates[category]
60+
if (!categoryState || !categoryState.hasMore || categoryState.status !== 'loaded') {
61+
return
62+
}
63+
categoryState.status = 'loading'
64+
categoryState.loadMoreFailed = false
65+
66+
const { request, cancel } = unifiedSearch({
67+
type: category,
68+
query: this.query,
69+
cursor: categoryState.cursor,
70+
})
71+
72+
this.pendingCancels.push(cancel)
73+
74+
try {
75+
const response = await request()
76+
if (this.searchGeneration !== generation) {
77+
return
78+
}
79+
const { entries, cursor, hasMore } = response.data.ocs.data
80+
categoryState.entries.push(...entries)
81+
categoryState.cursor = cursor
82+
categoryState.hasMore = hasMore
83+
} catch {
84+
if (this.searchGeneration !== generation) {
85+
return
86+
}
87+
categoryState.loadMoreFailed = true
88+
}
89+
90+
categoryState.status = 'loaded'
91+
}
92+
93+
/**
94+
* A shallow copy of the current per-category state, safe to read for rendering.
95+
*
96+
* @return the current search states keyed by category id
97+
*/
98+
getSnapshot(): Record<string, CategorySearchState> {
99+
return { ...this.searchStates }
100+
}
101+
102+
/**
103+
* Tear down on unmount: cancels in-flight requests and stops the reveal timer.
104+
*/
105+
dispose(): void {
106+
this.cancelPendingRequests()
107+
this.stopRevealTimer()
108+
}
109+
110+
private async searchCategory(category: string, generation: number, categories: string[]): Promise<void> {
111+
this.searchStates[category] = {
112+
status: 'loading',
113+
entries: [],
114+
cursor: null,
115+
hasMore: false,
116+
loadMoreFailed: false,
117+
}
118+
const { request, cancel } = unifiedSearch({
119+
type: category,
120+
query: this.query,
121+
cursor: null,
122+
})
123+
124+
this.pendingCancels.push(cancel)
125+
126+
try {
127+
const response = await request()
128+
if (this.searchGeneration !== generation) {
129+
// A new search has been started, ignore this result
130+
return
131+
}
132+
133+
const { entries, cursor, hasMore } = response.data.ocs.data
134+
this.searchStates[category] = {
135+
status: 'loaded',
136+
entries,
137+
cursor,
138+
hasMore,
139+
loadMoreFailed: false,
140+
}
141+
} catch {
142+
if (this.searchGeneration !== generation) {
143+
return
144+
}
145+
this.searchStates[category] = {
146+
status: 'failed',
147+
entries: [],
148+
cursor: null,
149+
hasMore: false,
150+
loadMoreFailed: false,
151+
}
152+
}
153+
154+
this.reconcileCategoryStatuses(categories)
155+
}
156+
157+
private reconcileCategoryStatuses(categories: string[]): void {
158+
categories.forEach((category) => {
159+
if (['loading', 'failed'].includes(this.searchStates[category].status)) {
160+
return
161+
}
162+
this.searchStates[category].status = this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded'
163+
})
164+
}
165+
166+
private startRevealTimer(): void {
167+
this.stopRevealTimer()
168+
this.revealTimer = setTimeout(() => {
169+
const categories = Object.keys(this.searchStates)
170+
const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchStates[category].status))
171+
this.unblockAllCategories(categories)
172+
if (hasPendingCategories) {
173+
this.startRevealTimer()
174+
}
175+
}, REVEAL_INTERVAL_MS)
176+
}
177+
178+
private stopRevealTimer(): void {
179+
if (this.revealTimer) {
180+
clearTimeout(this.revealTimer)
181+
this.revealTimer = null
182+
}
183+
}
184+
185+
private cancelPendingRequests(): void {
186+
this.pendingCancels.forEach((cancel) => cancel())
187+
this.pendingCancels = []
188+
}
189+
190+
private unblockAllCategories(categories: string[]): void {
191+
categories.forEach((category) => {
192+
if (this.searchStates[category].status === 'blocked') {
193+
this.searchStates[category].status = 'loaded'
194+
}
195+
})
196+
}
197+
198+
private shouldBlockCategory(category: string, categories: string[]): boolean {
199+
if (!this.searchStates[category]) {
200+
return false
201+
}
202+
203+
return categories.slice(0, categories.indexOf(category)).some((c) => {
204+
const categoryState = this.searchStates[c]
205+
return categoryState && ['loading', 'blocked'].includes(categoryState.status)
206+
})
207+
}
208+
}

core/src/services/UnifiedSearchService.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ export async function getProviders() {
4343
*
4444
* @param {object} options destructuring object
4545
* @param {string} options.type the type to search
46-
* @param {string} options.query the search
47-
* @param {number|string|undefined} options.cursor the offset for paginated searches
48-
* @param {string} options.since the search
49-
* @param {string} options.until the search
50-
* @param {string} options.limit the search
51-
* @param {string} options.person the search
52-
* @param {object} options.extraQueries additional queries to filter search results
46+
* @param {string} options.query the search term
47+
* @param {number|string|null} [options.cursor] the offset for paginated searches
48+
* @param {string} [options.since] start of the date-range filter
49+
* @param {string} [options.until] end of the date-range filter
50+
* @param {string} [options.limit] maximum number of results
51+
* @param {string} [options.person] filter results by person
52+
* @param {object} [options.extraQueries] additional queries to filter search results
5353
* @return {object} {request: Promise, cancel: Promise}
5454
*/
5555
export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) {

0 commit comments

Comments
 (0)